From 05fc40263bfafde596b093dafc9638a4c71f3eec Mon Sep 17 00:00:00 2001 From: protom Date: Wed, 19 Aug 2026 21:36:48 +0200 Subject: [PATCH] test: playwright e2e setup with onboarding, navigation and receive specs --- .gitignore | 4 ++ e2e/fixtures.ts | 12 ++++++ e2e/helpers/MintMocker.ts | 73 ++++++++++++++++++++++++++++++++++++ e2e/helpers/wallet.ts | 12 ++++++ e2e/specs/navigation.spec.ts | 19 ++++++++++ e2e/specs/onboarding.spec.ts | 26 +++++++++++++ e2e/specs/receive.spec.ts | 61 ++++++++++++++++++++++++++++++ e2e/tsconfig.json | 10 +++++ package-lock.json | 64 +++++++++++++++++++++++++++++++ package.json | 3 ++ playwright.config.ts | 45 ++++++++++++++++++++++ tsconfig.json | 14 ++++++- 12 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 e2e/fixtures.ts create mode 100644 e2e/helpers/MintMocker.ts create mode 100644 e2e/helpers/wallet.ts create mode 100644 e2e/specs/navigation.spec.ts create mode 100644 e2e/specs/onboarding.spec.ts create mode 100644 e2e/specs/receive.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 54404c5..4cb1ded 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ node_modules npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright (e2e/ itself is tracked - only its artifacts are ignored) +/playwright-report/ +/test-results/ diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..5090553 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,12 @@ +import { test as base, expect } from '@playwright/test'; +import { MintMocker } from './helpers/MintMocker'; + +export const test = base.extend<{ mint: MintMocker }>({ + mint: async ({ page }, use) => { + const mint = new MintMocker(page); + await use(mint); + await page.unrouteAll({ behavior: 'wait' }); + }, +}); + +export { expect }; diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts new file mode 100644 index 0000000..a16c68f --- /dev/null +++ b/e2e/helpers/MintMocker.ts @@ -0,0 +1,73 @@ +import type { Page, Route } from '@playwright/test'; + +// The mock mint lives on a .test origin that never resolves - every request +// to it is intercepted by Playwright and fulfilled in-process, so the specs +// never touch the network. Protocol shapes mirror lnurlcash-kit's client: +// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03 +// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the +// rotation GET on the callback (rotateNote) expects {status: "OK"}. +export const MINT_ORIGIN = 'https://mint.test'; +export const NOTE_PATH = '/note'; +export const CALLBACK_PATH = '/callback'; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const fulfillJson = async (route: Route, body: unknown): Promise => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + // the app runs on http://localhost:9333, so the mint is cross-origin - + // keep Chromium's CORS check on the fulfilled response happy + headers: { 'Access-Control-Allow-Origin': '*' }, + body: JSON.stringify(body), + }); +}; + +interface MockNoteInfoOptions { + // note value in msat, reported as maxWithdrawable + amountMsat?: number; + // when set, the mint answers the ERROR envelope with this reason instead + // (a reason matching /spent/i surfaces the "already been spent" UI) + spentReason?: string; +} + +export class MintMocker { + constructor(private page: Page) {} + + // The note's informational GET: a spec-shaped withdrawRequest, or the + // ERROR envelope for a note the mint considers spent. The kit validates + // that the service echoes back the queried k1, so read it off the request. + async mockNoteInfo(options: MockNoteInfoOptions): Promise { + await this.page.route( + new RegExp(`^${escapeRegExp(MINT_ORIGIN + NOTE_PATH)}\\?`), + async (route: Route) => { + if (options.spentReason !== undefined) { + await fulfillJson(route, { status: 'ERROR', reason: options.spentReason }); + return; + } + const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; + await fulfillJson(route, { + tag: 'withdrawRequest', + callback: `${MINT_ORIGIN}${CALLBACK_PATH}`, + k1, + minWithdrawable: options.amountMsat ?? 0, + maxWithdrawable: options.amountMsat ?? 0, + defaultDescription: 'mock mint note', + }); + }, + ); + } + + // The rotation callback GET (k1 + h params): confirm with the OK envelope. + // No sig is returned - a plain LUD-03-style service that speaks rotate but + // does not sign notes. + async mockRotateOk(): Promise { + await this.page.route( + new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`), + async (route: Route) => { + await fulfillJson(route, { status: 'OK' }); + }, + ); + } +} diff --git a/e2e/helpers/wallet.ts b/e2e/helpers/wallet.ts new file mode 100644 index 0000000..fd708d8 --- /dev/null +++ b/e2e/helpers/wallet.ts @@ -0,0 +1,12 @@ +import type { Page } from '@playwright/test'; + +// Drives onboarding end to end on a fresh browser context: /#/welcome -> +// "Create wallet" (empty password = stored unencrypted) -> confirm the +// recovery phrase -> lands unlocked on /#/ with a 0-sats balance. +export const createFreshWallet = async (page: Page): Promise => { + await page.goto('/#/welcome'); + await page.getByRole('button', { name: 'Create wallet' }).click(); + await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.waitForURL(/#\/$/); +}; diff --git a/e2e/specs/navigation.spec.ts b/e2e/specs/navigation.spec.ts new file mode 100644 index 0000000..ede630d --- /dev/null +++ b/e2e/specs/navigation.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '../fixtures'; + +test.describe('Navigation', () => { + test('hamburger opens Settings and the back button returns to the main page', async ({ + page, + }) => { + // fresh device: the main page shows the welcome card + await page.goto('/'); + await expect(page.getByText('Welcome to sattle')).toBeVisible(); + + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page).toHaveURL(/#\/settings$/); + await expect(page.getByText('Settings', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Back' }).click(); + await expect(page).toHaveURL(/#\/$/); + await expect(page.getByText('Welcome to sattle')).toBeVisible(); + }); +}); diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts new file mode 100644 index 0000000..00f7276 --- /dev/null +++ b/e2e/specs/onboarding.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../fixtures'; + +test.describe('Onboarding', () => { + test('creating a wallet lands on the unlocked main screen', async ({ page }) => { + await page.goto('/#/welcome'); + + // "Create new" is the default tab - its create button is right there + await expect(page.getByRole('button', { name: 'Create new' })).toBeVisible(); + await page.getByRole('button', { name: 'Create wallet' }).click(); + + // the recovery phrase is shown exactly once before the wallet opens + await expect(page.getByText('Your recovery phrase')).toBeVisible(); + await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // unlocked main screen: 0-sats balance card, the three actions, history + await expect(page).toHaveURL(/#\/$/); + const balanceCard = page.locator('.balance-card'); + await expect(balanceCard.locator('.text-h2')).toHaveText('0'); + await expect(balanceCard).toContainText('sats'); + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Scan' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + await expect(page.getByText('History', { exact: true })).toBeVisible(); + }); +}); diff --git a/e2e/specs/receive.spec.ts b/e2e/specs/receive.spec.ts new file mode 100644 index 0000000..ad55851 --- /dev/null +++ b/e2e/specs/receive.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; +import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; +import { createFreshWallet } from '../helpers/wallet'; + +const AMOUNT_MSAT = 21_000; // 21 sats + +// a syntactically valid bearer note against the mock mint - the k1 is a +// fresh random secret, so every test redeems a distinct note +const freshNoteUrl = (): string => + buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT); + +// main page -> Receive -> "Bearer note" -> paste -> redeem +const redeemNote = async (page: Page, noteUrl: string): Promise => { + await page.getByRole('button', { name: 'Receive' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await dialog.locator('textarea').fill(noteUrl); + await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); +}; + +test.describe('Receive bearer note', () => { + test('redeeming a valid note updates the balance', async ({ page, mint }) => { + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); + await mint.mockRotateOk(); + await createFreshWallet(page); + + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await redeemNote(page, freshNoteUrl()); + + // success screen, then the balance reflects the redeemed amount + await expect(dialog.getByText('Received 21 sats')).toBeVisible(); + // no "stored unconfirmed" / rotation banner: both mocked mint endpoints + // were really consumed (the kit bug this guards against silently fell + // back to the declared amount without any request) + await expect(dialog.locator('.q-banner')).toHaveCount(0); + await dialog.getByRole('button', { name: 'Done' }).click(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('21'); + }); + + test('a spent note shows the spent error and leaves the balance untouched', async ({ + page, + mint, + }) => { + await mint.mockNoteInfo({ spentReason: 'note already spent' }); + await createFreshWallet(page); + + await redeemNote(page, freshNoteUrl()); + + await expect( + page.locator('.q-banner', { hasText: 'This note has already been spent.' }), + ).toBeVisible(); + + // nothing was stored: back on the main page the balance is still 0 + await page.keyboard.press('Escape'); + await expect(page.locator('.balance-card .text-h2')).toHaveText('0'); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a10c056 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["."], + // the root config excludes "e2e" for the app typecheck; that inherited + // exclude would void this config's own inputs, so reset it here + "exclude": [] +} diff --git a/package-lock.json b/package-lock.json index 7bcbfd9..231513c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@intlify/unplugin-vue-i18n": "^11.0.0", + "@playwright/test": "^1.62.1", "@quasar/app-vite": "^3.7.0", "@types/node": "^22.19.11", "@vue/eslint-config-prettier": "^10.2.0", @@ -2153,6 +2154,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@quasar/app-vite": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@quasar/app-vite/-/app-vite-3.7.0.tgz", @@ -5900,6 +5917,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/package.json b/package.json index 487b857..3d23e70 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev": "quasar dev", "build": "quasar build", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "typecheck": "vue-tsc --noEmit", "postinstall": "quasar prepare --silent" }, @@ -34,6 +36,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@intlify/unplugin-vue-i18n": "^11.0.0", + "@playwright/test": "^1.62.1", "@quasar/app-vite": "^3.7.0", "@types/node": "^22.19.11", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f4e07da --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; +import { execSync } from 'child_process'; + +// no playwright-bundled chrome on this machine - use the system chromium +// (override with CHROMIUM_PATH when it lives elsewhere) +const findSystemChromium = (): string | undefined => { + try { + return execSync('command -v chromium').toString().trim() || undefined; + } catch { + return undefined; + } +}; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:9333', + trace: 'on-first-retry', + serviceWorkers: 'block', + launchOptions: { + executablePath: process.env.CHROMIUM_PATH || findSystemChromium(), + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], serviceWorkers: 'block' }, + }, + ], + + webServer: { + command: 'npx quasar dev -m spa --port 9333', + url: 'http://localhost:9333', + // quasar dev needs ~15-25s before vite starts answering + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 61fec8f..0f8291c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,17 @@ // these two extended flags. Keep them off so the core stays untouched. "exactOptionalPropertyTypes": false, "noUncheckedIndexedAccess": false - } + }, + // restates the inherited .quasar excludes (a local "exclude" replaces + // rather than merges) and adds e2e - the Playwright suite has its own + // tsconfig (e2e/tsconfig.json) + "exclude": [ + "dist", + "node_modules", + "src-capacitor", + "src-cordova", + "quasar.config.*.temporary.compiled*", + "src-pwa/sw", + "e2e" + ] }