test: playwright e2e setup with onboarding, navigation and receive specs

This commit is contained in:
2026-08-19 21:36:48 +02:00
parent 1839700215
commit 05fc40263b
12 changed files with 342 additions and 1 deletions
+12
View File
@@ -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 };
+73
View File
@@ -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' });
},
);
}
}
+12
View File
@@ -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(/#\/$/);
};
+19
View File
@@ -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();
});
});
+26
View File
@@ -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();
});
});
+61
View File
@@ -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');
});
});
+10
View File
@@ -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": []
}