From 9b5679946a058a31c765659ca753f0741a28ffa9 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 11:46:36 +0200 Subject: [PATCH 01/11] vitest setup with both unit and integration tests Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/package.json | 4 ++-- sdk/dapp-sdk/vitest.config.ts | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/sdk/dapp-sdk/package.json b/sdk/dapp-sdk/package.json index e4b54e104..b72fcde6f 100644 --- a/sdk/dapp-sdk/package.json +++ b/sdk/dapp-sdk/package.json @@ -21,8 +21,8 @@ "dev": "tsup --watch --onSuccess \"tsc\"", "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", "clean": "tsc -b --clean; rm -rf dist", - "test": "vitest run --project browser-integration --passWithNoTests", - "test:coverage": "vitest run --project browser-integration --coverage --passWithNoTests" + "test": "vitest run --project browser-unit --project browser-integration", + "test:coverage": "vitest run --project browser-integration && vitest run --project browser-unit --coverage" }, "dependencies": { "@canton-network/core-provider-dapp": "workspace:^", diff --git a/sdk/dapp-sdk/vitest.config.ts b/sdk/dapp-sdk/vitest.config.ts index 6e2d81cef..65e062e08 100644 --- a/sdk/dapp-sdk/vitest.config.ts +++ b/sdk/dapp-sdk/vitest.config.ts @@ -9,7 +9,11 @@ export default defineConfig({ globalSetup: ['./vitest.global-setup.ts'], coverage: { include: ['src/**/*.ts'], - exclude: ['src/integration-test'], + exclude: [ + 'src/integration-test/**', + 'src/**/*.test.ts', + 'src/dapp-api/rpc-gen/**', + ], provider: 'v8', reporter: ['text', 'html', 'lcov'], thresholds: { @@ -20,8 +24,25 @@ export default defineConfig({ }, }, environment: 'node', - include: [], + // include: [], projects: [ + defineProject({ + test: { + name: 'browser-unit', + include: ['src/**/*.test.ts'], + exclude: ['src/integration-test/*.test.ts'], + browser: { + enabled: true, + provider: playwright({ + trace: 'off', + screenshot: 'off', + video: 'off', + }), + instances: [{ browser: 'chromium' }], + headless: true, + }, + }, + }), defineProject({ test: { name: 'browser-integration', From 4a829978183d12e8cc34636647e332b82d2b6751 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 11:46:47 +0200 Subject: [PATCH 02/11] utils and storage tests Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/src/storage.test.ts | 124 +++++++++++++++++++++++++++++++ sdk/dapp-sdk/src/util.test.ts | 89 ++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 sdk/dapp-sdk/src/storage.test.ts create mode 100644 sdk/dapp-sdk/src/util.test.ts diff --git a/sdk/dapp-sdk/src/storage.test.ts b/sdk/dapp-sdk/src/storage.test.ts new file mode 100644 index 000000000..6c025ebeb --- /dev/null +++ b/sdk/dapp-sdk/src/storage.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StatusEvent } from '@canton-network/core-wallet-dapp-rpc-client' +import { + getKernelDiscovery, + getKernelSession, + removeKernelDiscovery, + removeKernelSession, + setKernelDiscovery, + setKernelSession, +} from './storage' + +const kernelSession = (): StatusEvent => ({ + provider: { + id: 'remote-kernel', + version: '1.0.0', + providerType: 'remote', + url: 'https://gateway.example.com/api', + userUrl: 'https://gateway.example.com/user', + }, + connection: { + isConnected: true, + isNetworkConnected: true, + reason: 'OK', + networkReason: 'OK', + userUrl: 'https://gateway.example.com/user', + }, + network: { + networkId: 'test-network', + ledgerApi: 'https://ledger.example.com', + accessToken: 'test-access-token', + }, + session: { + accessToken: 'test-access-token', + userId: 'test-user', + }, +}) + +describe('storage', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('kernel discovery', () => { + it('round-trips remote discovery through localStorage', () => { + setKernelDiscovery({ + walletType: 'remote', + url: 'https://gateway.example.com', + }) + + expect(getKernelDiscovery()).toEqual({ + walletType: 'remote', + url: 'https://gateway.example.com', + }) + }) + + it('round-trips extension discovery through localStorage', () => { + setKernelDiscovery({ + walletType: 'extension', + providerId: 'browser:ext:abc', + }) + + expect(getKernelDiscovery()).toEqual({ + walletType: 'extension', + providerId: 'browser:ext:abc', + }) + }) + + it('returns undefined for invalid stored discovery', () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + localStorage.setItem( + 'splice_wallet_kernel_discovery', + JSON.stringify({ walletType: 'remote', url: 'not-a-url' }) + ) + + expect(getKernelDiscovery()).toBeUndefined() + expect(errorSpy).toHaveBeenCalled() + }) + + it('removes stored discovery', () => { + setKernelDiscovery({ + walletType: 'remote', + url: 'https://gateway.example.com', + }) + removeKernelDiscovery() + + expect(getKernelDiscovery()).toBeUndefined() + }) + }) + + describe('kernel session', () => { + it('round-trips session through localStorage', () => { + const session = kernelSession() + + setKernelSession(session) + expect(getKernelSession()).toEqual(session) + }) + + it('returns undefined for invalid stored session', () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + localStorage.setItem('splice_wallet_kernel_session', '{not-json') + + expect(getKernelSession()).toBeUndefined() + expect(errorSpy).toHaveBeenCalled() + }) + + it('removes stored session', () => { + setKernelSession(kernelSession()) + removeKernelSession() + + expect(getKernelSession()).toBeUndefined() + }) + }) +}) diff --git a/sdk/dapp-sdk/src/util.test.ts b/sdk/dapp-sdk/src/util.test.ts new file mode 100644 index 000000000..e2e637b4b --- /dev/null +++ b/sdk/dapp-sdk/src/util.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { popup } from '@canton-network/core-wallet-ui-components' +import * as storage from './storage' +import { clearAllLocalState, composeSIWXMessage } from './util' + +vi.mock('./storage', () => ({ + removeKernelSession: vi.fn(), + removeKernelDiscovery: vi.fn(), +})) + +vi.mock('@canton-network/core-wallet-ui-components', () => ({ + popup: { + close: vi.fn(), + }, +})) + +describe('util', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('clearAllLocalState', () => { + it('clears kernel session and discovery', () => { + clearAllLocalState() + + expect(storage.removeKernelSession).toHaveBeenCalledOnce() + expect(storage.removeKernelDiscovery).toHaveBeenCalledOnce() + expect(popup.close).not.toHaveBeenCalled() + }) + + it('closes the popup when requested', () => { + clearAllLocalState({ closePopup: true }) + + expect(popup.close).toHaveBeenCalledOnce() + }) + }) + + describe('composeSIWXMessage', () => { + it('builds a minimal SIWX message', () => { + const message = composeSIWXMessage({ + domain: 'example.com', + uri: 'https://example.com/login', + version: '1', + nonce: 'nonce-1', + chainId: '42', + accountAddress: 'party::abc', + }) + + expect(message).toBe( + [ + 'example.com wants you to sign in with your Canton account:', + 'party::abc', + '', + 'URI: https://example.com/login', + 'Version: 1', + 'Chain ID: 42', + 'Nonce: nonce-1', + ].join('\n') + ) + }) + + it('includes optional SIWX fields when provided', () => { + const message = composeSIWXMessage({ + domain: 'example.com', + uri: 'uri:1234567890', + version: '1', + nonce: 'nonce-1', + chainId: '42', + accountAddress: 'party::abc', + statement: 'Sign in to the app', + issuedAt: '2026-01-01T00:00:00Z', + expirationTime: '2026-01-02T00:00:00Z', + notBefore: '2025-12-31T00:00:00Z', + requestId: 'req-1', + resources: ['resource-1'], + }) + + expect(message).toContain('\nSign in to the app\n') + expect(message).toContain('Issued At: 2026-01-01T00:00:00Z') + expect(message).toContain('Expiration Time: 2026-01-02T00:00:00Z') + expect(message).toContain('Not Before: 2025-12-31T00:00:00Z') + expect(message).toContain('Request ID: req-1') + expect(message).toContain('Resources:\n- resource-1') + }) + }) +}) From 95fc392c209c2cb1fa24027b9d01ca892073ebfe Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 13:50:03 +0200 Subject: [PATCH 03/11] announce discovery test Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/src/announce-discovery.test.ts | 80 +++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 sdk/dapp-sdk/src/announce-discovery.test.ts diff --git a/sdk/dapp-sdk/src/announce-discovery.test.ts b/sdk/dapp-sdk/src/announce-discovery.test.ts new file mode 100644 index 000000000..66b87656d --- /dev/null +++ b/sdk/dapp-sdk/src/announce-discovery.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CANTON_ANNOUNCE_PROVIDER_EVENT, + CANTON_REQUEST_PROVIDER_EVENT, +} from '@canton-network/core-types' +import { requestAnnouncedProviders } from './announce-discovery' + +describe('requestAnnouncedProviders', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('collects announced providers and deduplicates by id', async () => { + const announce = (detail: Record) => { + window.dispatchEvent( + new CustomEvent(CANTON_ANNOUNCE_PROVIDER_EVENT, { detail }) + ) + } + + const promise = requestAnnouncedProviders({ timeoutMs: 50 }) + + window.dispatchEvent( + new CustomEvent(CANTON_REQUEST_PROVIDER_EVENT, { detail: {} }) + ) + + announce({ + id: 'ext-1', + name: 'Extension One', + icon: 'data:image/png;base64,abc', + target: 'ext-1', + }) + announce({ + id: 'ext-1', + name: 'Duplicate', + }) + announce({ + id: 'ext-2', + name: 'Extension Two', + }) + announce({ + id: 'invalid', + // no name + }) + + await expect(promise).resolves.toEqual([ + { + id: 'ext-1', + name: 'Extension One', + icon: 'data:image/png;base64,abc', + target: 'ext-1', + }, + { + id: 'ext-2', + name: 'Extension Two', + icon: undefined, + target: undefined, + }, + ]) + }) + + it('removes the announce listener after the timeout', async () => { + const addListenerSpy = vi.spyOn(window, 'addEventListener') + const removeListenerSpy = vi.spyOn(window, 'removeEventListener') + + // this keeps pending until the timeout passes + await requestAnnouncedProviders({ timeoutMs: 10 }) + + const announceHandler = addListenerSpy.mock.calls.find( + ([event]) => event === CANTON_ANNOUNCE_PROVIDER_EVENT + )?.[1] + expect(announceHandler).toBeTypeOf('function') + expect(removeListenerSpy).toHaveBeenCalledWith( + CANTON_ANNOUNCE_PROVIDER_EVENT, + announceHandler + ) + }) +}) From af05753114a3a770ed1efbc406bae8cc43d1fa9d Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 14:18:42 +0200 Subject: [PATCH 04/11] client tests Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/src/client.test.ts | 210 ++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 sdk/dapp-sdk/src/client.test.ts diff --git a/sdk/dapp-sdk/src/client.test.ts b/sdk/dapp-sdk/src/client.test.ts new file mode 100644 index 000000000..e5479606b --- /dev/null +++ b/sdk/dapp-sdk/src/client.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { WalletEvent } from '@canton-network/core-types' +import { popup } from '@canton-network/core-wallet-ui-components' +import type { Provider } from '@canton-network/core-splice-provider' +import type { + LedgerApiParams, + PrepareExecuteParams, + RpcTypes as DappRpcTypes, + SignMessageParams, +} from '@canton-network/core-wallet-dapp-rpc-client' +import { DappClient } from './client' +import * as util from './util' + +vi.mock('./util', () => ({ + clearAllLocalState: vi.fn(), +})) + +vi.mock('@canton-network/core-wallet-ui-components', () => ({ + popup: { + open: vi.fn(), + close: vi.fn(), + }, +})) + +type MockDappProvider = { + request: Mock['request']> + on: Mock['on']> + removeListener: Mock['removeListener']> +} + +const makeProvider = (): MockDappProvider => ({ + request: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), +}) + +// MockDappProvider when calling vitest mocking methods +// Provider when passing to source code methods +const asProvider = (mock: MockDappProvider): Provider => + mock as unknown as Provider + +const prepareExecuteParams: PrepareExecuteParams = { commands: [] } +const signMessageParams: SignMessageParams = { message: 'hello' } +const ledgerApiParams: LedgerApiParams = { + requestMethod: 'get', + resource: '/v2/state/active-contracts', +} + +describe('DappClient', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('exposes the underlying provider', () => { + const mock = makeProvider() + const client = new DappClient(asProvider(mock)) + + expect(client.getProvider()).toBe(mock) + }) + + it('delegates RPC calls to the provider', async () => { + const mock = makeProvider() + mock.request.mockImplementation(async ({ method }) => { + switch (method) { + case 'connect': + return { isConnected: true } + case 'status': + return { connection: { isConnected: true } } + case 'listAccounts': + return { accounts: [] } + case 'prepareExecute': + return null + case 'prepareExecuteAndWait': + return { tx: { commandId: 'cmd-1' } } + case 'signMessage': + return { signature: 'sig' } + case 'ledgerApi': + return { result: 'ok' } + case 'isConnected': + return { isConnected: true } + default: + throw new Error(`unexpected method ${method}`) + } + }) + + const client = new DappClient(asProvider(mock)) + + await expect(client.connect()).resolves.toEqual({ isConnected: true }) + await expect(client.status()).resolves.toEqual({ + connection: { isConnected: true }, + }) + await expect(client.listAccounts()).resolves.toEqual({ accounts: [] }) + await expect( + client.prepareExecute(prepareExecuteParams) + ).resolves.toBeNull() + await expect( + client.prepareExecuteAndWait(prepareExecuteParams) + ).resolves.toEqual({ tx: { commandId: 'cmd-1' } }) + await expect(client.signMessage(signMessageParams)).resolves.toEqual({ + signature: 'sig', + }) + await expect(client.ledgerApi(ledgerApiParams)).resolves.toEqual({ + result: 'ok', + }) + await expect(client.isConnected()).resolves.toEqual({ + isConnected: true, + }) + }) + + it('registers and removes event listeners on the provider', () => { + const mock = makeProvider() + const client = new DappClient(asProvider(mock)) + const listener = vi.fn() + + client.onStatusChanged(listener) + client.onAccountsChanged(listener) + client.onConnected(listener) + client.onTxChanged(listener) + client.onMessageSignature(listener) + + expect(mock.on).toHaveBeenCalledWith('statusChanged', listener) + expect(mock.on).toHaveBeenCalledWith('accountsChanged', listener) + expect(mock.on).toHaveBeenCalledWith('connected', listener) + expect(mock.on).toHaveBeenCalledWith('txChanged', listener) + expect(mock.on).toHaveBeenCalledWith('messageSignature', listener) + + client.removeOnStatusChanged(listener) + client.removeOnAccountsChanged(listener) + client.removeOnConnected(listener) + client.removeOnTxChanged(listener) + client.removeOnMessageSignature(listener) + + expect(mock.removeListener).toHaveBeenCalledWith( + 'statusChanged', + listener + ) + expect(mock.removeListener).toHaveBeenCalledWith( + 'accountsChanged', + listener + ) + expect(mock.removeListener).toHaveBeenCalledWith('connected', listener) + expect(mock.removeListener).toHaveBeenCalledWith('txChanged', listener) + expect(mock.removeListener).toHaveBeenCalledWith( + 'messageSignature', + listener + ) + }) + + it('opens the wallet popup for remote providers', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + provider: { userUrl: 'https://wallet.example.com/user' }, + }) + + const client = new DappClient(asProvider(mock)) + await client.open() + + expect(popup.open).toHaveBeenCalledWith( + 'https://wallet.example.com/user' + ) + }) + + it('posts an extension open message for browser providers', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + provider: { userUrl: 'https://wallet.example.com/user' }, + }) + const postMessageSpy = vi.spyOn(window, 'postMessage') + + const client = new DappClient(asProvider(mock), { + providerType: 'browser', + target: 'extension-target', + }) + await client.open() + + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: WalletEvent.SPLICE_WALLET_EXT_OPEN, + url: 'https://wallet.example.com/user', + target: 'extension-target', + }, + '*' + ) + }) + + it('throws when status does not include a user URL', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ provider: {} }) + + const client = new DappClient(asProvider(mock)) + await expect(client.open()).rejects.toThrow( + 'User URL not found in status' + ) + }) + + it('disconnects and clears local state even when the RPC fails', async () => { + const mock = makeProvider() + mock.request.mockRejectedValue(new Error('disconnect failed')) + + const client = new DappClient(asProvider(mock)) + await expect(client.disconnect()).rejects.toThrow('disconnect failed') + + expect(util.clearAllLocalState).toHaveBeenCalledWith({ + closePopup: true, + }) + }) +}) From 1cdbc5c4d1c2b00c043ec1be53102583be486a83 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 14:50:58 +0200 Subject: [PATCH 05/11] controller tests Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/src/sdk-controller.test.ts | 289 ++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 sdk/dapp-sdk/src/sdk-controller.test.ts diff --git a/sdk/dapp-sdk/src/sdk-controller.test.ts b/sdk/dapp-sdk/src/sdk-controller.test.ts new file mode 100644 index 000000000..5edc640cc --- /dev/null +++ b/sdk/dapp-sdk/src/sdk-controller.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest' +import type { EventListener } from '@canton-network/core-splice-provider' +import type { DappAsyncProvider } from '@canton-network/core-provider-dapp' +import type { + LedgerApiParams, + PrepareExecuteParams, + SignMessageParams, +} from './dapp-api/rpc-gen/typings' +import { ErrorCode } from './error' +import { dappSDKController } from './sdk-controller' + +const { popupOpen } = vi.hoisted(() => ({ + popupOpen: vi.fn(), +})) + +vi.mock('@canton-network/core-wallet-ui-components', () => ({ + popup: { + open: popupOpen, + }, +})) + +type MockDappAsyncProvider = { + request: Mock + on: Mock + removeListener: Mock + emit: (event: string, ...payload: unknown[]) => void +} + +const makeProvider = (): MockDappAsyncProvider => { + const listeners = new Map[]>() + + const mock: MockDappAsyncProvider = { + request: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + // Test helper only - real DappAsyncProvider emits via AbstractProvider/SSE. + emit(event: string, ...payload: unknown[]) { + for (const listener of listeners.get(event) ?? []) { + listener(...payload) + } + }, + } + + mock.on.mockImplementation((event, listener) => { + const current = listeners.get(event) ?? [] + current.push(listener) + listeners.set(event, current) + // Provider.on returns the provider for chaining, like the real implementation. + return asProvider(mock) + }) + + mock.removeListener.mockImplementation((event, listener) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((fn) => fn !== listener) + ) + return asProvider(mock) + }) + + return mock +} + +const asProvider = (mock: MockDappAsyncProvider): DappAsyncProvider => + mock as unknown as DappAsyncProvider + +const prepareExecuteParams: PrepareExecuteParams = { commands: [] } +const signMessageParams: SignMessageParams = { message: 'hello' } +const ledgerApiParams: LedgerApiParams = { + requestMethod: 'get', + resource: '/v2/state/active-contracts', +} + +describe('dappSDKController', () => { + beforeEach(() => { + popupOpen.mockReset() + vi.spyOn(crypto, 'randomUUID').mockReturnValue( + '00000000-0000-4000-8000-000000000001' + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('delegates simple RPC methods to the provider', async () => { + const mock = makeProvider() + const controller = dappSDKController(asProvider(mock)) + + mock.request.mockResolvedValueOnce(null) + await expect(controller.disconnect()).resolves.toBeNull() + + mock.request.mockResolvedValueOnce({ isConnected: false }) + await expect(controller.isConnected()).resolves.toEqual({ + isConnected: false, + }) + + mock.request.mockResolvedValueOnce({ + connection: { isConnected: true }, + }) + await expect(controller.status()).resolves.toEqual({ + connection: { isConnected: true }, + }) + + mock.request.mockResolvedValueOnce({ accounts: [] }) + await expect(controller.listAccounts()).resolves.toEqual({ + accounts: [], + }) + + mock.request.mockResolvedValueOnce({ network: 'mainnet' }) + await expect(controller.getActiveNetwork()).resolves.toEqual({ + network: 'mainnet', + }) + + mock.request.mockResolvedValueOnce({ wallet: 'primary' }) + await expect(controller.getPrimaryAccount()).resolves.toEqual({ + wallet: 'primary', + }) + + mock.request.mockResolvedValueOnce({ result: 'ok' }) + await expect(controller.ledgerApi(ledgerApiParams)).resolves.toEqual({ + result: 'ok', + }) + }) + + it('opens the popup and resolves connect after statusChanged', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + userUrl: 'https://wallet.example.com/connect', + }) + + const controller = dappSDKController(asProvider(mock)) + const connectPromise = controller.connect() + + await vi.waitFor(() => { + expect(popupOpen).toHaveBeenCalledWith( + 'https://wallet.example.com/connect' + ) + }) + + mock.emit('statusChanged', { + connection: { isConnected: true, isNetworkConnected: true }, + }) + + await expect(connectPromise).resolves.toEqual({ + isConnected: true, + isNetworkConnected: true, + }) + }) + + it('opens the popup for prepareExecute when a user URL is returned', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + userUrl: 'https://wallet.example.com/prepare', + }) + + const controller = dappSDKController(asProvider(mock)) + await expect( + controller.prepareExecute(prepareExecuteParams) + ).resolves.toBeNull() + + expect(popupOpen).toHaveBeenCalledWith( + 'https://wallet.example.com/prepare' + ) + }) + + it('waits for executed transactions in prepareExecuteAndWait', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + userUrl: 'https://wallet.example.com/prepare', + }) + + const controller = dappSDKController(asProvider(mock)) + const waitPromise = + controller.prepareExecuteAndWait(prepareExecuteParams) + + await vi.waitFor(() => { + expect(mock.on).toHaveBeenCalledWith( + 'txChanged', + expect.any(Function) + ) + }) + + mock.emit('txChanged', { + commandId: 'other-command', + status: 'executed', + }) + mock.emit('txChanged', { + commandId: '00000000-0000-4000-8000-000000000001', + status: 'executed', + updateId: 'update-1', + }) + + await expect(waitPromise).resolves.toEqual({ + tx: { + commandId: '00000000-0000-4000-8000-000000000001', + status: 'executed', + updateId: 'update-1', + }, + }) + }) + + it('rejects prepareExecuteAndWait when the transaction fails', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + userUrl: 'https://wallet.example.com/prepare', + }) + + const controller = dappSDKController(asProvider(mock)) + const waitPromise = + controller.prepareExecuteAndWait(prepareExecuteParams) + + await vi.waitFor(() => { + expect(mock.on).toHaveBeenCalledWith( + 'txChanged', + expect.any(Function) + ) + }) + + mock.emit('txChanged', { + commandId: '00000000-0000-4000-8000-000000000001', + status: 'failed', + }) + + await expect(waitPromise).rejects.toEqual({ + status: 'error', + error: ErrorCode.TransactionFailed, + details: + 'Transaction with commandId 00000000-0000-4000-8000-000000000001 failed to execute.', + }) + }) + + it('resolves signMessage after a matching signature event', async () => { + const mock = makeProvider() + mock.request.mockResolvedValue({ + userUrl: 'https://wallet.example.com/sign?messageId=message-1', + }) + + const controller = dappSDKController(asProvider(mock)) + const signPromise = controller.signMessage(signMessageParams) + + await vi.waitFor(() => { + expect(mock.on).toHaveBeenCalledWith( + 'messageSignature', + expect.any(Function) + ) + }) + + mock.emit('messageSignature', { + messageId: 'other-message', + status: 'signed', + signature: 'ignored', + }) + mock.emit('messageSignature', { + messageId: 'message-1', + status: 'pending', + }) + mock.emit('messageSignature', { + messageId: 'message-1', + status: 'signed', + signature: 'signed-message', + }) + + await expect(signPromise).resolves.toEqual({ + signature: 'signed-message', + }) + }) + + it('throws for event-only controller methods', async () => { + const mock = makeProvider() + const controller = dappSDKController(asProvider(mock)) + + await expect(controller.accountsChanged()).rejects.toThrow( + 'Only for events.' + ) + await expect(controller.txChanged()).rejects.toThrow('Only for events.') + expect(() => controller.messageSignature()).toThrow('Only for events.') + }) +}) From b7f0ca553971a093471e5664634aaed81fbefb4c Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Thu, 11 Jun 2026 17:43:43 +0200 Subject: [PATCH 06/11] ext adapter Signed-off-by: Pawel Stepien --- .../src/adapter/extension-adapter.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 sdk/dapp-sdk/src/adapter/extension-adapter.test.ts diff --git a/sdk/dapp-sdk/src/adapter/extension-adapter.test.ts b/sdk/dapp-sdk/src/adapter/extension-adapter.test.ts new file mode 100644 index 000000000..f123181ca --- /dev/null +++ b/sdk/dapp-sdk/src/adapter/extension-adapter.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest' +import { WalletEvent } from '@canton-network/core-types' +import type { Provider } from '@canton-network/core-splice-provider' +import type { + RpcTypes as DappRpcTypes, + StatusEvent, +} from '@canton-network/core-wallet-dapp-rpc-client' +import * as storage from '../storage' +import { ExtensionAdapter } from './extension-adapter' + +type MockProvider = { + request: Mock['request']> +} + +const connectedStatus = (): StatusEvent => ({ + provider: { id: 'browser:ext:test' }, + connection: { + isConnected: true, + isNetworkConnected: true, + }, +}) + +const makeMockProvider = (): MockProvider => ({ + request: vi.fn(), +}) + +const asProvider = (mock: MockProvider): Provider => + mock as unknown as Provider + +describe('ExtensionAdapter', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('exposes configured wallet metadata', () => { + const adapter = new ExtensionAdapter({ + providerId: 'browser:ext:my-wallet', + name: 'My Extension', + icon: 'data:image/png;base64,abc', + description: 'Test extension', + target: 'my-wallet', + }) + + expect(adapter.providerId).toBe('browser:ext:my-wallet') + expect(adapter.getInfo()).toEqual({ + providerId: 'browser:ext:my-wallet', + name: 'My Extension', + type: 'browser', + description: 'Test extension', + icon: 'data:image/png;base64,abc', + }) + expect(adapter.target).toBe('my-wallet') + }) + + it('uses defaults when config is omitted', () => { + const adapter = new ExtensionAdapter() + + expect(adapter.providerId).toBe('browser') + expect(adapter.getInfo().name).toBe('Browser Extension') + }) + + it('detects via the extension ready/ack handshake', async () => { + vi.useFakeTimers() + const postMessageSpy = vi.spyOn(window, 'postMessage') + const adapter = new ExtensionAdapter({ target: 'ext-target' }) + + const detectPromise = adapter.detect() + + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: WalletEvent.SPLICE_WALLET_EXT_READY, + target: 'ext-target', + }, + '*' + ) + + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: WalletEvent.SPLICE_WALLET_EXT_ACK, + target: 'ext-target', + }, + }) + ) + + await expect(detectPromise).resolves.toBe(true) + }) + + it('ignores ack messages for a different target', async () => { + vi.useFakeTimers() + const adapter = new ExtensionAdapter({ target: 'expected-target' }) + const detectPromise = adapter.detect() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: WalletEvent.SPLICE_WALLET_EXT_ACK, + target: 'other-target', + }, + }) + ) + + await vi.advanceTimersByTimeAsync(2000) + await expect(detectPromise).resolves.toBe(false) + }) + + it('restores a connected provider when kernel discovery matches', async () => { + const adapter = new ExtensionAdapter({ + providerId: 'browser:ext:test', + }) + const mockProvider = makeMockProvider() + mockProvider.request.mockResolvedValue(connectedStatus()) + vi.spyOn(adapter, 'provider').mockReturnValue(asProvider(mockProvider)) + + storage.setKernelDiscovery({ + walletType: 'extension', + providerId: 'browser:ext:test', + }) + + await expect(adapter.restore()).resolves.toBe(asProvider(mockProvider)) + expect(mockProvider.request).toHaveBeenCalledWith({ method: 'connect' }) + expect(mockProvider.request).toHaveBeenCalledWith({ method: 'status' }) + }) + + it('returns null when the provider is not connected', async () => { + const adapter = new ExtensionAdapter() + const mockProvider = makeMockProvider() + mockProvider.request.mockResolvedValue({ + provider: { id: 'browser' }, + connection: { + isConnected: false, + isNetworkConnected: false, + }, + }) + vi.spyOn(adapter, 'provider').mockReturnValue(asProvider(mockProvider)) + + await expect(adapter.restore()).resolves.toBeNull() + }) +}) From dbbf3fa8605c1178bfb09275688049628a64e886 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Fri, 12 Jun 2026 13:21:10 +0200 Subject: [PATCH 07/11] remote adapter tests Signed-off-by: Pawel Stepien --- .../src/adapter/remote-adapter.test.ts | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 sdk/dapp-sdk/src/adapter/remote-adapter.test.ts diff --git a/sdk/dapp-sdk/src/adapter/remote-adapter.test.ts b/sdk/dapp-sdk/src/adapter/remote-adapter.test.ts new file mode 100644 index 000000000..05deefdb4 --- /dev/null +++ b/sdk/dapp-sdk/src/adapter/remote-adapter.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WalletEvent } from '@canton-network/core-types' +import { popup } from '@canton-network/core-wallet-ui-components' +import type { EventListener } from '@canton-network/core-splice-provider' +import type { + PrepareExecuteParams, + StatusEvent, +} from '@canton-network/core-wallet-dapp-rpc-client' +import * as storage from '../storage' +import { clearAllLocalState } from '../util' +import { RemoteAdapter } from './remote-adapter' + +const RPC_URL = 'https://gateway.example.com' + +const { + mockDappAsyncProvider, + mockController, + DappAsyncProviderMock, + dappSDKControllerMock, +} = vi.hoisted(() => { + const listeners = new Map[]>() + + const mockDappAsyncProvider = { + on: vi.fn((event: string, listener: EventListener) => { + const current = listeners.get(event) ?? [] + current.push(listener) + listeners.set(event, current) + }), + emit: vi.fn((event: string, ...payload: unknown[]) => { + for (const listener of listeners.get(event) ?? []) { + listener(...payload) + } + return true + }), + removeListener: vi.fn(), + // only a helper for testing + emitToListeners(event: string, ...payload: unknown[]) { + for (const listener of listeners.get(event) ?? []) { + listener(...payload) + } + }, + } + + const mockController = { + status: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(), + ledgerApi: vi.fn(), + prepareExecute: vi.fn(), + listAccounts: vi.fn(), + prepareExecuteAndWait: vi.fn(), + getPrimaryAccount: vi.fn(), + getActiveNetwork: vi.fn(), + signMessage: vi.fn(), + } + + class DappAsyncProviderMock { + on = mockDappAsyncProvider.on + emit = mockDappAsyncProvider.emit + removeListener = mockDappAsyncProvider.removeListener + } + + return { + mockDappAsyncProvider, + mockController, + DappAsyncProviderMock, + dappSDKControllerMock: vi.fn(() => mockController), + } +}) + +vi.mock('@canton-network/core-provider-dapp', () => ({ + DappAsyncProvider: DappAsyncProviderMock, +})) + +vi.mock('../sdk-controller', () => ({ + dappSDKController: dappSDKControllerMock, +})) + +vi.mock('../util', () => ({ + clearAllLocalState: vi.fn(), +})) + +vi.mock('@canton-network/core-wallet-ui-components', () => ({ + popup: { + close: vi.fn(), + }, +})) + +const kernelSession = (): StatusEvent => ({ + provider: { + id: 'remote-gateway', + providerType: 'remote', + url: RPC_URL, + }, + connection: { + isConnected: true, + isNetworkConnected: true, + }, + session: { + accessToken: 'test-token', + userId: 'test-user', + }, +}) + +describe('RemoteAdapter', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + mockController.status.mockResolvedValue(kernelSession()) + mockController.connect.mockResolvedValue(kernelSession().connection) + mockController.disconnect.mockResolvedValue(null) + mockController.isConnected.mockResolvedValue(kernelSession().connection) + mockController.listAccounts.mockResolvedValue([]) + mockController.prepareExecute.mockResolvedValue(null) + mockController.prepareExecuteAndWait.mockResolvedValue({ + tx: { commandId: 'cmd-1', status: 'executed' }, + }) + mockController.signMessage.mockResolvedValue({ signature: 'sig' }) + mockController.ledgerApi.mockResolvedValue({ ok: true }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('derives providerId from rpcUrl by default', () => { + const adapter = new RemoteAdapter({ + name: 'Gateway', + rpcUrl: RPC_URL, + }) + + expect(adapter.providerId).toBe(`remote:${RPC_URL}`) + expect(adapter.getInfo()).toEqual({ + providerId: `remote:${RPC_URL}`, + name: 'Gateway', + type: 'remote', + description: undefined, + icon: undefined, + url: RPC_URL, + reuseGlobalWalletPopup: true, + }) + }) + + it('always reports the gateway as available', async () => { + await expect( + new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }).detect() + ).resolves.toBe(true) + }) + + it('creates a mapped provider backed by DappAsyncProvider', async () => { + storage.setKernelSession(kernelSession()) + + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + const provider = adapter.provider() + + expect(dappSDKControllerMock).not.toHaveBeenCalled() + + await provider.request({ method: 'status' }) + + expect(dappSDKControllerMock).toHaveBeenCalledTimes(1) + expect(provider.request).toBeTypeOf('function') + }) + + it('routes RPC calls through dappSDKController', async () => { + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + const provider = adapter.provider() + + mockController.status.mockResolvedValueOnce(kernelSession()) + await expect(provider.request({ method: 'status' })).resolves.toEqual( + kernelSession() + ) + + mockController.connect.mockResolvedValueOnce(kernelSession().connection) + await expect(provider.request({ method: 'connect' })).resolves.toEqual( + kernelSession().connection + ) + + mockController.disconnect.mockResolvedValueOnce(null) + await expect( + provider.request({ method: 'disconnect' }) + ).resolves.toBeNull() + + mockController.listAccounts.mockResolvedValueOnce([]) + await expect( + provider.request({ method: 'listAccounts' }) + ).resolves.toEqual([]) + + const prepareExecuteParams: PrepareExecuteParams = { commands: [] } + mockController.prepareExecute.mockResolvedValueOnce(null) + await expect( + provider.request({ + method: 'prepareExecute', + params: prepareExecuteParams, + }) + ).resolves.toBeNull() + + mockController.prepareExecuteAndWait.mockResolvedValueOnce({ + tx: { commandId: 'cmd-1', status: 'executed' }, + }) + await expect( + provider.request({ + method: 'prepareExecuteAndWait', + params: prepareExecuteParams, + }) + ).resolves.toEqual({ + tx: { commandId: 'cmd-1', status: 'executed' }, + }) + + mockController.signMessage.mockResolvedValueOnce({ signature: 'sig' }) + await expect( + provider.request({ + method: 'signMessage', + params: { message: 'hello' }, + }) + ).resolves.toEqual({ signature: 'sig' }) + + mockController.ledgerApi.mockResolvedValueOnce({ ok: true }) + await expect( + provider.request({ + method: 'ledgerApi', + params: { + requestMethod: 'get', + resource: '/v2/state/active-contracts', + }, + }) + ).resolves.toEqual({ ok: true }) + }) + + it('forwards provider events to the remote provider', () => { + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + const provider = adapter.provider() + const listener = vi.fn>() + + provider.on('statusChanged', listener) + provider.emit('statusChanged', kernelSession()) + + expect(mockDappAsyncProvider.on).toHaveBeenCalledWith( + 'statusChanged', + listener + ) + expect(mockDappAsyncProvider.emit).toHaveBeenCalledWith( + 'statusChanged', + kernelSession() + ) + }) + + it('persists kernel session when statusChanged includes a session', () => { + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + adapter.provider() + + mockDappAsyncProvider.emitToListeners('statusChanged', kernelSession()) + + expect(storage.getKernelSession()).toEqual(kernelSession()) + }) + + it('clears local state when statusChanged reports a disconnect', () => { + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + adapter.provider() + + mockDappAsyncProvider.emitToListeners('statusChanged', { + provider: { id: 'remote-gateway' }, + connection: { + isConnected: false, + isNetworkConnected: false, + }, + }) + + expect(clearAllLocalState).toHaveBeenCalledWith({ closePopup: true }) + }) + + it('clears local state when the wallet gateway logs out', () => { + const adapter = new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }) + adapter.provider() + + window.dispatchEvent( + new MessageEvent('message', { + data: { type: WalletEvent.SPLICE_WALLET_LOGOUT }, + }) + ) + + expect(clearAllLocalState).toHaveBeenCalledWith({ closePopup: true }) + }) + + it('closes the popup on teardown', () => { + new RemoteAdapter({ name: 'Gateway', rpcUrl: RPC_URL }).teardown() + + expect(popup.close).toHaveBeenCalled() + }) + + describe('restore', () => { + it('returns null when discovery does not match this gateway', async () => { + storage.setKernelDiscovery({ + walletType: 'remote', + url: 'https://other.gateway.test', + }) + storage.setKernelSession(kernelSession()) + + const adapter = new RemoteAdapter({ + name: 'Gateway', + rpcUrl: RPC_URL, + }) + await expect(adapter.restore()).resolves.toBeNull() + }) + + it('returns null when no kernel session is stored', async () => { + storage.setKernelDiscovery({ + walletType: 'remote', + url: RPC_URL, + }) + + const adapter = new RemoteAdapter({ + name: 'Gateway', + rpcUrl: RPC_URL, + }) + await expect(adapter.restore()).resolves.toBeNull() + }) + + it('returns the provider when the stored session is still connected', async () => { + storage.setKernelDiscovery({ + walletType: 'remote', + url: RPC_URL, + }) + storage.setKernelSession(kernelSession()) + + const adapter = new RemoteAdapter({ + name: 'Gateway', + rpcUrl: RPC_URL, + }) + const restored = await adapter.restore() + + expect(restored).not.toBeNull() + expect(restored?.request).toBeTypeOf('function') + expect(mockController.status).toHaveBeenCalled() + }) + }) +}) From b00a2c46fb34fdb0be17827a550dcfa0f25c531d Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Fri, 12 Jun 2026 14:40:08 +0200 Subject: [PATCH 08/11] walletconnect adapters tests Signed-off-by: Pawel Stepien --- .../src/adapter/walletconnect-adapter.test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts diff --git a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts new file mode 100644 index 000000000..77ca85992 --- /dev/null +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EventListener } from '@canton-network/core-splice-provider' +import type { StatusEvent } from '@canton-network/core-wallet-dapp-rpc-client' +import { WALLETCONNECT_ICON } from '../assets' +import { + WalletConnectAdapter, + type WalletConnectAdapterConfig, +} from './walletconnect-adapter' + +const mockSession = { + topic: 'session-topic', + namespaces: { + canton: { + accounts: ['account'], + }, + }, +} + +const { mockSignClient, SignClientInit, sessionEventHandler } = vi.hoisted( + () => { + let sessionEventHandler: + | ((event: { + params: { event: { name: string; data: unknown } } + }) => void) + | undefined + + const mockSignClient = { + connect: vi.fn(), + request: vi.fn(), + disconnect: vi.fn(), + on: vi.fn((event: string, handler: (arg: unknown) => void) => { + if (event === 'session_event') sessionEventHandler = handler + }), + session: { + getAll: vi.fn().mockReturnValue([]), + }, + } + + return { + mockSignClient, + SignClientInit: vi.fn().mockResolvedValue(mockSignClient), + sessionEventHandler: () => sessionEventHandler, + } + } +) + +vi.mock('@walletconnect/sign-client', () => ({ + default: { + init: SignClientInit, + }, +})) + +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'generated-nonce'), +})) + +const makeAdapter = ( + overrides: Partial< + Pick< + WalletConnectAdapterConfig, + 'onUri' | 'onSignInWithCanton' | 'signInWithCanton' + > + > = {} +) => + WalletConnectAdapter.create({ + projectId: 'test-project-id', + ...overrides, + }) + +describe('WalletConnectAdapter', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSignClient.session.getAll.mockReturnValue([]) + mockSignClient.connect.mockResolvedValue({ + uri: 'wc:test-uri', + approval: vi.fn().mockResolvedValue(mockSession), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes wallet picker metadata', () => { + const adapter = makeAdapter() + + expect(adapter.providerId).toBe('walletconnect') + expect(adapter.getInfo()).toEqual({ + providerId: 'walletconnect', + name: 'WalletConnect', + type: 'remote', + icon: WALLETCONNECT_ICON, + description: 'Connect via WalletConnect', + reuseGlobalWalletPopup: true, + }) + }) + + it('returns itself as the provider instance', () => { + const adapter = makeAdapter() + + expect(adapter.provider()).toBe(adapter) + }) + + it('reports disconnected status before a session exists', async () => { + const adapter = makeAdapter() + + await expect(adapter.request({ method: 'status' })).resolves.toEqual({ + provider: { + id: 'walletconnect', + providerType: 'remote', + }, + connection: { + isConnected: false, + isNetworkConnected: false, + }, + }) + }) + + it('establishes a session on connect and emits statusChanged', async () => { + const onUri = vi.fn() + const statusListener = vi.fn>() + const adapter = makeAdapter({ onUri }) + adapter.on('statusChanged', statusListener) + + await expect(adapter.request({ method: 'connect' })).resolves.toEqual({ + isConnected: true, + isNetworkConnected: true, + }) + + expect(SignClientInit).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'test-project-id' }) + ) + expect(onUri).toHaveBeenCalledWith('wc:test-uri') + expect(statusListener).toHaveBeenCalledWith( + expect.objectContaining({ + connection: { + isConnected: true, + isNetworkConnected: true, + }, + }) + ) + }) + + it('disconnects the WalletConnect session and emits a local status update', async () => { + const adapter = makeAdapter() + const statusListener = vi.fn>() + + await adapter.request({ method: 'connect' }) + adapter.on('statusChanged', statusListener) + statusListener.mockClear() + + await expect( + adapter.request({ method: 'disconnect' }) + ).resolves.toBeNull() + + expect(mockSignClient.disconnect).toHaveBeenCalledWith({ + topic: 'session-topic', + reason: { code: 6000, message: 'User disconnected' }, + }) + expect(statusListener).toHaveBeenCalledWith( + expect.objectContaining({ + connection: expect.objectContaining({ + isConnected: false, + reason: 'User disconnected', + }), + }) + ) + }) + + it('buffers events until a listener is attached', () => { + const adapter = makeAdapter() + const listener = vi.fn>() + + adapter.emit('statusChanged', { + provider: { id: 'walletconnect', providerType: 'remote' }, + connection: { isConnected: true, isNetworkConnected: true }, + }) + expect(listener).not.toHaveBeenCalled() + + adapter.on('statusChanged', listener) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + connection: { isConnected: true, isNetworkConnected: true }, + }) + ) + }) + + it('removes event listeners', () => { + const adapter = makeAdapter() + const listener = vi.fn>() + + adapter.on('statusChanged', listener) + adapter.removeListener('statusChanged', listener) + adapter.emit('statusChanged', { + provider: { id: 'walletconnect', providerType: 'remote' }, + connection: { isConnected: false, isNetworkConnected: false }, + }) + + expect(listener).not.toHaveBeenCalled() + }) + + it('restores an existing Canton WalletConnect session', async () => { + mockSignClient.session.getAll.mockReturnValue([mockSession]) + + const adapter = makeAdapter() + const restored = await adapter.restore() + + expect(restored).toBe(adapter) + expect(mockSignClient.on).toHaveBeenCalledWith( + 'session_event', + expect.any(Function) + ) + }) + + it('forwards session events to local listeners', async () => { + mockSignClient.session.getAll.mockReturnValue([mockSession]) + const adapter = makeAdapter() + const listener = vi.fn() + + await adapter.restore() + adapter.on('accountsChanged', listener) + + sessionEventHandler()?.({ + params: { + event: { + name: 'accountsChanged', + data: [{ partyId: 'party::alice' }], + }, + }, + }) + + expect(listener).toHaveBeenCalledWith([{ partyId: 'party::alice' }]) + }) + + it('maps prepareExecute to canton_prepareSignExecute and emits txChanged', async () => { + const adapter = makeAdapter() + const txListener = vi.fn() + + await adapter.request({ method: 'connect' }) + adapter.on('txChanged', txListener) + + mockSignClient.request.mockResolvedValueOnce({ + commandId: 'cmd-1', + status: 'executed', + }) + + await expect( + adapter.request({ + method: 'prepareExecute', + params: { commands: [] }, + }) + ).resolves.toEqual({ + tx: { commandId: 'cmd-1', status: 'executed' }, + }) + + expect(mockSignClient.request).toHaveBeenCalledWith({ + topic: 'session-topic', + chainId: 'canton:devnet', + request: { + method: 'canton_prepareSignExecute', + params: { commands: [] }, + }, + }) + expect(txListener).toHaveBeenCalledWith({ + commandId: 'cmd-1', + status: 'executed', + }) + }) + + it('wraps WalletConnect RPC failures with a readable error', async () => { + const adapter = makeAdapter() + + await adapter.request({ method: 'connect' }) + + mockSignClient.request.mockImplementationOnce(() => + Promise.reject({ code: 4001, message: 'User rejected' }) + ) + + await expect( + adapter.request({ method: 'listAccounts' }) + ).rejects.toThrow('RPC error: 4001 - User rejected') + }) +}) From 5e50b7666b4bb909cde2e18a936e0cde36ea64dd Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Fri, 12 Jun 2026 15:31:01 +0200 Subject: [PATCH 09/11] sdk tests Signed-off-by: Pawel Stepien --- .../src/adapter/walletconnect-adapter.test.ts | 8 +- sdk/dapp-sdk/src/sdk.test.ts | 776 ++++++++++++++++++ 2 files changed, 780 insertions(+), 4 deletions(-) create mode 100644 sdk/dapp-sdk/src/sdk.test.ts diff --git a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts index 77ca85992..055d3e3c8 100644 --- a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts @@ -91,7 +91,7 @@ describe('WalletConnectAdapter', () => { expect(adapter.getInfo()).toEqual({ providerId: 'walletconnect', name: 'WalletConnect', - type: 'remote', + type: 'mobile', icon: WALLETCONNECT_ICON, description: 'Connect via WalletConnect', reuseGlobalWalletPopup: true, @@ -110,7 +110,7 @@ describe('WalletConnectAdapter', () => { await expect(adapter.request({ method: 'status' })).resolves.toEqual({ provider: { id: 'walletconnect', - providerType: 'remote', + providerType: 'mobile', }, connection: { isConnected: false, @@ -175,7 +175,7 @@ describe('WalletConnectAdapter', () => { const listener = vi.fn>() adapter.emit('statusChanged', { - provider: { id: 'walletconnect', providerType: 'remote' }, + provider: { id: 'walletconnect', providerType: 'mobile' }, connection: { isConnected: true, isNetworkConnected: true }, }) expect(listener).not.toHaveBeenCalled() @@ -195,7 +195,7 @@ describe('WalletConnectAdapter', () => { adapter.on('statusChanged', listener) adapter.removeListener('statusChanged', listener) adapter.emit('statusChanged', { - provider: { id: 'walletconnect', providerType: 'remote' }, + provider: { id: 'walletconnect', providerType: 'mobile' }, connection: { isConnected: false, isNetworkConnected: false }, }) diff --git a/sdk/dapp-sdk/src/sdk.test.ts b/sdk/dapp-sdk/src/sdk.test.ts new file mode 100644 index 000000000..6976cffa3 --- /dev/null +++ b/sdk/dapp-sdk/src/sdk.test.ts @@ -0,0 +1,776 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest' +import type { + EventListener, + Provider, +} from '@canton-network/core-splice-provider' +import type { ProviderAdapter } from '@canton-network/core-wallet-discovery' +import type { + AccountsChangedEvent, + ConnectResult, + LedgerApiParams, + LedgerApiResult, + ListAccountsResult, + MessageSignatureEvent, + PrepareExecuteAndWaitResult, + PrepareExecuteParams, + RpcTypes as DappRpcTypes, + SignMessageParams, + SignMessageResult, + StatusEvent, + TxChangedEvent, +} from '@canton-network/core-wallet-dapp-rpc-client' +import * as storage from './storage' +import { + DappSDK, + connect, + disconnect, + getConnectedProvider, + init, + isConnected, + ledgerApi, + listAccounts, + onAccountsChanged, + onConnected, + onMessageSignature, + onStatusChanged, + onTxChanged, + open, + prepareExecute, + prepareExecuteAndWait, + removeOnAccountsChanged, + removeOnConnected, + removeOnMessageSignature, + removeOnStatusChanged, + removeOnTxChanged, + sdk, + status, +} from './sdk' + +const { + mockRequestAnnouncedProviders, + mockNotifyWalletPickerConnected, + mockNotifyWalletPickerError, + mockWaitForWalletPickerRetrySelection, + mockClearAllLocalState, + remoteAdapterInstances, + RemoteAdapterMock, + setRemoteProviderFactory, +} = vi.hoisted(() => { + const remoteAdapterInstances: Array<{ + providerId: string + rpcUrl: string + name: string + provider: ReturnType + }> = [] + + let createRemoteProvider: (() => unknown) | undefined + + const setRemoteProviderFactory = (factory: () => unknown) => { + createRemoteProvider = factory + } + + class RemoteAdapterMock { + readonly providerId: string + readonly name: string + readonly type = 'remote' as const + readonly rpcUrl: string + readonly icon: string | undefined + readonly provider: ReturnType + readonly detect: ReturnType + readonly teardown: ReturnType + + constructor(config: { + providerId?: string | undefined + rpcUrl: string + name: string + icon?: string | undefined + }) { + this.providerId = config.providerId ?? `remote:${config.rpcUrl}` + this.name = config.name + this.rpcUrl = config.rpcUrl + this.icon = config.icon + this.provider = vi.fn(() => { + if (!createRemoteProvider) { + throw new Error('Remote provider factory not configured') + } + return createRemoteProvider() + }) + this.detect = vi.fn().mockResolvedValue(true) + this.teardown = vi.fn() + remoteAdapterInstances.push({ + providerId: this.providerId, + rpcUrl: this.rpcUrl, + name: this.name, + provider: this.provider, + }) + } + + getInfo() { + return { + providerId: this.providerId, + name: this.name, + type: this.type, + url: this.rpcUrl, + reuseGlobalWalletPopup: true, + } + } + } + + return { + mockRequestAnnouncedProviders: vi.fn(), + mockNotifyWalletPickerConnected: vi.fn(), + mockNotifyWalletPickerError: vi.fn(), + mockWaitForWalletPickerRetrySelection: vi.fn(), + mockClearAllLocalState: vi.fn(), + remoteAdapterInstances, + RemoteAdapterMock, + setRemoteProviderFactory, + } +}) + +vi.mock('./announce-discovery', () => ({ + requestAnnouncedProviders: mockRequestAnnouncedProviders, +})) + +vi.mock('./util', () => ({ + clearAllLocalState: mockClearAllLocalState, +})) + +vi.mock('./adapter/remote-adapter', () => ({ + RemoteAdapter: RemoteAdapterMock, +})) + +vi.mock('@canton-network/core-wallet-ui-components', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@canton-network/core-wallet-ui-components') + >() + return { + ...actual, + notifyWalletPickerConnected: mockNotifyWalletPickerConnected, + notifyWalletPickerError: mockNotifyWalletPickerError, + waitForWalletPickerRetrySelection: + mockWaitForWalletPickerRetrySelection, + } +}) + +type MockProvider = { + request: Mock['request']> + on: Mock['on']> + removeListener: Mock['removeListener']> +} + +const prepareExecuteParams: PrepareExecuteParams = { commands: [] } +const signMessageParams: SignMessageParams = { message: 'hello' } +const ledgerApiParams: LedgerApiParams = { + requestMethod: 'get', + resource: '/v2/state', +} + +const connectedStatus = ( + overrides: Partial = {} +): StatusEvent => ({ + provider: { + id: 'remote:test', + providerType: 'remote', + url: 'https://gateway.test', + userUrl: 'https://gateway.test/user', + }, + connection: { + isConnected: true, + isNetworkConnected: true, + reason: 'OK', + networkReason: 'OK', + }, + ...overrides, +}) + +const connectedResult = (): ConnectResult => connectedStatus().connection + +const makeMockProvider = ( + overrides: Partial = {} +): MockProvider => { + const provider: MockProvider = { + request: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + ...overrides, + } + + provider.request.mockImplementation(async ({ method }) => { + switch (method) { + case 'connect': + return connectedResult() + case 'status': + return connectedStatus() + case 'isConnected': + return connectedResult() + case 'disconnect': + return null + case 'listAccounts': + return [] satisfies ListAccountsResult + case 'prepareExecute': + return null + case 'prepareExecuteAndWait': + return { + tx: { commandId: 'cmd-1', status: 'executed' }, + } satisfies PrepareExecuteAndWaitResult + case 'signMessage': + return { signature: 'signed' } satisfies SignMessageResult + case 'ledgerApi': + return { ok: true } satisfies LedgerApiResult + default: + throw new Error(`unexpected method ${String(method)}`) + } + }) + + return provider +} + +const asProvider = (mock: MockProvider): Provider => + mock as unknown as Provider + +type MockAdapterOptions = { + providerId?: string + name?: string + type?: 'remote' | 'browser' + url?: string + provider?: MockProvider + detect?: boolean +} + +const makeMockAdapter = (options: MockAdapterOptions = {}): ProviderAdapter => { + const providerId = options.providerId ?? 'remote:test' + const name = options.name ?? 'Test Wallet' + const type = options.type ?? 'remote' + const url = options.url ?? 'https://gateway.test' + const mockProvider = options.provider ?? makeMockProvider() + + return { + providerId, + name, + type, + getInfo: () => ({ + providerId, + name, + type, + url, + reuseGlobalWalletPopup: false, + }), + detect: vi + .fn() + .mockResolvedValue(options.detect ?? true), + provider: vi + .fn() + .mockReturnValue(asProvider(mockProvider)), + teardown: vi.fn(), + } +} + +const makeListener = (): EventListener => + vi.fn>() as EventListener + +const getDiscovery = (sdk: DappSDK) => + (sdk as unknown as { discovery: { listAdapters(): ProviderAdapter[] } }) + .discovery + +describe('DappSDK', () => { + beforeEach(() => { + localStorage.clear() + remoteAdapterInstances.length = 0 + setRemoteProviderFactory(() => asProvider(makeMockProvider())) + mockRequestAnnouncedProviders.mockResolvedValue([]) + mockWaitForWalletPickerRetrySelection.mockReset() + mockNotifyWalletPickerConnected.mockReset() + mockNotifyWalletPickerError.mockReset() + mockClearAllLocalState.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('disconnected state', () => { + it('reports disconnected state when no client is active', async () => { + const sdk = new DappSDK() + + await expect(sdk.isConnected()).resolves.toEqual({ + isConnected: false, + isNetworkConnected: false, + reason: 'Unauthenticated', + networkReason: 'Unauthenticated', + }) + }) + + it('disconnects cleanly when not connected', async () => { + const sdk = new DappSDK() + + await expect(sdk.disconnect()).resolves.toBeNull() + }) + + it('returns null when no provider session exists', () => { + const sdk = new DappSDK() + + expect(sdk.getConnectedProvider()).toBeNull() + }) + + it('requires an active client for RPC helpers', async () => { + const sdk = new DappSDK() + + await expect(sdk.status()).rejects.toThrow( + 'Not connected — call connect() first' + ) + await expect(sdk.listAccounts()).rejects.toThrow( + 'Not connected — call connect() first' + ) + await expect( + sdk.prepareExecute(prepareExecuteParams) + ).rejects.toThrow('Not connected — call connect() first') + await expect( + sdk.prepareExecuteAndWait(prepareExecuteParams) + ).rejects.toThrow('Not connected — call connect() first') + await expect(sdk.signMessage(signMessageParams)).rejects.toThrow( + 'Not connected — call connect() first' + ) + await expect(sdk.ledgerApi(ledgerApiParams)).rejects.toThrow( + 'Not connected — call connect() first' + ) + await expect(sdk.open()).rejects.toThrow( + 'Not connected — call connect() first' + ) + }) + + it('ignores removeListener calls when no client is active', async () => { + const sdk = new DappSDK() + const statusListener = makeListener() + const accountsListener = makeListener() + const txListener = makeListener() + const signatureListener = makeListener() + + await sdk.removeOnStatusChanged(statusListener) + await sdk.removeOnAccountsChanged(accountsListener) + await sdk.removeOnConnected(statusListener) + await sdk.removeOnTxChanged(txListener) + await sdk.removeOnMessageSignature(signatureListener) + }) + }) + + describe('init', () => { + it('registers configured adapters and queries announced providers', async () => { + const adapter = makeMockAdapter() + const sdk = new DappSDK() + + await sdk.init({ defaultAdapters: [adapter] }) + + expect(mockRequestAnnouncedProviders).toHaveBeenCalled() + expect(getDiscovery(sdk).listAdapters()).toEqual( + expect.arrayContaining([adapter]) + ) + }) + + it('uses the persisted remote gateway URL when no options are passed', async () => { + storage.setKernelDiscovery({ + walletType: 'remote', + url: 'https://gateway.test', + }) + + const sdk = new DappSDK() + await sdk.init() + + const adapterIds = getDiscovery(sdk) + .listAdapters() + .map((adapter) => adapter.providerId) + expect(adapterIds).toContain('remote:https://gateway.test') + }) + + it('serializes concurrent init calls', async () => { + const adapter = makeMockAdapter() + const sdk = new DappSDK() + + await Promise.all([ + sdk.init({ defaultAdapters: [adapter] }), + sdk.init({ defaultAdapters: [adapter] }), + ]) + + expect(getDiscovery(sdk).listAdapters()).toHaveLength(1) + }) + }) + + describe('connect', () => { + it('connects through the wallet picker and persists remote discovery', async () => { + const adapter = makeMockAdapter() + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }), + }) + + await sdk.init({ defaultAdapters: [adapter] }) + await expect(sdk.connect()).resolves.toEqual(connectedResult()) + + expect(mockClearAllLocalState).toHaveBeenCalled() + expect(mockNotifyWalletPickerConnected).toHaveBeenCalledWith(false) + expect(storage.getKernelDiscovery()).toEqual({ + walletType: 'remote', + url: 'https://gateway.test', + }) + expect( + JSON.parse( + localStorage.getItem('splice_wallet_picker_recent') ?? '[]' + ) + ).toEqual([{ name: 'Test Wallet', rpcUrl: 'https://gateway.test' }]) + expect(sdk.getConnectedProvider()).toBe(adapter.provider()) + }) + + it('registers a dynamic remote adapter for custom gateway URLs', async () => { + const provider = makeMockProvider() + setRemoteProviderFactory(() => asProvider(provider)) + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'custom-entry', + name: 'Custom Gateway', + type: 'remote', + url: 'https://custom.gateway.test', + }), + }) + + await sdk.init({ defaultAdapters: [] }) + await sdk.connect() + + expect(remoteAdapterInstances).toEqual([ + expect.objectContaining({ + providerId: 'remote:https://custom.gateway.test', + rpcUrl: 'https://custom.gateway.test', + name: 'Custom Gateway', + }), + ]) + expect(provider.request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'connect' }) + ) + }) + + it('retries after a connection failure and appends HTTP status details', async () => { + const provider = makeMockProvider() + let connectCalls = 0 + const defaultProvider = makeMockProvider() + provider.request.mockImplementation(async (args) => { + if (args.method === 'connect') { + connectCalls += 1 + if (connectCalls === 1) { + throw Object.assign(new Error('Gateway unavailable'), { + status: 503, + }) + } + } + return defaultProvider.request(args) + }) + + const adapter = makeMockAdapter({ provider }) + mockWaitForWalletPickerRetrySelection.mockResolvedValue({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }) + + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }), + }) + + await sdk.init({ defaultAdapters: [adapter] }) + await expect(sdk.connect()).resolves.toEqual(connectedResult()) + + expect(mockNotifyWalletPickerError).toHaveBeenCalledWith( + 'Gateway unavailable (HTTP 503)' + ) + expect(connectCalls).toBe(2) + }) + + it('rejects when the user cancels the retry picker', async () => { + const provider = makeMockProvider() + provider.request.mockImplementation(async (args) => { + if (args.method === 'connect') { + throw new Error('User rejected') + } + return makeMockProvider().request(args) + }) + + const adapter = makeMockAdapter({ provider }) + const retryError = new Error('User cancelled') + mockWaitForWalletPickerRetrySelection.mockRejectedValue(retryError) + + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }), + }) + + await sdk.init({ defaultAdapters: [adapter] }) + await expect(sdk.connect()).rejects.toThrow('User cancelled') + }) + + it('supports the deprecated connect(options) entrypoint', async () => { + const adapter = makeMockAdapter() + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }), + }) + + await expect( + sdk.connect({ defaultAdapters: [adapter] }) + ).resolves.toEqual(connectedResult()) + }) + }) + + describe('connected client delegation', () => { + const connectSdk = async ( + providerOverrides: Partial = {} + ) => { + const provider = makeMockProvider(providerOverrides) + const adapter = makeMockAdapter({ provider }) + const sdk = new DappSDK({ + walletPicker: async () => ({ + providerId: 'remote:test', + name: 'Test Wallet', + type: 'remote', + }), + }) + + await sdk.init({ defaultAdapters: [adapter] }) + await sdk.connect() + return { sdk, provider, adapter } + } + + it('delegates RPC helpers to the active client', async () => { + const { sdk, provider } = await connectSdk() + + await expect(sdk.status()).resolves.toEqual(connectedStatus()) + await expect(sdk.listAccounts()).resolves.toEqual([]) + await expect( + sdk.prepareExecute(prepareExecuteParams) + ).resolves.toBeNull() + await expect( + sdk.prepareExecuteAndWait(prepareExecuteParams) + ).resolves.toEqual({ + tx: { commandId: 'cmd-1', status: 'executed' }, + }) + await expect(sdk.signMessage(signMessageParams)).resolves.toEqual({ + signature: 'signed', + }) + await expect(sdk.ledgerApi(ledgerApiParams)).resolves.toEqual({ + ok: true, + }) + await expect(sdk.isConnected()).resolves.toEqual(connectedResult()) + await sdk.open() + await sdk.disconnect() + + expect(provider.request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'status' }) + ) + expect(provider.request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'disconnect' }) + ) + }) + + it('registers and removes event listeners on the active client', async () => { + const { sdk, provider } = await connectSdk() + const statusListener = makeListener() + const accountsListener = makeListener() + const txListener = makeListener() + const signatureListener = makeListener() + + await sdk.onStatusChanged(statusListener) + await sdk.onAccountsChanged(accountsListener) + await sdk.onConnected(statusListener) + await sdk.onTxChanged(txListener) + await sdk.onMessageSignature(signatureListener) + + expect(provider.on).toHaveBeenCalledWith( + 'statusChanged', + statusListener + ) + expect(provider.on).toHaveBeenCalledWith( + 'accountsChanged', + accountsListener + ) + expect(provider.on).toHaveBeenCalledWith( + 'messageSignature', + signatureListener + ) + + await sdk.removeOnStatusChanged(statusListener) + await sdk.removeOnAccountsChanged(accountsListener) + await sdk.removeOnConnected(statusListener) + await sdk.removeOnTxChanged(txListener) + await sdk.removeOnMessageSignature(signatureListener) + + expect(provider.removeListener).toHaveBeenCalledWith( + 'statusChanged', + statusListener + ) + expect(provider.removeListener).toHaveBeenCalledWith( + 'accountsChanged', + accountsListener + ) + }) + }) + + it('exposes detached helper functions from sdk object', async () => { + const statusListener = makeListener() + const accountsListener = makeListener() + const txListener = makeListener() + const signatureListener = makeListener() + + const initSpy = vi.spyOn(sdk, 'init').mockResolvedValue() + const connectSpy = vi + .spyOn(sdk, 'connect') + .mockResolvedValue(connectedResult()) + const disconnectSpy = vi + .spyOn(sdk, 'disconnect') + .mockResolvedValue(null) + const isConnectedSpy = vi + .spyOn(sdk, 'isConnected') + .mockResolvedValue(connectedResult()) + const getConnectedProviderSpy = vi + .spyOn(sdk, 'getConnectedProvider') + .mockReturnValue(null) + const statusSpy = vi + .spyOn(sdk, 'status') + .mockResolvedValue(connectedStatus()) + const listAccountsSpy = vi + .spyOn(sdk, 'listAccounts') + .mockResolvedValue([]) + const prepareExecuteSpy = vi + .spyOn(sdk, 'prepareExecute') + .mockResolvedValue(null) + const prepareExecuteAndWaitSpy = vi + .spyOn(sdk, 'prepareExecuteAndWait') + .mockResolvedValue({ + tx: { + commandId: 'cmd-1', + status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, + }, + }) + const ledgerApiSpy = vi + .spyOn(sdk, 'ledgerApi') + .mockResolvedValue({ ok: true }) + const openSpy = vi.spyOn(sdk, 'open').mockResolvedValue() + const onStatusChangedSpy = vi + .spyOn(sdk, 'onStatusChanged') + .mockResolvedValue() + const onAccountsChangedSpy = vi + .spyOn(sdk, 'onAccountsChanged') + .mockResolvedValue() + const onConnectedSpy = vi.spyOn(sdk, 'onConnected').mockResolvedValue() + const onTxChangedSpy = vi.spyOn(sdk, 'onTxChanged').mockResolvedValue() + const onMessageSignatureSpy = vi + .spyOn(sdk, 'onMessageSignature') + .mockResolvedValue() + const removeOnStatusChangedSpy = vi + .spyOn(sdk, 'removeOnStatusChanged') + .mockResolvedValue() + const removeOnAccountsChangedSpy = vi + .spyOn(sdk, 'removeOnAccountsChanged') + .mockResolvedValue() + const removeOnConnectedSpy = vi + .spyOn(sdk, 'removeOnConnected') + .mockResolvedValue() + const removeOnTxChangedSpy = vi + .spyOn(sdk, 'removeOnTxChanged') + .mockResolvedValue() + const removeOnMessageSignatureSpy = vi + .spyOn(sdk, 'removeOnMessageSignature') + .mockResolvedValue() + + await init() + expect(initSpy).toHaveBeenCalled() + + await connect() + expect(connectSpy).toHaveBeenCalled() + + await disconnect() + expect(disconnectSpy).toHaveBeenCalled() + + await isConnected() + expect(isConnectedSpy).toHaveBeenCalled() + + getConnectedProvider() + expect(getConnectedProviderSpy).toHaveBeenCalled() + + await status() + expect(statusSpy).toHaveBeenCalled() + + await listAccounts() + expect(listAccountsSpy).toHaveBeenCalled() + + await prepareExecute(prepareExecuteParams) + expect(prepareExecuteSpy).toHaveBeenCalledWith(prepareExecuteParams) + + await prepareExecuteAndWait(prepareExecuteParams) + expect(prepareExecuteAndWaitSpy).toHaveBeenCalledWith( + prepareExecuteParams + ) + + await ledgerApi(ledgerApiParams) + expect(ledgerApiSpy).toHaveBeenCalledWith(ledgerApiParams) + + await open() + expect(openSpy).toHaveBeenCalled() + + await onStatusChanged(statusListener) + expect(onStatusChangedSpy).toHaveBeenCalledWith(statusListener) + + await onAccountsChanged(accountsListener) + expect(onAccountsChangedSpy).toHaveBeenCalledWith(accountsListener) + + await onConnected(statusListener) + expect(onConnectedSpy).toHaveBeenCalledWith(statusListener) + + await onTxChanged(txListener) + expect(onTxChangedSpy).toHaveBeenCalledWith(txListener) + + await onMessageSignature(signatureListener) + expect(onMessageSignatureSpy).toHaveBeenCalledWith(signatureListener) + + await removeOnStatusChanged(statusListener) + expect(removeOnStatusChangedSpy).toHaveBeenCalledWith(statusListener) + + await removeOnAccountsChanged(accountsListener) + expect(removeOnAccountsChangedSpy).toHaveBeenCalledWith( + accountsListener + ) + + await removeOnConnected(statusListener) + expect(removeOnConnectedSpy).toHaveBeenCalledWith(statusListener) + + await removeOnTxChanged(txListener) + expect(removeOnTxChangedSpy).toHaveBeenCalledWith(txListener) + + await removeOnMessageSignature(signatureListener) + expect(removeOnMessageSignatureSpy).toHaveBeenCalledWith( + signatureListener + ) + }) +}) From ae0b8a3a3d6b0afb91cd34e721f565cbef82dc86 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Fri, 12 Jun 2026 15:45:35 +0200 Subject: [PATCH 10/11] fix ts Signed-off-by: Pawel Stepien --- .../src/adapter/walletconnect-adapter.test.ts | 8 +++++++- sdk/dapp-sdk/src/sdk.test.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts index 055d3e3c8..6067dff2f 100644 --- a/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts +++ b/sdk/dapp-sdk/src/adapter/walletconnect-adapter.test.ts @@ -245,6 +245,7 @@ describe('WalletConnectAdapter', () => { mockSignClient.request.mockResolvedValueOnce({ commandId: 'cmd-1', status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, }) await expect( @@ -253,7 +254,11 @@ describe('WalletConnectAdapter', () => { params: { commands: [] }, }) ).resolves.toEqual({ - tx: { commandId: 'cmd-1', status: 'executed' }, + tx: { + commandId: 'cmd-1', + status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, + }, }) expect(mockSignClient.request).toHaveBeenCalledWith({ @@ -267,6 +272,7 @@ describe('WalletConnectAdapter', () => { expect(txListener).toHaveBeenCalledWith({ commandId: 'cmd-1', status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, }) }) diff --git a/sdk/dapp-sdk/src/sdk.test.ts b/sdk/dapp-sdk/src/sdk.test.ts index 6976cffa3..2461f3ae2 100644 --- a/sdk/dapp-sdk/src/sdk.test.ts +++ b/sdk/dapp-sdk/src/sdk.test.ts @@ -224,7 +224,11 @@ const makeMockProvider = ( return null case 'prepareExecuteAndWait': return { - tx: { commandId: 'cmd-1', status: 'executed' }, + tx: { + commandId: 'cmd-1', + status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, + }, } satisfies PrepareExecuteAndWaitResult case 'signMessage': return { signature: 'signed' } satisfies SignMessageResult @@ -573,7 +577,11 @@ describe('DappSDK', () => { await expect( sdk.prepareExecuteAndWait(prepareExecuteParams) ).resolves.toEqual({ - tx: { commandId: 'cmd-1', status: 'executed' }, + tx: { + commandId: 'cmd-1', + status: 'executed', + payload: { updateId: '1', completionOffset: 1 }, + }, }) await expect(sdk.signMessage(signMessageParams)).resolves.toEqual({ signature: 'signed', From 752b5d6b549c551c980ac9e6624153d284b5d957 Mon Sep 17 00:00:00 2001 From: Pawel Stepien Date: Tue, 16 Jun 2026 17:51:29 +0200 Subject: [PATCH 11/11] cleanup vitest config Signed-off-by: Pawel Stepien --- sdk/dapp-sdk/vitest.config.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/sdk/dapp-sdk/vitest.config.ts b/sdk/dapp-sdk/vitest.config.ts index 65e062e08..c8a4c108a 100644 --- a/sdk/dapp-sdk/vitest.config.ts +++ b/sdk/dapp-sdk/vitest.config.ts @@ -9,22 +9,17 @@ export default defineConfig({ globalSetup: ['./vitest.global-setup.ts'], coverage: { include: ['src/**/*.ts'], - exclude: [ - 'src/integration-test/**', - 'src/**/*.test.ts', - 'src/dapp-api/rpc-gen/**', - ], + exclude: ['src/integration-test/**', 'src/dapp-api/rpc-gen/**'], provider: 'v8', reporter: ['text', 'html', 'lcov'], thresholds: { - lines: 0, - functions: 0, - branches: 0, - statements: 0, + lines: 80, + functions: 80, + branches: 70, + statements: 80, }, }, environment: 'node', - // include: [], projects: [ defineProject({ test: {