Extract M3: foreground recovery, PWA install/update lifecycle, WebAuthn/PRF detection
- commit
- ff88666dd98198808821494dad5875b99c2735f0
- parent
- 57718ef68ac0a21ac3517ff12688101b523919a5
- parent
- 10273a35d9456c2658378a6a198b9aab3a623f33
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -1,33 +1,13 @@
1-# lofi1+# @nzip/lofi22 3-**A local-first mobile PWA meta-framework, built as an honest prototype.**4-5-Write a schema, build Preact islands, ship an installable offline-capable app. The UI always6-hydrates from local data, identity never waits on a server, and the sync module is the only network7-surface an app developer ever sees.3+**A local-first mobile PWA meta-framework for Deno.** Declare a schema, build Preact islands, ship4+an installable offline-capable app. The UI always hydrates from local data, identity never waits on5+a server, and the sync module is the only network surface you touch.86 97 Built on [Jazz 2](https://jazz.tools) (CRDT data + sync) · [Preact](https://preactjs.com) islands in108 [Astro](https://astro.build) · the [Deno](https://deno.com) toolchain.119 12-> **Status: working prototype, pre-release.** Nothing is published yet. M0 (DevX contract) and M113-> (feasibility) are complete; all three M2 source layers are merged on `m2` and undergoing combined14-> promotion review. See the [roadmap](#roadmap).15-16-## Why lofi17-18-- **Local-first without the plumbing.** Product code touches schema, config, islands, styles, and19- tests — never providers, workers, transports, or service-worker internals20- ([DX-AUTHOR-01, DX-LEAK-01](docs/devx-contract.md#measurable-promises)).21-- **Honest by construction.** Durable storage never silently degrades to memory; diagnostics only22- claim what the underlying APIs can actually observe; identity wording matches real custody and23- recovery semantics.24-- **Mobile PWA first.** Platform floors (Android Chrome 148+, iOS Safari 16.4+) are enforced boot25- gates, not advisory footnotes. Unsupported browsers get an explicit answer, never silent data26- loss.27-28-## The north star29-30-When every milestone below has landed, this is the entire onboarding experience:10+## Quick start3111 3212 ```sh3313 deno run -A jsr:@nzip/lofi/create my-app@@ -35,105 +15,89 @@ cd my-app
3515 deno task dev3616 ```3717 38-From there a developer makes a retained local write, reloads it, works offline, runs checks, builds,39-previews, and opens the same stable secure origin on a physical phone — with Deno as the only40-required global runtime and a command surface of exactly seven verbs: `create`, `dev`, `doctor`,41-`check`, `test`, `build`, `preview`.18+That's the whole onboarding. You now have an installable, offline-capable local-first PWA — make a19+write, reload it, go offline, and the UI keeps working because it reads from durable local storage,20+not the network.4221 43-## Try it today (checkout journey)22+> Requires **Deno 2.9+**, the only global runtime. With no `.env` the app runs local-only (stated23+> explicitly at boot); for managed sync, copy `.env.example` and set the public `JAZZ_APP_ID` /24+> `JAZZ_SERVER_URL` pair. Server-only secrets never reach client output.4425 45-The `m2` branch contains the accepted source generator, package-owned commands, development46-inspector, and browser-testing helpers. The package is not published until explicitly authorized and47-the registry-backed smoke passes, so the currently available journey runs from a checkout:26+## What a generated project looks like4827 49-```sh50-git clone https://github.com/FelineStateMachine/lofi.git51-cd lofi52-deno task dev53-```54-55-- **Deno 2.9+** is the only required global runtime.56-- **No `.env` means local-only mode**, stated explicitly at boot. For cloud sync, copy57- `.env.example` and set the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair. Server-only secrets never58- reach client output — this is tested, not hoped.59-- From `deno task dev` you should reach a retained local write in under 60 seconds60- ([DX-PROTOTYPE-01](docs/devx-contract.md#measurable-promises)).61-62-### Command surface63-64-| Command | What it does today |65-| ------------------- | ------------------------------------------------------------------------ |66-| `deno task dev` | Serves the prototype; prints URL plus storage, identity, and sync state. |67-| `deno task check` | Format, lint, type, Astro, env-contract, and secret-leak checks. |68-| `deno task test` | Deterministic local-first tests (no hand-timed sleeps). |69-| `deno task build` | Production build through the supported Deno path. |70-| `deno task preview` | Serves the production build. |71-72-`create` and `doctor` have graduated through the source-backed generated-project golden journey and73-clean-room review. The public JSR invocation remains unavailable until publication and its74-registry-backed smoke pass; the output contracts are specified in the75-[DevX contract](docs/devx-contract.md#canonical-command-surface-and-output-contract).76-77-## How this repository works78-79-Development here is **contract-driven**: the product is a set of falsifiable promises, and code80-graduates only by producing evidence against them.81-82-1. **[The DevX contract](docs/devx-contract.md)** defines measurable promises with IDs (like83- `DX-LOCAL-01`), budgets, and statuses (`proposed → validated / revised / rejected`).84-2. **[Decision records](docs/decisions/)** capture each experiment: question, exact versions,85- control, procedure, retained evidence, and the resulting contract delta. A spike is complete when86- it supports a decision — including a negative one — not when a demo renders once.87-3. **[Milestones](https://github.com/FelineStateMachine/lofi/milestones)** gate scope. Issues carry88- `area:*`, `type:*`, and `priority:p0–p2` labels; p0 blocks the golden path.89-90-Notable honest outcomes so far: the Jazz 2 alpha passkey-backup ceremony was **rejected** on91-security grounds rather than demoed92-([decision 0004](docs/decisions/0004-reject-alpha53-passkey-backup.md)), and live transport state is93-reported as _unavailable_ rather than faked, because the pinned API exposes no supported signal.94-95-## Roadmap96-97-| Milestone | Goal | Status |98-| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------- |99-| **M0 — DevX contract** | Define the golden path, command surface, environment contract, and measurable acceptance criteria. | ✅ Done |100-| **M1 — Feasibility spikes** | Prove Jazz 2, Preact, OPFS, identity, Astro islands, and Deno behavior with go/no-go decisions. | ✅ Done |101-| **M2 — DevX vertical slice** | `create`, `dev`, `doctor`, diagnostics, inspector, and testing workflows around a reference app. | 🧪 Promotion review |102-| **M3 — Mobile PWA hardening** | Install, persistence, offline cold start, lifecycle recovery, and physical-device validation. | ⏳ Queued |103-| **M4 — Runtime API extraction** | Extract remaining proven runtime seams as `@nzip/lofi` subpaths; validate them through a second app. | ⏳ Queued |104-105-The definition of done for the whole prototype is the [north star](#the-north-star) journey passing106-its contract budgets end-to-end, on real devices.107-108-## Repository map28+A new project is split into two zones — what you own, and the runtime you don't touch:10929 11030 ```text111-lofi/112-├── docs/113-│ ├── devx-contract.md # The product contract: promises, budgets, statuses114-│ └── decisions/ # Evidence-backed decision records (ADR-style)115-├── apps/reference/ # The integrated app graduated from M1 and reshaped in M2116-├── package/ # The single @nzip/lofi package source and generated-project commands117-├── spikes/ # M1 feasibility experiments, kept for evidence118-├── tools/ # Deno tasks: env contract, secret scanning, runners119-├── init.md # Original research plan — input, not the contract120-├── deno.json # Workspace tasks and pinned toolchain121-└── jsr.json # One package name, version, export map, and publish manifest31+my-app/32+├── deno.json # tasks + pinned toolchain33+├── astro.config.ts34+├── public/ # manifest, service worker, icons35+├── src/36+│ ├── schema.ts # ← your data model37+│ ├── permissions.ts # ← your access policy38+│ ├── app.ts # ← your app configuration39+│ ├── pages/ # ← your Astro shell40+│ ├── islands/ # ← your Preact UI (starts as a minimal task list to replace)41+│ ├── styles/42+│ └── _lofi/ # runtime: storage, identity, sync, lifecycle, diagnostics — don't edit43+└── tests/ # your tests + the @nzip/lofi/testing contract12244 ```12345 46+You write `schema.ts`, `permissions.ts`, islands, and styles. Everything under `src/_lofi/` is the47+framework runtime; product work never needs to touch it.48+49+## Commands50+51+Every generated project ships this task surface (`deno task <name>`):52+53+| Command | What it does |54+| --------------------------------------- | ----------------------------------------------------------- |55+| `dev` | Astro dev server; prints storage, identity, and sync state. |56+| `doctor` | Value-free readiness report — no secrets, no faked claims. |57+| `test` | Deterministic local-first tests (no hand-timed sleeps). |58+| `build` | Static production build into `dist/`. |59+| `preview` | Serves the production build locally. |60+| `deploy` / `deploy:create` | Host the static build on Deno Deploy. |61+| `schema:validate` / `schema:deploy` | Validate and publish your Jazz schema. |62+| `migrations:create` / `migrations:push` | Author and push schema migrations. |63+64+## Testing local-first behavior65+66+`@nzip/lofi/testing` provides Playwright-backed helpers for the parts that are genuinely hard to67+test — offline and multi-client behavior: a two-client fixture, concurrent-offline convergence,68+readiness waits without arbitrary sleeps, and value-free (secret-free) failure artifacts. Every69+project includes a worked example at `tests/convergence_e2e_test.ts`.70+71+## Why lofi72+73+- **Local-first without the plumbing.** Product code touches schema, config, islands, styles, and74+ tests — never providers, workers, transports, or service-worker internals.75+- **Honest by construction.** Durable storage never silently degrades to memory; diagnostics only76+ claim what the underlying APIs can actually observe; identity wording matches real custody and77+ recovery semantics.78+- **Mobile PWA first.** Platform floors (Android Chrome 148+, iOS Safari 16.4+) are enforced boot79+ gates, not advisory footnotes. Unsupported browsers get an explicit answer, never silent data80+ loss.81+12482 ## Stack12583 12684 | Layer | Choice | Pinned at |12785 | ---------- | --------------------------------------------- | ------------------- |128-| Data/sync | jazz-tools 2.0 alpha (exact pin), OPFS | `2.0.0-alpha.53` |86+| Data/sync | jazz-tools 2.0 alpha, OPFS | `2.0.0-alpha.53` |12987 | UI runtime | Preact islands, `client:only`, thin adapter | Preact 10 / Astro 7 |13088 | Shell | Astro, fully prerendered, static-through-Deno | Astro 7 |13189 | Toolchain | Deno tasks + npm-compat | Deno 2.9 |13290 133-Version pins are deliberate: the data layer is an alpha, so every bump is a reviewed decision, and134-`docs/decisions/` records what each pin was verified to do.91+Version pins are deliberate: the data layer is an alpha, so every bump is a reviewed decision.92+93+## Status94+95+Early release, pre-1.0. Passkey backup and recovery are intentionally absent — the pinned Jazz alpha96+exposes a rejected credential design — so a fresh project uses a device-local identity and does not97+imply recoverability.13598 136-## Contributing99+---137100 138-See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow: how promises, evidence, and decision139-records fit together, what `deno task check` enforces, and how issues and milestones are organized.101+This repository is also the framework's development monorepo: `package/` is the `@nzip/lofi` source102+and `apps/reference/` is the reference app the generator is validated against. See103+[CONTRIBUTING.md](CONTRIBUTING.md) and the [DevX contract](docs/devx-contract.md).@@ -1,18 +1,36 @@
11 const BUILD_REVISION = "__LOFI_BUILD_REVISION__";22 const CACHE_NAME = `lofi-shell-${BUILD_REVISION}`;33 4+async function reportFailure(code, message) {5+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });6+ for (const client of windows) {7+ client.postMessage({ type: "LOFI_PWA_FAILURE", code, message });8+ }9+}10+411 self.addEventListener("install", (event) => {512 event.waitUntil((async () => {6- const manifestUrl = new URL("./lofi-precache.json", self.registration.scope);7- const response = await fetch(manifestUrl, { cache: "no-store" });8- if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);9- const paths = await response.json();10- const cache = await caches.open(CACHE_NAME);11- await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));12- await self.skipWaiting();13+ try {14+ const manifestUrl = new URL("./lofi-precache.json", self.registration.scope);15+ const response = await fetch(manifestUrl, { cache: "no-store" });16+ if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);17+ const paths = await response.json();18+ const cache = await caches.open(CACHE_NAME);19+ await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));20+ } catch (error) {21+ await reportFailure(22+ "precache",23+ error instanceof Error ? error.message : "precache failed",24+ );25+ throw error;26+ }1327 })());1428 });1529 30+self.addEventListener("message", (event) => {31+ if (event.data?.type === "LOFI_SKIP_WAITING") event.waitUntil(self.skipWaiting());32+});33+1634 self.addEventListener("activate", (event) => {1735 event.waitUntil((async () => {1836 for (const name of await caches.keys()) {@@ -35,7 +53,12 @@ self.addEventListener("fetch", (event) => {
3553 const response = await fetch(event.request);3654 if (response.ok) {3755 const cache = await caches.open(CACHE_NAME);38- void cache.put(event.request, response.clone());56+ void cache.put(event.request, response.clone()).catch((error) =>57+ reportFailure(58+ "runtime-cache",59+ error instanceof Error ? error.message : "runtime cache write failed",60+ )61+ );3962 }4063 return response;4164 } catch (error) {@@ -5,6 +5,8 @@ export type DeviceCapabilityReport = {
55 webLocks: boolean;66 messageChannel: boolean;77 durableDriverSupported: boolean;8+ webAuthn: boolean;9+ prf: "available" | "not-reported" | "unknown" | "unavailable";810 persistentPermission: "granted" | "not-granted" | "unavailable" | "error";911 displayMode: "standalone" | "browser";1012 };@@ -19,6 +21,24 @@ function browserStorage(): StorageManagerWithOpfs | undefined {
1921 : navigator.storage as StorageManagerWithOpfs | undefined;2022 }2123 24+type PublicKeyCredentialCapabilities = typeof PublicKeyCredential & {25+ getClientCapabilities?: () => Promise<Record<string, boolean>>;26+};27+28+async function readPrfCapability(29+ webAuthn: boolean,30+): Promise<DeviceCapabilityReport["prf"]> {31+ if (!webAuthn) return "unavailable";32+ const publicKeyCredential = PublicKeyCredential as PublicKeyCredentialCapabilities;33+ if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";34+ try {35+ const capabilities = await publicKeyCredential.getClientCapabilities();36+ return capabilities["extension:prf"] === true ? "available" : "not-reported";37+ } catch {38+ return "unknown";39+ }40+}41+2242 export function durableCapabilityReport(): Omit<2343 DeviceCapabilityReport,2444 "persistentPermission"@@ -37,6 +57,8 @@ export function durableCapabilityReport(): Omit<
3757 webLocks,3858 messageChannel,3959 durableDriverSupported: secureContext && opfs && sharedWorker && webLocks && messageChannel,60+ webAuthn: typeof PublicKeyCredential === "function",61+ prf: "unknown",4062 displayMode: typeof globalThis.matchMedia === "function" &&4163 globalThis.matchMedia("(display-mode: standalone)").matches4264 ? "standalone"@@ -47,16 +69,18 @@ export function durableCapabilityReport(): Omit<
4769 export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityReport> {4870 const base = durableCapabilityReport();4971 const storage = browserStorage();72+ const prf = await readPrfCapability(base.webAuthn);5073 if (typeof storage?.persisted !== "function") {51- return { ...base, persistentPermission: "unavailable" };74+ return { ...base, prf, persistentPermission: "unavailable" };5275 }5376 try {5477 return {5578 ...base,79+ prf,5680 persistentPermission: await storage.persisted() ? "granted" : "not-granted",5781 };5882 } catch {59- return { ...base, persistentPermission: "error" };83+ return { ...base, prf, persistentPermission: "error" };6084 }6185 }6286 @@ -13,6 +13,8 @@ test("unsupported durable storage names missing capabilities and the stable-orig
1313 webLocks: false,1414 messageChannel: false,1515 durableDriverSupported: false,16+ webAuthn: false,17+ prf: "unavailable",1618 displayMode: "browser",1719 });1820 assert(error.name === "DurableStorageUnsupportedError", "durability error needs a stable name");@@ -0,0 +1,95 @@
1+export type ForegroundRecoveryReason = "pageshow" | "visibilitychange" | "online";2+3+export type ForegroundRecoveryState = {4+ mode: "local-only" | "managed";5+ status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";6+ attempts: number;7+ lastReason: ForegroundRecoveryReason | "none";8+};9+10+export type ForegroundRecovery = {11+ getState(): ForegroundRecoveryState;12+ subscribe(listener: () => void): () => void;13+ request(reason: ForegroundRecoveryReason): Promise<void>;14+ dispose(): void;15+};16+17+export function createForegroundRecovery(options: {18+ enabled: boolean;19+ pageTarget: EventTarget;20+ visibilityTarget: EventTarget;21+ isVisible(): boolean;22+ isOnline(): boolean;23+ reconnect(): Promise<void>;24+}): ForegroundRecovery {25+ let active = true;26+ let recovery: Promise<void> | null = null;27+ let state: ForegroundRecoveryState = {28+ mode: options.enabled ? "managed" : "local-only",29+ status: "idle",30+ attempts: 0,31+ lastReason: "none",32+ };33+ const listeners = new Set<() => void>();34+ const publish = (patch: Partial<ForegroundRecoveryState>) => {35+ state = { ...state, ...patch };36+ for (const listener of listeners) listener();37+ };38+ const request = (reason: ForegroundRecoveryReason): Promise<void> => {39+ if (!active || !options.enabled) return Promise.resolve();40+ if (reason !== "online" && !options.isOnline()) {41+ publish({ status: "offline-deferred", lastReason: reason });42+ return Promise.resolve();43+ }44+ if (recovery) return recovery;45+ publish({ status: "recovering", attempts: state.attempts + 1, lastReason: reason });46+ const operation = options.reconnect().then(47+ () => publish({ status: "completed" }),48+ (error) => {49+ publish({ status: "failed" });50+ throw error;51+ },52+ );53+ const tracked = operation.finally(() => {54+ if (recovery === tracked) recovery = null;55+ });56+ recovery = tracked;57+ return tracked;58+ };59+ const requestFromEvent = (reason: ForegroundRecoveryReason) => {60+ void request(reason).catch(() => {61+ // The state already records the failed recovery. Browser event callbacks62+ // have no promise consumer, so the failure must not become unhandled.63+ });64+ };65+ const onPageShow = (event: Event) => {66+ if ((event as PageTransitionEvent).persisted === true) requestFromEvent("pageshow");67+ };68+ const onVisibilityChange = () => {69+ if (options.isVisible()) requestFromEvent("visibilitychange");70+ };71+ const onOnline = () => requestFromEvent("online");72+ if (options.enabled) {73+ options.pageTarget.addEventListener("pageshow", onPageShow);74+ options.pageTarget.addEventListener("online", onOnline);75+ options.visibilityTarget.addEventListener("visibilitychange", onVisibilityChange);76+ }77+ return {78+ getState: () => ({ ...state }),79+ subscribe(listener) {80+ listeners.add(listener);81+ return () => {82+ listeners.delete(listener);83+ };84+ },85+ request,86+ dispose() {87+ if (!active) return;88+ active = false;89+ listeners.clear();90+ options.pageTarget.removeEventListener("pageshow", onPageShow);91+ options.pageTarget.removeEventListener("online", onOnline);92+ options.visibilityTarget.removeEventListener("visibilitychange", onVisibilityChange);93+ },94+ };95+}@@ -0,0 +1,150 @@
1+import { createForegroundRecovery } from "./foreground-recovery.ts";2+import { assert, assertCount } from "./test-assert.ts";3+4+const test = (globalThis as unknown as {5+ Deno: { test(name: string, body: () => void | Promise<void>): void };6+}).Deno.test;7+8+function event(name: string, values: Record<string, unknown> = {}): Event {9+ return Object.assign(new Event(name), values);10+}11+12+test("foreground signals single-flight a managed reconnect", async () => {13+ const page = new EventTarget();14+ const document = new EventTarget();15+ let resolveReconnect: (() => void) | undefined;16+ let reconnects = 0;17+ const manager = createForegroundRecovery({18+ enabled: true,19+ pageTarget: page,20+ visibilityTarget: document,21+ isVisible: () => true,22+ isOnline: () => true,23+ reconnect: () => {24+ reconnects += 1;25+ return new Promise<void>((resolve) => resolveReconnect = resolve);26+ },27+ });28+ document.dispatchEvent(event("visibilitychange"));29+ page.dispatchEvent(event("online"));30+ assertCount(reconnects, 1, "overlapping foreground signals stacked reconnect calls");31+ resolveReconnect?.();32+ await Promise.resolve();33+ await Promise.resolve();34+ assert(manager.getState().status === "completed", "completed reconnect was not observable");35+ assertCount(manager.getState().attempts, 1, "single-flight reconnect counted twice");36+ manager.dispose();37+});38+39+test("offline foreground recovery defers until the online event", async () => {40+ const page = new EventTarget();41+ const document = new EventTarget();42+ let online = false;43+ let reconnects = 0;44+ const manager = createForegroundRecovery({45+ enabled: true,46+ pageTarget: page,47+ visibilityTarget: document,48+ isVisible: () => true,49+ isOnline: () => online,50+ reconnect: () => {51+ reconnects += 1;52+ return Promise.resolve();53+ },54+ });55+ document.dispatchEvent(event("visibilitychange"));56+ assert(manager.getState().status === "offline-deferred", "offline state was not retained");57+ assertCount(reconnects, 0, "offline foreground signal attempted reconnect");58+ online = true;59+ page.dispatchEvent(event("online"));60+ await Promise.resolve();61+ await Promise.resolve();62+ assertCount(reconnects, 1, "online recovery did not request reconnect");63+ manager.dispose();64+});65+66+test("pageshow recovers only a restored BFCache document", async () => {67+ const page = new EventTarget();68+ const document = new EventTarget();69+ let reconnects = 0;70+ const manager = createForegroundRecovery({71+ enabled: true,72+ pageTarget: page,73+ visibilityTarget: document,74+ isVisible: () => true,75+ isOnline: () => true,76+ reconnect: () => {77+ reconnects += 1;78+ return Promise.resolve();79+ },80+ });81+ page.dispatchEvent(event("pageshow", { persisted: false }));82+ page.dispatchEvent(event("pageshow", { persisted: true }));83+ await Promise.resolve();84+ assertCount(reconnects, 1, "pageshow did not distinguish initial load from BFCache recovery");85+ manager.dispose();86+});87+88+test("local-only lifecycle attaches no reconnect behavior", async () => {89+ const page = new EventTarget();90+ const document = new EventTarget();91+ let reconnects = 0;92+ const manager = createForegroundRecovery({93+ enabled: false,94+ pageTarget: page,95+ visibilityTarget: document,96+ isVisible: () => true,97+ isOnline: () => true,98+ reconnect: () => {99+ reconnects += 1;100+ return Promise.resolve();101+ },102+ });103+ page.dispatchEvent(event("online"));104+ document.dispatchEvent(event("visibilitychange"));105+ await manager.request("online");106+ assertCount(reconnects, 0, "local-only mode invented a transport recovery action");107+ assert(manager.getState().mode === "local-only", "local-only lifecycle mode was hidden");108+ manager.dispose();109+});110+111+test("event recovery records a reconnect rejection without leaving it unhandled", async () => {112+ const page = new EventTarget();113+ const document = new EventTarget();114+ const manager = createForegroundRecovery({115+ enabled: true,116+ pageTarget: page,117+ visibilityTarget: document,118+ isVisible: () => true,119+ isOnline: () => true,120+ reconnect: () => Promise.reject(new Error("sentinel reconnect failure")),121+ });122+ page.dispatchEvent(event("online"));123+ await Promise.resolve();124+ await Promise.resolve();125+ assert(manager.getState().status === "failed", "reconnect failure was not observable");126+ manager.dispose();127+});128+129+test("disposed lifecycle removes every browser event listener", async () => {130+ const page = new EventTarget();131+ const document = new EventTarget();132+ let reconnects = 0;133+ const manager = createForegroundRecovery({134+ enabled: true,135+ pageTarget: page,136+ visibilityTarget: document,137+ isVisible: () => true,138+ isOnline: () => true,139+ reconnect: () => {140+ reconnects += 1;141+ return Promise.resolve();142+ },143+ });144+ manager.dispose();145+ page.dispatchEvent(event("online"));146+ page.dispatchEvent(event("pageshow", { persisted: true }));147+ document.dispatchEvent(event("visibilitychange"));148+ await Promise.resolve();149+ assertCount(reconnects, 0, "disposed lifecycle retained a browser listener");150+});@@ -24,6 +24,13 @@ export type InspectorSnapshot = {
2424 mutationListeners: number;2525 mutationErrors: number;2626 };27+ lifecycle: {28+ mode: "local-only" | "managed";29+ status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";30+ attempts: number;31+ lastReason: "none" | "pageshow" | "visibilitychange" | "online";32+ transportDetail: "live detail unavailable" | "not configured";33+ };2734 multiTab: {2835 role: "unavailable" | "leader" | "follower";2936 detail: string;@@ -60,6 +67,11 @@ export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {
6067 { label: "Vendor subscriptions", value: String(snapshot.runtime.vendorSubscriptions) },6168 { label: "Mutation listeners", value: String(snapshot.runtime.mutationListeners) },6269 { label: "Mutation errors", value: String(snapshot.runtime.mutationErrors) },70+ { label: "Lifecycle mode", value: snapshot.lifecycle.mode },71+ { label: "Lifecycle action", value: snapshot.lifecycle.status },72+ { label: "Lifecycle attempts", value: String(snapshot.lifecycle.attempts) },73+ { label: "Lifecycle reason", value: snapshot.lifecycle.lastReason },74+ { label: "Lifecycle transport", value: snapshot.lifecycle.transportDetail },6375 { label: "Multi-tab role", value: snapshot.multiTab.role },6476 { label: "Multi-tab detail", value: snapshot.multiTab.detail },6577 ];@@ -25,6 +25,13 @@ const snapshot: InspectorSnapshot = {
2525 mutationListeners: 1,2626 mutationErrors: 0,2727 },28+ lifecycle: {29+ mode: "managed",30+ status: "completed",31+ attempts: 2,32+ lastReason: "visibilitychange",33+ transportDetail: "live detail unavailable",34+ },2835 multiTab: {2936 role: "unavailable",3037 detail: "Jazz alpha.53 exposes no supported leader/follower signal",@@ -0,0 +1,30 @@
1+import { serverUrl } from "./config.ts";2+import { createForegroundRecovery, type ForegroundRecoveryState } from "./foreground-recovery.ts";3+import { getRuntime } from "./runtime.ts";4+5+const recovery = typeof document === "undefined" ? null : createForegroundRecovery({6+ enabled: Boolean(serverUrl),7+ pageTarget: globalThis,8+ visibilityTarget: document,9+ isVisible: () => document.visibilityState === "visible",10+ isOnline: () => navigator.onLine,11+ reconnect: async () => {12+ const runtime = await getRuntime();13+ await runtime.db.reconnect();14+ },15+});16+17+export function getForegroundRecoveryState(): ForegroundRecoveryState {18+ return recovery?.getState() ?? {19+ mode: serverUrl ? "managed" : "local-only",20+ status: "idle",21+ attempts: 0,22+ lastReason: "none",23+ };24+}25+26+export function subscribeForegroundRecovery(listener: () => void): () => void {27+ return recovery?.subscribe(listener) ?? (() => undefined);28+}29+30+import.meta.hot?.dispose(() => recovery?.dispose());@@ -1,6 +1,7 @@
11 import { serverUrl } from "./config.ts";22 import { readDeviceCapabilityReport } from "./device-capabilities.ts";33 import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";4+import { getForegroundRecoveryState, subscribeForegroundRecovery } from "./lifecycle.ts";45 import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";56 67 export type LofiDevelopmentBridge = InspectorAdapter;@@ -31,6 +32,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
3132 readDeviceCapabilityReport(),3233 ]);3334 const diagnostics = getRuntimeDiagnostics();35+ const lifecycle = getForegroundRecoveryState();3436 const authMode = runtime.db.getAuthState().authMode;3537 return {3638 identity: {@@ -70,6 +72,10 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
7072 mutationListeners: diagnostics.activeMutationListeners,7173 mutationErrors: diagnostics.mutationErrors,7274 },75+ lifecycle: {76+ ...lifecycle,77+ transportDetail: serverUrl ? "live detail unavailable" : "not configured",78+ },7379 multiTab: {7480 role: "unavailable",7581 detail: "Jazz alpha.53 exposes no supported leader/follower signal",@@ -82,12 +88,14 @@ const bridge: LofiDevelopmentBridge = {
8288 subscribe(listener) {8389 stateListeners.add(listener);8490 const unsubscribeRuntime = subscribeRuntimeDiagnostics(listener);91+ const unsubscribeLifecycle = subscribeForegroundRecovery(listener);8592 let active = true;8693 return () => {8794 if (!active) return;8895 active = false;8996 stateListeners.delete(listener);9097 unsubscribeRuntime();98+ unsubscribeLifecycle();9199 };92100 },93101 async setTransportPaused(paused) {@@ -1,22 +1,119 @@
11 /// <reference path="../env.d.ts" />2-3-export type PwaRegistrationState =2+export type PwaWorkerState =43 | "development-disabled"54 | "unsupported"65 | "registering"76 | "ready"7+ | "update-available"88 | "failed";99 10+export type PwaInstallState =11+ | "installed"12+ | "available"13+ | "prompting"14+ | "accepted"15+ | "dismissed"16+ | "manual-ios"17+ | "unavailable";18+19+export type PwaFailureCode = "registration" | "installation" | "precache" | "runtime-cache";20+21+export type PwaState = {22+ worker: PwaWorkerState;23+ install: PwaInstallState;24+ failure?: {25+ code: PwaFailureCode;26+ message: string;27+ };28+};29+30+type InstallPromptEvent = Event & {31+ prompt(): Promise<void>;32+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;33+};34+35+type NavigatorWithStandalone = Navigator & { standalone?: boolean };36+37+type InstallEnvironment = {38+ displayModeStandalone: boolean;39+ navigatorStandalone: boolean;40+ platform: string;41+ maxTouchPoints: number;42+};43+1044 declare global {1145 interface Window {12- __LOFI_PWA_STATE__?: PwaRegistrationState;46+ __LOFI_PWA_STATE__?: PwaState;1347 }1448 }1549 16-function setState(state: PwaRegistrationState) {50+const subscribers = new Set<(state: PwaState) => void>();51+let deferredInstallPrompt: InstallPromptEvent | undefined;52+let waitingWorker: ServiceWorker | undefined;53+let reloadForUpdate = false;54+let initialized = false;55+56+let state: PwaState = {57+ worker: "registering",58+ install: "unavailable",59+};60+61+function publish(next: PwaState) {62+ state = next;1763 if (typeof document !== "undefined") {1864 (globalThis as typeof globalThis & Window).__LOFI_PWA_STATE__ = state;1965 }66+ for (const subscriber of subscribers) subscriber(state);67+}68+69+function update(patch: Partial<PwaState>) {70+ publish({ ...state, ...patch });71+}72+73+export function getPwaState(): PwaState {74+ return state;75+}76+77+export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {78+ subscribers.add(subscriber);79+ subscriber(state);80+ return () => subscribers.delete(subscriber);81+}82+83+export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {84+ if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";85+ const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||86+ (environment.platform === "MacIntel" && environment.maxTouchPoints > 1);87+ return iosLike ? "manual-ios" : "unavailable";88+}89+90+function currentInstallExperience(): PwaInstallState {91+ const standalone = navigator as NavigatorWithStandalone;92+ return classifyInstallExperience({93+ displayModeStandalone: matchMedia("(display-mode: standalone)").matches,94+ navigatorStandalone: standalone.standalone === true,95+ platform: navigator.platform,96+ maxTouchPoints: navigator.maxTouchPoints,97+ });98+}99+100+export async function requestPwaInstall(): Promise<PwaInstallState> {101+ const prompt = deferredInstallPrompt;102+ if (!prompt) return state.install;103+ deferredInstallPrompt = undefined;104+ update({ install: "prompting" });105+ await prompt.prompt();106+ const choice = await prompt.userChoice;107+ const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";108+ update({ install: outcome });109+ return outcome;110+}111+112+export function applyPwaUpdate(): boolean {113+ if (!waitingWorker) return false;114+ reloadForUpdate = true;115+ waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });116+ return true;20117 }21118 22119 export function waitForActivation(worker: ServiceWorker): Promise<void> {@@ -38,22 +135,101 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
38135 });39136 }40137 138+function watchForUpdate(registration: ServiceWorkerRegistration) {139+ const exposeWaitingWorker = (worker: ServiceWorker) => {140+ waitingWorker = worker;141+ update({ worker: "update-available" });142+ };143+144+ if (registration.waiting && navigator.serviceWorker.controller) {145+ exposeWaitingWorker(registration.waiting);146+ }147+ registration.addEventListener("updatefound", () => {148+ const worker = registration.installing;149+ if (!worker) return;150+ worker.addEventListener("statechange", () => {151+ if (worker.state === "installed" && navigator.serviceWorker.controller) {152+ exposeWaitingWorker(registration.waiting ?? worker);153+ }154+ if (worker.state === "redundant") {155+ update({156+ worker: "failed",157+ failure: { code: "installation", message: "service worker installation failed" },158+ });159+ }160+ });161+ });162+}163+164+function watchInstallExperience() {165+ update({ install: currentInstallExperience() });166+ globalThis.addEventListener("beforeinstallprompt", (event) => {167+ event.preventDefault();168+ deferredInstallPrompt = event as InstallPromptEvent;169+ update({ install: "available" });170+ });171+ globalThis.addEventListener("appinstalled", () => {172+ deferredInstallPrompt = undefined;173+ update({ install: "installed" });174+ });175+}176+177+function watchWorkerMessages() {178+ navigator.serviceWorker.addEventListener("message", (event) => {179+ const message = event.data as {180+ type?: string;181+ code?: PwaFailureCode;182+ message?: string;183+ };184+ if (185+ message?.type !== "LOFI_PWA_FAILURE" ||186+ !["precache", "runtime-cache"].includes(message.code ?? "")187+ ) return;188+ update({189+ worker: "failed",190+ failure: {191+ code: message.code as PwaFailureCode,192+ message: message.message || "service worker cache operation failed",193+ },194+ });195+ });196+}197+41198 export function registerProductionServiceWorker(): void {199+ if (initialized || typeof document === "undefined") return;200+ initialized = true;201+ watchInstallExperience();42202 if (!import.meta.env.PROD) {43- setState("development-disabled");203+ update({ worker: "development-disabled" });44204 return;45205 }46206 if (!("serviceWorker" in navigator)) {47- setState("unsupported");207+ update({ worker: "unsupported" });48208 return;49209 }50- setState("registering");210+ update({ worker: "registering" });211+ watchWorkerMessages();212+ navigator.serviceWorker.addEventListener("controllerchange", () => {213+ if (reloadForUpdate) location.reload();214+ });51215 const workerUrl = new URL("./sw.js", document.baseURI);52216 void (async () => {53- const registration = await navigator.serviceWorker.register(workerUrl);54- const worker = registration.installing ?? registration.waiting ?? registration.active;217+ const registration = await navigator.serviceWorker.register(workerUrl, {218+ scope: new URL("./", document.baseURI).pathname,219+ });220+ watchForUpdate(registration);221+ if (registration.waiting && navigator.serviceWorker.controller) return;222+ const worker = registration.installing ?? registration.active ?? registration.waiting;55223 if (!worker) throw new Error("service worker registration has no worker");56224 await waitForActivation(worker);57- setState("ready");58- })().catch(() => setState("failed"));225+ update({ worker: "ready", failure: undefined });226+ })().catch((error) => {227+ update({228+ worker: "failed",229+ failure: {230+ code: "registration",231+ message: error instanceof Error ? error.message : "service worker registration failed",232+ },233+ });234+ });59235 }@@ -1,4 +1,4 @@
1-import { waitForActivation } from "./pwa.ts";1+import { classifyInstallExperience, waitForActivation } from "./pwa.ts";22 33 const test = (globalThis as unknown as {44 Deno: { test(name: string, body: () => void | Promise<void>): void };@@ -46,3 +46,41 @@ test("service worker activation rejects a redundant install", async () => {
4646 },4747 );4848 });49+50+test("install experience recognizes standalone display modes", () => {51+ const state = classifyInstallExperience({52+ displayModeStandalone: true,53+ navigatorStandalone: false,54+ platform: "Linux armv8l",55+ maxTouchPoints: 5,56+ });57+ if (state !== "installed") throw new Error(`expected installed, received ${state}`);58+});59+60+test("install experience gives iOS manual guidance without exposing a prompt event", () => {61+ const iphone = classifyInstallExperience({62+ displayModeStandalone: false,63+ navigatorStandalone: false,64+ platform: "iPhone",65+ maxTouchPoints: 5,66+ });67+ const ipadDesktopMode = classifyInstallExperience({68+ displayModeStandalone: false,69+ navigatorStandalone: false,70+ platform: "MacIntel",71+ maxTouchPoints: 5,72+ });73+ if (iphone !== "manual-ios" || ipadDesktopMode !== "manual-ios") {74+ throw new Error(`expected iOS guidance, received ${iphone} and ${ipadDesktopMode}`);75+ }76+});77+78+test("install experience degrades to browser mode when no install path is available", () => {79+ const state = classifyInstallExperience({80+ displayModeStandalone: false,81+ navigatorStandalone: false,82+ platform: "MacIntel",83+ maxTouchPoints: 0,84+ });85+ if (state !== "unavailable") throw new Error(`expected unavailable, received ${state}`);86+});@@ -2,6 +2,7 @@
22 "name": "@nzip/lofi",33 "version": "0.1.0",44 "license": "MIT",5+ "description": "A local-first mobile PWA meta-framework for Deno: declare a schema, build Preact islands, and ship an installable offline-capable app whose UI always hydrates from local data.",56 "exports": {67 "./build": "./package/commands/build.ts",78 "./create": "./package/create.ts",@@ -1,5 +1,13 @@
11 22 3+/**4+ * The `deno task build` command: builds the static PWA into `dist/` with a5+ * source-hash build id, a service-worker precache manifest, and a scan that6+ * fails the build if server secrets leak into client output.7+ *8+ * @module9+ */10+311 import { extname, join } from "node:path";412 import { LOFI_VERSION } from "../version.ts";513 import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";@@ -1,5 +1,13 @@
11 22 3+/**4+ * The `deno task doctor` command: prints a value-free readiness report covering5+ * the package, Deno, project layout, environment, storage, identity, sync, and6+ * PWA, and exits non-zero when the project is blocked.7+ *8+ * @module9+ */10+311 import { doctorReport, printDoctorReport } from "../tooling/diagnostics.ts";412 513 const report = await doctorReport();@@ -1,5 +1,12 @@
11 22 3+/**4+ * The `deno task preview` command: serves the built `dist/` output locally over5+ * HTTP for previewing the production PWA.6+ *7+ * @module8+ */9+310 import { extname, join, normalize } from "node:path";411 512 const portFlag = Deno.args.findIndex((argument) => argument === "--port");@@ -1,5 +1,12 @@
11 22 3+/**4+ * The `deno task test` command: runs the generated project's local-first test5+ * suite, retaining browser artifacts under `test-results/`.6+ *7+ * @module8+ */9+310 import { runDeno } from "../tooling/process.ts";411 import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";512 @@ -26,8 +26,11 @@ export const STARTER_FILES: readonly string[] = [
2626 "src/_lofi/diagnostics.ts",2727 "src/_lofi/device-capabilities.ts",2828 "src/_lofi/device-capabilities_test.ts",29+ "src/_lofi/foreground-recovery.ts",30+ "src/_lofi/foreground-recovery_test.ts",2931 "src/_lofi/inspector.ts",3032 "src/_lofi/inspector_test.ts",33+ "src/_lofi/lifecycle.ts",3134 "src/_lofi/probe.ts",3235 "src/_lofi/pwa.ts",3336 "src/_lofi/pwa_test.ts",@@ -5,19 +5,22 @@
55 "deno.json": "679b5f2a8da16e060c2423ae159af5fba9a60d4435b5a3696425978010cbd2b7",66 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",8- "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",8+ "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",99 "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10- "src/_lofi/boot.ts": "be739dbb65a963711f47c1ac801fda3afe3b32fb66b70d0a4c3552022adad6ad",10+ "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1111 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",12- "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",13- "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",12+ "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",13+ "src/_lofi/device-capabilities.ts": "6911fbe8ce93cdd79531982fdafd505202141985baec874e4dd965b02e6115d3",1414 "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",1515 "src/_lofi/diagnostics.ts": "abb2a5f8e4d6b8ab1a86d475db6c374aecf4580b534ba9d7d850e1dbb29bb7f8",16- "src/_lofi/inspector_test.ts": "05de6636a3ff5ff7f918f989ac919cde653fafd81cbd58c8f0c371badb554a07",17- "src/_lofi/inspector.ts": "19fbe4f2d300ca4886ad90f16f6539cc0ad04ba1f38ac28bd9dec269c3f872bd",18- "src/_lofi/probe.ts": "9bb284f4543e631acbfb22bb661b5cfc0798d561b0e95ee09796b36ebb5865b0",19- "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",20- "src/_lofi/pwa.ts": "de48d0653890eb735067a161e907f8c957cb11719ff99e5faf4394efb67cf681",16+ "src/_lofi/foreground-recovery_test.ts": "a5081aecaa70b71c63d721a0875f4d2b3898a49879eb8045c8e74baf0d867444",17+ "src/_lofi/foreground-recovery.ts": "a5ed7a65b9454770de62a4ba8cdac7b0d1a867ec3c703049d2c103791c4d0049",18+ "src/_lofi/inspector_test.ts": "895896572059b37bb8d320953839106efa3a8caa55754b9fbbab781303525a58",19+ "src/_lofi/inspector.ts": "c9f646b17954e07ecece69f9cbef2e3c4a70cd12f62a549220a3f34bee477cf2",20+ "src/_lofi/lifecycle.ts": "0652ce9d4564b830771522986e9d3d6a2afe0849661b43f2d8b4e47ff16c1a1d",21+ "src/_lofi/probe.ts": "818cd9546c900ba482fc6ae8420a5a6e47b8f106ac56dc89c3c647a31aa1c704",22+ "src/_lofi/pwa_test.ts": "bc9c04ff61e2f5a0dfd4601fbab929aa3815f4b174fad600d68b1f74f070d346",23+ "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",2124 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2225 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",2326 "src/_lofi/runtime.ts": "db204517acc708f5145f48ed5cbd1ab95c01e35ddedb09dc0ba9617ea8bc1f79",@@ -1,3 +1,4 @@
1+/** Thrown when Playwright's Chromium browser is not installed, with install guidance. */12 export class BrowserUnavailableError extends Error {23 override readonly name = "BrowserUnavailableError";34 @@ -9,6 +10,10 @@ export class BrowserUnavailableError extends Error {
910 }1011 }1112 13+/**14+ * Rethrow a browser launch failure as a {@link BrowserUnavailableError} when the15+ * message indicates a missing Chromium install; otherwise rethrow it unchanged.16+ */1217 export function rethrowBrowserLaunchError(error: unknown): never {1318 const message = error instanceof Error ? error.message : String(error);1419 if (@@ -1,12 +1,14 @@
11 import type { BrowserTestClient } from "./fixture.ts";22 import type { ValueFreeState } from "./safety.ts";33 4+/** Minimal client contract the convergence runner needs: offline state and toggles. */45 export interface OfflineTestClient {56 readonly offline: boolean;67 goOffline(): Promise<void>;78 goOnline(): Promise<void>;89 }910 11+/** Minimal two-client fixture contract required to drive an offline scenario. */1012 export interface OfflineTestFixture<Client extends OfflineTestClient> {1113 readonly clients: readonly [Client, Client];1214 readonly first: Client;@@ -19,6 +21,11 @@ export interface OfflineTestFixture<Client extends OfflineTestClient> {
1921 ): Promise<unknown>;2022 }2123 24+/**25+ * App-owned definition of a concurrent-offline convergence test: the two edits26+ * plus the hooks that assert readiness, apply and locally verify each edit, and27+ * confirm convergence after reconnection.28+ */2229 export interface ConcurrentOfflineScenario<2330 Edit,2431 Client extends OfflineTestClient = BrowserTestClient,@@ -44,9 +51,11 @@ export interface ConcurrentOfflineScenario<
4451 readonly failureLabel?: string;4552 }4653 54+/** Thrown when a convergence scenario fails, naming the stage that failed via {@link stage}. */4755 export class ConvergenceScenarioError extends Error {4856 override readonly name = "ConvergenceScenarioError";4957 58+ /** @param stage the lifecycle stage that was in progress when the scenario failed */5059 constructor(readonly stage: string, options?: ErrorOptions) {5160 super(`Concurrent offline convergence failed during ${stage}`, options);5261 }@@ -18,13 +18,19 @@ import {
1818 import { sanitizeTraceArchive } from "./trace.ts";1919 2020 type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;21+/**22+ * Playwright context options with `storageState` forbidden, so identity never23+ * leaves memory via an on-disk state file.24+ */2125 export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {2226 /** Identity state is managed in memory by the fixture. File paths are forbidden. */2327 storageState?: never;2428 };2529 30+/** Identifies which of the fixture's two clients a value belongs to. */2631 export type ClientName = "first" | "second";2732 33+/** A redacted console, page-error, or failed-request record captured from a client. */2834 export interface BrowserDiagnostic {2935 readonly client: ClientName;3036 readonly kind: "console" | "page-error" | "request-failed";@@ -33,6 +39,11 @@ export interface BrowserDiagnostic {
3339 readonly url?: string;3440 }3541 42+/**43+ * How the two clients obtain identity: `shared` clones the primary's prepared44+ * state (in memory) into the second client, `isolated` prepares each client on45+ * its own.46+ */3647 export type IdentityOptions =3748 | {3849 readonly mode: "shared";@@ -45,6 +56,7 @@ export type IdentityOptions =
4556 readonly prepare?: (client: BrowserTestClient) => Promise<void>;4657 };4758 59+/** Configures where and how redacted failure artifacts are written on capture. */4860 export interface FailureArtifactOptions {4961 readonly directory: string;5062 /** Literal credential values to remove from retained diagnostics. */@@ -53,6 +65,7 @@ export interface FailureArtifactOptions {
5365 readonly mask?: (client: BrowserTestClient) => readonly Locator[];5466 }5567 68+/** Options for constructing a {@link TwoClientFixture} via {@link createTwoClientFixture}. */5669 export interface TwoClientFixtureOptions {5770 readonly baseURL: string;5871 readonly identity: IdentityOptions;@@ -63,6 +76,7 @@ export interface TwoClientFixtureOptions {
6376 readonly traceOnFailure?: boolean;6477 }6578 79+/** Re-exported so callers can catch a missing-Chromium launch failure. */6680 export { BrowserUnavailableError };6781 6882 function validateContextOptions(options: SafeContextOptions | undefined): void {@@ -73,6 +87,11 @@ function validateContextOptions(options: SafeContextOptions | undefined): void {
7387 }7488 }7589 90+/**91+ * One browser client (context + page) whose identity and IndexedDB stay in92+ * memory. Records redacted diagnostics and can go offline/online, restart, and93+ * capture a sanitized trace.94+ */7695 export class BrowserTestClient {7796 readonly #diagnostics: BrowserDiagnostic[] = [];7897 #context: BrowserContext;@@ -94,22 +113,27 @@ export class BrowserTestClient {
94113 this.#page = page;95114 }96115 116+ /** The client's current Playwright browser context. */97117 get context(): BrowserContext {98118 return this.#context;99119 }100120 121+ /** The client's current Playwright page. */101122 get page(): Page {102123 return this.#page;103124 }104125 126+ /** Whether the client's network is currently forced offline. */105127 get offline(): boolean {106128 return this.#offline;107129 }108130 131+ /** Redacted diagnostics collected from the client's pages so far. */109132 get diagnostics(): readonly BrowserDiagnostic[] {110133 return this.#diagnostics;111134 }112135 136+ /** Attach diagnostic listeners and, if enabled, begin failure tracing. */113137 async startRecording(): Promise<void> {114138 if (this.#recording || this.#closed) return;115139 this.#attachDiagnostics(this.#page);@@ -119,11 +143,13 @@ export class BrowserTestClient {
119143 this.#recording = true;120144 }121145 146+ /** Force the client's network offline. */122147 async goOffline(): Promise<void> {123148 await this.#context.setOffline(true);124149 this.#offline = true;125150 }126151 152+ /** Restore the client's network to online. */127153 async goOnline(): Promise<void> {128154 await this.#context.setOffline(false);129155 this.#offline = false;@@ -168,6 +194,10 @@ export class BrowserTestClient {
168194 return this.#page;169195 }170196 197+ /**198+ * Stop tracing and write a sanitized trace archive to `path`. Returns false199+ * when tracing was not active; removes the archive if sanitization fails.200+ */171201 async captureTrace(path: string): Promise<boolean> {172202 if (!this.#recording || !this.traceOnFailure) return false;173203 await this.#context.tracing.stop({ path });@@ -181,6 +211,7 @@ export class BrowserTestClient {
181211 return true;182212 }183213 214+ /** Discard any pending trace and close the client's context. Idempotent. */184215 async close(): Promise<void> {185216 if (this.#closed) return;186217 this.#closed = true;@@ -222,12 +253,19 @@ export class BrowserTestClient {
222253 }223254 }224255 256+/** The directory and file paths produced by a failure capture. */225257 export interface FailureArtifacts {226258 readonly directory: string;227259 readonly files: readonly string[];228260 }229261 262+/**263+ * Coordinates a pair of {@link BrowserTestClient}s sharing one browser, with264+ * helpers to take both clients offline/online and to capture redacted,265+ * value-free failure artifacts.266+ */230267 export class TwoClientFixture {268+ /** The fixture's two clients, ordered `[first, second]`. */231269 readonly clients: readonly [BrowserTestClient, BrowserTestClient];232270 #closed = false;233271 @@ -241,22 +279,31 @@ export class TwoClientFixture {
241279 this.clients = [first, second];242280 }243281 282+ /** The first client. */244283 get first(): BrowserTestClient {245284 return this.clients[0];246285 }247286 287+ /** The second client. */248288 get second(): BrowserTestClient {249289 return this.clients[1];250290 }251291 292+ /** Take both clients offline. */252293 async goOffline(): Promise<void> {253294 await Promise.all(this.clients.map((client) => client.goOffline()));254295 }255296 297+ /** Restore both clients to online. */256298 async goOnline(): Promise<void> {257299 await Promise.all(this.clients.map((client) => client.goOnline()));258300 }259301 302+ /**303+ * Write redacted screenshots, traces, diagnostics, and (optionally) a304+ * value-free state-shape snapshot under a label-named directory. Returns305+ * undefined when no artifact directory was configured.306+ */260307 async captureFailure(261308 label: string,262309 snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,@@ -299,6 +346,10 @@ export class TwoClientFixture {
299346 return { directory, files };300347 }301348 349+ /**350+ * Close both clients and, when the fixture owns it, the browser. Idempotent;351+ * rethrows the first client close failure.352+ */302353 async close(): Promise<void> {303354 if (this.#closed) return;304355 this.#closed = true;@@ -309,6 +360,11 @@ export class TwoClientFixture {
309360 }310361 }311362 363+/**364+ * Launch (or reuse) a browser and build a {@link TwoClientFixture}: opens both365+ * clients at `baseURL`, applies the chosen identity mode with state kept in366+ * memory, and starts recording. Cleans up on any setup failure.367+ */312368 export async function createTwoClientFixture(313369 options: TwoClientFixtureOptions,314370 ): Promise<TwoClientFixture> {@@ -1,3 +1,11 @@
1+/**2+ * The `@nzip/lofi/testing` public surface: Playwright-backed helpers for testing3+ * local-first behavior, including two-client fixtures, concurrent offline4+ * convergence, app-owned readiness waits, and value-free failure artifacts.5+ *6+ * @module7+ */8+19 export {210 type BrowserDiagnostic,311 BrowserTestClient,@@ -1,5 +1,6 @@
11 import type { Page } from "npm:playwright@1.61.1";22 3+/** Options controlling {@link waitForReady}'s description, timeout, and polling. */34 export interface ReadinessOptions {45 /** A useful name for the condition, included when Playwright times out. */56 description?: string;@@ -9,6 +10,7 @@ export interface ReadinessOptions {
910 polling?: "raf" | number;1011 }1112 13+/** Thrown when the readiness predicate does not become true before the timeout. */1214 export class ReadinessError extends Error {1315 override readonly name = "ReadinessError";1416 @@ -1,3 +1,8 @@
1+/**2+ * A JSON-like value restricted to booleans, finite numbers, arrays, and plain3+ * objects. Strings are excluded so state snapshots record shape and counts but4+ * never user values or credentials.5+ */16 export type ValueFreeState =27 | null38 | boolean@@ -67,6 +72,10 @@ export function assertValueFreeState(
6772 throw new TypeError(`${path} contains an unsupported value`);6873 }6974 75+/**76+ * Normalize a label into a filesystem-safe artifact directory name, falling77+ * back to `"failure"` when nothing usable remains.78+ */7079 export function artifactName(value: string): string {7180 const safe = value.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "");7281 return safe || "failure";@@ -45,6 +45,11 @@ async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {
4545 return new Uint8Array(await new Response(output).arrayBuffer());4646 }4747 48+/**49+ * Read a Playwright trace ZIP at `path` into a map of entry name to50+ * decompressed bytes, supporting stored and deflate entries. Throws on51+ * encrypted, malformed, or unsupported-compression archives.52+ */4853 export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {4954 const bytes = await Deno.readFile(path);5055 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);@@ -81,6 +86,10 @@ export async function readTraceArchive(path: string): Promise<Record<string, Uin
8186 return entries;8287 }8388 89+/**90+ * Serialize the given entries into a stored (uncompressed) ZIP archive with91+ * CRC-32 checksums, suitable for rewriting a sanitized trace.92+ */8493 export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {8594 const localParts: Uint8Array[] = [];8695 const centralParts: Uint8Array[] = [];