clone

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

feat: graduate the M2 reference golden path

Dami committed d208665
commit
d2086652673d2784c6f56dfd4a45cf612d26d73e
parent
33fdd3593240e7abf4bd92eab0c3c38d85472333
author
Dami <dami.etoile@gmail.com>
47 files changed, +2064 -374
Dapps/prototype/src/components/DeviceStatus.tsx63---
Dapps/prototype/src/components/NotesIsland.tsx58---
Dapps/prototype/src/pages/index.astro35--
Dapps/prototype/src/permissions.ts9-
Dapps/prototype/src/runtime/probe.ts34--
Aapps/reference/README.md26+
Rapps/prototype/astro.config.ts → apps/reference/astro.config.ts0
Rapps/prototype/public/favicon.svg → apps/reference/public/favicon.svg0
Rapps/prototype/public/manifest.webmanifest → apps/reference/public/manifest.webmanifest4+-
Rapps/prototype/public/sw.js → apps/reference/public/sw.js0
Aapps/reference/src/_lofi/ReferenceShell.astro25+
Rapps/prototype/src/runtime/boot.ts → apps/reference/src/_lofi/boot.ts0
Rapps/prototype/src/runtime/notes-store.ts → apps/reference/src/_lofi/checklist-store.ts120++++--
Rapps/prototype/src/runtime/notes-store_test.ts → apps/reference/src/_lofi/checklist-store_test.ts78++-
Rapps/prototype/src/runtime/config.ts → apps/reference/src/_lofi/config.ts3+-
Rapps/prototype/src/runtime/device-capabilities.ts → apps/reference/src/_lofi/device-capabilities.ts0
Aapps/reference/src/_lofi/probe.ts39++
Rapps/prototype/src/runtime/pwa.ts → apps/reference/src/_lofi/pwa.ts0
Rapps/prototype/src/runtime/pwa_test.ts → apps/reference/src/_lofi/pwa_test.ts0
Rapps/prototype/src/runtime/resource-lifecycle.ts → apps/reference/src/_lofi/resource-lifecycle.ts0
Rapps/prototype/src/runtime/resource-lifecycle_test.ts → apps/reference/src/_lofi/resource-lifecycle_test.ts0
Rapps/prototype/src/runtime/lofi-runtime.ts → apps/reference/src/_lofi/runtime.ts12+-
Rapps/prototype/src/runtime/ui-mutation.ts → apps/reference/src/_lofi/ui-mutation.ts0
Rapps/prototype/src/runtime/ui-mutation_test.ts → apps/reference/src/_lofi/ui-mutation_test.ts0
Rapps/prototype/src/runtime/use-notes.ts → apps/reference/src/_lofi/use-checklist.ts37+-
Rapps/prototype/src/runtime/use-device-capabilities.ts → apps/reference/src/_lofi/use-device-capabilities.ts0
Aapps/reference/src/app.ts11+
Rapps/prototype/src/env.d.ts → apps/reference/src/env.d.ts0
Aapps/reference/src/islands/ChecklistIsland.tsx134+++++++
Aapps/reference/src/pages/index.astro25+
Aapps/reference/src/permissions.ts9+
Rapps/prototype/src/schema.ts → apps/reference/src/schema.ts5+-
Rapps/prototype/src/styles/global.css → apps/reference/src/styles/global.css132++++--
Aapps/reference/tests/author-boundary_test.ts31++
Rapps/prototype/tsconfig.json → apps/reference/tsconfig.json0
Mdeno.json16+-
Mdeno.lock41+-
Amigrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts28+
Amigrations/snapshots/20260715T194819-6c62fec42c35.json20+
Amigrations/snapshots/20260715T194947-ff85ac1d97ee.json27+
Mtools/artifact_files_test.ts6+-
Atools/golden_path.ts615++++++++++++++++++++++++++++++
Atools/golden_path_checkout.ts18+
Atools/golden_path_core.ts535++++++++++++++++++++++++++
Atools/golden_path_core_test.ts222+++++++++++
Mtools/preview.ts2+-
Rtools/run_prototype.ts → tools/run_reference.ts18+-
D apps/prototype/src/components/DeviceStatus.tsx +0 -63
@@ -1,63 +0,0 @@
1-import { useDeviceCapabilities } from "../runtime/use-device-capabilities.ts";2-import { settleUiMutation } from "../runtime/ui-mutation.ts";3-4-export default function DeviceStatus() {5- const { report, requestPersistence } = useDeviceCapabilities();6- if (!report) return <p class="device-status">Checking durable-storage capabilities…</p>;7-8- return (9- <section class="device-status" aria-labelledby="device-status-title">10- <header>11- <p class="eyebrow">Device gate</p>12- <h2 id="device-status-title">13- {report.durableDriverSupported ? "Durable driver supported" : "Durable driver blocked"}14- </h2>15- </header>16- <dl>17- <div>18- <dt>Context</dt>19- <dd>{report.secureContext ? "secure" : "not secure"}</dd>20- </div>21- <div>22- <dt>OPFS</dt>23- <dd>{report.opfs ? "available" : "missing"}</dd>24- </div>25- <div>26- <dt>SharedWorker</dt>27- <dd>{report.sharedWorker ? "available" : "missing"}</dd>28- </div>29- <div>30- <dt>Web Locks</dt>31- <dd>{report.webLocks ? "available" : "missing"}</dd>32- </div>33- <div>34- <dt>Storage persistence</dt>35- <dd>{report.persistentPermission}</dd>36- </div>37- <div>38- <dt>Display mode</dt>39- <dd>{report.displayMode}</dd>40- </div>41- <div>42- <dt>Identity</dt>43- <dd>device-local key</dd>44- </div>45- <div>46- <dt>Passkey backup</dt>47- <dd>blocked by alpha security review</dd>48- </div>49- </dl>50- <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>51- Request storage persistence52- </button>53- <p>54- OPFS capability and eviction protection are separate. A declined persistence request does55- not mean the Jazz driver is using memory.56- </p>57- <p>58- Clearing site data destroys the device-local identity unless a separate recovery mechanism59- has been verified. This prototype does not claim passkey recovery.60- </p>61- </section>62- );63-}
D apps/prototype/src/components/NotesIsland.tsx +0 -58
@@ -1,58 +0,0 @@
1-import { useState } from "preact/hooks";2-import "../runtime/boot.ts";3-import { settleUiMutation } from "../runtime/ui-mutation.ts";4-import { useNotes } from "../runtime/use-notes.ts";5-6-export interface NotesIslandProps {7- label: string;8-}9-10-export default function NotesIsland({ label }: NotesIslandProps) {11- const state = useNotes();12- const [body, setBody] = useState("");13- const inputId = `note-${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}`;14-15- return (16- <section class="island" data-island={label}>17- <header>18- <p class="eyebrow">{label}</p>19- <h2>Shared notes</h2>20- </header>21- <form22- onSubmit={(event) => {23- event.preventDefault();24- const next = body.trim();25- if (!next) return;26- setBody("");27- void settleUiMutation(state.add(next));28- }}29- >30- <label for={inputId}>New note</label>31- <div class="composer">32- <input33- id={inputId}34- value={body}35- onInput={(event) => setBody(event.currentTarget.value)}36- autocomplete="off"37- />38- <button type="submit">Add from {label}</button>39- </div>40- </form>41- <p class="state" role="status">42- {state.status === "loading" && "Opening persistent storage…"}43- {state.status === "error" && `Error: ${state.error}`}44- {state.status === "ready" &&45- `${state.notes.length} note(s) · last write ${state.durability}`}46- </p>47- <ul aria-label={`${label} notes`}>48- {state.notes.map((note) => <li key={note.id} data-note-id={note.id}>{note.body}</li>)}49- </ul>50- <p class="diagnostics" data-diagnostics>51- storage {state.diagnostics.storageState} · clients {state.diagnostics.activeClients}{" "}52- · consumers {state.diagnostics.activeConsumers} · subscriptions{" "}53- {state.diagnostics.activeVendorSubscriptions} · error listeners {state54- .diagnostics.activeMutationListeners}55- </p>56- </section>57- );58-}
D apps/prototype/src/pages/index.astro +0 -35
D apps/prototype/src/permissions.ts +0 -9
D apps/prototype/src/runtime/probe.ts +0 -34
A apps/reference/README.md +26 -0
R apps/prototype/astro.config.ts → apps/reference/astro.config.ts +0 -0

No content changes.

R apps/prototype/public/favicon.svg → apps/reference/public/favicon.svg +0 -0

No content changes.

R apps/prototype/public/manifest.webmanifest → apps/reference/public/manifest.webmanifest +2 -2
R apps/prototype/public/sw.js → apps/reference/public/sw.js +0 -0

No content changes.

A apps/reference/src/_lofi/ReferenceShell.astro +25 -0
R apps/prototype/src/runtime/boot.ts → apps/reference/src/_lofi/boot.ts +0 -0

No content changes.

