mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
test: playwright e2e setup with onboarding, navigation and receive specs
This commit is contained in:
@@ -24,3 +24,7 @@ node_modules
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Playwright (e2e/ itself is tracked - only its artifacts are ignored)
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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<void> => {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.page.route(
|
||||||
|
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
|
||||||
|
async (route: Route) => {
|
||||||
|
await fulfillJson(route, { status: 'OK' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> => {
|
||||||
|
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(/#\/$/);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
Generated
+64
@@ -27,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@quasar/app-vite": "^3.7.0",
|
"@quasar/app-vite": "^3.7.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
@@ -2153,6 +2154,22 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@quasar/app-vite": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@quasar/app-vite/-/app-vite-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@quasar/app-vite/-/app-vite-3.7.0.tgz",
|
||||||
@@ -5900,6 +5917,53 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"dev": "quasar dev",
|
"dev": "quasar dev",
|
||||||
"build": "quasar build",
|
"build": "quasar build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"postinstall": "quasar prepare --silent"
|
"postinstall": "quasar prepare --silent"
|
||||||
},
|
},
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@quasar/app-vite": "^3.7.0",
|
"@quasar/app-vite": "^3.7.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
+13
-1
@@ -6,5 +6,17 @@
|
|||||||
// these two extended flags. Keep them off so the core stays untouched.
|
// these two extended flags. Keep them off so the core stays untouched.
|
||||||
"exactOptionalPropertyTypes": false,
|
"exactOptionalPropertyTypes": false,
|
||||||
"noUncheckedIndexedAccess": 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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user