diff --git a/.github/CI.md b/.github/CI.md index 6eb153d55..46c5b997e 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -20,6 +20,8 @@ The CI workflow runs on pull request events: | `build-docs` | Documentation build | Always | `needs: build` | Sphinx docs build for wallet integration guide | | `ping-e2e` | E2E worker (ping app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-ping` | | `test-ping-e2e` | Aggregator/reporting | Always | `needs: ping-e2e`, `if: always()` | fails if `ping-e2e` did not succeed | +| `extension-e2e` | E2E worker (browser extension) | Always | `needs: [version-config, build, hydrate-canton-caches]` | loads the MV3 extension and runs its OAuth, party creation, Ping, and approval flow on devnet | +| `test-extension-e2e` | Aggregator/reporting | Always | `needs: extension-e2e`, `if: always()` | fails if `extension-e2e` did not succeed | | `portfolio-e2e` | E2E worker (portfolio app) | Always | `needs: build` | starts Canton+services and runs Playwright for `@canton-network/example-portfolio` | | `test-portfolio-e2e` | Aggregator/reporting | Always | `needs: portfolio-e2e`, `if: always()` | fails if `portfolio-e2e` did not succeed | | `wallet-sdk-snippets-e2e` | Wallet SDK snippets E2E | Always | `needs: build` | snippet tests on matrix `devnet` + `mainnet` | @@ -29,4 +31,4 @@ The CI workflow runs on pull request events: | `test-migrations` | SQL migration tests | Conditional | `needs: build` | `pnpm nx affected -t test:migrations --base=origin/${{ github.base_ref }} --head=HEAD --parallel=1` | | `check-migration-lock` | Test SQL migration immutability | Conditional | None // TODO verify if doesnt need build | `pnpm nx affected -t migrations:check-lock --base=origin/${{ github.base_ref }} --head=HEAD --parallel` | -The workflow uses aggregator wrappers (`test-ping-e2e`, `test-portfolio-e2e`, and `test-wallet-sdk-e2e`) that always run and validate the success of the corresponding worker jobs. +The workflow uses aggregator wrappers (`test-ping-e2e`, `test-extension-e2e`, `test-portfolio-e2e`, and `test-wallet-sdk-e2e`) that always run and validate the success of the corresponding worker jobs. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0850cb0ef..b57c6d72e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -366,6 +366,51 @@ jobs: fi echo "test-ping-e2e passed" + extension-e2e: + name: extension-e2e (devnet) + runs-on: ubuntu-latest + needs: [version-config, build, hydrate-canton-caches] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v7.0.0 + + - uses: ./.github/actions/setup_pnpm/artifacts + with: + daml_release_version: ${{ needs.version-config.outputs.daml_release_version }} + + - uses: ./.github/actions/setup_canton/artifacts + with: + network: devnet + canton_version: ${{ needs.version-config.outputs.devnet_canton_version }} + + - uses: ./.github/actions/setup_playwright + + - name: Build extension + run: pnpm --filter @canton-network/wallet-gateway-extension build + + - name: Run extension e2e + run: pnpm --filter @canton-network/wallet-gateway-extension e2e + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: wallet-gateway-extension-playwright-report + path: wallet-gateway/extension/playwright-report/ + retention-days: 5 + + test-extension-e2e: + runs-on: ubuntu-latest + needs: extension-e2e + if: always() + steps: + - name: Report extension e2e execution + run: | + if [ "${{ needs.extension-e2e.result }}" != "success" ]; then + echo "extension-e2e did not succeed" + exit 1 + fi + echo "test-extension-e2e passed" + portfolio-e2e: name: portfolio-e2e (${{ matrix.network }}) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b060dc5f8..318fce9e4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,12 @@ generated/ storybook-static -# unit tests output +# tests output coverage/ __screenshots__/ .vitest-attachments/ +playwright-report/ +test-results/ **/target/ diff --git a/core/wallet-test-utils/src/index.ts b/core/wallet-test-utils/src/index.ts index dbec154f1..252e61c0e 100644 --- a/core/wallet-test-utils/src/index.ts +++ b/core/wallet-test-utils/src/index.ts @@ -3,5 +3,6 @@ export { OTCTrade } from './otc-trade.js' export * from './wallet-gateway.js' +export * from './wallet-picker.js' export { test, expect } from './fixtures.js' export * from './signing-provider-mocks/index.js' diff --git a/core/wallet-test-utils/src/wallet-gateway.ts b/core/wallet-test-utils/src/wallet-gateway.ts index c9becd51a..3f67ce55b 100644 --- a/core/wallet-test-utils/src/wallet-gateway.ts +++ b/core/wallet-test-utils/src/wallet-gateway.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, Locator, Page, test } from '@playwright/test' +import { openWalletPicker } from './wallet-picker.js' export interface NetworkFormInput { id: string @@ -114,9 +115,10 @@ export class WalletGateway { 'the dApp should offer a way to connect a wallet' ).toBeVisible() - const discoverPopupPromise = dapp.dappPage.waitForEvent('popup') - await connectButton.click() - const pickerPopup = await discoverPopupPromise + const pickerPopup = await openWalletPicker( + dapp.dappPage, + connectButton + ) await this.selectFromWalletPicker(pickerPopup, args.customURL) diff --git a/core/wallet-test-utils/src/wallet-picker.ts b/core/wallet-test-utils/src/wallet-picker.ts new file mode 100644 index 000000000..96d938c0f --- /dev/null +++ b/core/wallet-test-utils/src/wallet-picker.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Locator, Page } from '@playwright/test' + +export async function openWalletPicker( + dappPage: Page, + connectButton: Locator +): Promise { + const pickerPopup = dappPage.waitForEvent('popup') + await connectButton.click() + return pickerPopup +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8f749f6..b56ac6fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2647,6 +2647,12 @@ importers: specifier: ^10.3.1 version: 10.3.1 devDependencies: + '@canton-network/core-wallet-test-utils': + specifier: workspace:^ + version: link:../../core/wallet-test-utils + '@playwright/test': + specifier: ^1.61.1 + version: 1.62.1 '@wxt-dev/module-react': specifier: ^1.1.5 version: 1.2.2(supports-color@10.2.2)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(wxt@0.21.4(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(oxc-parser@0.127.0)(rolldown@1.2.2)(rollup@4.62.4)(typescript@5.9.3)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(web-ext@10.6.0(express@5.2.1(supports-color@10.2.2))(jiti@2.7.0)(supports-color@10.2.2))) diff --git a/wallet-gateway/extension/package.json b/wallet-gateway/extension/package.json index bc17466e4..c579ec9ea 100644 --- a/wallet-gateway/extension/package.json +++ b/wallet-gateway/extension/package.json @@ -14,6 +14,8 @@ "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "compile": "tsc --noEmit", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", "postinstall": "wxt prepare" }, "dependencies": { @@ -36,9 +38,26 @@ "pino": "^10.3.1" }, "devDependencies": { + "@canton-network/core-wallet-test-utils": "workspace:^", + "@playwright/test": "^1.61.1", "@wxt-dev/module-react": "^1.1.5", "typescript": "^5.9.3", "web-ext": "^10.5.0", "wxt": "^0.21.3" + }, + "nx": { + "targets": { + "build": { + "outputs": [ + "{projectRoot}/.output" + ] + }, + "playwright:e2e": { + "dependsOn": [ + "build", + "^build" + ] + } + } } } diff --git a/wallet-gateway/extension/playwright.config.ts b/wallet-gateway/extension/playwright.config.ts new file mode 100644 index 000000000..4aeffd292 --- /dev/null +++ b/wallet-gateway/extension/playwright.config.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + timeout: 120 * 1000, + expect: { + timeout: 15_000, + }, + testDir: './tests/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + trace: process.env.CI ? 'on-first-retry' : 'on', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium' }], + webServer: [ + { + command: + 'pnpm --filter @canton-network/example-ping dev -- --port 8080 --strictPort', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm --filter @canton-network/mock-oauth2 start', + url: 'http://127.0.0.1:8889/.well-known/openid-configuration', + reuseExistingServer: !process.env.CI, + timeout: 30 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}) diff --git a/wallet-gateway/extension/tests/e2e/connect-ping.spec.ts b/wallet-gateway/extension/tests/e2e/connect-ping.spec.ts new file mode 100644 index 000000000..312efc834 --- /dev/null +++ b/wallet-gateway/extension/tests/e2e/connect-ping.spec.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from './fixtures.js' +import { ExtensionPage, PingPage } from './pages/index.js' + +test('creates and approves a Ping contract with the Canton Wallet extension', async ({ + dappPage, + extensionId, + extensionPage, +}) => { + const extension = new ExtensionPage(extensionPage, extensionId) + const ping = new PingPage(dappPage) + const partyHint = `extension-e2e-${Date.now()}` + + await test.step('log in to the extension with local OAuth', async () => { + await extension.loginWithLocalOAuth() + }) + + const partyId = + await test.step('create an allocated primary wallet-kernel party', () => + extension.createPrimaryParty(partyHint)) + + await test.step('connect Ping to the installed extension', async () => { + await ping.expectDisconnected() + await ping.connectToExtension() + }) + + await test.step('Ping lists the extension primary party', async () => { + await ping.expectAccount(partyId) + }) + + await test.step('prepare a Ping contract for approval', async () => { + await ping.preparePingContract() + }) + + const commandId = + await test.step('open the queued approval through the extension popup', () => + extension.openNextApproval()) + + await test.step('approve and execute the Ping contract', async () => { + await extension.approvePendingPing(partyId, commandId) + }) + + await test.step('Ping reports no submission failure', async () => { + await ping.expectNoSubmissionError() + }) +}) diff --git a/wallet-gateway/extension/tests/e2e/fixtures.ts b/wallet-gateway/extension/tests/e2e/fixtures.ts new file mode 100644 index 000000000..8d0bb43c5 --- /dev/null +++ b/wallet-gateway/extension/tests/e2e/fixtures.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { access } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + chromium, + test as base, + type BrowserContext, + type Page, + type Worker, +} from '@playwright/test' + +const extensionPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../.output/chrome-mv3' +) + +type ExtensionFixtures = { + context: BrowserContext + serviceWorker: Worker + extensionId: string + extensionPage: Page + dappPage: Page +} + +export const test = base.extend({ + context: async ({ headless }, use) => { + await access(extensionPath).catch(() => { + throw new Error( + `Extension build not found at ${extensionPath}. Run "pnpm --filter @canton-network/wallet-gateway-extension build" first.` + ) + }) + + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }) + + try { + await use(context) + } finally { + await context.close() + } + }, + + serviceWorker: async ({ context }, use, testInfo) => { + const errors: string[] = [] + const recordPageErrors = (page: Page) => { + page.on('pageerror', (error) => { + errors.push(`[page ${page.url()}] ${error.message}`) + }) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(`[console ${page.url()}] ${message.text()}`) + } + }) + } + context.pages().forEach(recordPageErrors) + context.on('page', recordPageErrors) + + const serviceWorker = + context.serviceWorkers()[0] ?? + (await context.waitForEvent('serviceworker')) + serviceWorker.on('console', (message) => { + if (message.type() === 'error') { + errors.push(`[service worker] ${message.text()}`) + } + }) + + await use(serviceWorker) + + if (testInfo.status !== testInfo.expectedStatus && errors.length > 0) { + await testInfo.attach('browser-errors', { + body: errors.join('\n'), + contentType: 'text/plain', + }) + } + }, + + extensionId: async ({ serviceWorker }, use) => { + await use(new URL(serviceWorker.url()).host) + }, + + extensionPage: async ({ context, extensionId }, use) => { + const page = await context.newPage() + await page.goto(`chrome-extension://${extensionId}/popup.html`) + await use(page) + }, + + dappPage: async ({ context }, use) => { + const page = await context.newPage() + await page.goto('http://localhost:8080') + await use(page) + }, +}) + +export const expect = test.expect diff --git a/wallet-gateway/extension/tests/e2e/pages/extension-page.ts b/wallet-gateway/extension/tests/e2e/pages/extension-page.ts new file mode 100644 index 000000000..4a773a5b4 --- /dev/null +++ b/wallet-gateway/extension/tests/e2e/pages/extension-page.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Page } from '@playwright/test' +import { expect } from '../fixtures.js' + +const PING_TEMPLATE = 'canton-builtin-admin-workflow-ping' + +export class ExtensionPage { + constructor( + private readonly page: Page, + private readonly extensionId: string + ) {} + + private extensionUrl(path: string): string { + return `chrome-extension://${this.extensionId}/${path}` + } + + async loginWithLocalOAuth(): Promise { + await this.page.waitForURL(/\/login\.html$/) + await expect( + this.page.getByRole('heading', { name: 'Wallet Gateway' }) + ).toBeVisible() + + const network = this.page.getByLabel('Select a network') + await expect(network).toBeEnabled() + await network.selectOption({ label: 'Local (OAuth IDP)' }) + await this.page.getByRole('button', { name: 'Connect' }).click() + + await this.page.waitForURL(/\/parties\.html(?:\?.*)?$/, { + timeout: 30_000, + }) + await expect( + this.page.getByRole('heading', { name: 'Parties' }) + ).toBeVisible() + } + + async createPrimaryParty(partyHint: string): Promise { + await this.page.goto(this.extensionUrl('parties-add.html')) + await expect( + this.page.getByRole('heading', { name: 'Create a new party' }) + ).toBeVisible() + + await this.page.getByLabel('Party ID Hint').fill(partyHint) + await this.page + .getByLabel('Signing Provider') + .selectOption('wallet-kernel') + await this.page.getByLabel('Set as primary wallet').check() + await this.page.getByRole('button', { name: 'Create party' }).click() + + await this.page.waitForURL(/\/parties\.html(?:\?.*)?$/, { + timeout: 60_000, + }) + await expect(this.page.getByText('Party created')).toBeVisible() + + const card = this.page + .locator('wg-wallet-card') + .filter({ hasText: partyHint }) + await expect(card).toHaveCount(1) + await expect(card.getByText('PRIMARY')).toBeVisible() + await expect(card.getByText('CanActAs')).toBeVisible() + + const partyId = await card.getAttribute('party-id') + if (!partyId?.startsWith(`${partyHint}::`)) { + throw new Error( + `Expected an allocated party ID for ${partyHint}, got ${partyId ?? 'none'}` + ) + } + return partyId + } + + async openNextApproval(): Promise { + await this.page.goto(this.extensionUrl('popup.html')) + await this.page.waitForURL(/\/approve\.html\?.*transactionId=/) + + const commandId = new URL(this.page.url()).searchParams.get('commandId') + if (!commandId) { + throw new Error('The approval URL did not contain a command ID') + } + return commandId + } + + async approvePendingPing( + partyId: string, + commandId: string + ): Promise { + await expect( + this.page.getByRole('heading', { name: 'Activity Details' }) + ).toBeVisible() + await expect( + this.page.getByText('pending', { exact: true }) + ).toBeVisible() + await expect( + this.page.getByText(commandId, { exact: true }) + ).toBeVisible() + await expect(this.page.getByText(PING_TEMPLATE)).toBeVisible() + await expect( + this.page.getByText(partyId, { exact: true }) + ).toBeVisible() + await expect( + this.page.getByRole('button', { name: 'Approve' }) + ).toBeEnabled() + + await this.page.getByRole('button', { name: 'Approve' }).click() + await expect( + this.page.getByText('Activity executed successfully') + ).toBeVisible({ timeout: 60_000 }) + await this.page.waitForURL( + (url) => !url.pathname.endsWith('/approve.html') + ) + } +} diff --git a/wallet-gateway/extension/tests/e2e/pages/index.ts b/wallet-gateway/extension/tests/e2e/pages/index.ts new file mode 100644 index 000000000..c0efd8dfb --- /dev/null +++ b/wallet-gateway/extension/tests/e2e/pages/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { ExtensionPage } from './extension-page.js' +export { PingPage } from './ping-page.js' diff --git a/wallet-gateway/extension/tests/e2e/pages/ping-page.ts b/wallet-gateway/extension/tests/e2e/pages/ping-page.ts new file mode 100644 index 000000000..c417d4220 --- /dev/null +++ b/wallet-gateway/extension/tests/e2e/pages/ping-page.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { openWalletPicker } from '@canton-network/core-wallet-test-utils' +import type { Locator, Page } from '@playwright/test' +import { expect } from '../fixtures.js' + +const CANTON_WALLET_GATEWAY_ID = 'browser:ext:canton-wallet' + +export class PingPage { + constructor(private readonly page: Page) {} + + async expectDisconnected(): Promise { + await expect(this.page).toHaveTitle(/Example dApp/) + await expect( + this.page.getByTestId('connection-indicator-disconnected') + ).toBeVisible() + await expect(this.page.getByTestId('connect-wallet')).toBeVisible() + } + + async connectToExtension(): Promise { + const picker = await openWalletPicker( + this.page, + this.page.getByTestId('connect-wallet') + ) + const wallet = picker.getByRole('button', { + name: 'Connect to Canton Wallet', + }) + await expect(wallet).toBeVisible() + await expect(picker.getByLabel('Install Canton Wallet')).toHaveCount(0) + + const pickerClosed = picker.waitForEvent('close') + await wallet.click() + await pickerClosed + + await expect(async () => { + expect(await this.page.getByTestId('connect-wallet').count()).toBe( + 0 + ) + expect(await this.page.getByTestId('open-wallet').isEnabled()).toBe( + true + ) + expect( + await this.page + .getByTestId('connection-indicator-connected') + .isVisible() + ).toBe(true) + expect( + await this.page.getByTestId('connected-gateway').textContent() + ).toBe(CANTON_WALLET_GATEWAY_ID) + expect(await this.page.locator('p.error').count()).toBe(0) + }).toPass({ timeout: 10_000 }) + } + + async expectAccount(partyId: string): Promise { + await this.page.getByRole('button', { name: 'Accounts' }).click() + const account = this.page + .getByRole('listitem') + .filter({ hasText: partyId }) + await expect(account).toBeVisible() + await expect(account).toContainText('(primary)') + } + + async preparePingContract(): Promise { + await this.page + .getByRole('button', { name: 'Ledger Submission' }) + .click() + const createButton = this.page.getByRole('button', { + name: 'create Ping contract', + exact: true, + }) + await expect(createButton).toBeEnabled() + await createButton.click() + await expect(createButton).toBeEnabled({ timeout: 30_000 }) + await expect(this.page.locator('p.error')).toHaveCount(0) + return createButton + } + + async expectNoSubmissionError(): Promise { + await expect(this.page.locator('p.error')).toHaveCount(0) + } +}