R apps/prototype/src/runtime/notes-store.ts → apps/reference/src/_lofi/checklist-store.ts +77 -43
@@ -1,11 +1,17 @@
11 import type { Db, MutationErrorEvent } from "jazz-tools";2-import { app } from "../schema.ts";2+import { referenceApp } from "../app.ts";33 import { serverUrl } from "./config.ts";44 5-export type Note = { id: string; body: string; createdAt: Date };6-export type NotesSnapshot = {5+export type ChecklistTask = {6+ id: string;7+ text: string;8+ completed: boolean;9+ createdAt: Date;10+};11+12+export type ChecklistSnapshot = {713 status: "loading" | "ready" | "error";8- notes: Note[];14+ tasks: ChecklistTask[];915 durability: "none" | "local" | "global" | "failed";1016 error: string | null;1117 };
@@ -25,25 +31,31 @@ export type RuntimeDiagnostics = {
2531 };2632 2733 type Listener = () => void;34+type MutationHandle = {35+ wait(options: { tier: "local" | "global" }): Promise<unknown>;36+};2837 29-export class NotesStore {38+export class ChecklistStore {3039 readonly #db: Db;3140 readonly #diagnostics: RuntimeDiagnostics;41+ readonly #syncConfigured: boolean;3242 readonly #listeners = new Set<Listener>();3343 #vendorUnsubscribe: (() => void) | null = null;34- #snapshot: NotesSnapshot = {44+ #writeGeneration = 0;45+ #snapshot: ChecklistSnapshot = {3546 status: "loading",36- notes: [],47+ tasks: [],3748 durability: "none",3849 error: null,3950 };4051 41- constructor(db: Db, diagnostics: RuntimeDiagnostics) {52+ constructor(db: Db, diagnostics: RuntimeDiagnostics, syncConfigured = Boolean(serverUrl)) {4253 this.#db = db;4354 this.#diagnostics = diagnostics;55+ this.#syncConfigured = syncConfigured;4456 }4557 46- getSnapshot = (): NotesSnapshot => this.#snapshot;58+ getSnapshot = (): ChecklistSnapshot => this.#snapshot;4759 4860 subscribe = (listener: Listener): () => void => {4961 this.#listeners.add(listener);
@@ -61,54 +73,76 @@ export class NotesStore {
6173 };6274 };6375 64- async add(body: string): Promise<void> {65- const write = this.#db.insert(app.notes, { body, createdAt: new Date() });66- try {67- await write.wait({ tier: "local" });68- this.#diagnostics.localWaitCalls += 1;69- this.#set({ durability: "local", error: null });70- if (serverUrl) {71- void write.wait({ tier: "global" }).then(72- () => this.#set({ durability: "global", error: null }),73- (error) => this.#fail(error),74- );75- }76- } catch (error) {77- this.#fail(error);78- throw error;79- }76+ async create(text: string): Promise<void> {77+ const now = new Date();78+ await this.#settle(this.#db.insert(referenceApp.schema.tasks, {79+ text,80+ completed: false,81+ createdAt: now,82+ }));8083 }8184 82- async update(id: string, body: string): Promise<void> {83- try {84- await this.#db.update(app.notes, id, { body }).wait({ tier: "local" });85- this.#diagnostics.localWaitCalls += 1;86- this.#set({ durability: "local", error: null });87- } catch (error) {88- this.#fail(error);89- throw error;90- }85+ async update(id: string, text: string): Promise<void> {86+ await this.#settle(87+ this.#db.update(referenceApp.schema.tasks, id, { text }),88+ );89+ }90+91+ async setCompleted(id: string, completed: boolean): Promise<void> {92+ await this.#settle(93+ this.#db.update(referenceApp.schema.tasks, id, { completed }),94+ );95+ }96+97+ async delete(id: string): Promise<void> {98+ await this.#settle(this.#db.delete(referenceApp.schema.tasks, id));9199 }92100 93- reportMutationError(event: MutationErrorEvent) {101+ reportMutationError(event: MutationErrorEvent): void {94102 this.#diagnostics.mutationErrors += 1;95103 this.#fail(new Error(`${event.code}: ${event.reason}`));96104 }97105 98- close() {106+ close(): void {99107 this.#listeners.clear();100108 this.#diagnostics.activeConsumers = 0;101109 this.#closeVendorSubscription();102110 }103111 104- #openVendorSubscription() {112+ async #settle(mutation: MutationHandle): Promise<void> {113+ const generation = ++this.#writeGeneration;114+ try {115+ await mutation.wait({ tier: "local" });116+ this.#diagnostics.localWaitCalls += 1;117+ if (generation === this.#writeGeneration) {118+ this.#set({ durability: "local", error: null });119+ }120+ if (this.#syncConfigured) {121+ void mutation.wait({ tier: "global" }).then(122+ () => {123+ if (generation === this.#writeGeneration) {124+ this.#set({ durability: "global", error: null });125+ }126+ },127+ (error) => {128+ if (generation === this.#writeGeneration) this.#fail(error);129+ },130+ );131+ }132+ } catch (error) {133+ if (generation === this.#writeGeneration) this.#fail(error);134+ throw error;135+ }136+ }137+138+ #openVendorSubscription(): void {105139 if (this.#vendorUnsubscribe) return;106140 try {107- this.#vendorUnsubscribe = this.#db.subscribeAll(app.notes, (delta) => {141+ this.#vendorUnsubscribe = this.#db.subscribeAll(referenceApp.schema.tasks, (delta) => {108142 this.#snapshot = {109143 ...this.#snapshot,110144 status: "ready",111- notes: delta.all as Note[],145+ tasks: delta.all as ChecklistTask[],112146 error: null,113147 };114148 this.#emit();
@@ -120,7 +154,7 @@ export class NotesStore {
120154 }121155 }122156 123- #closeVendorSubscription() {157+ #closeVendorSubscription(): void {124158 if (!this.#vendorUnsubscribe) return;125159 this.#vendorUnsubscribe();126160 this.#diagnostics.unsubscribeCalls += 1;
@@ -128,12 +162,12 @@ export class NotesStore {
128162 this.#diagnostics.activeVendorSubscriptions -= 1;129163 }130164 131- #set(change: Partial<NotesSnapshot>) {165+ #set(change: Partial<ChecklistSnapshot>): void {132166 this.#snapshot = { ...this.#snapshot, ...change };133167 this.#emit();134168 }135169 136- #fail(error: unknown) {170+ #fail(error: unknown): void {137171 this.#snapshot = {138172 ...this.#snapshot,139173 status: "error",
@@ -143,7 +177,7 @@ export class NotesStore {
143177 this.#emit();144178 }145179 146- #emit() {180+ #emit(): void {147181 for (const listener of this.#listeners) listener();148182 }149183 }
R apps/prototype/src/runtime/notes-store_test.ts → apps/reference/src/_lofi/checklist-store_test.ts +48 -30
@@ -1,5 +1,5 @@
11 import type { Db, MutationErrorEvent } from "jazz-tools";2-import { NotesStore, type RuntimeDiagnostics } from "./notes-store.ts";2+import { ChecklistStore, type RuntimeDiagnostics } from "./checklist-store.ts";33 44 const test = (globalThis as unknown as {55 Deno: { test(name: string, body: () => void | Promise<void>): void };
@@ -9,7 +9,7 @@ function assert(condition: unknown, message: string): asserts condition {
99 if (!condition) throw new Error(message);1010 }1111 12-function assertCount(actual: number, expected: number, message: string) {12+function assertCount(actual: number, expected: number, message: string): void {1313 if (actual !== expected) throw new Error(`${message}: expected ${expected}, received ${actual}`);1414 }1515
@@ -33,13 +33,12 @@ function fakeDb() {
3333 let subscriber: ((delta: { all: unknown[] }) => void) | null = null;3434 let unsubscribeCalls = 0;3535 let rejectNextWrite: Error | null = null;36- const writes: Array<{ operation: "insert" | "update"; value: unknown }> = [];36+ const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];3737 3838 function handle() {3939 return {4040 wait: ({ tier }: { tier: "local" | "global" }) => {41- assert(tier === "local", "local-only tests must not request a global wait");42- if (rejectNextWrite) {41+ if (tier === "local" && rejectNextWrite) {4342 const error = rejectNextWrite;4443 rejectNextWrite = null;4544 return Promise.reject(error);
@@ -65,6 +64,10 @@ function fakeDb() {
6564 writes.push({ operation: "update", value: { id, next } });6665 return handle();6766 },67+ delete(_collection: unknown, id: string) {68+ writes.push({ operation: "delete", value: id });69+ return handle();70+ },6871 } as unknown as Db;6972 7073 return {
@@ -81,38 +84,34 @@ function fakeDb() {
8184 };8285 }8386 84-test("two consumers share one vendor subscription and clean up idempotently", () => {87+test("two consumers share one task subscription and clean up idempotently", () => {8588 const db = fakeDb();8689 const counts = diagnostics();87- const store = new NotesStore(db.value, counts);90+ const store = new ChecklistStore(db.value, counts);8891 let firstUpdates = 0;8992 let secondUpdates = 0;9093 9194 const stopFirst = store.subscribe(() => firstUpdates += 1);9295 const stopSecond = store.subscribe(() => secondUpdates += 1);9396 assertCount(counts.activeConsumers, 2, "both component consumers must be counted");94- assertCount(95- counts.activeVendorSubscriptions,96- 1,97- "one query should have one vendor subscription",98- );97+ assertCount(counts.activeVendorSubscriptions, 1, "one query should have one subscription");9998 assertCount(counts.totalVendorSubscriptions, 1, "the underlying query should open once");10099 101- db.publish([{ id: "note-1", body: "shared", createdAt: new Date(0) }]);100+ db.publish([{101+ id: "task-1",102+ text: "shared",103+ completed: false,104+ createdAt: new Date(0),105+ }]);102106 assert(store.getSnapshot().status === "ready", "the first query result must end loading");103- assert(store.getSnapshot().notes.length === 1, "the query result must be published");107+ assert(store.getSnapshot().tasks.length === 1, "the query result must be published");104108 assert(firstUpdates > 0 && secondUpdates > 0, "both roots must receive reactive updates");105109 106110 stopFirst();107111 assertCount(counts.activeConsumers, 1, "unmounting one root must leave the other active");108- assertCount(109- counts.activeVendorSubscriptions,110- 1,111- "the remaining consumer must retain the query",112- );112+ assertCount(counts.activeVendorSubscriptions, 1, "the remaining consumer retains the query");113113 stopFirst();114114 assertCount(counts.activeConsumers, 1, "unsubscribe must be idempotent");115-116115 stopSecond();117116 assertCount(counts.activeConsumers, 0, "the final unmount must remove all consumers");118117 assertCount(counts.activeVendorSubscriptions, 0, "the final unmount must close the query");
@@ -120,21 +119,41 @@ test("two consumers share one vendor subscription and clean up idempotently", ()
120119 assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");121120 });122121 123-test("local writes are awaited and failures remain visible", async () => {122+test("every checklist mutation waits for local durability", async () => {124123 const db = fakeDb();125124 const counts = diagnostics();126- const store = new NotesStore(db.value, counts);125+ const store = new ChecklistStore(db.value, counts);126+127+ await store.create("first");128+ await store.update("task-1", "edited");129+ await store.setCompleted("task-1", true);130+ await store.delete("task-1");127131 128- await store.add("first");129- await store.update("note-1", "edited");130- assert(counts.localWaitCalls === 2, "every accepted write must await local durability");131- assert(db.writes.length === 2, "insert and update must reach the vanilla Jazz boundary");132+ assertCount(counts.localWaitCalls, 4, "each accepted mutation must await local durability");133+ assert(134+ db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete",135+ "create, edit, complete, and delete must reach the Jazz boundary",136+ );132137 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");138+});139+140+test("configured sync advances the latest write from local to global durability", async () => {141+ const store = new ChecklistStore(fakeDb().value, diagnostics(), true);142+ await store.create("sync after reconnect");143+ await Promise.resolve();144+ assert(145+ store.getSnapshot().durability === "global",146+ "the public mutation handle must make completed global durability visible",147+ );148+});133149 150+test("local write failures remain visible and reject the mutation", async () => {151+ const db = fakeDb();152+ const store = new ChecklistStore(db.value, diagnostics());134153 db.failNextWrite("disk full");135154 let rejected = false;136155 try {137- await store.add("cannot persist");156+ await store.create("cannot persist");138157 } catch {139158 rejected = true;140159 }
@@ -144,13 +163,12 @@ test("local writes are awaited and failures remain visible", async () => {
144163 });145164 146165 test("public mutation errors update observable diagnostics", () => {147- const db = fakeDb();148166 const counts = diagnostics();149- const store = new NotesStore(db.value, counts);167+ const store = new ChecklistStore(fakeDb().value, counts);150168 store.reportMutationError({151169 code: "WriteRejected",152170 reason: "permission denied",153171 } as MutationErrorEvent);154- assert(counts.mutationErrors === 1, "mutation errors must be counted");172+ assertCount(counts.mutationErrors, 1, "mutation errors must be counted");155173 assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");156174 });
R apps/prototype/src/runtime/config.ts → apps/reference/src/_lofi/config.ts +2 -1
R apps/prototype/src/runtime/device-capabilities.ts → apps/reference/src/_lofi/device-capabilities.ts +0 -0

No content changes.

A apps/reference/src/_lofi/probe.ts +39 -0
R apps/prototype/src/runtime/pwa.ts → apps/reference/src/_lofi/pwa.ts +0 -0

No content changes.

R apps/prototype/src/runtime/pwa_test.ts → apps/reference/src/_lofi/pwa_test.ts +0 -0

No content changes.

R apps/prototype/src/runtime/resource-lifecycle.ts → apps/reference/src/_lofi/resource-lifecycle.ts +0 -0

No content changes.

R apps/prototype/src/runtime/resource-lifecycle_test.ts → apps/reference/src/_lofi/resource-lifecycle_test.ts +0 -0

No content changes.

R apps/prototype/src/runtime/lofi-runtime.ts → apps/reference/src/_lofi/runtime.ts +6 -6
@@ -1,7 +1,7 @@
11 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";2+import { ChecklistStore, type RuntimeDiagnostics } from "./checklist-store.ts";23 import { appId, databaseConfig } from "./config.ts";34 import { assertDurableBrowser } from "./device-capabilities.ts";4-import { NotesStore, type RuntimeDiagnostics } from "./notes-store.ts";55 import {66 AdapterLifecycle,77 createSerializedResourceState,
@@ -13,7 +13,7 @@ import {
1313 1414 export type LofiRuntime = {1515 db: Db;16- notes: NotesStore;16+ checklist: ChecklistStore;1717 diagnostics: RuntimeDiagnostics;1818 shutdown(): Promise<void>;1919 };
@@ -73,19 +73,19 @@ let recreationPromise: Promise<LofiRuntime> | null = null;
7373 let shutdownPromise: Promise<void> | null = null;7474 7575 function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {76- const notes = new NotesStore(db, state.diagnostics);77- const stopMutationErrors = db.onMutationError((event) => notes.reportMutationError(event));76+ const checklist = new ChecklistStore(db, state.diagnostics);77+ const stopMutationErrors = db.onMutationError((event) => checklist.reportMutationError(event));7878 state.diagnostics.activeMutationListeners += 1;7979 state.diagnostics.totalMutationListeners += 1;8080 let active = true;8181 const runtime: LofiRuntime = {8282 db,83- notes,83+ checklist,8484 diagnostics: state.diagnostics,8585 shutdown() {8686 if (!active) return Promise.resolve();8787 active = false;88- notes.close();88+ checklist.close();8989 stopMutationErrors();9090 state.diagnostics.activeMutationListeners -= 1;9191 return Promise.resolve();
R apps/prototype/src/runtime/ui-mutation.ts → apps/reference/src/_lofi/ui-mutation.ts +0 -0

No content changes.

R apps/prototype/src/runtime/ui-mutation_test.ts → apps/reference/src/_lofi/ui-mutation_test.ts +0 -0

No content changes.

R apps/prototype/src/runtime/use-notes.ts → apps/reference/src/_lofi/use-checklist.ts +18 -19
@@ -1,17 +1,16 @@
11 import { useCallback, useEffect, useState } from "preact/hooks";2-import { getRuntime, getRuntimeDiagnostics, runtimeRecreatedEvent } from "./lofi-runtime.ts";3-import type { NotesSnapshot, RuntimeDiagnostics } from "./notes-store.ts";2+import type { ChecklistSnapshot } from "./checklist-store.ts";3+import { getRuntime, runtimeRecreatedEvent } from "./runtime.ts";44 5-const initial: NotesSnapshot = {5+const initial: ChecklistSnapshot = {66 status: "loading",7- notes: [],7+ tasks: [],88 durability: "none",99 error: null,1010 };1111 12-export function useNotes() {12+export function useChecklist() {1313 const [snapshot, setSnapshot] = useState(initial);14- const [diagnostics, setDiagnostics] = useState<RuntimeDiagnostics>(getRuntimeDiagnostics());1514 1615 useEffect(() => {1716 let cancelled = false;
@@ -24,11 +23,8 @@ export function useNotes() {
2423 setSnapshot(initial);2524 void getRuntime().then((runtime) => {2625 if (cancelled || generation !== connectionGeneration) return;27- const update = () => {28- setSnapshot(runtime.notes.getSnapshot());29- setDiagnostics(getRuntimeDiagnostics());30- };31- unsubscribe = runtime.notes.subscribe(update);26+ const update = () => setSnapshot(runtime.checklist.getSnapshot());27+ unsubscribe = runtime.checklist.subscribe(update);3228 update();3329 }, (error) => {3430 if (cancelled || generation !== connectionGeneration) return;
@@ -50,15 +46,18 @@ export function useNotes() {
5046 };5147 }, []);5248 53- const add = useCallback(async (body: string) => {54- const runtime = await getRuntime();55- await runtime.notes.add(body);49+ const create = useCallback(async (text: string) => {50+ await (await getRuntime()).checklist.create(text);5651 }, []);57-58- const update = useCallback(async (id: string, body: string) => {59- const runtime = await getRuntime();60- await runtime.notes.update(id, body);52+ const update = useCallback(async (id: string, text: string) => {53+ await (await getRuntime()).checklist.update(id, text);54+ }, []);55+ const setCompleted = useCallback(async (id: string, completed: boolean) => {56+ await (await getRuntime()).checklist.setCompleted(id, completed);57+ }, []);58+ const remove = useCallback(async (id: string) => {59+ await (await getRuntime()).checklist.delete(id);6160 }, []);6261 63- return { ...snapshot, diagnostics, add, update };62+ return { ...snapshot, create, update, setCompleted, remove };6463 }
R apps/prototype/src/runtime/use-device-capabilities.ts → apps/reference/src/_lofi/use-device-capabilities.ts +0 -0

No content changes.

A apps/reference/src/app.ts +11 -0
R apps/prototype/src/env.d.ts → apps/reference/src/env.d.ts +0 -0

No content changes.

A apps/reference/src/islands/ChecklistIsland.tsx +134 -0
@@ -0,0 +1,134 @@
1+import { useEffect, useState } from "preact/hooks";2+import type { ChecklistTask } from "../_lofi/checklist-store.ts";3+import { settleUiMutation } from "../_lofi/ui-mutation.ts";4+import { useChecklist } from "../_lofi/use-checklist.ts";5+6+export interface ChecklistIslandProps {7+ label: string;8+}9+10+type ChecklistRowProps = {11+ task: ChecklistTask;12+ update(id: string, text: string): Promise<void>;13+ setCompleted(id: string, completed: boolean): Promise<void>;14+ remove(id: string): Promise<void>;15+};16+17+function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps) {18+ const [editing, setEditing] = useState(false);19+ const [draft, setDraft] = useState(task.text);20+ const editId = `edit-${task.id}`;21+22+ useEffect(() => {23+ if (!editing) setDraft(task.text);24+ }, [editing, task.text]);25+26+ return (27+ <li class={task.completed ? "task task-complete" : "task"} data-task-id={task.id}>28+ <label class="task-toggle">29+ <input30+ type="checkbox"31+ checked={task.completed}32+ aria-label={`Complete ${task.text}`}33+ onChange={(event) =>34+ void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}35+ />36+ <span>{task.text}</span>37+ </label>38+ {editing39+ ? (40+ <form41+ class="edit-form"42+ onSubmit={(event) => {43+ event.preventDefault();44+ const next = draft.trim();45+ if (!next) return;46+ setEditing(false);47+ void settleUiMutation(update(task.id, next));48+ }}49+ >50+ <label class="visually-hidden" for={editId}>Edit {task.text}</label>51+ <input52+ id={editId}53+ value={draft}54+ onInput={(event) => setDraft(event.currentTarget.value)}55+ autocomplete="off"56+ />57+ <button type="submit" aria-label={`Save ${task.text}`}>Save</button>58+ <button type="button" onClick={() => setEditing(false)}>Cancel</button>59+ </form>60+ )61+ : (62+ <div class="task-actions">63+ <button64+ type="button"65+ aria-label={`Edit ${task.text}`}66+ onClick={() => setEditing(true)}67+ >68+ Edit69+ </button>70+ <button71+ type="button"72+ class="button-danger"73+ aria-label={`Delete ${task.text}`}74+ onClick={() => void settleUiMutation(remove(task.id))}75+ >76+ Delete77+ </button>78+ </div>79+ )}80+ </li>81+ );82+}83+84+export default function ChecklistIsland({ label }: ChecklistIslandProps) {85+ const checklist = useChecklist();86+ const [text, setText] = useState("");87+ const inputId = `task-${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}`;88+89+ return (90+ <section class="island" data-island={label}>91+ <header>92+ <p class="eyebrow">{label}</p>93+ <h2>Shared checklist</h2>94+ </header>95+ <form96+ onSubmit={(event) => {97+ event.preventDefault();98+ const next = text.trim();99+ if (!next) return;100+ setText("");101+ void settleUiMutation(checklist.create(next));102+ }}103+ >104+ <label for={inputId}>New item</label>105+ <div class="composer">106+ <input107+ id={inputId}108+ value={text}109+ onInput={(event) => setText(event.currentTarget.value)}110+ autocomplete="off"111+ />112+ <button type="submit">Add from {label}</button>113+ </div>114+ </form>115+ <p class="state" role="status">116+ {checklist.status === "loading" && "Opening persistent storage…"}117+ {checklist.status === "error" && `Write failed: ${checklist.error}`}118+ {checklist.status === "ready" &&119+ `${checklist.tasks.length} item(s) · last write ${checklist.durability}`}120+ </p>121+ <ul aria-label={`${label} checklist`}>122+ {checklist.tasks.map((task) => (123+ <ChecklistRow124+ key={task.id}125+ task={task}126+ update={checklist.update}127+ setCompleted={checklist.setCompleted}128+ remove={checklist.remove}129+ />130+ ))}131+ </ul>132+ </section>133+ );134+}
A apps/reference/src/pages/index.astro +25 -0
A apps/reference/src/permissions.ts +9 -0
R apps/prototype/src/schema.ts → apps/reference/src/schema.ts +3 -2
R apps/prototype/src/styles/global.css → apps/reference/src/styles/global.css +90 -42
@@ -23,8 +23,7 @@ main {
2323 max-width: 58rem;2424 }2525 26-.eyebrow,27-.diagnostics {26+.eyebrow {2827 color: #9bc9b1;2928 font-size: 0.75rem;3029 font-weight: 800;
@@ -61,40 +60,6 @@ h1 {
6160 padding: 1.4rem;6261 }6362 64-.device-status {65- background: #1d2b25;66- border: 1px solid #3b594c;67- border-radius: 0.7rem;68- margin-top: 1rem;69- padding: 1.4rem;70-}71-72-.device-status h2 {73- margin: 0.25rem 0 1rem;74-}75-76-.device-status dl {77- display: grid;78- gap: 0.5rem;79- grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));80-}81-82-.device-status dl div {83- background: #18231f;84- padding: 0.75rem;85-}86-87-.device-status dt {88- color: #9bc9b1;89- font-size: 0.75rem;90- font-weight: 800;91- text-transform: uppercase;92-}93-94-.device-status dd {95- margin: 0.25rem 0 0;96-}97-9863 .island h2 {9964 font-size: 1.5rem;10065 margin: 0.25rem 0 1.5rem;
@@ -106,7 +71,9 @@ label {
10671 margin-bottom: 0.5rem;10772 }10873 109-.composer {74+.composer,75+.edit-form,76+.task-actions {11077 display: flex;11178 gap: 0.5rem;11279 }
@@ -119,8 +86,10 @@ button {
11986 padding: 0.7rem 0.8rem;12087 }12188 122-input {89+input[type="text"],90+input:not([type]) {12391 background: #f3faf6;92+ color: #122019;12493 flex: 1;12594 min-width: 0;12695 }
@@ -132,6 +101,17 @@ button {
132101 font-weight: 800;133102 }134103 104+button:focus-visible,105+input:focus-visible {106+ outline: 3px solid #d5f26a;107+ outline-offset: 2px;108+}109+110+.button-danger {111+ background: transparent;112+ color: #ffd6d6;113+}114+135115 .state {136116 color: #bed0c6;137117 min-height: 1.5rem;
@@ -139,21 +119,89 @@ button {
139119 140120 ul {141121 display: grid;142- gap: 0.5rem;122+ gap: 0.65rem;143123 list-style: none;144124 padding: 0;145125 }146126 147-li {127+.task {128+ align-items: center;148129 background: #18231f;149130 border-left: 3px solid #99e5bd;131+ display: grid;132+ gap: 0.75rem;133+ grid-template-columns: minmax(0, 1fr) auto;150134 padding: 0.75rem 0.9rem;151135 }152136 137+.task-toggle {138+ align-items: center;139+ display: flex;140+ gap: 0.65rem;141+ margin: 0;142+ min-width: 0;143+}144+145+.task-toggle input {146+ height: 1.15rem;147+ width: 1.15rem;148+}149+150+.task-toggle span {151+ overflow-wrap: anywhere;152+}153+154+.task-complete .task-toggle span {155+ color: #9bc9b1;156+ text-decoration: line-through;157+}158+159+.edit-form {160+ grid-column: 1 / -1;161+}162+163+.identity-note {164+ background: #1d2b25;165+ border: 1px solid #3b594c;166+ border-radius: 0.7rem;167+ color: #bed0c6;168+ line-height: 1.6;169+ margin-top: 1rem;170+ padding: 1rem 1.4rem;171+}172+173+.identity-note strong {174+ color: #eff8f2;175+}176+177+.visually-hidden {178+ clip: rect(0 0 0 0);179+ clip-path: inset(50%);180+ height: 1px;181+ overflow: hidden;182+ position: absolute;183+ white-space: nowrap;184+ width: 1px;185+}186+153187 footer {188+ color: #9bc9b1;154189 margin-top: 2rem;155190 }156191 157-a {158- color: #99e5bd;192+@media (max-width: 40rem) {193+ main {194+ padding: 2.5rem 1rem;195+ }196+197+ .composer,198+ .edit-form,199+ .task-actions {200+ align-items: stretch;201+ flex-direction: column;202+ }203+204+ .task {205+ grid-template-columns: 1fr;206+ }159207 }
A apps/reference/tests/author-boundary_test.ts +31 -0
R apps/prototype/tsconfig.json → apps/reference/tsconfig.json +0 -0

No content changes.

M deno.json +10 -6
@@ -6,6 +6,7 @@
66 "astro/config": "npm:astro@7.0.9/config",77 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",88 "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",9+ "playwright": "npm:playwright@1.61.1",910 "preact": "npm:preact@10.29.7",1011 "preact/hooks": "npm:preact@10.29.7/hooks",1112 "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",
@@ -22,12 +23,15 @@
2223 "tasks": {2324 "check:env": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env tools/check_env.ts",2425 "check:secrets": "deno run --allow-env=JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read --allow-run=git tools/check_secret_leaks.ts",25- "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/prototype",26- "check": "deno fmt --check && deno lint && deno check tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/prototype/*.ts apps/prototype/src/**/*.ts apps/prototype/src/**/*.tsx && deno task check:astro && deno task test && deno task check:env && deno task check:secrets",27- "test": "deno test --allow-read --allow-write=. --allow-run=git tools apps/prototype/src/runtime",28- "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/prototype/.env --allow-write=apps/prototype/.env --allow-run tools/run_prototype.ts dev",29- "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/prototype/.env,apps/prototype/dist --allow-write=apps/prototype/.env,apps/prototype/dist --allow-run tools/run_prototype.ts build",30- "preview": "deno run --allow-net --allow-read=apps/prototype/dist tools/preview.ts",26+ "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/reference",27+ "check": "deno fmt --check && deno lint && deno check tools/*.ts migrations/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets",28+ "test:unit": "deno test --allow-read --allow-write=. --allow-run=git tools apps/reference/src/_lofi apps/reference/tests",29+ "test": "deno task test:unit",30+ "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",31+ "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",32+ "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env --allow-write=apps/reference/.env --allow-run tools/run_reference.ts dev",33+ "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env,apps/reference/dist --allow-write=apps/reference/.env,apps/reference/dist --allow-run tools/run_reference.ts build",34+ "preview": "deno run --allow-net --allow-read=apps/reference/dist tools/preview.ts",3135 "spike:jazz2:dev": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts local",3236 "spike:jazz2:cloud": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts cloud",3337 "spike:jazz2:build": "deno run -A npm:vite@8.0.1 build spikes/jazz2-baseline --config spikes/jazz2-baseline/vite.config.ts",
M deno.lock +30 -11
@@ -5,6 +5,8 @@
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:playwright@*": "1.61.1",9+ "npm:playwright@1.61.1": "1.61.1",810 "npm:preact@10.29.7": "10.29.7_preact-render-to-string@6.7.0",911 "npm:typescript@6.0.3": "6.0.3",1012 "npm:vite@8.0.1": "8.0.1_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"
@@ -1475,7 +1477,7 @@
14751477 "emmet",14761478 "jsonc-parser@2.3.1",14771479 "vscode-languageserver-textdocument",1478- "vscode-languageserver-types@3.18.0",1480+ "vscode-languageserver-types",14791481 "vscode-uri"14801482 ]14811483 },
@@ -1934,6 +1936,11 @@
19341936 "tiny-inflate"19351937 ]19361938 },1939+ "fsevents@2.3.2": {1940+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",1941+ "os": ["darwin"],1942+ "scripts": true1943+ },19371944 "fsevents@2.3.3": {19381945 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",19391946 "os": ["darwin"],
@@ -2390,6 +2397,20 @@
23902397 "picomatch@4.0.5": {23912398 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="23922399 },2400+ "playwright-core@1.61.1": {2401+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",2402+ "bin": true2403+ },2404+ "playwright@1.61.1": {2405+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",2406+ "dependencies": [2407+ "playwright-core"2408+ ],2409+ "optionalDependencies": [2410+ "fsevents@2.3.2"2411+ ],2412+ "bin": true2413+ },23932414 "pluralize-esm@9.0.5": {23942415 "integrity": "sha512-Kb2dcpMsIutFw2hYrN0EhsAXOUJTd6FVMIxvNAkZCMQLVt9NGZqQczvGpYDxNWCZeCWLHUPxQIBudWzt1h7VVA=="23952416 },
@@ -2852,7 +2873,7 @@
28522873 "tinyglobby"28532874 ],28542875 "optionalDependencies": [2855- "fsevents"2876+ "fsevents@2.3.3"28562877 ],28572878 "bin": true28582879 },
@@ -2867,7 +2888,7 @@
28672888 "tinyglobby"28682889 ],28692890 "optionalDependencies": [2870- "fsevents"2891+ "fsevents@2.3.3"28712892 ],28722893 "optionalPeers": [28732894 "esbuild@0.28.1"
@@ -2971,7 +2992,7 @@
29712992 "dependencies": [29722993 "@vscode/l10n",29732994 "vscode-languageserver-textdocument",2974- "vscode-languageserver-types@3.17.5",2995+ "vscode-languageserver-types",29752996 "vscode-uri"29762997 ]29772998 },
@@ -2980,7 +3001,7 @@
29803001 "dependencies": [29813002 "@vscode/l10n",29823003 "vscode-languageserver-textdocument",2983- "vscode-languageserver-types@3.18.0",3004+ "vscode-languageserver-types",29843005 "vscode-uri"29853006 ]29863007 },
@@ -2989,7 +3010,7 @@
29893010 "dependencies": [29903011 "jsonc-parser@3.3.1",29913012 "vscode-languageserver-textdocument",2992- "vscode-languageserver-types@3.18.0",3013+ "vscode-languageserver-types",29933014 "vscode-nls",29943015 "vscode-uri"29953016 ]
@@ -3001,7 +3022,7 @@
30013022 "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",30023023 "dependencies": [30033024 "vscode-jsonrpc",3004- "vscode-languageserver-types@3.17.5"3025+ "vscode-languageserver-types"30053026 ]30063027 },30073028 "vscode-languageserver-textdocument@1.0.12": {
@@ -3010,9 +3031,6 @@
30103031 "vscode-languageserver-types@3.17.5": {30113032 "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="30123033 },3013- "vscode-languageserver-types@3.18.0": {3014- "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="3015- },30163034 "vscode-languageserver@9.0.1": {30173035 "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",30183036 "dependencies": [
@@ -3061,7 +3079,7 @@
30613079 "vscode-json-languageservice",30623080 "vscode-languageserver",30633081 "vscode-languageserver-textdocument",3064- "vscode-languageserver-types@3.18.0",3082+ "vscode-languageserver-types",30653083 "vscode-uri",30663084 "yaml"30673085 ],
@@ -3108,6 +3126,7 @@
31083126 "npm:@astrojs/preact@6.0.1",31093127 "npm:astro@7.0.9",31103128 "npm:jazz-tools@2.0.0-alpha.53",3129+ "npm:playwright@1.61.1",31113130 "npm:preact@10.29.7",31123131 "npm:typescript@6.0.3",31133132 "npm:vite@8.0.1"
A migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts +28 -0
A migrations/snapshots/20260715T194819-6c62fec42c35.json +20 -0
A migrations/snapshots/20260715T194947-ff85ac1d97ee.json +27 -0
M tools/artifact_files_test.ts +3 -3
A tools/golden_path.ts +615 -0
@@ -0,0 +1,615 @@
1+import { join, resolve } from "node:path";2+import { type Browser, type BrowserContext, chromium, type Page } from "playwright";3+import { environmentNames, validateEnvironment } from "./env_contract.ts";4+import {5+ artifactPaths,6+ type CapturedProcess,7+ type CommandRecord,8+ initializeFixtureGit,9+ installNodeSentinel,10+ type JourneyAssertion,11+ type JourneyReport,12+ type JourneySource,13+ materializeCleanProject,14+ reserveLocalPort,15+ runCapturedCommand,16+ safeChildEnvironment,17+ scanAuthorBoundary,18+ startReadyProcess,19+ writeJourneyReport,20+} from "./golden_path_core.ts";21+import { loadEnvironment } from "./load_env.ts";22+23+export type GoldenPathCommands = {24+ dev: string;25+ check: string;26+ test: string;27+ build: string;28+ preview: string;29+};30+31+export type GoldenPathOptions = {32+ source: JourneySource;33+ /** Repository working tree for checkout mode, or the already-created project for create mode. */34+ projectRoot?: string;35+ artifactRoot?: string;36+ authorPaths?: string[];37+ commands?: Partial<GoldenPathCommands>;38+ cacheMode?: "existing" | "cold";39+ environmentMode?: "isolated-local" | "cloud-from-root";40+ timeoutMs?: number;41+ islandLabels?: [string, string];42+};43+44+const defaultCommands: GoldenPathCommands = {45+ dev: "dev",46+ check: "check",47+ test: "test",48+ build: "build",49+ preview: "preview",50+};51+52+const defaultAuthorPaths = [53+ "apps/reference/src/app.ts",54+ "apps/reference/src/islands",55+ "apps/reference/src/pages",56+ "apps/reference/src/permissions.ts",57+ "apps/reference/src/schema.ts",58+ "apps/reference/src/styles",59+];60+61+const retainedBody = "Golden path retained item";62+const updatedBody = "Golden path retained item updated";63+const disposableBody = "Golden path disposable item";64+const offlineDevelopmentBody = "Golden path development offline item";65+const offlineProductionBody = "Golden path production offline item";66+67+function assert(condition: unknown, message: string): asserts condition {68+ if (!condition) throw new Error(message);69+}70+71+function taskArgs(task: string, extra: string[] = []): string[] {72+ return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];73+}74+75+function escaped(value: string): string {76+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");77+}78+79+async function waitForItem(80+ page: Page,81+ islandLabels: readonly string[],82+ body: string,83+): Promise<void> {84+ for (const [index, label] of islandLabels.entries()) {85+ const island = page.locator("[data-island]").filter({ hasText: label });86+ const item = island.getByRole("listitem").filter({ hasText: body });87+ if (index === 0) {88+ await Promise.race([89+ item.waitFor({ state: "visible" }),90+ island.getByRole("status").filter({ hasText: "Write failed:" }).waitFor({91+ state: "visible",92+ }).then(async () => {93+ throw new Error((await island.getByRole("status").textContent()) ?? "Write failed");94+ }),95+ ]);96+ } else {97+ await item.waitFor({ state: "visible" });98+ }99+ }100+}101+102+async function waitForItemAbsent(103+ page: Page,104+ islandLabels: readonly string[],105+ body: string,106+): Promise<void> {107+ for (const label of islandLabels) {108+ const island = page.locator("[data-island]").filter({ hasText: label });109+ await island.getByRole("listitem").filter({ hasText: body }).waitFor({ state: "detached" });110+ }111+}112+113+async function addItem(page: Page, island: string, body: string): Promise<void> {114+ const section = page.locator("[data-island]").filter({ hasText: island });115+ const textbox = section.getByRole("textbox", { name: "New item" });116+ try {117+ await textbox.waitFor({ state: "visible" });118+ } catch (error) {119+ throw new Error(120+ `reference UI is missing accessible textbox "New item" in ${island}; #9 must expose CRUD controls`,121+ { cause: error },122+ );123+ }124+ await textbox.fill(body);125+ const add = section.getByRole("button", { name: `Add from ${island}` });126+ try {127+ await add.waitFor({ state: "visible" });128+ } catch (error) {129+ throw new Error(130+ `reference UI is missing accessible button "Add from ${island}"; #9 must expose CRUD controls`,131+ { cause: error },132+ );133+ }134+ await add.click();135+}136+137+async function waitForLocalDurability(page: Page): Promise<void> {138+ await page.getByRole("status").filter({ hasText: /last write (?:local|global)/ }).first().waitFor(139+ {140+ state: "visible",141+ },142+ );143+}144+145+async function assertRuntimeCardinality(146+ page: Page,147+ islandLabels: readonly string[],148+): Promise<void> {149+ assert(islandLabels.length === 2, "golden path cardinality requires two mounted islands");150+ await page.waitForFunction(() => {151+ const probe = (globalThis as typeof globalThis & {152+ __LOFI_REFERENCE__?: {153+ diagnostics(): {154+ storageState: string;155+ activeClients: number;156+ activeConsumers: number;157+ activeVendorSubscriptions: number;158+ activeMutationListeners: number;159+ };160+ };161+ }).__LOFI_REFERENCE__;162+ const diagnostics = probe?.diagnostics();163+ return diagnostics?.storageState === "persistent-driver-open" &&164+ diagnostics.activeClients === 1 &&165+ diagnostics.activeConsumers === 2 &&166+ diagnostics.activeVendorSubscriptions === 1 &&167+ diagnostics.activeMutationListeners === 1;168+ });169+}170+171+async function observeReconnectSettlement(172+ page: Page,173+ timeoutMs: number,174+ required: boolean,175+): Promise<JourneyAssertion> {176+ try {177+ await page.getByRole("status").filter({ hasText: /last write global/ }).first().waitFor({178+ state: "visible",179+ timeout: Math.min(timeoutMs, 10_000),180+ });181+ return {182+ name: "development reconnect settlement",183+ status: "passed",184+ detail: "The configured development server exposed global settlement after network return.",185+ };186+ } catch {187+ if (required) {188+ throw new Error(189+ "cloud evidence failed: reconnect did not expose global settlement before the readiness deadline",190+ );191+ }192+ return {193+ name: "development reconnect settlement",194+ status: "blocked",195+ detail:196+ "The local managed server did not expose global settlement before the readiness deadline; offline retention passed, but this run makes no transport convergence claim.",197+ };198+ }199+}200+201+async function journeyEnvironment(202+ sourceRoot: string,203+ mode: "isolated-local" | "cloud-from-root",204+ artifactRoot: string,205+): Promise<Record<string, string>> {206+ const isolated = safeChildEnvironment();207+ if (mode === "isolated-local") return await installNodeSentinel(isolated, artifactRoot);208+209+ const configured = await loadEnvironment(join(sourceRoot, ".env"));210+ const validation = validateEnvironment(configured);211+ if (!validation.ok) throw new Error(validation.errors.join(" "));212+ if (validation.mode !== "cloud-configured") {213+ throw new Error(214+ "cloud evidence requires the complete JAZZ_APP_ID/JAZZ_SERVER_URL pair in the root .env or process environment",215+ );216+ }217+ const allowlisted = { ...isolated };218+ for (const name of environmentNames) allowlisted[name] = configured[name]?.trim() ?? "";219+ return await installNodeSentinel(allowlisted, artifactRoot);220+}221+222+async function updateItem(page: Page, body: string, nextBody: string): Promise<void> {223+ const edit = page.getByRole("button", { name: `Edit ${body}`, exact: true }).first();224+ try {225+ await edit.click();226+ } catch (error) {227+ throw new Error(`reference UI is missing accessible button "Edit ${body}"`, { cause: error });228+ }229+ const textbox = page.getByRole("textbox", { name: `Edit ${body}`, exact: true }).first();230+ try {231+ await textbox.fill(nextBody);232+ } catch (error) {233+ throw new Error(`reference UI is missing accessible textbox "Edit ${body}"`, { cause: error });234+ }235+ const save = page.getByRole("button", { name: `Save ${body}`, exact: true }).first();236+ try {237+ await save.click();238+ } catch (error) {239+ throw new Error(`reference UI is missing accessible button "Save ${body}"`, { cause: error });240+ }241+}242+243+async function completeItem(page: Page, body: string): Promise<void> {244+ const name = new RegExp(`^(?:Complete ${escaped(body)}|Mark ${escaped(body)} complete)$`);245+ const checkboxes = page.getByRole("checkbox", { name });246+ try {247+ await checkboxes.first().check();248+ } catch (error) {249+ throw new Error(250+ `reference UI needs checkbox "Complete ${body}" or "Mark ${body} complete"`,251+ { cause: error },252+ );253+ }254+ await page.waitForFunction((accessibleName) => {255+ const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]256+ .filter((element) =>257+ element.getAttribute("aria-label") === accessibleName[0] ||258+ element.getAttribute("aria-label") === accessibleName[1]259+ );260+ return matches.length === 2 && matches.every((element) => element.checked);261+ }, [`Complete ${body}`, `Mark ${body} complete`]);262+}263+264+async function assertCompleted(page: Page, body: string): Promise<void> {265+ const name = new RegExp(`^(?:Complete ${escaped(body)}|Mark ${escaped(body)} complete)$`);266+ const checkboxes = page.getByRole("checkbox", { name });267+ await checkboxes.first().waitFor({ state: "visible" });268+ assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);269+ assert(270+ await checkboxes.evaluateAll((elements) =>271+ elements.every((element) => (element as HTMLInputElement).checked)272+ ),273+ `completion for ${body} did not survive reload in both islands`,274+ );275+}276+277+async function deleteItem(page: Page, body: string): Promise<void> {278+ const button = page.getByRole("button", { name: `Delete ${body}`, exact: true }).first();279+ try {280+ await button.click();281+ } catch (error) {282+ throw new Error(`reference UI is missing accessible button "Delete ${body}"`, { cause: error });283+ }284+}285+286+async function waitForProductionPwa(page: Page, timeoutMs: number): Promise<void> {287+ await page.waitForFunction(288+ () => {289+ return (globalThis as typeof globalThis & { __LOFI_PWA_STATE__?: string })290+ .__LOFI_PWA_STATE__ === "ready";291+ },292+ undefined,293+ { timeout: timeoutMs },294+ );295+ await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, {296+ timeout: timeoutMs,297+ });298+}299+300+async function screenshotFailure(page: Page | null, path: string): Promise<void> {301+ if (!page || page.isClosed()) return;302+ try {303+ await page.screenshot({ path, fullPage: true });304+ } catch {305+ // Process logs and the trace remain authoritative when the page itself is unavailable.306+ }307+}308+309+function commandFailure(record: CommandRecord): Error {310+ return new Error(311+ `${record.name} failed with exit ${record.exitCode}; rerun ${Deno.execPath()} ${312+ record.args.join(" ")313+ } and inspect ${record.stderrPath}`,314+ );315+}316+317+export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {318+ const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());319+ const environmentMode = options.environmentMode ?? "isolated-local";320+ const artifacts = artifactPaths(321+ resolve(322+ options.artifactRoot ??323+ join(324+ sourceRoot,325+ environmentMode === "cloud-from-root"326+ ? "test-results/golden-path-cloud"327+ : "test-results/golden-path",328+ ),329+ ),330+ );331+ await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {332+ if (!(error instanceof Deno.errors.NotFound)) throw error;333+ });334+ await Deno.mkdir(artifacts.root, { recursive: true });335+336+ const startedAt = new Date();337+ const timeoutMs = options.timeoutMs ?? 60_000;338+ const commands = { ...defaultCommands, ...options.commands };339+ const islandLabels = options.islandLabels ?? ["North island", "South island"];340+ const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);341+ const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);342+ const commandRecords: CommandRecord[] = [];343+ const assertions: JourneyAssertion[] = [];344+ let projectRoot = sourceRoot;345+ let browser: Browser | null = null;346+ let context: BrowserContext | null = null;347+ let page: Page | null = null;348+ let dev: CapturedProcess | null = null;349+ let preview: CapturedProcess | null = null;350+ let browserVersion = "not launched";351+ let devReadyMs: number | undefined;352+ let firstRetainedWriteMs: number | undefined;353+ let failure: unknown;354+355+ const report: JourneyReport = {356+ schemaVersion: 1,357+ source: options.source,358+ environmentMode: environmentMode === "cloud-from-root" ? "cloud-allowlisted" : "isolated-local",359+ status: "failed",360+ startedAt: startedAt.toISOString(),361+ completedAt: startedAt.toISOString(),362+ runtime: {363+ deno: Deno.version.deno,364+ typescript: Deno.version.typescript,365+ v8: Deno.version.v8,366+ os: Deno.build.os,367+ arch: Deno.build.arch,368+ browser: browserVersion,369+ },370+ cacheMode: options.cacheMode ?? "existing",371+ measurements: {372+ developerCommandCount: options.source === "create" ? 3 : 3,373+ },374+ commands: commandRecords,375+ assertions,376+ authorBoundaryViolations: [],377+ artifacts,378+ };379+380+ try {381+ if (options.source === "checkout") {382+ projectRoot = await materializeCleanProject(sourceRoot);383+ commandRecords.push(...await initializeFixtureGit(projectRoot, environment, artifacts.root));384+ }385+386+ report.authorBoundaryViolations = await scanAuthorBoundary(387+ projectRoot,388+ options.authorPaths ?? defaultAuthorPaths,389+ );390+ assert(391+ report.authorBoundaryViolations.length === 0,392+ `author boundary leaked framework plumbing: ${393+ report.authorBoundaryViolations.map((item) => `${item.path}:${item.line} ${item.rule}`)394+ .join(395+ ", ",396+ )397+ }`,398+ );399+ assertions.push({400+ name: "author boundary",401+ status: "passed",402+ detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",403+ });404+405+ try {406+ browser = await chromium.launch({ headless: true });407+ } catch (error) {408+ throw new Error(409+ "Chromium is unavailable; install the pinned browser through the repository's explicit Deno browser-install task.",410+ { cause: error },411+ );412+ }413+ browserVersion = browser.version();414+ report.runtime.browser = browserVersion;415+ context = await browser.newContext();416+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });417+ page = await context.newPage();418+419+ const port = reserveLocalPort();420+ const devStarted = performance.now();421+ dev = startReadyProcess({422+ cwd: projectRoot,423+ args: taskArgs(commands.dev, ["--host", "127.0.0.1", "--port", String(port)]),424+ environment,425+ artifactRoot: artifacts.root,426+ name: "dev",427+ readyPattern: /(http:\/\/(?:127\.0\.0\.1|localhost):\d+\/?)/,428+ timeoutMs,429+ redactValues,430+ });431+ const devUrl = await dev.ready;432+ devReadyMs = Math.round(performance.now() - devStarted);433+ await page.goto(devUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });434+ await page.getByRole("textbox", { name: "New item" }).first().waitFor({ state: "visible" });435+ await assertRuntimeCardinality(page, islandLabels);436+ await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({437+ state: "visible",438+ });439+ devReadyMs = Math.round(performance.now() - devStarted);440+ assertions.push({441+ name: "development readiness",442+ status: "passed",443+ detail: `Hydrated author UI and the persistent runtime were usable in ${devReadyMs}ms.`,444+ });445+446+ await addItem(page, islandLabels[0], retainedBody);447+ await waitForItem(page, islandLabels, retainedBody);448+ await waitForLocalDurability(page);449+ firstRetainedWriteMs = Math.round(performance.now() - devStarted);450+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });451+ await waitForItem(page, islandLabels, retainedBody);452+ await assertRuntimeCardinality(page, islandLabels);453+ assertions.push({454+ name: "retained development write",455+ status: "passed",456+ detail:457+ "The local write survived reload and both islands kept one shared runtime subscription.",458+ });459+460+ await updateItem(page, retainedBody, updatedBody);461+ await waitForItem(page, islandLabels, updatedBody);462+ await completeItem(page, updatedBody);463+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });464+ await waitForItem(page, islandLabels, updatedBody);465+ await assertCompleted(page, updatedBody);466+467+ await addItem(page, islandLabels[0], disposableBody);468+ await waitForItem(page, islandLabels, disposableBody);469+ await deleteItem(page, disposableBody);470+ await waitForItemAbsent(page, islandLabels, disposableBody);471+ assertions.push({472+ name: "accessible CRUD",473+ status: "passed",474+ detail: "Create, update, complete, and delete worked through named author-facing controls.",475+ });476+477+ await context.setOffline(true);478+ await addItem(page, islandLabels[1], offlineDevelopmentBody);479+ await waitForItem(page, islandLabels, offlineDevelopmentBody);480+ await waitForLocalDurability(page);481+ await context.setOffline(false);482+ assertions.push(483+ await observeReconnectSettlement(page, timeoutMs, environmentMode === "cloud-from-root"),484+ );485+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });486+ await waitForItem(page, islandLabels, offlineDevelopmentBody);487+ assertions.push({488+ name: "development offline write",489+ status: "passed",490+ detail: "A loaded page accepted a local write offline and retained it after network return.",491+ });492+493+ await dev.stop();494+ dev = null;495+ for (496+ const [name, task] of [497+ ["check", commands.check],498+ ["test", commands.test],499+ ["build", commands.build],500+ ] as const501+ ) {502+ const record = await runCapturedCommand({503+ cwd: projectRoot,504+ args: taskArgs(task),505+ environment,506+ artifactRoot: artifacts.root,507+ name,508+ redactValues,509+ });510+ commandRecords.push(record);511+ if (record.exitCode !== 0) throw commandFailure(record);512+ }513+ assertions.push({514+ name: "public verification commands",515+ status: "passed",516+ detail: "check, test, and build completed through Deno tasks.",517+ });518+519+ preview = startReadyProcess({520+ cwd: projectRoot,521+ args: taskArgs(commands.preview, ["--port", String(port)]),522+ environment,523+ artifactRoot: artifacts.root,524+ name: "preview",525+ readyPattern: /(http:\/\/127\.0\.0\.1:\d+\/)/,526+ timeoutMs,527+ redactValues,528+ });529+ const previewUrl = await preview.ready;530+ assert(new URL(previewUrl).origin === new URL(devUrl).origin, "preview must reuse dev origin");531+ await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });532+ await waitForItem(page, islandLabels, updatedBody);533+ await waitForProductionPwa(page, timeoutMs);534+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });535+ await waitForProductionPwa(page, timeoutMs);536+537+ await context.setOffline(true);538+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });539+ await waitForItem(page, islandLabels, updatedBody);540+ await addItem(page, islandLabels[0], offlineProductionBody);541+ await waitForItem(page, islandLabels, offlineProductionBody);542+ await waitForLocalDurability(page);543+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });544+ await waitForItem(page, islandLabels, offlineProductionBody);545+ await context.setOffline(false);546+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });547+ await waitForItem(page, islandLabels, offlineProductionBody);548+ assertions.push({549+ name: "production offline cold start",550+ status: "passed",551+ detail: "The service-worker shell and OPFS data survived cold offline reload and editing.",552+ });553+554+ if (environmentMode === "cloud-from-root") {555+ for (const body of [updatedBody, offlineDevelopmentBody, offlineProductionBody]) {556+ await deleteItem(page, body);557+ await waitForItemAbsent(page, islandLabels, body);558+ }559+ assertions.push(await observeReconnectSettlement(page, timeoutMs, true));560+ assertions.push({561+ name: "cloud fixture cleanup",562+ status: "passed",563+ detail:564+ "All golden-path rows were deleted through the public UI and the delete mutations reached global durability before the throwaway browser identity closed.",565+ });566+ }567+568+ report.status = "passed";569+ } catch (error) {570+ failure = error;571+ assertions.push({572+ name: "golden journey",573+ status: "failed",574+ detail: error instanceof Error ? error.message : String(error),575+ });576+ await screenshotFailure(page, artifacts.screenshot);577+ } finally {578+ if (context) {579+ await context.setOffline(false).catch(() => undefined);580+ await context.tracing.stop({ path: artifacts.trace }).catch(() => undefined);581+ }582+ await preview?.stop().catch(() => undefined);583+ await dev?.stop().catch(() => undefined);584+ await context?.close().catch(() => undefined);585+ await browser?.close().catch(() => undefined);586+ if (options.source === "checkout" && projectRoot !== sourceRoot) {587+ await Deno.remove(resolve(projectRoot, ".."), { recursive: true }).catch(() => {588+ assertions.push({589+ name: "clean checkout cleanup",590+ status: "blocked",591+ detail: `Temporary checkout cleanup failed for ${projectRoot}.`,592+ });593+ });594+ }595+ report.completedAt = new Date().toISOString();596+ report.measurements.devReadyMs = devReadyMs;597+ report.measurements.firstRetainedWriteMs = firstRetainedWriteMs;598+ report.runtime.browser = browserVersion;599+ await writeJourneyReport(report);600+ }601+602+ if (failure) throw failure;603+ return report;604+}605+606+if (import.meta.main) {607+ try {608+ const report = await runJourney({ source: "checkout" });609+ console.log(`lofi golden path: passed (${report.artifacts.report})`);610+ } catch (error) {611+ console.error(`lofi golden path: failed: ${error instanceof Error ? error.message : error}`);612+ console.error("rerun: deno task test:golden:checkout");613+ Deno.exit(1);614+ }615+}
A tools/golden_path_checkout.ts +18 -0
A tools/golden_path_core.ts +535 -0
@@ -0,0 +1,535 @@
1+import { basename, dirname, join, relative, resolve } from "node:path";2+3+export type JourneySource = "checkout" | "create";4+5+export type CommandRecord = {6+ name: string;7+ args: string[];8+ durationMs: number;9+ exitCode: number;10+ stdoutPath: string;11+ stderrPath: string;12+};13+14+export type JourneyAssertion = {15+ name: string;16+ status: "passed" | "failed" | "blocked";17+ detail: string;18+};19+20+export type AuthorBoundaryViolation = {21+ rule: string;22+ path: string;23+ line: number;24+ excerpt: string;25+};26+27+export type JourneyReport = {28+ schemaVersion: 1;29+ source: JourneySource;30+ environmentMode: "isolated-local" | "cloud-allowlisted";31+ status: "passed" | "failed";32+ startedAt: string;33+ completedAt: string;34+ runtime: {35+ deno: string;36+ typescript: string;37+ v8: string;38+ os: string;39+ arch: string;40+ browser: string;41+ };42+ cacheMode: "existing" | "cold";43+ measurements: {44+ developerCommandCount: number;45+ devReadyMs?: number;46+ firstRetainedWriteMs?: number;47+ };48+ commands: CommandRecord[];49+ assertions: JourneyAssertion[];50+ authorBoundaryViolations: AuthorBoundaryViolation[];51+ artifacts: {52+ root: string;53+ report: string;54+ trace: string;55+ screenshot: string;56+ };57+};58+59+export type BoundaryRule = {60+ id: string;61+ pattern: RegExp;62+ allow?: (path: string) => boolean;63+};64+65+export const defaultBoundaryRules: readonly BoundaryRule[] = [66+ {67+ id: "raw-jazz-runtime-import",68+ pattern: /(?:from\s+|import\s*\()["']jazz-tools(?:\/[^"']*)?["']/,69+ // The M1 contract still explicitly permits the pinned Jazz schema DSL in schema.ts.70+ allow: (path) => basename(path) === "schema.ts" || basename(path) === "permissions.ts",71+ },72+ {73+ id: "raw-jazz-client-api",74+ pattern: /\b(?:BrowserAuthSecretStore|createDb|JazzProvider|useJazzContext)\b/,75+ },76+ {77+ id: "worker-plumbing",78+ pattern: /\b(?:SharedWorker|navigator\.serviceWorker|new\s+Worker)\b/,79+ },80+ {81+ id: "transport-plumbing",82+ pattern: /\b(?:JAZZ_SERVER_URL|VITE_JAZZ_[A-Z0-9_]*|transportUrl)\b/,83+ },84+ { id: "workbox-plumbing", pattern: /\bworkbox\b/i },85+ {86+ id: "browser-capability-branch",87+ pattern: /\b(?:navigator\.storage|navigator\.locks|isSecureContext)\b/,88+ },89+];90+91+const ignoredDirectoryNames = new Set([92+ ".astro",93+ ".git",94+ ".playwright-cli",95+ ".vite",96+ "coverage",97+ "dist",98+ "node_modules",99+ "output",100+ "playwright-report",101+ "test-results",102+]);103+104+const childEnvironmentAllowlist = [105+ "CI",106+ "DENO_DIR",107+ "GITHUB_ACTIONS",108+ "LANG",109+ "LC_ALL",110+ "NIX_SSL_CERT_FILE",111+ "NO_PROXY",112+ "PATH",113+ "PLAYWRIGHT_BROWSERS_PATH",114+ "RUNNER_ARCH",115+ "RUNNER_OS",116+ "SSL_CERT_FILE",117+ "TEMP",118+ "TMP",119+ "TMPDIR",120+] as const;121+122+function assertWithin(root: string, path: string) {123+ const child = relative(resolve(root), resolve(path));124+ if (child === "" || (!child.startsWith("..") && !child.startsWith("/"))) return;125+ throw new Error(`path escapes project root: ${path}`);126+}127+128+function shouldExclude(name: string, isDirectory: boolean, relativePath: string): boolean {129+ if (isDirectory && (ignoredDirectoryNames.has(name) || name.startsWith(".nzip-"))) return true;130+ // The M2 reference application supersedes this ignored M1 working artifact. A working-tree copy131+ // must not accidentally resurrect it just because another agent is moving tracked paths.132+ if (isDirectory && relativePath === join("apps", "prototype")) return true;133+ if (name === ".env.example") return false;134+ if (name === ".env" || name.startsWith(".env.")) return true;135+ return name.endsWith(".sqlite") || name.includes(".sqlite-");136+}137+138+async function copyDirectory(139+ sourceRoot: string,140+ source: string,141+ destination: string,142+): Promise<void> {143+ await Deno.mkdir(destination, { recursive: true });144+ const entries: Deno.DirEntry[] = [];145+ for await (const entry of Deno.readDir(source)) entries.push(entry);146+ entries.sort((left, right) => left.name.localeCompare(right.name));147+148+ for (const entry of entries) {149+ const from = join(source, entry.name);150+ const to = join(destination, entry.name);151+ const displayPath = relative(sourceRoot, from);152+ if (shouldExclude(entry.name, entry.isDirectory, displayPath)) continue;153+ if (entry.isDirectory) {154+ await copyDirectory(sourceRoot, from, to);155+ } else if (entry.isFile) {156+ await Deno.copyFile(from, to);157+ } else if (entry.isSymlink) {158+ throw new Error(`clean checkout refuses symlink: ${relative(source, from)}`);159+ }160+ }161+}162+163+export async function materializeCleanProject(164+ sourceRoot: string,165+ destinationRoot?: string,166+): Promise<string> {167+ const source = resolve(sourceRoot);168+ const destination = destinationRoot169+ ? resolve(destinationRoot)170+ : join(await Deno.makeTempDir({ prefix: "lofi-golden-" }), "project");171+ if (source === destination) throw new Error("clean project destination must differ from source");172+ await copyDirectory(source, source, destination);173+ return destination;174+}175+176+export function safeChildEnvironment(177+ source: Record<string, string> = Deno.env.toObject(),178+): Record<string, string> {179+ const environment: Record<string, string> = {};180+ for (const name of childEnvironmentAllowlist) {181+ const value = source[name];182+ if (value) environment[name] = value;183+ }184+ environment.CI = "1";185+ environment.NO_COLOR = "1";186+ // Explicit empties prevent a parent shell's sync credentials from influencing local mode.187+ environment.JAZZ_APP_ID = "";188+ environment.JAZZ_SERVER_URL = "";189+ environment.JAZZ_ADMIN_SECRET = "";190+ environment.BACKEND_SECRET = "";191+ return environment;192+}193+194+export async function installNodeSentinel(195+ environment: Record<string, string>,196+ artifactRoot: string,197+): Promise<Record<string, string>> {198+ if (Deno.build.os === "windows") return { ...environment };199+ const directory = join(artifactRoot, "node-sentinel");200+ const executable = join(directory, "node");201+ await Deno.mkdir(directory, { recursive: true });202+ await Deno.writeTextFile(203+ executable,204+ '#!/bin/sh\necho "error: hidden Node invocation is forbidden by the lofi golden path" >&2\nexit 97\n',205+ );206+ await Deno.chmod(executable, 0o755);207+ return {208+ ...environment,209+ PATH: environment.PATH ? `${directory}:${environment.PATH}` : directory,210+ };211+}212+213+async function sourceFiles(path: string): Promise<string[]> {214+ const stat = await Deno.stat(path);215+ if (stat.isFile) return [path];216+ const files: string[] = [];217+ const entries: Deno.DirEntry[] = [];218+ for await (const entry of Deno.readDir(path)) entries.push(entry);219+ entries.sort((left, right) => left.name.localeCompare(right.name));220+ for (const entry of entries) {221+ const child = join(path, entry.name);222+ if (entry.isDirectory) files.push(...await sourceFiles(child));223+ else if (entry.isFile && /\.(?:astro|css|js|jsx|ts|tsx)$/.test(entry.name)) files.push(child);224+ }225+ return files;226+}227+228+export async function scanAuthorBoundary(229+ projectRoot: string,230+ authorPaths: readonly string[],231+ rules: readonly BoundaryRule[] = defaultBoundaryRules,232+): Promise<AuthorBoundaryViolation[]> {233+ const root = resolve(projectRoot);234+ const files: string[] = [];235+ for (const authorPath of authorPaths) {236+ const path = resolve(root, authorPath);237+ assertWithin(root, path);238+ try {239+ files.push(...await sourceFiles(path));240+ } catch (error) {241+ if (!(error instanceof Deno.errors.NotFound)) throw error;242+ }243+ }244+245+ const violations: AuthorBoundaryViolation[] = [];246+ for (const file of [...new Set(files)].sort()) {247+ const displayPath = relative(root, file);248+ const lines = (await Deno.readTextFile(file)).split("\n");249+ for (let index = 0; index < lines.length; index += 1) {250+ for (const rule of rules) {251+ rule.pattern.lastIndex = 0;252+ if (rule.allow?.(displayPath) || !rule.pattern.test(lines[index])) continue;253+ violations.push({254+ rule: rule.id,255+ path: displayPath,256+ line: index + 1,257+ excerpt: lines[index].trim().slice(0, 160),258+ });259+ }260+ }261+ }262+ return violations;263+}264+265+export async function withDeadline<T>(266+ operation: Promise<T>,267+ timeoutMs: number,268+ description: string,269+): Promise<T> {270+ let timeout: ReturnType<typeof setTimeout> | undefined;271+ const deadline = new Promise<never>((_, reject) => {272+ timeout = setTimeout(273+ () => reject(new Error(`${description} did not become ready within ${timeoutMs}ms`)),274+ timeoutMs,275+ );276+ });277+ try {278+ return await Promise.race([operation, deadline]);279+ } finally {280+ clearTimeout(timeout);281+ }282+}283+284+export type CapturedProcess = {285+ child: Deno.ChildProcess;286+ ready: Promise<string>;287+ completed: Promise<Deno.CommandStatus>;288+ stdoutPath: string;289+ stderrPath: string;290+ stop(): Promise<void>;291+};292+293+function isAlreadyTerminated(error: unknown): boolean {294+ return error instanceof Deno.errors.NotFound ||295+ (error instanceof TypeError && error.message.includes("already terminated"));296+}297+298+async function captureLines(299+ stream: ReadableStream<Uint8Array>,300+ path: string,301+ onLine: (line: string) => void,302+ redactValues: readonly string[] = [],303+): Promise<void> {304+ const file = await Deno.open(path, { create: true, truncate: true, write: true });305+ const writer = file.writable.getWriter();306+ const decoder = new TextDecoder();307+ const encoder = new TextEncoder();308+ let pending = "";309+ try {310+ for await (const chunk of stream) {311+ pending += decoder.decode(chunk, { stream: true });312+ const lines = pending.split(/\r?\n/);313+ pending = lines.pop() ?? "";314+ for (const line of lines) {315+ const redacted = redactText(line, redactValues);316+ await writer.write(encoder.encode(`${redacted}\n`));317+ onLine(redacted);318+ }319+ }320+ pending += decoder.decode();321+ if (pending) {322+ const redacted = redactText(pending, redactValues);323+ await writer.write(encoder.encode(redacted));324+ onLine(redacted);325+ }326+ } finally {327+ await writer.close();328+ }329+}330+331+export function redactText(text: string, values: readonly string[]): string {332+ let redacted = text;333+ const candidates = [...new Set(values.map((value) => value.trim()).filter(Boolean))]334+ .sort((left, right) => right.length - left.length);335+ for (const value of candidates) redacted = redacted.split(value).join("[redacted]");336+ return redacted;337+}338+339+export function startReadyProcess(options: {340+ executable?: string;341+ cwd: string;342+ args: string[];343+ environment: Record<string, string>;344+ artifactRoot: string;345+ name: string;346+ readyPattern: RegExp;347+ timeoutMs: number;348+ redactValues?: readonly string[];349+}): CapturedProcess {350+ const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);351+ const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);352+ const child = new Deno.Command(options.executable ?? Deno.execPath(), {353+ args: options.args,354+ cwd: options.cwd,355+ clearEnv: true,356+ env: options.environment,357+ stdin: "null",358+ stdout: "piped",359+ stderr: "piped",360+ }).spawn();361+362+ let resolveReady!: (value: string) => void;363+ let rejectReady!: (error: Error) => void;364+ let settled = false;365+ const readiness = new Promise<string>((resolveReadyPromise, rejectReadyPromise) => {366+ resolveReady = resolveReadyPromise;367+ rejectReady = rejectReadyPromise;368+ });369+ const inspectLine = (line: string) => {370+ options.readyPattern.lastIndex = 0;371+ const match = options.readyPattern.exec(line);372+ if (match && !settled) {373+ settled = true;374+ resolveReady(match[1] ?? match[0]);375+ }376+ };377+ const stdout = captureLines(child.stdout, stdoutPath, inspectLine, options.redactValues);378+ const stderr = captureLines(child.stderr, stderrPath, inspectLine, options.redactValues);379+ const completed = (async () => {380+ const [status] = await Promise.all([child.status, stdout, stderr]);381+ if (!settled) {382+ settled = true;383+ rejectReady(384+ new Error(`${options.name} exited with ${status.code} before printing its usable URL`),385+ );386+ }387+ return status;388+ })();389+ const ready = withDeadline(readiness, options.timeoutMs, `${options.name} URL`);390+391+ return {392+ child,393+ ready,394+ completed,395+ stdoutPath,396+ stderrPath,397+ async stop() {398+ try {399+ child.kill("SIGTERM");400+ } catch (error) {401+ if (!isAlreadyTerminated(error)) throw error;402+ }403+ try {404+ await withDeadline(completed, 5_000, `${options.name} shutdown`);405+ } catch {406+ try {407+ child.kill("SIGKILL");408+ } catch (error) {409+ if (!isAlreadyTerminated(error)) throw error;410+ }411+ await completed;412+ }413+ },414+ };415+}416+417+export async function runCapturedCommand(options: {418+ executable?: string;419+ cwd: string;420+ args: string[];421+ environment: Record<string, string>;422+ artifactRoot: string;423+ name: string;424+ redactValues?: readonly string[];425+}): Promise<CommandRecord> {426+ const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);427+ const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);428+ const started = performance.now();429+ const output = await new Deno.Command(options.executable ?? Deno.execPath(), {430+ args: options.args,431+ cwd: options.cwd,432+ clearEnv: true,433+ env: options.environment,434+ stdin: "null",435+ stdout: "piped",436+ stderr: "piped",437+ }).output();438+ const decoder = new TextDecoder();439+ await Promise.all([440+ Deno.writeTextFile(441+ stdoutPath,442+ redactText(decoder.decode(output.stdout), options.redactValues ?? []),443+ ),444+ Deno.writeTextFile(445+ stderrPath,446+ redactText(decoder.decode(output.stderr), options.redactValues ?? []),447+ ),448+ ]);449+ return {450+ name: options.name,451+ args: options.args,452+ durationMs: Math.round(performance.now() - started),453+ exitCode: output.code,454+ stdoutPath,455+ stderrPath,456+ };457+}458+459+export async function initializeFixtureGit(460+ projectRoot: string,461+ environment: Record<string, string>,462+ artifactRoot: string,463+): Promise<CommandRecord[]> {464+ // Deno does not proxy arbitrary git subcommands. Execute git directly, but retain the same465+ // structured evidence as every public Deno command.466+ const records: CommandRecord[] = [];467+ for (468+ const [name, args] of [469+ ["fixture-git-init", ["init", "-q"]],470+ ["fixture-git-add", ["add", "-A"]],471+ [472+ "fixture-git-commit",473+ [474+ "-c",475+ "user.name=lofi golden path",476+ "-c",477+ "user.email=golden-path@invalid",478+ "-c",479+ "commit.gpgsign=false",480+ "commit",481+ "-qm",482+ "clean checkout fixture",483+ ],484+ ],485+ ] as const486+ ) {487+ const stdoutPath = join(artifactRoot, `${name}.stdout.log`);488+ const stderrPath = join(artifactRoot, `${name}.stderr.log`);489+ const started = performance.now();490+ const output = await new Deno.Command("git", {491+ args: [...args],492+ cwd: projectRoot,493+ clearEnv: true,494+ env: environment,495+ stdout: "piped",496+ stderr: "piped",497+ }).output();498+ await Promise.all([499+ Deno.writeFile(stdoutPath, output.stdout),500+ Deno.writeFile(stderrPath, output.stderr),501+ ]);502+ const record = {503+ name,504+ args: ["git", ...args],505+ durationMs: Math.round(performance.now() - started),506+ exitCode: output.code,507+ stdoutPath,508+ stderrPath,509+ };510+ records.push(record);511+ if (!output.success) throw new Error(`${name} failed; see ${stderrPath}`);512+ }513+ return records;514+}515+516+export function reserveLocalPort(): number {517+ const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });518+ const address = listener.addr as Deno.NetAddr;519+ listener.close();520+ return address.port;521+}522+523+export function artifactPaths(root: string) {524+ return {525+ root,526+ report: join(root, "report.json"),527+ trace: join(root, "trace.zip"),528+ screenshot: join(root, "failure.png"),529+ };530+}531+532+export async function writeJourneyReport(report: JourneyReport): Promise<void> {533+ await Deno.mkdir(dirname(report.artifacts.report), { recursive: true });534+ await Deno.writeTextFile(report.artifacts.report, `${JSON.stringify(report, null, 2)}\n`);535+}
A tools/golden_path_core_test.ts +222 -0
@@ -0,0 +1,222 @@
1+import { join, resolve } from "node:path";2+import {3+ installNodeSentinel,4+ materializeCleanProject,5+ redactText,6+ runCapturedCommand,7+ safeChildEnvironment,8+ scanAuthorBoundary,9+ startReadyProcess,10+ withDeadline,11+} from "./golden_path_core.ts";12+13+function assert(condition: unknown, message: string): asserts condition {14+ if (!condition) throw new Error(message);15+}16+17+async function exists(path: string): Promise<boolean> {18+ try {19+ await Deno.stat(path);20+ return true;21+ } catch (error) {22+ if (error instanceof Deno.errors.NotFound) return false;23+ throw error;24+ }25+}26+27+async function workspaceTemp(prefix: string): Promise<string> {28+ return await Deno.makeTempDir({ dir: ".", prefix: `.lofi-${prefix}-` });29+}30+31+async function removeWorkspaceTemp(path: string): Promise<void> {32+ await Deno.remove(path, { recursive: true }).catch((error) => {33+ if (!(error instanceof Deno.errors.NotFound)) throw error;34+ });35+}36+37+Deno.test("safe child environment removes ambient application configuration", () => {38+ const environment = safeChildEnvironment({39+ PATH: "/test/bin",40+ DENO_DIR: "/test/deno",41+ JAZZ_APP_ID: "public-id",42+ JAZZ_SERVER_URL: "https://sync.invalid",43+ JAZZ_ADMIN_SECRET: "admin-secret",44+ BACKEND_SECRET: "backend-secret",45+ UNRELATED_SECRET: "also-secret",46+ });47+48+ assert(environment.PATH === "/test/bin", "PATH should remain available to Deno tasks");49+ assert(environment.DENO_DIR === "/test/deno", "the explicit Deno cache should remain available");50+ assert(environment.CI === "1", "clean-room commands should have deterministic CI output");51+ assert(environment.JAZZ_APP_ID === "", "public application configuration must be cleared");52+ assert(environment.JAZZ_ADMIN_SECRET === "", "server configuration must be cleared");53+ assert(!("UNRELATED_SECRET" in environment), "unrelated ambient secrets must not be inherited");54+});55+56+Deno.test("retained evidence redacts allowlisted configuration values", () => {57+ const output = redactText(58+ "app id public-id server https://sync.invalid secret server-secret",59+ ["public-id", "https://sync.invalid", "server-secret"],60+ );61+ assert(!output.includes("public-id"), "the app id must be redacted");62+ assert(!output.includes("sync.invalid"), "the server URL must be redacted");63+ assert(!output.includes("server-secret"), "server secrets must be redacted");64+ assert(output.match(/\[redacted\]/g)?.length === 3, "every configured value must be replaced");65+});66+67+Deno.test("Node sentinel is first on PATH", async () => {68+ if (Deno.build.os === "windows") return;69+ const root = await workspaceTemp("node-sentinel");70+ try {71+ const environment = await installNodeSentinel({ PATH: "/allowed/bin" }, root);72+ const sentinel = environment.PATH.split(":")[0];73+ assert(74+ resolve(sentinel).startsWith(resolve(root)),75+ "the sentinel directory must be first on PATH",76+ );77+ assert(78+ (await Deno.readTextFile(join(sentinel, "node"))).includes("hidden Node invocation"),79+ "the sentinel must fail with an actionable message",80+ );81+ } finally {82+ await removeWorkspaceTemp(root);83+ }84+});85+86+Deno.test("clean project copy excludes secrets caches and the retired prototype", async () => {87+ const source = await workspaceTemp("golden-source");88+ const destination = await workspaceTemp("golden-destination");89+ try {90+ await Deno.mkdir(join(source, "apps/prototype"), { recursive: true });91+ await Deno.mkdir(join(source, "apps/reference/src"), { recursive: true });92+ await Deno.mkdir(join(source, "apps/reference/node_modules/package"), { recursive: true });93+ await Deno.mkdir(join(source, "apps/reference/dist"), { recursive: true });94+ await Deno.mkdir(join(source, ".nzip-site"), { recursive: true });95+ await Promise.all([96+ Deno.writeTextFile(join(source, ".env"), "BACKEND_SECRET=do-not-copy\n"),97+ Deno.writeTextFile(join(source, ".env.local"), "JAZZ_APP_ID=do-not-copy\n"),98+ Deno.writeTextFile(join(source, ".env.example"), "JAZZ_APP_ID=\n"),99+ Deno.writeTextFile(join(source, "apps/prototype/retired.ts"), "retired\n"),100+ Deno.writeTextFile(join(source, "apps/reference/src/app.ts"), "export const app = {};\n"),101+ Deno.writeTextFile(join(source, "apps/reference/node_modules/package/index.js"), "cache\n"),102+ Deno.writeTextFile(join(source, "apps/reference/dist/index.html"), "build\n"),103+ Deno.writeTextFile(join(source, ".nzip-site/index.html"), "hosted\n"),104+ ]);105+106+ await materializeCleanProject(source, destination);107+ assert(await exists(join(destination, ".env.example")), ".env.example should be copied");108+ assert(109+ await exists(join(destination, "apps/reference/src/app.ts")),110+ "source should be copied",111+ );112+ assert(!await exists(join(destination, ".env")), ".env must be excluded");113+ assert(!await exists(join(destination, ".env.local")), ".env.local must be excluded");114+ assert(115+ !await exists(join(destination, "apps/prototype")),116+ "retired prototype must be excluded",117+ );118+ assert(119+ !await exists(join(destination, "apps/reference/node_modules")),120+ "node_modules must be excluded",121+ );122+ assert(!await exists(join(destination, "apps/reference/dist")), "dist must be excluded");123+ assert(!await exists(join(destination, ".nzip-site")), "hosted artifacts must be excluded");124+ } finally {125+ await Promise.all([removeWorkspaceTemp(source), removeWorkspaceTemp(destination)]);126+ }127+});128+129+Deno.test("author boundary permits schema declarations and reports UI plumbing", async () => {130+ const root = await workspaceTemp("golden-boundary");131+ try {132+ await Deno.mkdir(join(root, "src/islands"), { recursive: true });133+ await Promise.all([134+ Deno.writeTextFile(135+ join(root, "src/schema.ts"),136+ 'import { co } from "jazz-tools";\nexport const schema = co;\n',137+ ),138+ Deno.writeTextFile(139+ join(root, "src/permissions.ts"),140+ 'import type { Group } from "jazz-tools";\nexport type Permission = Group;\n',141+ ),142+ Deno.writeTextFile(143+ join(root, "src/islands/App.tsx"),144+ 'import { createDb } from "jazz-tools";\nconst ready = navigator.storage.persisted();\n',145+ ),146+ ]);147+148+ const violations = await scanAuthorBoundary(root, ["src"]);149+ assert(violations.length === 3, `expected three violations, found ${violations.length}`);150+ assert(151+ violations.every((violation) => violation.path === "src/islands/App.tsx"),152+ "schema and permissions should retain the explicit M1 exception",153+ );154+ assert(155+ violations.some((violation) => violation.rule === "raw-jazz-runtime-import"),156+ "raw imports in product UI should be reported",157+ );158+ assert(159+ violations.some((violation) => violation.rule === "raw-jazz-client-api"),160+ "raw client construction should be reported",161+ );162+ assert(163+ violations.some((violation) => violation.rule === "browser-capability-branch"),164+ "capability branching should be reported",165+ );166+ } finally {167+ await removeWorkspaceTemp(root);168+ }169+});170+171+Deno.test("process readiness resolves from output rather than a fixed sleep", async () => {172+ const artifacts = await workspaceTemp("golden-process");173+ try {174+ const process = startReadyProcess({175+ executable: "git",176+ cwd: Deno.cwd(),177+ args: ["--version"],178+ environment: safeChildEnvironment({}),179+ artifactRoot: artifacts,180+ name: "ready-control",181+ readyPattern: /(git version [^\n]+)/,182+ timeoutMs: 5_000,183+ });184+ assert(185+ (await process.ready).startsWith("git version "),186+ "the emitted control line should satisfy readiness",187+ );188+ assert((await process.completed).success, "the readiness control should exit successfully");189+ await process.stop();190+ } finally {191+ await removeWorkspaceTemp(artifacts);192+ }193+});194+195+Deno.test("captured commands retain output and duration", async () => {196+ const artifacts = await workspaceTemp("golden-command");197+ try {198+ const record = await runCapturedCommand({199+ executable: "git",200+ cwd: Deno.cwd(),201+ args: ["--version"],202+ environment: safeChildEnvironment({}),203+ artifactRoot: artifacts,204+ name: "capture-control",205+ });206+ assert(record.exitCode === 0, "the control command should pass");207+ assert(record.durationMs >= 0, "duration should be retained");208+ assert(209+ (await Deno.readTextFile(record.stdoutPath)).includes("git version"),210+ "stdout should be retained",211+ );212+ } finally {213+ await removeWorkspaceTemp(artifacts);214+ }215+});216+217+Deno.test("deadline helper preserves a readiness result", async () => {218+ assert(219+ await withDeadline(Promise.resolve("ready"), 1_000, "control") === "ready",220+ "a ready operation should win the deadline race",221+ );222+});
M tools/preview.ts +1 -1
R tools/run_prototype.ts → tools/run_reference.ts +9 -9
@@ -1,7 +1,7 @@
11 import { environmentNames, validateEnvironment } from "./env_contract.ts";22 import { loadEnvironment } from "./load_env.ts";33 4-const INTERNAL_ENV = "apps/prototype/.env";4+const INTERNAL_ENV = "apps/reference/.env";55 const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";66 77 async function ensureStableLocalAppId() {
@@ -63,7 +63,7 @@ async function routeCount(): Promise<number> {
6363 }6464 }6565 try {66- await visit("apps/prototype/dist");66+ await visit("apps/reference/dist");6767 } catch (error) {6868 if (!(error instanceof Deno.errors.NotFound)) throw error;6969 }
@@ -85,7 +85,7 @@ async function buildFiles(root: string): Promise<string[]> {
8585 8686 const command = Deno.args[0];8787 if (command !== "dev" && command !== "build") {88- console.error("usage: run_prototype.ts <dev|build>");88+ console.error("usage: run_reference.ts <dev|build>");8989 Deno.exit(2);9090 }9191
@@ -135,7 +135,7 @@ const exitCode = await run(
135135 "npm:astro@7.0.9",136136 command,137137 "--root",138- "apps/prototype",138+ "apps/reference",139139 ...forwardedArgs,140140 ],141141 childEnvironment,
@@ -150,21 +150,21 @@ if (command === "build") {
150150 }).output()).stdout,151151 ).trim();152152 await Deno.writeTextFile(153- "apps/prototype/dist/lofi-build.json",153+ "apps/reference/dist/lofi-build.json",154154 `${JSON.stringify({ revision, builtAt: new Date().toISOString() })}\n`,155155 );156- const serviceWorkerPath = "apps/prototype/dist/sw.js";156+ const serviceWorkerPath = "apps/reference/dist/sw.js";157157 const serviceWorker = await Deno.readTextFile(serviceWorkerPath);158158 await Deno.writeTextFile(159159 serviceWorkerPath,160160 serviceWorker.replace("__LOFI_BUILD_REVISION__", revision),161161 );162- const precache = (await buildFiles("apps/prototype/dist"))162+ const precache = (await buildFiles("apps/reference/dist"))163163 .filter((path) => path !== "./lofi-precache.json")164164 .map((path) => path === "./index.html" ? "./" : path);165165 precache.push("./lofi-precache.json");166166 await Deno.writeTextFile(167- "apps/prototype/dist/lofi-precache.json",167+ "apps/reference/dist/lofi-precache.json",168168 `${JSON.stringify(precache.sort())}\n`,169169 );170170 const scanCode = await run(
@@ -172,5 +172,5 @@ if (command === "build") {
172172 validatedEnvironment,173173 );174174 if (scanCode !== 0) Deno.exit(scanCode);175- console.log(`lofi build: apps/prototype/dist (${await routeCount()} routes, ${revision})`);175+ console.log(`lofi build: apps/reference/dist (${await routeCount()} routes, ${revision})`);176176 }