clone

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

feat: ship generated app PWA and release gate

Dami committed 5a2a57a
commit
5a2a57aa33fff7218fbde36936eb65f501e446d8
parent
e9f0f6da511728592378aa8dccf43f3af8e3424b
author
Dami <dami.etoile@gmail.com>
35 files changed, +1576 -206
A.github/workflows/ci.yml44++
M.github/workflows/publish.yml27+-
MREADME.md3+
Mapps/reference/README.md3+
Mapps/reference/deno.json1+
Mapps/reference/deno.lock2+
Aapps/reference/public/apple-touch-icon.pngbin
Aapps/reference/public/icon-192.pngbin
Aapps/reference/public/icon-512.pngbin
Aapps/reference/public/icon-maskable-512.pngbin
Mapps/reference/public/manifest.webmanifest20+-
Mapps/reference/src/layouts/Shell.astro1+
Mapps/reference/src/styles/global.css27+
Mapps/reference/tests/author-boundary_test.ts18+-
Mdeno.json4+
Mdeno.lock1+
Mdocs/reference/project-layout.md88+++-
Mpackage/astro/mod.ts7+-
Mpackage/commands/preview.ts1+
Mpackage/create_core.ts7+-
Mpackage/create_core_test.ts15+
Mpackage/preact/DeviceStatus.tsx3+-
Apackage/preact/PwaActions.tsx74+++
Apackage/preact/PwaActions_test.tsx58++
Mpackage/preact/mod.ts1+
Apackage/project_layout_doc_test.ts47++
Apackage/pwa_assets_test.ts55++
Mpackage/runtime/mod.ts8+
Mpackage/runtime/pwa.ts330++++++++------
Mpackage/runtime/pwa_test.ts184+++++++-
Apackage/runtime/sw_test.ts28+
Mpackage/starter_template.ts10+-
Mpackage/testdata/starter.snapshot.json16+-
Mpackage/tooling/project.ts1+
Atools/golden.ts698++++++++++++++++++++++++++++++
A .github/workflows/ci.yml +44 -0
M .github/workflows/publish.yml +25 -2
M README.md +3 -0
M apps/reference/README.md +3 -0
M apps/reference/deno.json +1 -0
M apps/reference/deno.lock +2 -0
A apps/reference/public/apple-touch-icon.png +0 -0

Binary file.

A apps/reference/public/icon-192.png +0 -0

Binary file.

A apps/reference/public/icon-512.png +0 -0

Binary file.

A apps/reference/public/icon-maskable-512.png +0 -0

Binary file.

