clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

Merge pull request #89 from FelineStateMachine/feat/e2e-coverage-ci

FelineStateMachine committed f078903
feat(testing): run CI on main PRs, add real-browser backup-migration gate
commit
f0789032eaba46a1d608e6a638fc4e1e2fee5700
parent
79ecf21f94b3c55010b35b26d01d6de177ec246d
parent
f57307b99b3bfb72b5a1db835c426b3d3ba74fa6
author
FelineStateMachine <dami.etoile@gmail.com>
8 files changed, +216 -34
M.github/workflows/ci.yml2+-
Aapps/reference/tests/backup_migration_e2e_test.ts138++++++++++++++++++++++++++++++
Mdocs/reference/project-layout.md61+++++++-------
Mdocs/testing.md23+++++
Mpackage/starter_template.ts1+
Mpackage/testdata/starter.snapshot.json1+
Mpackage/testing/webauthn.ts9++
Mtools/golden.ts15+++-
M .github/workflows/ci.yml +1 -1
A apps/reference/tests/backup_migration_e2e_test.ts +138 -0
@@ -0,0 +1,138 @@
1+import type { VirtualAuthenticatorHandle } from "@nzip/lofi/testing";2+3+/**4+ * A real-browser account journey: rows written local-only must survive the5+ * election to back up and sync (the runtime copies them into the managed6+ * namespace during the reload), and the phrase-reveal guard must accept only7+ * its enrolled passkey.8+ *9+ * This is an opt-in browser gate, not part of `deno task test`:10+ * 1. Configure managed sync (JAZZ_APP_ID + JAZZ_SERVER_URL in `.env`). The11+ * sync server does NOT need to be reachable — the migration completes at12+ * local durability, which is exactly what this test pins down.13+ * 2. Serve the app on a hostname a passkey RP-ID can bind to — `localhost`14+ * works: `deno task build && deno task preview`, or `deno task dev`.15+ * 3. Point the test at that URL and run it directly:16+ * LOFI_E2E_BASE_URL=http://localhost:4321/ deno test -A tests/backup_migration_e2e_test.ts17+ *18+ * Without LOFI_E2E_BASE_URL (or without Chromium installed) it skips, so the19+ * default suite stays fast and never launches a browser.20+ */21+22+function e2eBaseURL(): string | undefined {23+ // The default suite runs without --allow-env; treat a denied read as "skip".24+ try {25+ return Deno.env.get("LOFI_E2E_BASE_URL") || undefined;26+ } catch {27+ return undefined;28+ }29+}30+31+// Runs inside the browser page: the status line reports "N item(s) …" once the32+// local store has hydrated.33+const taskListReady = () => {34+ const status = document.querySelector('[data-island="tasks"] [role="status"]');35+ return status !== null && /item\(s\)/.test(status.textContent ?? "");36+};37+38+Deno.test("local rows survive sync election and the phrase guard stays pinned", async () => {39+ const baseURL = e2eBaseURL();40+ if (!baseURL) {41+ console.log(42+ "skipping backup-migration journey; set LOFI_E2E_BASE_URL to a sync-configured deployment",43+ );44+ return;45+ }46+47+ // Imported lazily so the default suite never loads Playwright.48+ const { chromium } = await import("npm:playwright@1.61.1");49+ const { BrowserUnavailableError, waitForReady, withVirtualAuthenticator } = await import(50+ "@nzip/lofi/testing"51+ );52+53+ let browser;54+ try {55+ browser = await chromium.launch({ headless: true });56+ } catch (error) {57+ const message = error instanceof Error ? error.message : String(error);58+ if (59+ /executable doesn't exist|browser executable|playwright install|failed to launch/i.test(60+ message,61+ )62+ ) {63+ console.log(new BrowserUnavailableError({ cause: error }).message);64+ return;65+ }66+ throw error;67+ }68+69+ let authenticator: VirtualAuthenticatorHandle | undefined;70+ try {71+ const context = await browser.newContext({ baseURL });72+ const page = await context.newPage();73+ authenticator = await withVirtualAuthenticator(page);74+ await page.goto(baseURL, { waitUntil: "domcontentloaded" });75+ await waitForReady(page, taskListReady, undefined, { description: "task list ready" });76+77+ // Rows written while the account is still local-only.78+ for (const text of ["survives migration", "second local row"]) {79+ await page.fill("#new-task", text);80+ await page.press("#new-task", "Enter");81+ await page.locator(".task", { hasText: text }).first().waitFor({ timeout: 15_000 });82+ }83+84+ // Two-step backup: reveal the phrase, then confirm it is saved. The85+ // confirmation reloads the document into the managed namespace.86+ const enable = page.getByRole("button", { name: "Back up & enable sync" });87+ try {88+ await enable.waitFor({ timeout: 15_000 });89+ } catch {90+ throw new Error(91+ "the backup gate never rendered — configure JAZZ_APP_ID and JAZZ_SERVER_URL in .env",92+ );93+ }94+ await enable.click();95+ const phraseWords = page.locator('[aria-label="Recovery phrase"] li');96+ await phraseWords.first().waitFor({ timeout: 30_000 });97+ if (await phraseWords.count() !== 24) {98+ throw new Error("the revealed recovery phrase must contain 24 words");99+ }100+ await Promise.all([101+ page.waitForNavigation({ waitUntil: "domcontentloaded" }),102+ page.getByRole("button", { name: "I saved my phrase — enable sync" }).click(),103+ ]);104+ await waitForReady(page, taskListReady, undefined, { description: "managed runtime ready" });105+106+ // The core regression: every local-only row is visible after the election,107+ // with no sync server required — the migration settles at local tier.108+ await page.getByRole("heading", { name: "Backed up & syncing" }).waitFor({ timeout: 30_000 });109+ for (const text of ["survives migration", "second local row"]) {110+ await page.locator(".task", { hasText: text }).first().waitFor({ timeout: 15_000 });111+ }112+113+ // Enroll the phrase-reveal guard, then reveal through the pinned ceremony114+ // while the earlier recoverable-backup passkey is still present as a decoy115+ // resident credential for the same RP ID.116+ await page.getByRole("button", { name: "Add local phrase-reveal guard" }).click();117+ await page.getByRole("button", { name: "Add local phrase-reveal guard" }).waitFor({118+ state: "detached",119+ timeout: 30_000,120+ });121+ await page.getByRole("button", { name: "Show recovery phrase" }).click();122+ await phraseWords.first().waitFor({ timeout: 30_000 });123+ await page.getByRole("button", { name: "Hide phrase" }).click();124+125+ // Lose the device's credentials: the pinned ceremony must fail closed —126+ // never reveal the phrase — rather than accept any remaining passkey.127+ // (A no-match WebAuthn request can take up to its 60s timeout to reject.)128+ await authenticator.clearCredentials();129+ await page.getByRole("button", { name: "Show recovery phrase" }).click();130+ await page.locator(".account-error").waitFor({ timeout: 90_000 });131+ if (await phraseWords.count() !== 0) {132+ throw new Error("the phrase was revealed without the enrolled guard credential");133+ }134+ } finally {135+ await authenticator?.dispose();136+ await browser.close();137+ }138+});
M docs/reference/project-layout.md +31 -30
@@ -26,36 +26,37 @@ project. Adding or removing a generated file requires updating this map in the s
2626 2727 <!-- generated-file-map:start -->2828 29-| Path | Ownership category | Role |30-| -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------- |31-| `.env.example` | Generated configuration | Names optional public sync settings and server-only secrets without values. |32-| `.gitignore` | Generated configuration | Keeps secrets, dependencies, package tooling, builds, and failure artifacts untracked. |33-| `README.md` | Generated configuration | Gives the generated app's first commands, package boundary, and hosting path. |34-| `deno.json` | Generated configuration | Pins one lofi package version and exposes the supported task surface. |35-| `public/apple-touch-icon.png` | Product asset | Supplies the replaceable 180x180 iOS Home Screen icon. |36-| `public/favicon.svg` | Product asset | Supplies the replaceable vector browser icon and source mark. |37-| `public/icon-192.png` | Product asset | Supplies the regular 192x192 install icon. |38-| `public/icon-512.png` | Product asset | Supplies the regular 512x512 install icon. |39-| `public/icon-maskable-512.png` | Product asset | Supplies a padded 512x512 maskable icon with a safe-zone-aware mark. |40-| `public/icon-monochrome.svg` | Product asset | Supplies the transparent silhouette used where the OS applies a solid icon color. |41-| `public/screenshot-narrow.png` | Product asset | Supplies a replaceable labeled phone-sized install screenshot. |42-| `public/screenshot-wide.png` | Product asset | Supplies a replaceable labeled desktop-sized install screenshot. |43-| `public/manifest.webmanifest` | Product asset | Declares stable install identity, locale, scope, orientation, icons, and shortcuts. |44-| `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. |45-| `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. |46-| `src/islands/AccountGate.tsx` | Author-owned source | Presents opt-in account backup, sync, and recovery controls. |47-| `src/islands/TaskList.tsx` | Author-owned source | Implements the replaceable starter task experience. |48-| `src/islands/use-tasks.ts` | Author-owned source | Binds the starter task table to Preact through public package APIs. |49-| `src/layouts/Shell.astro` | Author-owned source | Owns document metadata, install links, and package boot. |50-| `src/pages/index.astro` | Author-owned source | Composes the starter page and package-owned optional UI. |51-| `src/permissions.ts` | Author-owned source | Declares application read and mutation policy. |52-| `src/schema.ts` | Author-owned source | Declares persisted application tables and fields. |53-| `src/styles/global.css` | Author-owned source | Styles the starter page, islands, and optional package surfaces. |54-| `tests/auth_e2e_test.ts` | Application test | Demonstrates an opt-in browser credential round trip. |55-| `tests/author-boundary_test.ts` | Application test | Prevents framework plumbing from returning to author UI. |56-| `tests/convergence_e2e_test.ts` | Application test | Demonstrates the optional two-client synced browser journey. |57-| `tests/testing-contract_test.ts` | Application test | Type-checks the public local-first browser helpers. |58-| `tsconfig.json` | Generated configuration | Enables strict Astro and Preact source checking. |29+| Path | Ownership category | Role |30+| ------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------- |31+| `.env.example` | Generated configuration | Names optional public sync settings and server-only secrets without values. |32+| `.gitignore` | Generated configuration | Keeps secrets, dependencies, package tooling, builds, and failure artifacts untracked. |33+| `README.md` | Generated configuration | Gives the generated app's first commands, package boundary, and hosting path. |34+| `deno.json` | Generated configuration | Pins one lofi package version and exposes the supported task surface. |35+| `public/apple-touch-icon.png` | Product asset | Supplies the replaceable 180x180 iOS Home Screen icon. |36+| `public/favicon.svg` | Product asset | Supplies the replaceable vector browser icon and source mark. |37+| `public/icon-192.png` | Product asset | Supplies the regular 192x192 install icon. |38+| `public/icon-512.png` | Product asset | Supplies the regular 512x512 install icon. |39+| `public/icon-maskable-512.png` | Product asset | Supplies a padded 512x512 maskable icon with a safe-zone-aware mark. |40+| `public/icon-monochrome.svg` | Product asset | Supplies the transparent silhouette used where the OS applies a solid icon color. |41+| `public/screenshot-narrow.png` | Product asset | Supplies a replaceable labeled phone-sized install screenshot. |42+| `public/screenshot-wide.png` | Product asset | Supplies a replaceable labeled desktop-sized install screenshot. |43+| `public/manifest.webmanifest` | Product asset | Declares stable install identity, locale, scope, orientation, icons, and shortcuts. |44+| `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. |45+| `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. |46+| `src/islands/AccountGate.tsx` | Author-owned source | Presents opt-in account backup, sync, and recovery controls. |47+| `src/islands/TaskList.tsx` | Author-owned source | Implements the replaceable starter task experience. |48+| `src/islands/use-tasks.ts` | Author-owned source | Binds the starter task table to Preact through public package APIs. |49+| `src/layouts/Shell.astro` | Author-owned source | Owns document metadata, install links, and package boot. |50+| `src/pages/index.astro` | Author-owned source | Composes the starter page and package-owned optional UI. |51+| `src/permissions.ts` | Author-owned source | Declares application read and mutation policy. |52+| `src/schema.ts` | Author-owned source | Declares persisted application tables and fields. |53+| `src/styles/global.css` | Author-owned source | Styles the starter page, islands, and optional package surfaces. |54+| `tests/auth_e2e_test.ts` | Application test | Demonstrates an opt-in browser credential round trip. |55+| `tests/author-boundary_test.ts` | Application test | Prevents framework plumbing from returning to author UI. |56+| `tests/backup_migration_e2e_test.ts` | Application test | Proves local rows survive sync election and the phrase guard stays pinned. |57+| `tests/convergence_e2e_test.ts` | Application test | Demonstrates the optional two-client synced browser journey. |58+| `tests/testing-contract_test.ts` | Application test | Type-checks the public local-first browser helpers. |59+| `tsconfig.json` | Generated configuration | Enables strict Astro and Preact source checking. |5960 6061 <!-- generated-file-map:end -->6162
M docs/testing.md +23 -0
@@ -78,6 +78,17 @@ sequenceDiagram
7878 Both browser contexts use one test identity. Without `LOFI_E2E_BASE_URL`, the example skips so the7979 default suite remains fast.8080 81+Two more opt-in browser gates ship with the starter:82+83+- `tests/backup_migration_e2e_test.ts` — the account journey in one real browser: rows written84+ local-only must survive electing backup and sync (the runtime copies them into the managed85+ namespace during the reload, settling at local durability, so the sync server does not need to be86+ reachable), and the phrase-reveal guard must accept only its enrolled passkey — after the87+ authenticator loses its credentials, the reveal fails closed. Serve on `localhost` (a passkey88+ RP-ID cannot bind to a bare IP).89+- `tests/auth_e2e_test.ts` — the device-credential enroll → authenticate round-trip on a stable90+ origin listed in `credentialOrigins`.91+8192 If Chromium is missing:8293 8394 ```sh
@@ -126,3 +137,15 @@ convergence without the local Jazz server used by the journey.
126137 Browser automation does not prove installed-PWA storage and lifecycle behavior on iOS or Android.127138 Before shipping, use a stable HTTPS origin and exercise installation, termination, device restart,128139 offline cold start, foreground recovery, and account recovery on every supported mobile surface.140+141+The WebAuthn PRF extension is the one credential path no virtual authenticator models, so PRF is142+feature-detected in the runtime and must be validated on real hardware. The manual pass, per143+supported platform/passkey-provider pair:144+145+1. `getAuthCapability()` reports `prf: "available"` (or `"not-reported"` with a working derive).146+2. Enroll a device credential on the pinned production RP-ID and derive a PRF secret twice with the147+ same salt — both ceremonies must yield the same 32 bytes.148+3. Encrypt with the derived at-rest key, restart the browser/app, derive again, and decrypt.149+4. Confirm a different salt yields a different secret and cannot decrypt the first blob.150+5. On providers that sync passkeys, repeat the derive on a second device of the same ecosystem and151+ record whether PRF results roam — do not assume they do.
M package/starter_template.ts +1 -0
M package/testdata/starter.snapshot.json +1 -0
M package/testing/webauthn.ts +9 -0
M tools/golden.ts +12 -3