Skip to content

Commit e14c7c8

Browse files
pepakrizclaude
andauthored
feat(e2e): add Playwright coverage, onboarding seed bridge, and CI (#68)
Adds Playwright e2e infrastructure over HTTPS with Phase 1 happy-path coverage for onboarding, payments, activity, and settings. Most specs skip onboarding via a dev/test-build-only window.__e2eSeedOnboarding bridge that calls the same production Task actions the UI does, since Playwright's storageState can't snapshot Evolu's OPFS-backed SQLite. Adds test:e2e:preview to run the suite against a one-off production build, and a labeled-PR GitHub Actions workflow to run it in CI. Claude-Session: https://claude.ai/code/session_01WgU1jAJx3RvxuwWUpbQwmp Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 988163f commit e14c7c8

26 files changed

Lines changed: 1268 additions & 102 deletions

.github/workflows/e2e.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: E2E
2+
3+
on:
4+
pull_request:
5+
types: [labeled, synchronize]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: e2e-${{ github.event.pull_request.number || github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
e2e:
14+
name: Playwright E2E (production build)
15+
if: >-
16+
github.event_name == 'workflow_dispatch' ||
17+
contains(github.event.pull_request.labels.*.name, 'run-e2e')
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: read
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: 24
30+
31+
- name: Setup Bun
32+
uses: oven-sh/setup-bun@v2
33+
34+
- name: Install dependencies
35+
run: bun install --frozen-lockfile
36+
37+
- name: Install Playwright browser
38+
run: bunx playwright install --with-deps chromium
39+
40+
# test:e2e:preview builds once (PAYKY_E2E_BUILD=1 vite build) and runs
41+
# the full Playwright suite against that build via `vite preview`,
42+
# instead of the dev server test:e2e uses.
43+
- name: Run e2e tests against a production build
44+
run: bun run test:e2e:preview
45+
46+
- name: Upload Playwright report
47+
if: ${{ !cancelled() }}
48+
uses: actions/upload-artifact@v4
49+
with:
50+
name: playwright-report
51+
path: playwright-report/
52+
retention-days: 7

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ coverage
3333
.data
3434
.claude
3535

36+
# Playwright
37+
playwright-report
38+
test-results
39+
blob-report
40+
3641
# Local environment (may contain secrets)
3742
.env
3843
.env.*

.mcp.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"mcpServers": {
3+
"playwright": {
4+
"command": "bunx",
5+
"args": ["@playwright/mcp@latest"]
6+
},
7+
"chrome-devtools": {
8+
"command": "bunx",
9+
"args": ["-y", "chrome-devtools-mcp@latest"]
10+
}
11+
}
12+
}

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
- `src/assets` contains static frontend assets.
3838
- `src/index.css` contains global Tailwind and theme styles.
3939
- `src/zod-utils.ts` contains app-level Zod helpers that are not specific to one domain module.
40+
- `e2e` contains Playwright end-to-end tests (`playwright.config.ts` at the repo root). See "E2E Testing" below for conventions.
4041

4142
## Domain Module Structure
4243

@@ -127,3 +128,17 @@
127128
- For aggregate extension/detail tables sharing the root id, soft delete only the root row unless the detail has its own lifecycle.
128129
- For CRDT actions, write tombstones and updates directly without preloading rows, unless current data is required for a domain invariant.
129130
- Pass Evolu mutation payloads through `removeUndefinedValues` to avoid extra or undefined fields.
131+
132+
## E2E Testing
133+
134+
- Run tests with `bun run test:e2e` (headless, dev server) or `test:e2e:ui` (interactive). `bun run test:e2e:preview` runs the same suite against a one-off production build instead: `PAYKY_E2E_BUILD=1 vite build` once, then Playwright's `webServer` runs `vite preview` (set via `PAYKY_E2E_SERVER=preview`, read in `playwright.config.ts`) rather than `bun run dev`. Tests live in `e2e/*.spec.ts`; `playwright.config.ts` at the repo root configures a single `chromium` project.
135+
- The Playwright `webServer` boots the real Vite dev server (or, for `test:e2e:preview`, `vite preview` serving a real build) with basic-SSL left enabled (never set `PAYKY_DISABLE_BASIC_SSL` for e2e) so tests run over HTTPS with a self-signed cert (`ignoreHTTPSErrors` in the config), the same way production TLS behaves — `@vitejs/plugin-basic-ssl` applies to both `server.https` and `preview.https`. Some browser features (for example `navigator.clipboard`) are unavailable under plain HTTP, so testing over HTTP would hide regressions in those code paths.
136+
- Evolu/SQLite persists through OPFS (Origin Private File System) in Chromium, not IndexedDB — Playwright's `storageState({ indexedDB: true })` snapshot/restore does **not** capture it, so pre-seeding an onboarded account via storageState does not work here. Don't reintroduce that approach.
137+
- `src/components/e2e-test-bridge.tsx` (mounted in `App.tsx`) exposes `window.__e2eSeedOnboarding`, which calls the same production Task actions the onboarding UI does (`saveCashRegisterAccount`, `saveSparkAccount`, `saveFiatBankAccount`, `completeOnboarding`) directly, skipping the onboarding UI. It's gated on `import.meta.env.DEV || __E2E_TEST_BUILD__`, **not** `import.meta.env.DEV` alone — that define is `false` in every `vite build` output regardless of how it's later served, so DEV alone would make the bridge dead code in the `test:e2e:preview` build too. `__E2E_TEST_BUILD__` is a `vite.config.ts` `define` wired to `PAYKY_E2E_BUILD=1`, set only by `test:e2e:preview`'s build step — a real production build never sets it, so the bridge stays dead code (removed) there. `e2e/fixtures.ts`'s `seedOnboarding`/`seedCurrentAccountOnboarding` call the bridge via `page.evaluate` and wait for the app's own reactive redirect off `/onboarding`. Use this in specs that don't test onboarding itself; `completeOnboarding()` (real UI clicks) remains for specs that do (`e2e/onboarding.spec.ts`, `e2e/smoke.spec.ts`) and for a second device account created mid-test (see below).
138+
- Switching the active device account recreates the app's Evolu client, and `E2eTestBridge`'s effect doesn't reliably reattach `window.__e2eSeedOnboarding` to the new client in time — use `completeOnboardingDefaults()` (real UI clicks) for a second account instead of the seed bridge, as `e2e/settings-accounts.spec.ts` does.
139+
- `e2e/fixtures.ts` holds reusable flow helpers (`completeOnboarding`, `completeOnboardingDefaults`, `seedOnboarding`, `seedCurrentAccountOnboarding`, `enterAmount`, `createPayment`, `markCashPaid`, `translate`, `translateValue`, `gotoPage`, `reloadPage`, `pageWidth`/`pageHeight`) plus a `test`/`expect` re-export extended with a `seededPage` fixture. It is imported both by `*.spec.ts` files and by `bin/generate-doc-screenshots.ts`, which drives a manually launched `chromium.launch()` browser outside the Playwright test runner. Because of that second consumer, functions in `e2e/fixtures.ts` must never call `test.step(...)` (or other APIs that require an active test) — they throw `test.step() can only be called from a test` outside a real test run. Extend `e2e/fixtures.ts` instead of duplicating flow steps in a spec file or in the screenshot script, but keep it runner-agnostic.
140+
- Import `test`/`expect` from `./fixtures.ts`, not `@playwright/test`, in every spec except `e2e/onboarding.spec.ts` and `e2e/smoke.spec.ts` (which test onboarding itself and must not auto-seed). Destructure the `seededPage` fixture instead of `page` — it seeds onboarding before the test body runs, so specs don't repeat a manual "seed onboarding" step. `settings-accounts.spec.ts` still seeds the *first* account this way but falls back to `completeOnboardingDefaults()` for the second (see the account-switch caveat above).
141+
- Use `gotoPage(page, path, language, headingKey)`/`reloadPage(page, language, headingKey)` instead of hand-rolling `page.goto(path, { waitUntil: "domcontentloaded" }) + getByRole("heading", ...).waitFor()` — nearly every spec starts with this pattern and every reload-persistence check repeats it. Use `translateValue(language, key, value)` instead of hardcoding a rendered `{value}`-templated string (for example `"10%"` or `"Remove 20%"`) — it substitutes into the real translation key so the test tracks copy changes instead of silently drifting from it.
142+
- Prefer `getByRole` with the translated accessible name (via `translate(language, key)`) as the default locator — it doubles as an accessibility check and tracks markup changes for free. Reserve `data-testid` for elements without a stable accessible name/role, or for elements that stay mounted in the DOM regardless of visibility (for example the payment-paid success overlay, which is toggled via `aria-hidden`/opacity rather than conditionally rendered — matched via `data-testid="payment-paid-panel"`, not a generic `[aria-hidden="false"]` attribute selector). Most local UI primitives (`Button`, `TabsTrigger`, `ToggleGroupItem`, ...) spread `...props` through to the native element, so `data-testid` can be passed directly as a prop without changing the component.
143+
- Wrap each logical phase of a test — not each individual click — in `test.step(...)` inside the `*.spec.ts` file, typically one step per fixture-helper call (`completeOnboarding`, `createPayment`, `markCashPaid`, final assertion). This keeps the HTML report/trace readable without requiring step support inside the shared fixtures.
144+
- There is no network mocking yet for Spark/FIO/Yadio/LNURL (planned for a later phase in `e2e.md`) — payment flows that hit those integrations are not yet deterministic in e2e.

bin/generate-doc-screenshots.ts

Lines changed: 14 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ import { fileURLToPath } from "node:url"
55
import { chromium, expect, type Page } from "@playwright/test"
66
import sharp from "sharp"
77
import {
8-
type Language,
9-
resources,
10-
type TranslationKey,
11-
} from "../src/i18n/resources.ts"
8+
completeOnboarding,
9+
createPayment,
10+
enterAmount,
11+
markCashPaid,
12+
pageHeight,
13+
pageWidth,
14+
translate,
15+
} from "../e2e/fixtures.ts"
16+
import { type Language, resources } from "../src/i18n/resources.ts"
1217

1318
interface ScreenshotScenario {
1419
readonly name: "home" | "payment" | "paid" | "settings"
@@ -17,20 +22,12 @@ interface ScreenshotScenario {
1722

1823
const documentationLanguages = Object.keys(resources) as Language[]
1924

20-
const languageOptionKeyByLanguage: Record<Language, TranslationKey> = {
21-
en: "settings.language.english.title",
22-
cs: "settings.language.czech.title",
23-
sk: "settings.language.slovak.title",
24-
}
25-
2625
const localeByLanguage: Record<Language, string> = {
2726
en: "en-US",
2827
cs: "cs-CZ",
2928
sk: "sk-SK",
3029
}
3130

32-
const pageWidth = 406
33-
const pageHeight = 818
3431
const deviceScaleFactor = 3.5
3532
const capturedWidth = pageWidth * deviceScaleFactor
3633
const capturedHeight = pageHeight * deviceScaleFactor
@@ -86,81 +83,6 @@ async function waitForApp(): Promise<void> {
8683
throw new Error("Timed out waiting for the documentation screenshot server.")
8784
}
8885

89-
function translate(language: Language, key: TranslationKey): string {
90-
return resources[language][key]
91-
}
92-
93-
async function completeOnboarding(
94-
page: Page,
95-
language: Language
96-
): Promise<void> {
97-
await page.goto(appUrl, { waitUntil: "domcontentloaded" })
98-
await page
99-
.getByRole("heading", { name: translate(language, "onboarding.title") })
100-
.waitFor()
101-
await page
102-
.getByRole("button", {
103-
name: translate(language, languageOptionKeyByLanguage[language]),
104-
})
105-
.click()
106-
await page
107-
.getByRole("button", { name: translate(language, "onboarding.next") })
108-
.click()
109-
await page
110-
.getByRole("button", {
111-
name: translate(language, "onboarding.accountChoice.new.title"),
112-
})
113-
.click()
114-
await page
115-
.getByRole("button", { name: translate(language, "onboarding.next") })
116-
.click()
117-
await page
118-
.getByRole("button", { name: translate(language, "onboarding.next") })
119-
.click()
120-
await page
121-
.getByRole("checkbox", {
122-
name: translate(language, "onboarding.payments.btc.title"),
123-
})
124-
.click()
125-
await page
126-
.getByRole("checkbox", {
127-
name: translate(language, "onboarding.payments.iban.title"),
128-
})
129-
.click()
130-
await page.getByRole("textbox").fill("CZ6508000000192000145399")
131-
await page
132-
.getByRole("button", { name: translate(language, "onboarding.next") })
133-
.click()
134-
await page
135-
.getByRole("button", { name: translate(language, "onboarding.finish") })
136-
.click()
137-
await page
138-
.getByRole("button", { name: translate(language, "settings.title") })
139-
.waitFor()
140-
}
141-
142-
async function enterAmount(page: Page, language: Language): Promise<void> {
143-
await page.getByRole("button", { name: "5", exact: true }).click()
144-
await page
145-
.getByRole("button", {
146-
name: translate(language, "home.keypad.decimal"),
147-
})
148-
.click()
149-
await page.getByRole("button", { name: "9", exact: true }).click()
150-
}
151-
152-
async function createPayment(page: Page, language: Language): Promise<void> {
153-
await enterAmount(page, language)
154-
await page
155-
.getByRole("button", { name: translate(language, "home.pay") })
156-
.click()
157-
await page
158-
.getByRole("tab", {
159-
name: translate(language, "paymentWait.method.iban"),
160-
})
161-
.waitFor()
162-
}
163-
16486
async function capturePage(
16587
page: Page,
16688
name: ScreenshotScenario["name"],
@@ -175,15 +97,15 @@ const scenarios: ReadonlyArray<ScreenshotScenario> = [
17597
{
17698
name: "home",
17799
async capture(page, language) {
178-
await completeOnboarding(page, language)
100+
await completeOnboarding(page, language, { baseURL: appUrl })
179101
await enterAmount(page, language)
180102
await capturePage(page, "home", language)
181103
},
182104
},
183105
{
184106
name: "payment",
185107
async capture(page, language) {
186-
await completeOnboarding(page, language)
108+
await completeOnboarding(page, language, { baseURL: appUrl })
187109
await createPayment(page, language)
188110
await page
189111
.getByRole("tab", {
@@ -201,25 +123,17 @@ const scenarios: ReadonlyArray<ScreenshotScenario> = [
201123
{
202124
name: "paid",
203125
async capture(page, language) {
204-
await completeOnboarding(page, language)
126+
await completeOnboarding(page, language, { baseURL: appUrl })
205127
await createPayment(page, language)
206-
await page
207-
.getByRole("button", {
208-
name: translate(language, "paymentWait.cashPaid.action"),
209-
})
210-
.click()
211-
await page
212-
.locator('[aria-hidden="false"]')
213-
.getByText(translate(language, "paymentWait.paid"))
214-
.waitFor()
128+
await markCashPaid(page, language)
215129
await page.waitForTimeout(350)
216130
await capturePage(page, "paid", language)
217131
},
218132
},
219133
{
220134
name: "settings",
221135
async capture(page, language) {
222-
await completeOnboarding(page, language)
136+
await completeOnboarding(page, language, { baseURL: appUrl })
223137
await page
224138
.getByRole("button", { name: translate(language, "settings.title") })
225139
.click()

e2e/activity.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
createPayment,
3+
expect,
4+
gotoPage,
5+
markCashPaid,
6+
test,
7+
translate,
8+
} from "./fixtures.ts"
9+
10+
test("a paid payment shows up in activity list and detail", async ({
11+
seededPage: page,
12+
}) => {
13+
await test.step("create a cash payment", () => createPayment(page, "en"))
14+
await test.step("mark the payment as paid", () => markCashPaid(page, "en"))
15+
16+
await test.step("open activity from the paid confirmation", async () => {
17+
await page
18+
.getByTestId("payment-paid-panel")
19+
.getByRole("button", { name: translate("en", "paymentWait.detail") })
20+
.click()
21+
await page
22+
.getByRole("heading", { name: translate("en", "paymentDetail.title") })
23+
.waitFor()
24+
})
25+
26+
await test.step("verify the payment detail shows paid status", async () => {
27+
await expect(
28+
page.getByText(translate("en", "paymentDetail.status.paid"), {
29+
exact: true,
30+
})
31+
).toBeVisible()
32+
})
33+
34+
await test.step("navigate to the activity list", () =>
35+
gotoPage(page, "/activity", "en", "activity.title"))
36+
37+
await test.step("open the payment from the activity list", async () => {
38+
await page
39+
.locator("nav")
40+
.getByRole("link", {
41+
name: translate("en", "paymentHistory.status.paid"),
42+
})
43+
.first()
44+
.click()
45+
await page
46+
.getByRole("heading", { name: translate("en", "paymentDetail.title") })
47+
.waitFor()
48+
await expect(
49+
page.getByText(translate("en", "paymentDetail.status.paid"), {
50+
exact: true,
51+
})
52+
).toBeVisible()
53+
})
54+
})

0 commit comments

Comments
 (0)