Ship the generated-app PWA and golden release gate
- commit
- 6c20c75dd05e6b0aa173d91088adae7f8c201ab3
- parent
- e9f0f6da511728592378aa8dccf43f3af8e3424b
- parent
- 5a2a57aa33fff7218fbde36936eb65f501e446d8
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -0,0 +1,44 @@
1+name: Generated app release gate2+3+on:4+ pull_request:5+ branches:6+ - dev7+8+concurrency:9+ group: generated-app-${{ github.event.pull_request.number }}10+ cancel-in-progress: true11+12+jobs:13+ release-gate:14+ runs-on: ubuntu-latest15+ steps:16+ - uses: actions/checkout@v417+18+ - uses: denoland/setup-deno@v219+ with:20+ deno-version: v2.x21+22+ - name: Formatting, lint, and unit/integration tests23+ run: deno task check24+25+ - name: Reference production build26+ run: deno task build --cwd apps/reference27+28+ - name: Publish dry run29+ run: deno task publish:dry --allow-dirty30+31+ - name: Install Chromium32+ run: deno task test:browser:install33+34+ - name: Generated-app golden journey (local workspace package)35+ run: deno task test:golden36+37+ - name: Retain sanitized golden failure evidence38+ if: failure()39+ uses: actions/upload-artifact@v440+ with:41+ name: generated-app-golden-failure42+ path: test-results/golden/43+ if-no-files-found: ignore44+ retention-days: 7@@ -29,8 +29,31 @@ jobs:
2929 exit 13030 fi3131 32- - name: Test33- run: deno task test:unit32+ - name: Formatting, lint, and unit/integration tests33+ run: deno task check34+35+ - name: Reference production build36+ run: deno task build --cwd apps/reference37+38+ - name: Publish dry run39+ run: deno task publish:dry --allow-dirty40+41+ - name: Install Chromium42+ run: deno task test:browser:install3443 3544 - name: Publish to JSR3645 run: deno publish46+47+ - name: Generated-app golden journey (exact published version)48+ run: |49+ version="${GITHUB_REF_NAME#v}"50+ deno run -A tools/golden.ts --source registry --version "$version"51+52+ - name: Retain sanitized golden failure evidence53+ if: failure()54+ uses: actions/upload-artifact@v455+ with:56+ name: generated-app-registry-golden-failure57+ path: test-results/golden/58+ if-no-files-found: ignore59+ retention-days: 7@@ -104,6 +104,9 @@ Product work stays in `schema.ts`, `permissions.ts`, `app.ts`, `pages/`, `layout
104104 `styles/`. Framework behavior is imported from `@nzip/lofi`; upgrading the package updates runtime105105 code without copying it into your source tree.106106 107+See the [exact generated-project map](docs/reference/project-layout.md) for every source-controlled108+path, its ownership category, and the separately regenerated `.lofi/` and `dist/` trees.109+107110 ## Testing local-first behavior108111 109112 `@nzip/lofi/testing` provides Playwright-backed helpers for behavior that is difficult to test with@@ -24,6 +24,9 @@ Application work imports supported package APIs and does not carry framework imp
2424 `public/manifest.webmanifest`, `public/favicon.svg`, `src/layouts/`, and `src/env.d.ts` remain2525 application-owned integration and branding surfaces.2626 27+The [exact generated-project map](../../docs/reference/project-layout.md) lists every generated28+source-controlled path and separates it from regenerated `.lofi/` tooling and `dist/` output.29+2730 Browser journeys import readiness, offline, two-client, and sanitized failure-artifact helpers from2831 `@nzip/lofi/testing`. `tests/convergence_e2e_test.ts` is a worked example: two clients add a task2932 offline and converge after reconnecting. The normal `deno task test` skips it (and never launches@@ -22,6 +22,7 @@
2222 "preact/hooks": "npm:preact@10.29.7/hooks",2323 "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",2424 "preact/jsx-runtime": "npm:preact@10.29.7/jsx-runtime",25+ "preact-render-to-string": "npm:preact-render-to-string@6.7.0",2526 "typescript": "npm:typescript@6.0.3",2627 "vite": "npm:vite@8.0.1"2728 },@@ -5,6 +5,7 @@
55 "npm:@astrojs/preact@6.0.1": "6.0.1_preact@10.29.7__preact-render-to-string@6.7.0_@babel+core@7.29.7",66 "npm:astro@7.0.9": "7.0.9_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1",77 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",8+ "npm:preact-render-to-string@6.7.0": "6.7.0_preact@10.29.7",89 "npm:preact@10.29.7": "10.29.7_preact-render-to-string@6.7.0",910 "npm:typescript@6.0.3": "6.0.3",1011 "npm:vite@8.0.1": "8.0.1_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"@@ -3106,6 +3107,7 @@
31063107 "npm:@astrojs/preact@6.0.1",31073108 "npm:astro@7.0.9",31083109 "npm:jazz-tools@2.0.0-alpha.53",3110+ "npm:preact-render-to-string@6.7.0",31093111 "npm:preact@10.29.7",31103112 "npm:typescript@6.0.3",31113113 "npm:vite@8.0.1"Binary file.
Binary file.
Binary file.
Binary file.
@@ -9,10 +9,22 @@
99 "theme_color": "#18231f",1010 "icons": [1111 {12- "src": "./favicon.svg",13- "sizes": "any",14- "type": "image/svg+xml",15- "purpose": "any maskable"12+ "src": "./icon-192.png",13+ "sizes": "192x192",14+ "type": "image/png",15+ "purpose": "any"16+ },17+ {18+ "src": "./icon-512.png",19+ "sizes": "512x512",20+ "type": "image/png",21+ "purpose": "any"22+ },23+ {24+ "src": "./icon-maskable-512.png",25+ "sizes": "512x512",26+ "type": "image/png",27+ "purpose": "maskable"1628 }1729 ]1830 }@@ -14,6 +14,7 @@ const { title } = Astro.props;
1414 <meta name="viewport" content="width=device-width" />1515 <meta name="theme-color" content="#18231f" />1616 <link rel="icon" href="./favicon.svg" type="image/svg+xml" />17+ <link rel="apple-touch-icon" href="./apple-touch-icon.png" />1718 <link rel="manifest" href="./manifest.webmanifest" />1819 <title>{title}</title>1920 </head>@@ -118,6 +118,33 @@ h1 {
118118 color: #d9efe2;119119 }120120 121+.pwa-actions {122+ background: #18231f;123+ border: 1px solid #3b594c;124+ border-radius: 0.5rem;125+ margin-top: 1rem;126+ padding: 1rem;127+}128+129+.pwa-actions h3 {130+ margin: 0 0 0.75rem;131+}132+133+.pwa-actions p,134+.pwa-actions ol {135+ color: #bed0c6;136+ line-height: 1.55;137+}138+139+.pwa-actions ol {140+ margin-bottom: 0;141+ padding-left: 1.4rem;142+}143+144+.pwa-actions .pwa-error {145+ color: #f6b8b0;146+}147+121148 label {122149 display: block;123150 font-weight: 750;@@ -25,11 +25,19 @@ const forbidden = [
2525 ];2626 2727 test("author source consumes public lofi package seams and hides plumbing from product UI", async () => {28- try {29- await Deno.stat(new URL("../src/_lofi", import.meta.url));30- throw new Error("generated project still contains a vendored src/_lofi runtime");31- } catch (error) {32- if (!(error instanceof Deno.errors.NotFound)) throw error;28+ for (29+ const [path, name] of [30+ ["../src/_lofi", "vendored src/_lofi runtime"],31+ ["../public/sw.js", "source service worker"],32+ ["../astro.config.ts", "checked-in Astro configuration"],33+ ] as const34+ ) {35+ try {36+ await Deno.stat(new URL(path, import.meta.url));37+ throw new Error(`generated project still contains ${name}`);38+ } catch (error) {39+ if (!(error instanceof Deno.errors.NotFound)) throw error;40+ }3341 }3442 for (const file of packageConsumers) {3543 const source = await Deno.readTextFile(file);@@ -40,11 +40,14 @@
4040 "jsxImportSource": "preact"4141 },4242 "tasks": {43+ "build": "deno task --cwd apps/reference build",4344 "check": "deno fmt --check . && deno lint && deno task test:unit",4445 "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package apps/reference/tests",4546 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",4647 "test": "deno task test:unit",4748 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",49+ "test:golden": "deno run -A tools/golden.ts --source local",50+ "test:golden:registry": "deno run -A tools/golden.ts --source registry",4851 "publish:dry": "deno publish --dry-run"4952 },5053 "fmt": {@@ -81,6 +84,7 @@
8184 ],8285 "exclude": [8386 "package/**/*_test.ts",87+ "package/**/*_test.tsx",8488 "package/testing/fixture_integration.ts",8589 "package/testdata"8690 ]@@ -7,6 +7,7 @@
77 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",88 "npm:playwright@*": "1.61.1",99 "npm:playwright@1.61.1": "1.61.1",10+ "npm:preact-render-to-string@6.7.0": "6.7.0_preact@10.29.7",1011 "npm:preact@10.29.7": "10.29.7_preact-render-to-string@6.7.0",1112 "npm:typescript@6.0.3": "6.0.3",1213 "npm:vite@8.0.1": "8.0.1_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"@@ -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.@@ -29,7 +29,12 @@ const runtimeFiles = [
2929 "ui-mutation.ts",3030 ] as const;3131 32-const preactFiles = ["DeviceStatus.tsx", "mod.ts", "use-device-capabilities.ts"] as const;32+const preactFiles = [33+ "DeviceStatus.tsx",34+ "mod.ts",35+ "PwaActions.tsx",36+ "use-device-capabilities.ts",37+] as const;3338 3439 async function readPackageFile(path: string): Promise<string> {3540 const url = new URL(path, import.meta.url);@@ -33,6 +33,7 @@ const contentTypes: Record<string, string> = {
3333 ".html": "text/html; charset=utf-8",3434 ".js": "text/javascript; charset=utf-8",3535 ".json": "application/json; charset=utf-8",36+ ".png": "image/png",3637 ".svg": "image/svg+xml",3738 ".wasm": "application/wasm",3839 ".webmanifest": "application/manifest+json",@@ -70,6 +70,11 @@ Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/ba
7070 Start with the framework's7171 [generated-app guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/getting-started.md)7272 when replacing the task example with your own schema, permissions, hook, and UI.73+The [exact generated-project map](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/project-layout.md)74+lists every source-controlled path and its ownership.75+76+Runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration come from the one pinned77+\`@nzip/lofi\` package version. They are not copied into this project's source.7378 7479 ## Hosting7580 @@ -183,7 +188,7 @@ async function writeTemplate(destination: string): Promise<void> {
183188 const content = await readStarterFile(relativePath);184189 const path = join(destination, relativePath);185190 await Deno.mkdir(dirname(path), { recursive: true });186- await Deno.writeTextFile(path, content);191+ await Deno.writeFile(path, content);187192 }188193 }189194 @@ -113,6 +113,21 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.2.0/")),114114 "generated lofi commands do not resolve through one exact package version",115115 );116+ const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];117+ for (118+ const icon of [119+ "apple-touch-icon.png",120+ "icon-192.png",121+ "icon-512.png",122+ "icon-maskable-512.png",123+ ]124+ ) {125+ const bytes = await Deno.readFile(join(result.destination, "public", icon));126+ assert(127+ pngSignature.every((byte, index) => bytes[index] === byte),128+ `generated ${icon} was not copied as binary PNG data`,129+ );130+ }116131 } finally {117132 await Deno.remove(cwd, { recursive: true });118133 }@@ -5,6 +5,7 @@ import { serverUrl } from "../runtime/config.ts";
55 import { useDeviceCapabilities } from "./use-device-capabilities.ts";66 import { settleUiMutation } from "../runtime/ui-mutation.ts";77 import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";8+import { PwaActions } from "./PwaActions.tsx";89 import { runtimeRecreatedEvent } from "../runtime/runtime.ts";910 import { readSession, type Session } from "../runtime/session.ts";1011 @@ -128,7 +129,7 @@ export default function DeviceStatus(): JSX.Element {
128129 <Row label="Install" value={pwa.install} />129130 <Row label="Service worker" value={pwa.worker} />130131 </dl>131- {pwa.failure && <p class="account-error">{pwa.failure.code}: {pwa.failure.message}</p>}132+ <PwaActions title="Install & updates" />132133 </div>133134 </section>134135 );@@ -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+}@@ -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+});@@ -1,3 +1,4 @@
11 /** Optional Preact bindings and default framework diagnostics UI. */22 export { default as DeviceStatus } from "./DeviceStatus.tsx";3+export { PwaActions, type PwaActionsProps, pwaFailureMessage, usePwaState } from "./PwaActions.tsx";34 export { useDeviceCapabilities } from "./use-device-capabilities.ts";@@ -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+});@@ -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+});@@ -31,8 +31,16 @@ export {
3131 } from "./device-capabilities.ts";3232 export {3333 applyPwaUpdate,34+ createPwaController,3435 getPwaState,36+ type InstallPromptEvent,37+ type PwaController,38+ pwaController,39+ type PwaFailureCode,40+ pwaFailureMessage,41+ type PwaInstallState,3542 type PwaState,43+ type PwaWorkerState,3644 requestPwaInstall,3745 subscribePwaState,3846 } from "./pwa.ts";@@ -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 }@@ -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+}@@ -0,0 +1,28 @@
1+const serviceWorkerUrl = new URL("./sw.js", import.meta.url);2+3+Deno.test("service worker precaches the shell and reports install failures", async () => {4+ const source = await Deno.readTextFile(serviceWorkerUrl);5+ for (6+ const contract of [7+ 'new URL("./lofi-precache.json", self.registration.scope)',8+ "cache.addAll",9+ 'reportFailure(\n "precache"',10+ 'name.startsWith("lofi-shell-")',11+ ]12+ ) {13+ if (!source.includes(contract)) throw new Error(`service worker omitted ${contract}`);14+ }15+});16+17+Deno.test("service worker uses cached navigation shell as the offline fallback", async () => {18+ const source = await Deno.readTextFile(serviceWorkerUrl);19+ for (20+ const contract of [21+ 'event.request.mode === "navigate"',22+ 'caches.match(new URL("./", self.registration.scope))',23+ 'reportFailure(\n "runtime-cache"',24+ ]25+ ) {26+ if (!source.includes(contract)) throw new Error(`service worker omitted ${contract}`);27+ }28+});@@ -8,6 +8,10 @@ export const STARTER_FILES: readonly string[] = [
88 "deno.json",99 "tsconfig.json",1010 "public/favicon.svg",11+ "public/apple-touch-icon.png",12+ "public/icon-192.png",13+ "public/icon-512.png",14+ "public/icon-maskable-512.png",1115 "public/manifest.webmanifest",1216 "src/app.ts",1317 "src/env.d.ts",@@ -30,12 +34,12 @@ export const STARTER_FILES: readonly string[] = [
3034 * Works both from a source checkout (a `file:` URL read from disk) and from the3135 * published package (an `https:` JSR URL fetched over the network).3236 */33-export async function readStarterFile(relativePath: string): Promise<string> {37+export async function readStarterFile(relativePath: string): Promise<Uint8Array> {3438 const url = import.meta.resolve(`../apps/reference/${relativePath}`);35- if (url.startsWith("file:")) return await Deno.readTextFile(new URL(url));39+ if (url.startsWith("file:")) return await Deno.readFile(new URL(url));3640 const response = await fetch(url);3741 if (!response.ok) {3842 throw new Error(`failed to read starter file ${relativePath}: HTTP ${response.status}`);3943 }40- return await response.text();44+ return new Uint8Array(await response.arrayBuffer());4145 }@@ -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"@@ -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",@@ -0,0 +1,698 @@
1+2+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+}