M apps/reference/public/manifest.webmanifest +16 -4
M apps/reference/src/layouts/Shell.astro +1 -0
M apps/reference/src/styles/global.css +27 -0
M apps/reference/tests/author-boundary_test.ts +13 -5
M deno.json +4 -0
M deno.lock +1 -0
M docs/reference/project-layout.md +59 -29
@@ -1,40 +1,70 @@
11 # Generated project layout22 3+The generated project is deliberately small: application source and branding are yours, while the4+pinned `@nzip/lofi` package supplies runtime, PWA, identity, sync, diagnostics, and Astro/Vite5+integration. Package commands regenerate framework tooling and production output without copying6+framework source into the application.7+38 ```mermaid49 flowchart TB510 Root["my-app/"]6- Root --> Config["Project config<br/>deno.json · tsconfig.json"]7- Root --> Env[".env.example"]8- Root --> Public["public/<br/>favicon · manifest"]9- Root --> Src["src/"]10- Root --> Tests["tests/"]11-12- Src --> App["app.ts"]13- Src --> Schema["schema.ts"]14- Src --> Permissions["permissions.ts"]15- Src --> Pages["pages/"]16- Src --> Islands["islands/"]17- Src --> Styles["styles/"]18- Src --> Layouts["layouts/"]19- Root --> Runtime["@nzip/lofi<br/>versioned runtime"]11+ Root --> Config["Generated configuration"]12+ Root --> Public["Product assets"]13+ Root --> Src["Author-owned source"]14+ Root --> Tests["Application tests"]15+ Root -. command generated .-> Tooling[".lofi/<br/>ignored package tooling"]16+ Root -. build generated .-> Dist["dist/<br/>build output"]17+ Package["one pinned @nzip/lofi version"] --> Tooling18+ Package --> Dist19+ Package --> Src2020 ```2121 22-## Author-owned files22+## Exact source-controlled file map23+24+The following keys are extracted by the documentation drift test and compared with a real generated25+project. Adding or removing a generated file requires updating this map in the same change.26+27+<!-- generated-file-map:start -->28+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/manifest.webmanifest` | Product asset | Declares install metadata, theme colors, scope, and raster icons. |41+| `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. |42+| `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. |43+| `src/islands/AccountGate.tsx` | Author-owned source | Presents opt-in account backup, sync, and recovery controls. |44+| `src/islands/TaskList.tsx` | Author-owned source | Implements the replaceable starter task experience. |45+| `src/islands/use-tasks.ts` | Author-owned source | Binds the starter task table to Preact through public package APIs. |46+| `src/layouts/Shell.astro` | Author-owned source | Owns document metadata, install links, and package boot. |47+| `src/pages/index.astro` | Author-owned source | Composes the starter page and package-owned optional UI. |48+| `src/permissions.ts` | Author-owned source | Declares application read and mutation policy. |49+| `src/schema.ts` | Author-owned source | Declares persisted application tables and fields. |50+| `src/styles/global.css` | Author-owned source | Styles the starter page, islands, and optional package surfaces. |51+| `tests/auth_e2e_test.ts` | Application test | Demonstrates an opt-in browser credential round trip. |52+| `tests/author-boundary_test.ts` | Application test | Prevents framework plumbing from returning to author UI. |53+| `tests/convergence_e2e_test.ts` | Application test | Demonstrates the optional two-client synced browser journey. |54+| `tests/testing-contract_test.ts` | Application test | Type-checks the public local-first browser helpers. |55+| `tsconfig.json` | Generated configuration | Enables strict Astro and Preact source checking. |56+57+<!-- generated-file-map:end -->2358 24-- `src/schema.ts` declares persisted tables and their field types.25-- `src/permissions.ts` declares read and mutation policies.26-- `src/app.ts` composes product configuration.27-- `src/pages/`, `src/islands/`, and `src/styles/` contain the product experience.28-- `public/` contains install and static shell assets.29-- `tests/` contains application tests and worked local-first browser examples.59+## Regenerated and ignored output3060 31-## Versioned framework runtime61+These paths are not source-controlled generated files. Package commands recreate them as needed.3262 33-`@nzip/lofi` owns durable storage, the Jazz client, account sessions, recovery, lifecycle handling,34-PWA capability gates, diagnostics, and table stores. These modules are installed once through the35-pinned package version instead of copied into application source.63+| Path | Ownership category | Role |64+| -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |65+| `.lofi/` | Ignored package tooling | Materialized package runtime plus Astro/Vite/Jazz configuration used by `dev` and `build`. |66+| `dist/` | Build output | Static production shell, hashed assets, install icons, build identity, precache manifest, and package-emitted `sw.js`. |3667 37-The generated domain hook imports selected public package seams. Follow that pattern when binding a38-new table, but keep vendor setup, storage selection, transports, service-worker logic, and39-capability branching out of product components. `deno task dev` and `build` materialize ignored40-Astro integration files under `.lofi/`; `build` emits the service worker into `dist/`.68+Generated applications do not contain `src/_lofi/`, a source `public/sw.js`, or a checked-in69+`astro.config.ts`. Upgrade the single pinned `@nzip/lofi` version to change framework behavior; edit70+the author-owned source and product assets to change the application.
M package/astro/mod.ts +6 -1
M package/commands/preview.ts +1 -0
M package/create_core.ts +6 -1
M package/create_core_test.ts +15 -0
M package/preact/DeviceStatus.tsx +2 -1
A package/preact/PwaActions.tsx +74 -0
@@ -0,0 +1,74 @@
1+import type { JSX } from "preact";2+import { useEffect, useState } from "preact/hooks";3+import {4+ type PwaController,5+ pwaController,6+ pwaFailureMessage,7+ type PwaState,8+} from "../runtime/pwa.ts";9+10+export { pwaFailureMessage } from "../runtime/pwa.ts";11+12+export function usePwaState(controller: PwaController = pwaController): PwaState {13+ const [state, setState] = useState<PwaState>(controller.getState());14+ useEffect(() => controller.subscribe(setState), [controller]);15+ return state;16+}17+18+export interface PwaActionsProps {19+ readonly controller?: PwaController;20+ readonly title?: string;21+}22+23+/** A composable install/update surface that keeps browser event handling package-owned. */24+export function PwaActions({25+ controller = pwaController,26+ title = "Install & updates",27+}: PwaActionsProps): JSX.Element | null {28+ const state = usePwaState(controller);29+ const visible = state.install === "available" || state.install === "manual-ios" ||30+ state.install === "accepted" || state.install === "dismissed" ||31+ state.worker === "update-available" || Boolean(state.failure);32+ if (!visible) return null;33+34+ return (35+ <section class="pwa-actions" aria-labelledby="pwa-actions-title">36+ <h3 id="pwa-actions-title">{title}</h3>37+ {state.install === "available" && (38+ <button39+ type="button"40+ onClick={() => void controller.requestInstall()}41+ >42+ Install app43+ </button>44+ )}45+ {state.install === "accepted" && (46+ <p role="status">Installation accepted. Follow the browser prompt to finish.</p>47+ )}48+ {state.install === "dismissed" && (49+ <p role="status">Installation dismissed. You can use the browser install menu later.</p>50+ )}51+ {state.install === "manual-ios" && (52+ <div class="pwa-ios-guidance">53+ <p>Install this app on your iPhone or iPad:</p>54+ <ol>55+ <li>Open the Share menu.</li>56+ <li>Choose Add to Home Screen.</li>57+ <li>Confirm Add.</li>58+ </ol>59+ </div>60+ )}61+ {state.worker === "update-available" && (62+ <button63+ type="button"64+ onClick={() => controller.applyUpdate()}65+ >66+ Update app67+ </button>68+ )}69+ {state.failure && (70+ <p class="pwa-error" role="alert">{pwaFailureMessage(state.failure.code)}</p>71+ )}72+ </section>73+ );74+}
A package/preact/PwaActions_test.tsx +58 -0
@@ -0,0 +1,58 @@
1+import { render } from "npm:preact-render-to-string@6.7.0";2+import type { PwaController, PwaState } from "../runtime/pwa.ts";3+import { PwaActions, pwaFailureMessage } from "./PwaActions.tsx";4+5+function controller(state: PwaState): PwaController {6+ return {7+ getState: () => state,8+ subscribe(subscriber) {9+ subscriber(state);10+ return () => undefined;11+ },12+ requestInstall: () => Promise.resolve(state.install),13+ applyUpdate: () => true,14+ initialize: () => undefined,15+ };16+}17+18+function renderState(state: PwaState): string {19+ return render(<PwaActions controller={controller(state)} />);20+}21+22+Deno.test("PwaActions renders a Chromium action only while the prompt is available", () => {23+ const available = renderState({ worker: "ready", install: "available" });24+ const dismissed = renderState({ worker: "ready", install: "dismissed" });25+ if (!available.includes("Install app")) throw new Error("install action was omitted");26+ if (dismissed.includes("Install app")) throw new Error("install action outlived its prompt");27+ if (!dismissed.includes("Installation dismissed")) {28+ throw new Error("dismissed outcome was hidden");29+ }30+});31+32+Deno.test("PwaActions renders complete iOS Add to Home Screen guidance", () => {33+ const html = renderState({ worker: "ready", install: "manual-ios" });34+ for (const step of ["Open the Share menu.", "Choose Add to Home Screen.", "Confirm Add."]) {35+ if (!html.includes(step)) throw new Error(`iOS guidance omitted: ${step}`);36+ }37+ if (/unavailable in (?:the )?EU/i.test(html)) throw new Error("obsolete EU guidance returned");38+});39+40+Deno.test("PwaActions renders the waiting-worker update action", () => {41+ const html = renderState({ worker: "update-available", install: "unavailable" });42+ if (!html.includes("Update app")) throw new Error("update action was omitted");43+});44+45+Deno.test("actionable PWA failure messages are fixed, useful, and non-secret", () => {46+ for (const code of ["registration", "installation", "precache", "runtime-cache"] as const) {47+ const message = pwaFailureMessage(code);48+ if (!/(Reload|Reconnect)/.test(message)) throw new Error(`${code} lacks an action`);49+ const html = renderState({50+ worker: "failed",51+ install: "unavailable",52+ failure: { code, message: "secret-value-must-not-render" },53+ });54+ if (!html.includes(message) || html.includes("secret-value")) {55+ throw new Error(`${code} failure rendering is not safely actionable`);56+ }57+ }58+});
M package/preact/mod.ts +1 -0
A package/project_layout_doc_test.ts +47 -0
@@ -0,0 +1,47 @@
1+import { join, relative } from "node:path";2+import { createProject } from "./create_core.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+async function generatedFiles(root: string): Promise<string[]> {9+ const files: string[] = [];10+ async function visit(directory: string) {11+ for await (const entry of Deno.readDir(directory)) {12+ const path = join(directory, entry.name);13+ if (entry.isDirectory) await visit(path);14+ else if (entry.isFile) files.push(relative(root, path).replaceAll("\\", "/"));15+ }16+ }17+ await visit(root);18+ return files.sort();19+}20+21+function documentedFiles(markdown: string): string[] {22+ const start = "<!-- generated-file-map:start -->";23+ const end = "<!-- generated-file-map:end -->";24+ const section = markdown.slice(markdown.indexOf(start) + start.length, markdown.indexOf(end));25+ assert(markdown.includes(start) && markdown.includes(end), "project map markers are missing");26+ return [...section.matchAll(/^\|\s+`([^`]+)`\s+\|/gm)].map((match) => match[1]).sort();27+}28+29+Deno.test("documented project map matches a real generated project", async () => {30+ const temporaryRoot = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-layout-doc-test-" });31+ try {32+ const project = await createProject({ cwd: temporaryRoot, name: "mapped-app" });33+ const actual = await generatedFiles(project.destination);34+ const markdown = await Deno.readTextFile(35+ new URL("../docs/reference/project-layout.md", import.meta.url),36+ );37+ const documented = documentedFiles(markdown);38+ assert(39+ JSON.stringify(actual) === JSON.stringify(documented),40+ `generated project map drifted\nactual: ${actual.join(", ")}\ndocumented: ${41+ documented.join(", ")42+ }`,43+ );44+ } finally {45+ await Deno.remove(temporaryRoot, { recursive: true });46+ }47+});
A package/pwa_assets_test.ts +55 -0
@@ -0,0 +1,55 @@
1+import { basename } from "node:path";2+3+type ManifestIcon = { src: string; sizes: string; type: string; purpose: string };4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+function pngDimensions(bytes: Uint8Array): { width: number; height: number } {10+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];11+ assert(signature.every((byte, index) => bytes[index] === byte), "asset is not a PNG");12+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);13+ assert(new TextDecoder().decode(bytes.subarray(12, 16)) === "IHDR", "PNG has no IHDR");14+ return { width: view.getUint32(16), height: view.getUint32(20) };15+}16+17+Deno.test("starter manifest references install-grade raster icons with exact dimensions", async () => {18+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);19+ const manifest = JSON.parse(20+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),21+ ) as {22+ icons: ManifestIcon[];23+ };24+ const expected = new Map([25+ ["icon-192.png", { size: 192, purpose: "any" }],26+ ["icon-512.png", { size: 512, purpose: "any" }],27+ ["icon-maskable-512.png", { size: 512, purpose: "maskable" }],28+ ]);29+ assert(manifest.icons.length === expected.size, "manifest icon set changed unexpectedly");30+ for (const icon of manifest.icons) {31+ const file = basename(icon.src);32+ const contract = expected.get(file);33+ assert(contract, `manifest references unexpected icon ${file}`);34+ assert(icon.type === "image/png", `${file} must declare image/png`);35+ assert(icon.purpose === contract.purpose, `${file} has the wrong purpose`);36+ assert(37+ icon.sizes === `${contract.size}x${contract.size}`,38+ `${file} has the wrong declared size`,39+ );40+ const dimensions = pngDimensions(await Deno.readFile(new URL(file, publicRoot)));41+ assert(42+ dimensions.width === contract.size && dimensions.height === contract.size,43+ `${file} is ${dimensions.width}x${dimensions.height}`,44+ );45+ }46+ const apple = pngDimensions(await Deno.readFile(new URL("apple-touch-icon.png", publicRoot)));47+ assert(apple.width === 180 && apple.height === 180, "apple-touch-icon.png must be 180x180");48+ const shell = await Deno.readTextFile(49+ new URL("../apps/reference/src/layouts/Shell.astro", import.meta.url),50+ );51+ assert(52+ shell.includes('rel="apple-touch-icon" href="./apple-touch-icon.png"'),53+ "iOS icon is not linked",54+ );55+});
M package/runtime/mod.ts +8 -0
M package/runtime/pwa.ts +186 -144
@@ -28,14 +28,12 @@ export type PwaState = {
2828 };2929 };3030 31-type InstallPromptEvent = Event & {31+export type InstallPromptEvent = Event & {3232 prompt(): Promise<void>;3333 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;3434 };3535 36-type NavigatorWithStandalone = Navigator & { standalone?: boolean };37-38-type InstallEnvironment = {36+export type InstallEnvironment = {3937 displayModeStandalone: boolean;4038 navigatorStandalone: boolean;4139 platform: string;
@@ -44,37 +42,37 @@ type InstallEnvironment = {
4442 4543 type LofiPwaGlobal = typeof globalThis & { __LOFI_PWA_STATE__?: PwaState };4644 47-const subscribers = new Set<(state: PwaState) => void>();48-let deferredInstallPrompt: InstallPromptEvent | undefined;49-let waitingWorker: ServiceWorker | undefined;50-let reloadForUpdate = false;51-let initialized = false;52-53-let state: PwaState = {54- worker: "registering",55- install: "unavailable",45+const actionableFailureMessages: Record<PwaFailureCode, string> = {46+ registration:47+ "Offline support could not start. Reload on a stable connection; if it continues, clear this site's storage and retry.",48+ installation:49+ "The offline update could not install. Reconnect, keep this tab open, and reload to try again.",50+ precache:51+ "Required offline files were not saved. Reconnect, reload, and wait for offline support before disconnecting.",52+ "runtime-cache":53+ "A recently opened file was not saved for offline use. Reconnect and open it again before going offline.",5654 };5755 58-function publish(next: PwaState) {59- state = next;60- if (typeof document !== "undefined") {61- (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = state;62- }63- for (const subscriber of subscribers) subscriber(state);56+export function pwaFailureMessage(code: PwaFailureCode): string {57+ return actionableFailureMessages[code];6458 }6559 66-function update(patch: Partial<PwaState>) {67- publish({ ...state, ...patch });60+export interface PwaControllerDependencies {61+ readonly eventTarget?: () => EventTarget;62+ readonly serviceWorker?: () => ServiceWorkerContainer | undefined;63+ readonly installEnvironment?: () => InstallEnvironment;64+ readonly production?: () => boolean;65+ readonly documentBaseURI?: () => string;66+ readonly reload?: () => void;67+ readonly exposeState?: (state: PwaState) => void;6868 }6969 70-export function getPwaState(): PwaState {71- return state;72-}73-74-export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {75- subscribers.add(subscriber);76- subscriber(state);77- return () => subscribers.delete(subscriber);70+export interface PwaController {71+ getState(): PwaState;72+ subscribe(subscriber: (state: PwaState) => void): () => void;73+ requestInstall(): Promise<PwaInstallState>;74+ applyUpdate(): boolean;75+ initialize(): void;7876 }7977 8078 export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {
@@ -84,35 +82,6 @@ export function classifyInstallExperience(environment: InstallEnvironment): PwaI
8482 return iosLike ? "manual-ios" : "unavailable";8583 }8684 87-function currentInstallExperience(): PwaInstallState {88- const standalone = navigator as NavigatorWithStandalone;89- return classifyInstallExperience({90- displayModeStandalone: matchMedia("(display-mode: standalone)").matches,91- navigatorStandalone: standalone.standalone === true,92- platform: navigator.platform,93- maxTouchPoints: navigator.maxTouchPoints,94- });95-}96-97-export async function requestPwaInstall(): Promise<PwaInstallState> {98- const prompt = deferredInstallPrompt;99- if (!prompt) return state.install;100- deferredInstallPrompt = undefined;101- update({ install: "prompting" });102- await prompt.prompt();103- const choice = await prompt.userChoice;104- const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";105- update({ install: outcome });106- return outcome;107-}108-109-export function applyPwaUpdate(): boolean {110- if (!waitingWorker) return false;111- reloadForUpdate = true;112- waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });113- return true;114-}115-11685 export function waitForActivation(worker: ServiceWorker): Promise<void> {11786 if (worker.state === "activated") return Promise.resolve();11887 if (worker.state === "redundant") return Promise.reject(new Error("service worker is redundant"));
@@ -132,101 +101,174 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
132101 });133102 }134103 135-function watchForUpdate(registration: ServiceWorkerRegistration) {136- const exposeWaitingWorker = (worker: ServiceWorker) => {137- waitingWorker = worker;138- update({ worker: "update-available" });104+export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {105+ const subscribers = new Set<(state: PwaState) => void>();106+ let deferredInstallPrompt: InstallPromptEvent | undefined;107+ let waitingWorker: ServiceWorker | undefined;108+ let reloadForUpdate = false;109+ let initialized = false;110+ let state: PwaState = { worker: "registering", install: "unavailable" };111+112+ const publish = (next: PwaState) => {113+ state = next;114+ dependencies.exposeState?.(state);115+ for (const subscriber of subscribers) subscriber(state);116+ };117+ const update = (patch: Partial<PwaState>) => publish({ ...state, ...patch });118+ const fail = (code: PwaFailureCode) => {119+ update({ worker: "failed", failure: { code, message: pwaFailureMessage(code) } });120+ };121+ const target = () => dependencies.eventTarget?.() ?? globalThis;122+ const serviceWorker = () =>123+ dependencies.serviceWorker?.() ??124+ (typeof navigator !== "undefined" && "serviceWorker" in navigator125+ ? navigator.serviceWorker126+ : undefined);127+ const installEnvironment = () =>128+ dependencies.installEnvironment?.() ?? {129+ displayModeStandalone: matchMedia("(display-mode: standalone)").matches,130+ navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,131+ platform: navigator.platform,132+ maxTouchPoints: navigator.maxTouchPoints,133+ };134+135+ const watchForUpdate = (136+ registration: ServiceWorkerRegistration,137+ container: ServiceWorkerContainer,138+ ) => {139+ const exposeWaitingWorker = (worker: ServiceWorker) => {140+ waitingWorker = worker;141+ update({ worker: "update-available", failure: undefined });142+ };143+144+ if (registration.waiting && container.controller) exposeWaitingWorker(registration.waiting);145+ registration.addEventListener("updatefound", () => {146+ const worker = registration.installing;147+ if (!worker) return;148+ worker.addEventListener("statechange", () => {149+ if (worker.state === "installed" && container.controller) {150+ exposeWaitingWorker(registration.waiting ?? worker);151+ } else if (worker.state === "redundant") {152+ fail("installation");153+ }154+ });155+ });156+ };157+158+ const watchInstallExperience = () => {159+ update({ install: classifyInstallExperience(installEnvironment()) });160+ target().addEventListener("beforeinstallprompt", (event) => {161+ event.preventDefault();162+ deferredInstallPrompt = event as InstallPromptEvent;163+ update({ install: "available" });164+ });165+ target().addEventListener("appinstalled", () => {166+ deferredInstallPrompt = undefined;167+ update({ install: "installed" });168+ });139169 };140170 141- if (registration.waiting && navigator.serviceWorker.controller) {142- exposeWaitingWorker(registration.waiting);143- }144- registration.addEventListener("updatefound", () => {145- const worker = registration.installing;146- if (!worker) return;147- worker.addEventListener("statechange", () => {148- if (worker.state === "installed" && navigator.serviceWorker.controller) {149- exposeWaitingWorker(registration.waiting ?? worker);171+ const initialize = () => {172+ if (initialized) return;173+ initialized = true;174+ watchInstallExperience();175+ if (!(dependencies.production?.() ?? import.meta.env.PROD)) {176+ update({ worker: "development-disabled" });177+ return;178+ }179+ const container = serviceWorker();180+ if (!container) {181+ update({ worker: "unsupported" });182+ return;183+ }184+ update({ worker: "registering" });185+ container.addEventListener("message", (event) => {186+ const message = (event as MessageEvent).data as { type?: string; code?: PwaFailureCode };187+ if (188+ message?.type === "LOFI_PWA_FAILURE" &&189+ (message.code === "precache" || message.code === "runtime-cache")190+ ) fail(message.code);191+ });192+ container.addEventListener("controllerchange", () => {193+ if (!reloadForUpdate) return;194+ if (dependencies.reload) dependencies.reload();195+ else location.reload();196+ });197+ const workerUrl = new URL("./sw.js", dependencies.documentBaseURI?.() ?? document.baseURI);198+ void (async () => {199+ const registration = await container.register(workerUrl, {200+ scope: new URL("./", dependencies.documentBaseURI?.() ?? document.baseURI).pathname,201+ });202+ watchForUpdate(registration, container);203+ if (registration.waiting && container.controller) return;204+ const worker = registration.installing ?? registration.active ?? registration.waiting;205+ if (!worker) throw new Error("service worker registration has no worker");206+ try {207+ await waitForActivation(worker);208+ } catch {209+ fail("installation");210+ return;150211 }151- if (worker.state === "redundant") {152- update({153- worker: "failed",154- failure: { code: "installation", message: "service worker installation failed" },155- });212+ update({ worker: "ready", failure: undefined });213+ })().catch(() => fail("registration"));214+ };215+216+ return {217+ getState: () => state,218+ subscribe(subscriber) {219+ subscribers.add(subscriber);220+ subscriber(state);221+ return () => subscribers.delete(subscriber);222+ },223+ async requestInstall() {224+ const prompt = deferredInstallPrompt;225+ if (!prompt) return state.install;226+ deferredInstallPrompt = undefined;227+ update({ install: "prompting" });228+ try {229+ await prompt.prompt();230+ const choice = await prompt.userChoice;231+ const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";232+ update({ install: outcome });233+ return outcome;234+ } catch {235+ update({ install: "unavailable" });236+ fail("installation");237+ return "unavailable";156238 }157- });158- });239+ },240+ applyUpdate() {241+ if (!waitingWorker) return false;242+ reloadForUpdate = true;243+ waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });244+ return true;245+ },246+ initialize,247+ };159248 }160249 161-function watchInstallExperience() {162- update({ install: currentInstallExperience() });163- globalThis.addEventListener("beforeinstallprompt", (event) => {164- event.preventDefault();165- deferredInstallPrompt = event as InstallPromptEvent;166- update({ install: "available" });167- });168- globalThis.addEventListener("appinstalled", () => {169- deferredInstallPrompt = undefined;170- update({ install: "installed" });171- });250+export const pwaController: PwaController = createPwaController({251+ exposeState(next) {252+ if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;253+ },254+});255+256+export function getPwaState(): PwaState {257+ return pwaController.getState();172258 }173259 174-function watchWorkerMessages() {175- navigator.serviceWorker.addEventListener("message", (event) => {176- const message = event.data as {177- type?: string;178- code?: PwaFailureCode;179- message?: string;180- };181- if (182- message?.type !== "LOFI_PWA_FAILURE" ||183- !["precache", "runtime-cache"].includes(message.code ?? "")184- ) return;185- update({186- worker: "failed",187- failure: {188- code: message.code as PwaFailureCode,189- message: message.message || "service worker cache operation failed",190- },191- });192- });260+export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {261+ return pwaController.subscribe(subscriber);262+}263+264+export function requestPwaInstall(): Promise<PwaInstallState> {265+ return pwaController.requestInstall();266+}267+268+export function applyPwaUpdate(): boolean {269+ return pwaController.applyUpdate();193270 }194271 195272 export function registerProductionServiceWorker(): void {196- if (initialized || typeof document === "undefined") return;197- initialized = true;198- watchInstallExperience();199- if (!import.meta.env.PROD) {200- update({ worker: "development-disabled" });201- return;202- }203- if (!("serviceWorker" in navigator)) {204- update({ worker: "unsupported" });205- return;206- }207- update({ worker: "registering" });208- watchWorkerMessages();209- navigator.serviceWorker.addEventListener("controllerchange", () => {210- if (reloadForUpdate) location.reload();211- });212- const workerUrl = new URL("./sw.js", document.baseURI);213- void (async () => {214- const registration = await navigator.serviceWorker.register(workerUrl, {215- scope: new URL("./", document.baseURI).pathname,216- });217- watchForUpdate(registration);218- if (registration.waiting && navigator.serviceWorker.controller) return;219- const worker = registration.installing ?? registration.active ?? registration.waiting;220- if (!worker) throw new Error("service worker registration has no worker");221- await waitForActivation(worker);222- update({ worker: "ready", failure: undefined });223- })().catch((error) => {224- update({225- worker: "failed",226- failure: {227- code: "registration",228- message: error instanceof Error ? error.message : "service worker registration failed",229- },230- });231- });273+ pwaController.initialize();232274 }
M package/runtime/pwa_test.ts +174 -10
@@ -1,5 +1,12 @@
1-import { classifyInstallExperience, waitForActivation } from "./pwa.ts";2-// Package contract tests.1+import {2+ classifyInstallExperience,3+ createPwaController,4+ type InstallPromptEvent,5+ type PwaController,6+ type PwaInstallState,7+ type PwaState,8+ waitForActivation,9+} from "./pwa.ts";310 411 const test = (globalThis as unknown as {512 Deno: { test(name: string, body: () => void | Promise<void>): void };
@@ -7,6 +14,7 @@ const test = (globalThis as unknown as {
714 815 class FakeServiceWorker extends EventTarget {916 state: ServiceWorkerState;17+ readonly messages: unknown[] = [];1018 1119 constructor(state: ServiceWorkerState) {1220 super();
@@ -17,12 +25,80 @@ class FakeServiceWorker extends EventTarget {
1725 this.state = state;1826 this.dispatchEvent(new Event("statechange"));1927 }28+29+ postMessage(message: unknown) {30+ this.messages.push(message);31+ }32+}33+34+class FakeRegistration extends EventTarget {35+ installing: ServiceWorker | null = null;36+ waiting: ServiceWorker | null = null;37+ active: ServiceWorker | null = null;38+}39+40+class FakeContainer extends EventTarget {41+ controller: ServiceWorker | null = null;42+ registration = new FakeRegistration();43+ registerError: Error | undefined;44+45+ register(): Promise<ServiceWorkerRegistration> {46+ if (this.registerError) return Promise.reject(this.registerError);47+ return Promise.resolve(this.registration as unknown as ServiceWorkerRegistration);48+ }2049 }2150 2251 function asServiceWorker(worker: FakeServiceWorker): ServiceWorker {2352 return worker as unknown as ServiceWorker;2453 }2554 55+function testController(options: {56+ production?: boolean;57+ container?: FakeContainer;58+ target?: EventTarget;59+ install?: PwaInstallState;60+ reload?: () => void;61+} = {}): PwaController {62+ const environment = options.install === "manual-ios"63+ ? { platform: "iPhone", maxTouchPoints: 5 }64+ : { platform: "Linux x86_64", maxTouchPoints: 0 };65+ return createPwaController({66+ eventTarget: () => options.target ?? new EventTarget(),67+ serviceWorker: () => options.container as unknown as ServiceWorkerContainer,68+ installEnvironment: () => ({69+ displayModeStandalone: options.install === "installed",70+ navigatorStandalone: false,71+ ...environment,72+ }),73+ production: () => options.production ?? false,74+ documentBaseURI: () => "http://127.0.0.1:4321/",75+ reload: options.reload,76+ });77+}78+79+function waitForState(controller: PwaController, predicate: (state: PwaState) => boolean) {80+ return new Promise<PwaState>((resolve) => {81+ const unsubscribe = controller.subscribe((state) => {82+ if (!predicate(state)) return;83+ unsubscribe();84+ resolve(state);85+ });86+ });87+}88+89+function installPrompt(90+ outcome: "accepted" | "dismissed",91+ prompted: () => void,92+): InstallPromptEvent {93+ return Object.assign(new Event("beforeinstallprompt", { cancelable: true }), {94+ prompt() {95+ prompted();96+ return Promise.resolve();97+ },98+ userChoice: Promise.resolve({ outcome }),99+ }) as InstallPromptEvent;100+}101+26102 test("service worker activation resolves for an active worker", async () => {27103 await waitForActivation(asServiceWorker(new FakeServiceWorker("activated")));28104 });
@@ -58,7 +134,7 @@ test("install experience recognizes standalone display modes", () => {
58134 if (state !== "installed") throw new Error(`expected installed, received ${state}`);59135 });60136 61-test("install experience gives iOS manual guidance without exposing a prompt event", () => {137+test("install experience gives iOS manual guidance in every region", () => {62138 const iphone = classifyInstallExperience({63139 displayModeStandalone: false,64140 navigatorStandalone: false,
@@ -76,12 +152,100 @@ test("install experience gives iOS manual guidance without exposing a prompt eve
76152 }77153 });78154 79-test("install experience degrades to browser mode when no install path is available", () => {80- const state = classifyInstallExperience({81- displayModeStandalone: false,82- navigatorStandalone: false,83- platform: "MacIntel",84- maxTouchPoints: 0,155+test("install prompt availability and accepted outcome are observable", async () => {156+ const target = new EventTarget();157+ const controller = testController({ target });158+ controller.initialize();159+ let prompted = false;160+ target.dispatchEvent(installPrompt("accepted", () => prompted = true));161+ if (controller.getState().install !== "available") throw new Error("prompt was not exposed");162+ if (await controller.requestInstall() !== "accepted" || !prompted) {163+ throw new Error("accepted prompt outcome was not reflected");164+ }165+});166+167+test("dismissed install outcome and appinstalled are observable", async () => {168+ const target = new EventTarget();169+ const controller = testController({ target });170+ controller.initialize();171+ target.dispatchEvent(installPrompt("dismissed", () => undefined));172+ if (await controller.requestInstall() !== "dismissed") {173+ throw new Error("dismissed prompt outcome was not reflected");174+ }175+ target.dispatchEvent(new Event("appinstalled"));176+ if (controller.getState().install !== "installed") throw new Error("appinstalled was ignored");177+});178+179+test("waiting-worker update activation posts skip-waiting and reloads on controller change", async () => {180+ const target = new EventTarget();181+ const container = new FakeContainer();182+ const waiting = new FakeServiceWorker("installed");183+ container.controller = asServiceWorker(new FakeServiceWorker("activated"));184+ container.registration.waiting = asServiceWorker(waiting);185+ let reloaded = false;186+ const controller = testController({187+ target,188+ container,189+ production: true,190+ reload: () => reloaded = true,85191 });86- if (state !== "unavailable") throw new Error(`expected unavailable, received ${state}`);192+ const available = waitForState(controller, (state) => state.worker === "update-available");193+ controller.initialize();194+ await available;195+ if (!controller.applyUpdate()) throw new Error("update action was unavailable");196+ if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {197+ throw new Error("update action did not request activation");198+ }199+ container.dispatchEvent(new Event("controllerchange"));200+ if (!reloaded) throw new Error("controller change did not reload after update action");87201 });202+203+test("registration failure is classified without leaking the browser error", async () => {204+ const container = new FakeContainer();205+ container.registerError = new Error("private registration detail");206+ const controller = testController({ container, production: true });207+ const failed = waitForState(controller, (state) => state.failure?.code === "registration");208+ controller.initialize();209+ const state = await failed;210+ if (state.failure?.message.includes("private")) throw new Error("registration detail leaked");211+ if (!state.failure?.message.includes("Reload")) {212+ throw new Error("registration failure is not actionable");213+ }214+});215+216+test("installation failure is classified from a redundant update", async () => {217+ const container = new FakeContainer();218+ const active = new FakeServiceWorker("activated");219+ container.registration.active = asServiceWorker(active);220+ container.controller = asServiceWorker(active);221+ const controller = testController({ container, production: true });222+ controller.initialize();223+ await waitForState(controller, (state) => state.worker === "ready");224+ const installing = new FakeServiceWorker("installing");225+ container.registration.installing = asServiceWorker(installing);226+ container.registration.dispatchEvent(new Event("updatefound"));227+ const failed = waitForState(controller, (state) => state.failure?.code === "installation");228+ installing.transition("redundant");229+ await failed;230+});231+232+for (const code of ["precache", "runtime-cache"] as const) {233+ test(`${code} worker failure is classified without exposing message payloads`, async () => {234+ const container = new FakeContainer();235+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));236+ const controller = testController({ container, production: true });237+ controller.initialize();238+ await waitForState(controller, (state) => state.worker === "ready");239+ const failed = waitForState(controller, (state) => state.failure?.code === code);240+ container.dispatchEvent(241+ new MessageEvent("message", {242+ data: { type: "LOFI_PWA_FAILURE", code, message: "private cache detail" },243+ }),244+ );245+ const state = await failed;246+ if (state.failure?.message.includes("private")) throw new Error(`${code} detail leaked`);247+ if (!state.failure?.message.includes("Reconnect")) {248+ throw new Error(`${code} failure is not actionable`);249+ }250+ });251+}
A package/runtime/sw_test.ts +28 -0
M package/starter_template.ts +7 -3
M package/testdata/starter.snapshot.json +10 -6
@@ -1,22 +1,26 @@
11 {22 ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "663cd027bee884c7e5ac6f5607c0a9ef2a0fac922ab94fe228be4f9eeb15c92d",4+ "deno.json": "17e468b307c1310c32157d8993c206d24cf1c6e012826073f1bfdda1cda9a8d3",5+ "public/apple-touch-icon.png": "7e1ef330b6bd071474b2db4f7e294ee8dc29046ec7e02484e0ff765f8dff89af",56 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",6- "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",7- "README.md": "777d1e5a00bf4501ac83d584b779467910b26dd8fdfc0e15f37c11c968cadb67",7+ "public/icon-192.png": "0006987d46922e4ed70730c1e0b94ac343e3c035fcfc872b56c656c94ca9e730",8+ "public/icon-512.png": "7900faacd2df9421da5494a4db4e0d4a76b106ec4ddf7f876353472ceeea5ca3",9+ "public/icon-maskable-512.png": "bac5120e97173ac04bca40f0dce480b6aa2ce6b9f7d4c780227e1974f483f736",10+ "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",11+ "README.md": "6cbf9ce46b9e91b97bf57bd2b33a3191dfdd19ce2650a9f2397cb949383ef2fa",812 "src/app.ts": "be92b21b00f9b737aef6676928573c02f058700058cdf7d8838df5a4933dd26f",913 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1014 "src/islands/AccountGate.tsx": "8dcb98ba3f2f7cb1b0a7494b7ac6cec02d65bab1f78fe6e4be88349e6a8227d2",1115 "src/islands/TaskList.tsx": "b38d32ecc08c60e1bef3c70e3e96914df1a2037419253324c4ac48443531f7e7",1216 "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",13- "src/layouts/Shell.astro": "50203c979679cfa1f9af461f053db29d8af3e4705497c80233fb94e4ba99c9c5",17+ "src/layouts/Shell.astro": "7bad8419d4ad8f89294c8c145fd2d710cde43f3bc6ebc40fd95793786bac5f1e",1418 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",1519 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",1620 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",17- "src/styles/global.css": "27e98368f3fa1c57befc038cc9bf36f800fef192e368f4c393327ac96bfdd448",21+ "src/styles/global.css": "d298ccc0db2749367232e0ad3e759698e98b04ea9c4516f1f5168ed67f43b93f",1822 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",19- "tests/author-boundary_test.ts": "3f0a605d1b334577d1ec02cf43073ee0c612cad739a16d6f82360919e5de63fb",23+ "tests/author-boundary_test.ts": "303691da91fd4d13ed2dca195b7745c5c106bcd0837338e632469dfec032d71b",2024 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",2125 "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",2226 "tsconfig.json": "c632473513c623f3cc69d7476159fb9421624521220029755695defa265c6cab"
M package/tooling/project.ts +1 -0
@@ -97,6 +97,7 @@ export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {
9797 ".css",9898 ".js",9999 ".json",100+ ".png",100101 ".svg",101102 ".ts",102103 ".tsx",
A tools/golden.ts +698 -0
@@ -0,0 +1,698 @@
1+#!/usr/bin/env -S deno run -A2+3+import { basename, dirname, join, resolve } from "node:path";4+import { fileURLToPath, pathToFileURL } from "node:url";5+import { type Browser, type BrowserContext, chromium, type Page } from "playwright";6+import { LOFI_VERSION } from "../package/version.ts";7+import { waitForReady } from "../package/testing/readiness.ts";8+import { redactDiagnosticText } from "../package/testing/safety.ts";9+import { sanitizeTraceArchive } from "../package/testing/trace.ts";10+11+type PackageSource = "local" | "registry";12+type StageLog = { command?: string; stdout: string; stderr: string };13+14+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");15+const artifactsRoot = join(repositoryRoot, "test-results", "golden");16+const privateValues = ["golden-online-task", "golden-offline-task"];17+const logs = new Map<string, StageLog>();18+const timings = new Map<string, number>();19+let activeStage = "setup";20+21+class GoldenStageError extends Error {22+ override readonly name = "GoldenStageError";23+24+ constructor(readonly stage: string, cause: unknown) {25+ super(26+ `golden stage ${JSON.stringify(stage)} failed: ${27+ cause instanceof Error ? cause.message : String(cause)28+ }`,29+ { cause },30+ );31+ }32+}33+34+function assert(condition: unknown, message: string): asserts condition {35+ if (!condition) throw new Error(message);36+}37+38+function parseArgs(args: readonly string[]): { source: PackageSource; version: string } {39+ let source: PackageSource = "local";40+ let version = LOFI_VERSION;41+ for (let index = 0; index < args.length; index++) {42+ const argument = args[index];43+ if (argument === "--source") {44+ const value = args[++index];45+ if (value !== "local" && value !== "registry") {46+ throw new Error("--source must be local or registry");47+ }48+ source = value;49+ } else if (argument === "--version") {50+ version = args[++index] ?? "";51+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {52+ throw new Error("--version must be an exact published semantic version");53+ }54+ } else {55+ throw new Error(`unknown golden-runner argument ${JSON.stringify(argument)}`);56+ }57+ }58+ if (source === "local" && version !== LOFI_VERSION) {59+ throw new Error(`local source is workspace version ${LOFI_VERSION}, not ${version}`);60+ }61+ return { source, version };62+}63+64+async function stage<T>(name: string, action: () => Promise<T>): Promise<T> {65+ activeStage = name;66+ const started = performance.now();67+ try {68+ const value = await action();69+ timings.set(name, Math.round(performance.now() - started));70+ console.log(`golden ${name}: passed (${timings.get(name)}ms)`);71+ return value;72+ } catch (error) {73+ throw error instanceof GoldenStageError ? error : new GoldenStageError(name, error);74+ }75+}76+77+async function runCommand(78+ stageName: string,79+ command: string,80+ args: readonly string[],81+ cwd: string,82+ environment: Readonly<Record<string, string>> = {},83+ expectSuccess = true,84+): Promise<Deno.CommandOutput> {85+ const output = await new Deno.Command(command, {86+ args: [...args],87+ cwd,88+ env: { ...environment },89+ stdout: "piped",90+ stderr: "piped",91+ }).output();92+ const stdout = new TextDecoder().decode(output.stdout);93+ const stderr = new TextDecoder().decode(output.stderr);94+ logs.set(stageName, { command: [command, ...args].join(" "), stdout, stderr });95+ if (output.success !== expectSuccess) {96+ throw new Error(97+ `${basename(command)} ${args.join(" ")} ${98+ expectSuccess ? "exited unsuccessfully" : "unexpectedly succeeded"99+ }\n${stderr || stdout}`,100+ );101+ }102+ return output;103+}104+105+function timeout<T>(promise: Promise<T>, milliseconds: number, description: string): Promise<T> {106+ return new Promise((resolvePromise, reject) => {107+ const timer = setTimeout(108+ () => reject(new Error(`${description} timed out after ${milliseconds}ms`)),109+ milliseconds,110+ );111+ promise.then(112+ (value) => {113+ clearTimeout(timer);114+ resolvePromise(value);115+ },116+ (error) => {117+ clearTimeout(timer);118+ reject(error);119+ },120+ );121+ });122+}123+124+class ManagedProcess {125+ readonly child: Deno.ChildProcess;126+ readonly status: Promise<Deno.CommandStatus>;127+ #stdout = "";128+ #stderr = "";129+ #listeners = new Set<() => void>();130+131+ constructor(132+ readonly name: string,133+ args: readonly string[],134+ readonly cwd: string,135+ ) {136+ this.child = new Deno.Command(Deno.execPath(), {137+ args: [...args],138+ cwd,139+ stdout: "piped",140+ stderr: "piped",141+ }).spawn();142+ this.status = this.child.status;143+ void this.#consume(this.child.stdout, "stdout");144+ void this.#consume(this.child.stderr, "stderr");145+ }146+147+ get output(): StageLog {148+ return { command: `deno (${this.name})`, stdout: this.#stdout, stderr: this.#stderr };149+ }150+151+ async #consume(stream: ReadableStream<Uint8Array>, destination: "stdout" | "stderr") {152+ const decoder = new TextDecoder();153+ for await (const chunk of stream) {154+ const text = decoder.decode(chunk, { stream: true });155+ if (destination === "stdout") this.#stdout += text;156+ else this.#stderr += text;157+ for (const listener of this.#listeners) listener();158+ }159+ }160+161+ waitForOutput(pattern: RegExp, milliseconds = 30_000): Promise<void> {162+ return timeout(163+ new Promise<void>((resolveReady, reject) => {164+ const inspect = () => {165+ if (!pattern.test(`${this.#stdout}\n${this.#stderr}`)) return;166+ this.#listeners.delete(inspect);167+ resolveReady();168+ };169+ this.#listeners.add(inspect);170+ inspect();171+ void this.status.then((status) => {172+ if (this.#listeners.delete(inspect)) {173+ reject(174+ new Error(175+ `${this.name} exited with code ${status.code} before readiness\n${this.#stderr}`,176+ ),177+ );178+ }179+ });180+ }),181+ milliseconds,182+ `${this.name} readiness`,183+ );184+ }185+186+ async stop(): Promise<void> {187+ try {188+ this.child.kill("SIGTERM");189+ } catch (error) {190+ if (error instanceof Deno.errors.NotFound) {191+ logs.set(this.name, this.output);192+ return;193+ }194+ throw error;195+ }196+ try {197+ await timeout(this.status, 8_000, `${this.name} graceful shutdown`);198+ } catch {199+ try {200+ this.child.kill("SIGKILL");201+ } catch (error) {202+ if (!(error instanceof Deno.errors.NotFound)) throw error;203+ }204+ await timeout(this.status, 5_000, `${this.name} forced termination`);205+ } finally {206+ logs.set(this.name, this.output);207+ }208+ }209+}210+211+function allocatePort(): number {212+ const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });213+ const port = (listener.addr as Deno.NetAddr).port;214+ listener.close();215+ return port;216+}217+218+function waitForListening(process: ManagedProcess, port: number): Promise<void> {219+ return timeout(220+ new Promise<void>((resolveReady, reject) => {221+ let active = true;222+ const attempt = async () => {223+ if (!active) return;224+ try {225+ const connection = await Deno.connect({ hostname: "127.0.0.1", port });226+ connection.close();227+ active = false;228+ resolveReady();229+ } catch {230+ if (active) setTimeout(() => void attempt(), 20);231+ }232+ };233+ void process.status.then((status) => {234+ if (!active) return;235+ active = false;236+ reject(237+ new Error(`${process.name} exited with code ${status.code} before accepting connections`),238+ );239+ });240+ void attempt();241+ }),242+ 30_000,243+ `${process.name} listening on allocated port`,244+ );245+}246+247+async function waitForRegistryVersion(version: string): Promise<void> {248+ let lastError = "version not visible";249+ for (let attempt = 1; attempt <= 12; attempt++) {250+ try {251+ const response = await fetch("https://jsr.io/@nzip/lofi/meta.json", { cache: "no-store" });252+ if (!response.ok) throw new Error(`JSR metadata returned ${response.status}`);253+ const metadata = await response.json() as { versions?: Record<string, unknown> };254+ if (metadata.versions && version in metadata.versions) return;255+ lastError = `@nzip/lofi@${version} is not in JSR metadata`;256+ } catch (error) {257+ lastError = error instanceof Error ? error.message : String(error);258+ }259+ if (attempt < 12) await new Promise((resolveDelay) => setTimeout(resolveDelay, 5_000));260+ }261+ throw new Error(`registry availability retry exhausted: ${lastError}`);262+}263+264+async function generateProject(265+ source: PackageSource,266+ version: string,267+ temporaryRoot: string,268+): Promise<string> {269+ if (source === "registry") await waitForRegistryVersion(version);270+ const projectName = "golden-app";271+ const args = source === "registry"272+ ? ["run", "-A", `jsr:@nzip/lofi@${version}/create`, projectName]273+ : ["run", "-A", join(repositoryRoot, "package", "create.ts"), projectName];274+ const environment: Record<string, string> = source === "local"275+ ? {276+ LOFI_CREATE_DEVELOPMENT: "1",277+ LOFI_CREATE_PACKAGE_PREFIX: `${pathToFileURL(join(repositoryRoot, "package")).href}/`,278+ }279+ : {};280+ const output = await runCommand("create", Deno.execPath(), args, temporaryRoot, environment);281+ const stdout = new TextDecoder().decode(output.stdout).replaceAll("\r\n", "\n");282+ assert(283+ stdout.includes("Next:\n cd golden-app\n deno task dev\n"),284+ "generator did not print the exact next commands",285+ );286+ const projectRoot = join(temporaryRoot, projectName);287+ const marker = join(projectRoot, "golden-do-not-overwrite.txt");288+ await Deno.writeTextFile(marker, "preserved\n");289+ const refusal = await runCommand(290+ "create-refusal",291+ Deno.execPath(),292+ args,293+ temporaryRoot,294+ environment,295+ false,296+ );297+ const refusalOutput = `${new TextDecoder().decode(refusal.stdout)}\n${298+ new TextDecoder().decode(refusal.stderr)299+ }`;300+ assert(301+ refusalOutput.includes("already exists and is not empty"),302+ "generator refusal was not actionable",303+ );304+ assert(305+ await Deno.readTextFile(marker) === "preserved\n",306+ "generator overwrote a non-empty project",307+ );308+ await Deno.remove(marker);309+ return projectRoot;310+}311+312+async function filesUnder(root: string): Promise<string[]> {313+ const files: string[] = [];314+ async function visit(directory: string, prefix = "") {315+ for await (const entry of Deno.readDir(directory)) {316+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;317+ if (entry.isDirectory) await visit(join(directory, entry.name), relative);318+ else if (entry.isFile) files.push(relative);319+ }320+ }321+ await visit(root);322+ return files.sort();323+}324+325+async function assertGeneratedBoundary(326+ projectRoot: string,327+ source: PackageSource,328+ version: string,329+) {330+ for (const forbidden of ["src/_lofi", "public/sw.js", "astro.config.ts"]) {331+ try {332+ await Deno.stat(join(projectRoot, forbidden));333+ throw new Error(`generated project contains forbidden ${forbidden}`);334+ } catch (error) {335+ if (!(error instanceof Deno.errors.NotFound)) throw error;336+ }337+ }338+ const config = JSON.parse(await Deno.readTextFile(join(projectRoot, "deno.json"))) as {339+ imports: Record<string, string>;340+ };341+ const lofiImports = Object.entries(config.imports).filter(([name]) =>342+ name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")343+ );344+ assert(lofiImports.length === 11, `expected 11 lofi imports, received ${lofiImports.length}`);345+ const expected = source === "registry"346+ ? `jsr:@nzip/lofi@${version}`347+ : pathToFileURL(join(repositoryRoot, "package")).href;348+ assert(349+ lofiImports.every(([, target]) => target.startsWith(expected)),350+ `generated imports do not resolve through exactly one ${source} package ${version}`,351+ );352+ for (353+ const icon of [354+ "public/apple-touch-icon.png",355+ "public/icon-192.png",356+ "public/icon-512.png",357+ "public/icon-maskable-512.png",358+ ]359+ ) await Deno.stat(join(projectRoot, icon));360+}361+362+function pngDimensions(bytes: Uint8Array): [number, number] {363+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];364+ assert(signature.every((byte, index) => bytes[index] === byte), "icon is not PNG data");365+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);366+ return [view.getUint32(16), view.getUint32(20)];367+}368+369+async function assertProductionOutput(projectRoot: string) {370+ const dist = join(projectRoot, "dist");371+ const files = await filesUnder(dist);372+ for (373+ const required of [374+ "index.html",375+ "manifest.webmanifest",376+ "sw.js",377+ "lofi-precache.json",378+ "apple-touch-icon.png",379+ "icon-192.png",380+ "icon-512.png",381+ "icon-maskable-512.png",382+ ]383+ ) assert(files.includes(required), `production output is missing ${required}`);384+385+ const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest"))) as {386+ icons: Array<{ src: string; sizes: string; type: string; purpose: string }>;387+ };388+ const expected = new Map([389+ ["icon-192.png", [192, "any"] as const],390+ ["icon-512.png", [512, "any"] as const],391+ ["icon-maskable-512.png", [512, "maskable"] as const],392+ ]);393+ for (const icon of manifest.icons) {394+ const file = basename(icon.src);395+ const contract = expected.get(file);396+ assert(contract, `manifest references unexpected ${file}`);397+ assert(icon.type === "image/png", `${file} has incorrect manifest type`);398+ assert(icon.sizes === `${contract[0]}x${contract[0]}`, `${file} has incorrect manifest size`);399+ assert(icon.purpose === contract[1], `${file} has incorrect manifest purpose`);400+ const [width, height] = pngDimensions(await Deno.readFile(join(dist, file)));401+ assert(width === contract[0] && height === contract[0], `${file} dimensions are incorrect`);402+ }403+ assert(manifest.icons.length === expected.size, "manifest does not contain the exact icon set");404+ const [appleWidth, appleHeight] = pngDimensions(405+ await Deno.readFile(join(dist, "apple-touch-icon.png")),406+ );407+ assert(appleWidth === 180 && appleHeight === 180, "iOS icon is not 180x180");408+409+ const precache = JSON.parse(410+ await Deno.readTextFile(join(dist, "lofi-precache.json")),411+ ) as string[];412+ for (413+ const shellAsset of [414+ "./",415+ "./manifest.webmanifest",416+ "./apple-touch-icon.png",417+ "./icon-192.png",418+ "./icon-512.png",419+ "./icon-maskable-512.png",420+ ]421+ ) assert(precache.includes(shellAsset), `precache is missing ${shellAsset}`);422+ const worker = await Deno.readTextFile(join(dist, "sw.js"));423+ assert(424+ !worker.includes("__LOFI_BUILD_REVISION__"),425+ "service worker build revision was not replaced",426+ );427+ const html = await Deno.readTextFile(join(dist, "index.html"));428+ assert(html.includes("apple-touch-icon.png"), "production shell omitted the iOS icon");429+430+ const forbidden = [431+ "lofi-development-inspector",432+ "__LOFI_INSPECTOR__",433+ "__LOFI_TEST_PROBE__",434+ "data-lofi-test-probe",435+ ];436+ for (const file of files) {437+ if (!/\.(?:css|html|js|json|map|svg|webmanifest)$/.test(file)) continue;438+ const contents = await Deno.readTextFile(join(dist, file));439+ for (const marker of forbidden) {440+ assert(!contents.includes(marker), `production output ${file} contains ${marker}`);441+ }442+ }443+}444+445+async function captureFailure(446+ error: unknown,447+ projectRoot: string | undefined,448+ page: Page | undefined,449+ context: BrowserContext | undefined,450+ browserDiagnostics: readonly string[],451+) {452+ const stageName = error instanceof GoldenStageError ? error.stage : activeStage;453+ const directory = join(artifactsRoot, stageName.replaceAll(/[^a-z0-9._-]+/gi, "-"));454+ await Deno.mkdir(directory, { recursive: true });455+ const redact = (value: string) =>456+ redactDiagnosticText(value, [457+ ...privateValues,458+ ...(projectRoot ? [projectRoot, dirname(projectRoot)] : []),459+ ]);460+ if (page && !page.isClosed()) {461+ await page.screenshot({462+ path: join(directory, "page.png"),463+ fullPage: true,464+ mask: [page.locator("#new-task"), page.locator(".task")],465+ }).catch(() => undefined);466+ }467+ if (context) {468+ const trace = join(directory, "browser.trace.zip");469+ try {470+ await context.tracing.stop({ path: trace });471+ await sanitizeTraceArchive(trace, redact);472+ } catch {473+ await Deno.remove(trace).catch(() => undefined);474+ }475+ }476+ for (const [name, log] of logs) {477+ const content = [log.command ? `$ ${log.command}` : "", log.stdout, log.stderr]478+ .filter(Boolean)479+ .join("\n");480+ await Deno.writeTextFile(join(directory, `${name}.log`), `${redact(content)}\n`);481+ }482+ await Deno.writeTextFile(483+ join(directory, "browser.log"),484+ `${browserDiagnostics.map(redact).join("\n")}\n`,485+ );486+ await Deno.writeTextFile(487+ join(directory, "error.log"),488+ `${redact(error instanceof Error ? error.stack ?? error.message : String(error))}\n`,489+ );490+ console.error(`golden failure artifacts: ${directory}`);491+}492+493+const taskAppReady = () => {494+ const status = document.querySelector('[role="status"]');495+ return status !== null && /item\(s\)/.test(status.textContent ?? "");496+};497+498+async function main() {499+ const { source, version } = parseArgs(Deno.args);500+ await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {501+ if (!(error instanceof Deno.errors.NotFound)) throw error;502+ });503+ const temporaryRoot = await Deno.makeTempDir({ prefix: "lofi-golden-" });504+ const port = allocatePort();505+ const origin = `http://127.0.0.1:${port}/`;506+ let projectRoot: string | undefined;507+ let dev: ManagedProcess | undefined;508+ let preview: ManagedProcess | undefined;509+ let browser: Browser | undefined;510+ let context: BrowserContext | undefined;511+ let page: Page | undefined;512+ const browserDiagnostics: string[] = [];513+ try {514+ projectRoot = await stage("create", () => generateProject(source, version, temporaryRoot));515+ await stage("generated boundary", () => assertGeneratedBoundary(projectRoot!, source, version));516+ await stage(517+ "doctor",518+ () =>519+ runCommand("doctor", Deno.execPath(), ["task", "doctor"], projectRoot!).then(() =>520+ undefined521+ ),522+ );523+ await stage(524+ "test",525+ () =>526+ runCommand("test", Deno.execPath(), ["task", "test"], projectRoot!).then(() => undefined),527+ );528+529+ dev = new ManagedProcess("dev", [530+ "task",531+ "dev",532+ "--",533+ "--host",534+ "127.0.0.1",535+ "--port",536+ String(port),537+ ], projectRoot);538+ await stage(539+ "development readiness",540+ async () => {541+ await dev!.waitForOutput(new RegExp(`http://127\\.0\\.0\\.1:${port}/`));542+ await waitForListening(dev!, port);543+ },544+ );545+ browser = await chromium.launch({ headless: true });546+ context = await browser.newContext();547+ await context.tracing.start({ screenshots: false, snapshots: false, sources: false });548+ page = await context.newPage();549+ page.on(550+ "console",551+ (message) => browserDiagnostics.push(`console ${message.type()}: ${message.text()}`),552+ );553+ page.on(554+ "pageerror",555+ (error) => browserDiagnostics.push(`pageerror: ${error.name}: ${error.message}`),556+ );557+ page.on(558+ "requestfailed",559+ (request) =>560+ browserDiagnostics.push(561+ `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "unknown"}`,562+ ),563+ );564+ await stage("development browser", async () => {565+ await page!.goto(origin, { waitUntil: "domcontentloaded" });566+ await waitForReady(page!, taskAppReady, undefined, {567+ description: "generated task app local store",568+ timeoutMs: 30_000,569+ });570+ await page!.fill("#new-task", privateValues[0]);571+ await page!.press("#new-task", "Enter");572+ await page!.locator(".task", { hasText: privateValues[0] }).waitFor({ state: "visible" });573+ await page!.reload({ waitUntil: "domcontentloaded" });574+ await waitForReady(page!, taskAppReady, undefined, {575+ description: "task app after full reload",576+ });577+ await page!.locator(".task", { hasText: privateValues[0] }).waitFor({ state: "visible" });578+ const worker = await page!.evaluate(() =>579+ (globalThis as typeof globalThis & { __LOFI_PWA_STATE__?: { worker?: string } })580+ .__LOFI_PWA_STATE__?.worker581+ );582+ assert(worker === "development-disabled", `development worker state was ${worker}`);583+ });584+ await stage("development shutdown", () => dev!.stop());585+ dev = undefined;586+587+ await stage(588+ "build",589+ () =>590+ runCommand("build", Deno.execPath(), ["task", "build"], projectRoot!).then(() => undefined),591+ );592+ await stage("production output", () => assertProductionOutput(projectRoot!));593+ preview = new ManagedProcess(594+ "preview",595+ ["task", "preview", "--port", String(port)],596+ projectRoot,597+ );598+ await stage(599+ "preview readiness",600+ async () => {601+ await preview!.waitForOutput(602+ new RegExp(`lofi preview: http://127\\.0\\.0\\.1:${port}/`),603+ );604+ await waitForListening(preview!, port);605+ },606+ );607+ await stage("production offline browser", async () => {608+ await page!.goto(origin, { waitUntil: "domcontentloaded" });609+ await waitForReady(610+ page!,611+ () => {612+ const global = globalThis as typeof globalThis & {613+ __LOFI_PWA_STATE__?: { worker?: string };614+ };615+ return global.__LOFI_PWA_STATE__?.worker === "ready";616+ },617+ undefined,618+ { description: "production service worker ready", timeoutMs: 30_000 },619+ );620+ await waitForReady(page!, taskAppReady, undefined, {621+ description: "production task app ready",622+ });623+ const shellCached = await page!.evaluate(async () =>624+ Boolean(await caches.match(new URL("./", location.href)))625+ );626+ assert(shellCached, "service-worker precache does not contain the app shell");627+ await context!.setOffline(true);628+ await page!.reload({ waitUntil: "domcontentloaded" });629+ await waitForReady(page!, taskAppReady, undefined, {630+ description: "offline cold page ready",631+ });632+ await page!.locator(".task", { hasText: privateValues[0] }).waitFor({ state: "visible" });633+ await page!.fill("#new-task", privateValues[1]);634+ await page!.press("#new-task", "Enter");635+ await page!.locator(".task", { hasText: privateValues[1] }).waitFor({ state: "visible" });636+ await page!.reload({ waitUntil: "domcontentloaded" });637+ await waitForReady(page!, taskAppReady, undefined, { description: "offline task persisted" });638+ await page!.locator(".task", { hasText: privateValues[1] }).waitFor({ state: "visible" });639+ await context!.setOffline(false);640+ await waitForReady(641+ page!,642+ () => {643+ const syncLabel = [...document.querySelectorAll("dt")].find((node) =>644+ node.textContent?.trim() === "Sync"645+ );646+ const taskStatus = document.querySelector('[role="status"]')?.textContent ?? "";647+ return /item\(s\)/.test(taskStatus) &&648+ syncLabel?.nextElementSibling?.textContent?.trim() === "local-only";649+ },650+ undefined,651+ { description: "public local-only durability and sync state" },652+ );653+ const state = await page!.evaluate(() => {654+ const syncLabel = [...document.querySelectorAll("dt")].find((node) =>655+ node.textContent?.trim() === "Sync"656+ );657+ return {658+ worker: (globalThis as typeof globalThis & { __LOFI_PWA_STATE__?: { worker?: string } })659+ .__LOFI_PWA_STATE__?.worker,660+ status: document.querySelector('[role="status"]')?.textContent ?? "",661+ sync: syncLabel?.nextElementSibling?.textContent?.trim() ?? "",662+ };663+ });664+ assert(state.worker === "ready", `production service worker ended in ${state.worker}`);665+ assert(/item\(s\)/.test(state.status), "public retained-row state is missing");666+ assert(state.sync === "local-only", `unexpected public sync state ${state.sync}`);667+ });668+ await context.tracing.stop();669+ await stage("preview shutdown", () => preview!.stop());670+ preview = undefined;671+ console.log(672+ `golden result: ${source} @nzip/lofi ${version} passed on allocated origin ${origin}`,673+ );674+ console.log(675+ `golden timings: ${676+ [...timings].map(([name, milliseconds]) => `${name}=${milliseconds}ms`).join(", ")677+ }`,678+ );679+ } catch (error) {680+ await Promise.allSettled([dev?.stop(), preview?.stop()]);681+ dev = undefined;682+ preview = undefined;683+ await captureFailure(error, projectRoot, page, context, browserDiagnostics);684+ throw error;685+ } finally {686+ await Promise.allSettled([dev?.stop(), preview?.stop()]);687+ await context?.close().catch(() => undefined);688+ await browser?.close().catch(() => undefined);689+ await Deno.remove(temporaryRoot, { recursive: true }).catch(() => undefined);690+ }691+}692+693+if (import.meta.main) {694+ await main().catch((error) => {695+ console.error(error instanceof Error ? error.message : String(error));696+ Deno.exit(1);697+ });698+}