Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ generated/

storybook-static

# unit tests output
# tests output
coverage/
__screenshots__/
.vitest-attachments/
playwright-report/
test-results/


**/target/
Expand Down
1 change: 1 addition & 0 deletions core/wallet-test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
8 changes: 5 additions & 3 deletions core/wallet-test-utils/src/wallet-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions core/wallet-test-utils/src/wallet-picker.ts
Original file line number Diff line number Diff line change
@@ -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<Page> {
const pickerPopup = dappPage.waitForEvent('popup')
await connectButton.click()
return pickerPopup
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions wallet-gateway/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
]
}
}
}
}
44 changes: 44 additions & 0 deletions wallet-gateway/extension/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
],
})
48 changes: 48 additions & 0 deletions wallet-gateway/extension/tests/e2e/connect-ping.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
103 changes: 103 additions & 0 deletions wallet-gateway/extension/tests/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionFixtures>({
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
Loading
Loading