clone

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

feat/141-demo-review-fixes
lofi / apps / reference / tests / backup_migration_e2e_test.ts
backup_migration_e2e_test.ts 5.8K
1import type { VirtualAuthenticatorHandle } from "@nzip/lofi/testing";23/**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 */2122function 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}3031// Runs inside the browser page: the status line reports "N item(s) …" once the32// local store has hydrated.33const taskListReady = () => {34 const status = document.querySelector('[data-island="tasks"] [role="status"]');35 return status !== null && /item\(s\)/.test(status.textContent ?? "");36};3738Deno.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 }4647 // 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 );5253 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 }6869 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" });7677 // 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 }8384 // 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" });105106 // 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 }112113 // 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();124125 // 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});