clone

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

Merge pull request #26 from FelineStateMachine/agent/m2-layer-3-inspector-testing

FelineStateMachine committed dcc12ac
M2 Layer 3: graduate inspector and browser testing seams
commit
dcc12ac9fa936a0eb1a846680fc88056efa738ab
parent
ea3f4823befa4b222aae10069b3127c77ae8f8e1
parent
f4fb50ac2a88bb7725da906b2e15e5e0b3b7193e
author
FelineStateMachine <dami.etoile@gmail.com>
29 files changed, +2322 -99
Mapps/reference/README.md5+
Mapps/reference/src/_lofi/checklist-store.ts40++-
Mapps/reference/src/_lofi/checklist-store_test.ts18+-
Aapps/reference/src/_lofi/inspector.ts208++++++++++++
Aapps/reference/src/_lofi/inspector_test.ts41++
Mapps/reference/src/_lofi/probe.ts145++++++--
Mapps/reference/src/_lofi/runtime.ts31++-
Aapps/reference/tests/testing-contract_test.ts23+
Mdeno.json1+
Adocs/decisions/0006-bound-layer3-inspection-and-testing.md80+++++
Mdocs/devx-contract.md9+
Mdocs/m2-abstraction-leaks.md21+-
Mjsr.json2+
Mpackage/create_core.ts1+
Mpackage/create_core_test.ts6+-
Mpackage/starter_template.ts8+
Mpackage/testdata/starter.snapshot.json27+-
Apackage/testing/browser_error.ts20+
Apackage/testing/convergence.ts126+++++++
Apackage/testing/convergence_test.ts133++++++++
Apackage/testing/fixture.ts386++++++++++++++++++++++
Apackage/testing/fixture_integration.ts171++++++++++
Apackage/testing/mod.ts22+
Apackage/testing/readiness.ts40++
Apackage/testing/readiness_test.ts48+++
Apackage/testing/safety.ts73++++
Apackage/testing/safety_test.ts81+++++
Apackage/testing/trace.ts140++++++++
Mtools/golden_path.ts515++++++++++++++++++++++++++++--
M apps/reference/README.md +5 -0
M apps/reference/src/_lofi/checklist-store.ts +38 -2
@@ -27,6 +27,9 @@ export type RuntimeDiagnostics = {
2727 totalMutationListeners: number;2828 unsubscribeCalls: number;2929 localWaitCalls: number;30+ pendingLocalWrites: number;31+ pendingGlobalWrites: number;32+ lastWriteDurability: "none" | "local" | "global" | "failed";3033 mutationErrors: number;3134 };3235
@@ -39,6 +42,7 @@ export class ChecklistStore {
3942 readonly #db: Db;4043 readonly #diagnostics: RuntimeDiagnostics;4144 readonly #syncConfigured: boolean;45+ readonly #diagnosticsChanged: () => void;4246 readonly #listeners = new Set<Listener>();4347 #vendorUnsubscribe: (() => void) | null = null;4448 #writeGeneration = 0;
@@ -49,10 +53,16 @@ export class ChecklistStore {
4953 error: null,5054 };5155 52- constructor(db: Db, diagnostics: RuntimeDiagnostics, syncConfigured = Boolean(serverUrl)) {56+ constructor(57+ db: Db,58+ diagnostics: RuntimeDiagnostics,59+ syncConfigured = Boolean(serverUrl),60+ diagnosticsChanged: () => void = () => undefined,61+ ) {5362 this.#db = db;5463 this.#diagnostics = diagnostics;5564 this.#syncConfigured = syncConfigured;65+ this.#diagnosticsChanged = diagnosticsChanged;5666 }5767 5868 getSnapshot = (): ChecklistSnapshot => this.#snapshot;
@@ -60,6 +70,7 @@ export class ChecklistStore {
6070 subscribe = (listener: Listener): () => void => {6171 this.#listeners.add(listener);6272 this.#diagnostics.activeConsumers = this.#listeners.size;73+ this.#diagnosticsChanged();6374 if (this.#listeners.size === 1) this.#openVendorSubscription();6475 this.#emit();6576 let active = true;
@@ -68,6 +79,7 @@ export class ChecklistStore {
6879 active = false;6980 this.#listeners.delete(listener);7081 this.#diagnostics.activeConsumers = this.#listeners.size;82+ this.#diagnosticsChanged();7183 if (this.#listeners.size === 0) this.#closeVendorSubscription();7284 else this.#emit();7385 };
@@ -100,25 +112,35 @@ export class ChecklistStore {
100112 101113 reportMutationError(event: MutationErrorEvent): void {102114 this.#diagnostics.mutationErrors += 1;115+ this.#diagnosticsChanged();103116 this.#fail(new Error(`${event.code}: ${event.reason}`));104117 }105118 106119 close(): void {107120 this.#listeners.clear();108121 this.#diagnostics.activeConsumers = 0;122+ this.#diagnosticsChanged();109123 this.#closeVendorSubscription();110124 }111125 112126 async #settle(mutation: MutationHandle): Promise<void> {113127 const generation = ++this.#writeGeneration;128+ let localPending = true;129+ this.#diagnostics.pendingLocalWrites += 1;130+ this.#diagnosticsChanged();114131 this.#set({ status: "ready", durability: "none", error: null });115132 try {116133 await mutation.wait({ tier: "local" });134+ localPending = false;135+ this.#diagnostics.pendingLocalWrites -= 1;117136 this.#diagnostics.localWaitCalls += 1;137+ this.#diagnosticsChanged();118138 if (generation === this.#writeGeneration) {119139 this.#set({ durability: "local", error: null });120140 }121141 if (this.#syncConfigured) {142+ this.#diagnostics.pendingGlobalWrites += 1;143+ this.#diagnosticsChanged();122144 void mutation.wait({ tier: "global" }).then(123145 () => {124146 if (generation === this.#writeGeneration) {
@@ -127,12 +149,18 @@ export class ChecklistStore {
127149 },128150 (error) => {129151 this.#diagnostics.mutationErrors += 1;152+ this.#diagnosticsChanged();130153 this.#fail(error);131154 },132- );155+ ).finally(() => {156+ this.#diagnostics.pendingGlobalWrites -= 1;157+ this.#diagnosticsChanged();158+ });133159 }134160 } catch (error) {161+ if (localPending) this.#diagnostics.pendingLocalWrites -= 1;135162 this.#diagnostics.mutationErrors += 1;163+ this.#diagnosticsChanged();136164 this.#fail(error);137165 throw error;138166 }
@@ -152,6 +180,7 @@ export class ChecklistStore {
152180 });153181 this.#diagnostics.activeVendorSubscriptions += 1;154182 this.#diagnostics.totalVendorSubscriptions += 1;183+ this.#diagnosticsChanged();155184 } catch (error) {156185 this.#fail(error);157186 }
@@ -163,10 +192,15 @@ export class ChecklistStore {
163192 this.#diagnostics.unsubscribeCalls += 1;164193 this.#vendorUnsubscribe = null;165194 this.#diagnostics.activeVendorSubscriptions -= 1;195+ this.#diagnosticsChanged();166196 }167197 168198 #set(change: Partial<ChecklistSnapshot>): void {169199 this.#snapshot = { ...this.#snapshot, ...change };200+ if (change.durability) {201+ this.#diagnostics.lastWriteDurability = change.durability;202+ this.#diagnosticsChanged();203+ }170204 this.#emit();171205 }172206
@@ -177,6 +211,8 @@ export class ChecklistStore {
177211 durability: "failed",178212 error: error instanceof Error ? error.message : String(error),179213 };214+ this.#diagnostics.lastWriteDurability = "failed";215+ this.#diagnosticsChanged();180216 this.#emit();181217 }182218
M apps/reference/src/_lofi/checklist-store_test.ts +16 -2
@@ -18,6 +18,9 @@ function diagnostics(): RuntimeDiagnostics {
1818 totalMutationListeners: 1,1919 unsubscribeCalls: 0,2020 localWaitCalls: 0,21+ pendingLocalWrites: 0,22+ pendingGlobalWrites: 0,23+ lastWriteDurability: "none",2124 mutationErrors: 0,2225 };2326 }
@@ -139,12 +142,19 @@ test("two consumers share one task subscription and clean up idempotently", () =
139142 assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");140143 });141144 142-test("every checklist mutation waits for local durability", async () => {145+test("every checklist mutation exposes pending work and waits for local durability", async () => {143146 const db = fakeDb();144147 const counts = diagnostics();145148 const store = new ChecklistStore(db.value, counts);146149 147- await store.create("first");150+ const deferred = db.deferNextWrite();151+ const first = store.create("first");152+ await Promise.resolve();153+ assertCount(counts.pendingLocalWrites, 1, "an unsettled local write must be observable");154+ assert(String(counts.lastWriteDurability) === "none", "pending work must not claim durability");155+ deferred.resolve();156+ await first;157+ assertCount(counts.pendingLocalWrites, 0, "a retained local write must leave no pending work");148158 await store.update("task-1", "edited");149159 await store.setCompleted("task-1", true);150160 await store.delete("task-1");
@@ -155,6 +165,10 @@ test("every checklist mutation waits for local durability", async () => {
155165 "create, edit, complete, and delete must reach the Jazz boundary",156166 );157167 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");168+ assert(169+ String(counts.lastWriteDurability) === "local",170+ "diagnostics must expose local durability",171+ );158172 assertCount(159173 db.globalWaitCalls(),160174 0,
A apps/reference/src/_lofi/inspector.ts +208 -0
@@ -0,0 +1,208 @@
1+export const DEVELOPMENT_INSPECTOR_MARKER = "lofi-development-inspector";2+3+export type InspectorSnapshot = {4+ identity: {5+ state: "device-local key active" | "anonymous" | "external" | "unavailable";6+ backup: "blocked by alpha security review" | "configured" | "unavailable";7+ };8+ storage: {9+ driver: "persistent requested" | "persistent open" | "failed";10+ persistence: "granted" | "not granted" | "unavailable" | "error";11+ fallback: "none" | "explicit memory" | "unavailable";12+ };13+ sync: {14+ mode: "local-only" | "managed configured";15+ transport: "paused by inspector" | "live detail unavailable" | "not configured";16+ pendingLocalWrites: number;17+ pendingGlobalWrites: number;18+ lastWrite: "none" | "local" | "global" | "failed";19+ };20+ runtime: {21+ clients: number;22+ consumers: number;23+ vendorSubscriptions: number;24+ mutationListeners: number;25+ mutationErrors: number;26+ };27+ multiTab: {28+ role: "unavailable" | "leader" | "follower";29+ detail: string;30+ };31+};32+33+export type InspectorAdapter = {34+ readSnapshot(): InspectorSnapshot | Promise<InspectorSnapshot>;35+ subscribe(listener: () => void): () => void;36+ setTransportPaused(paused: boolean): Promise<void>;37+ restartClient(): Promise<void>;38+ clearLocalReplica(): Promise<void>;39+};40+41+export type InspectorRow = {42+ label: string;43+ value: string;44+};45+46+export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {47+ return [48+ { label: "Identity", value: snapshot.identity.state },49+ { label: "Auth backup", value: snapshot.identity.backup },50+ { label: "Storage driver", value: snapshot.storage.driver },51+ { label: "Persistence", value: snapshot.storage.persistence },52+ { label: "Storage fallback", value: snapshot.storage.fallback },53+ { label: "Sync mode", value: snapshot.sync.mode },54+ { label: "Transport", value: snapshot.sync.transport },55+ { label: "Pending lofi local", value: String(snapshot.sync.pendingLocalWrites) },56+ { label: "Pending lofi global", value: String(snapshot.sync.pendingGlobalWrites) },57+ { label: "Last write", value: snapshot.sync.lastWrite },58+ { label: "Clients", value: String(snapshot.runtime.clients) },59+ { label: "Consumers", value: String(snapshot.runtime.consumers) },60+ { label: "Vendor subscriptions", value: String(snapshot.runtime.vendorSubscriptions) },61+ { label: "Mutation listeners", value: String(snapshot.runtime.mutationListeners) },62+ { label: "Mutation errors", value: String(snapshot.runtime.mutationErrors) },63+ { label: "Multi-tab role", value: snapshot.multiTab.role },64+ { label: "Multi-tab detail", value: snapshot.multiTab.detail },65+ ];66+}67+68+function element<K extends keyof HTMLElementTagNameMap>(69+ document: Document,70+ name: K,71+ text?: string,72+): HTMLElementTagNameMap[K] {73+ const node = document.createElement(name);74+ if (text !== undefined) node.textContent = text;75+ return node;76+}77+78+export function mountInspector(79+ adapter: InspectorAdapter,80+ options: { document?: Document } = {},81+): { element: HTMLElement; dispose(): void } {82+ const document = options.document ?? globalThis.document;83+ if (!document?.body) {84+ throw new Error("lofi inspector requires a browser document body");85+ }86+87+ const host = element(document, "aside");88+ host.dataset.lofiInspector = DEVELOPMENT_INSPECTOR_MARKER;89+ host.setAttribute("aria-label", "lofi developer inspector");90+ const shadow = host.attachShadow({ mode: "open" });91+ const style = element(document, "style");92+ style.textContent = `93+ :host { all: initial; color: #ecf7f1; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }94+ section { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: min(390px, calc(100vw - 24px)); max-height: min(660px, calc(100vh - 24px)); overflow: auto; border: 1px solid #587066; border-radius: 10px; background: #101b17f2; box-shadow: 0 18px 54px #0008; }95+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; position: sticky; top: 0; background: #101b17; }96+ h2 { font: 700 13px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; }97+ button { border: 1px solid #6f8d80; border-radius: 6px; color: inherit; background: #1b3028; padding: 6px 8px; font: inherit; cursor: pointer; }98+ button:disabled { cursor: wait; opacity: .55; }99+ [data-body][hidden] { display: none; }100+ dl { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(0, 1.2fr); margin: 0; padding: 0 12px 10px; }101+ dt, dd { border-top: 1px solid #ffffff18; margin: 0; padding: 5px 0; overflow-wrap: anywhere; }102+ dt { color: #a8c3b7; }103+ .actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 12px; }104+ [role=status] { color: #f0c47c; min-height: 1.4em; margin: 0; padding: 0 12px 10px; }105+ `;106+ const panel = element(document, "section");107+ const header = element(document, "header");108+ const title = element(document, "h2", "lofi dev inspector");109+ const toggle = element(document, "button", "Hide");110+ toggle.type = "button";111+ toggle.setAttribute("aria-expanded", "true");112+ const body = element(document, "div");113+ body.dataset.body = "";114+ const list = element(document, "dl");115+ const actions = element(document, "div");116+ actions.className = "actions";117+ const offline = element(document, "button", "Simulate transport offline");118+ const restart = element(document, "button", "Restart client");119+ const clear = element(document, "button", "Clear local replica");120+ const status = element(document, "p");121+ status.setAttribute("role", "status");122+ for (const button of [offline, restart, clear]) {123+ button.type = "button";124+ actions.append(button);125+ }126+ header.append(title, toggle);127+ body.append(list, actions, status);128+ panel.append(header, body);129+ shadow.append(style, panel);130+ document.body.append(host);131+132+ let active = true;133+ let busy = false;134+ let current: InspectorSnapshot | null = null;135+ let refreshGeneration = 0;136+ const setBusy = (value: boolean) => {137+ busy = value;138+ for (const button of [offline, restart, clear]) button.disabled = value;139+ };140+ const render = (snapshot: InspectorSnapshot) => {141+ current = snapshot;142+ list.replaceChildren();143+ for (const row of inspectorRows(snapshot)) {144+ list.append(element(document, "dt", row.label), element(document, "dd", row.value));145+ }146+ offline.textContent = snapshot.sync.transport === "paused by inspector"147+ ? "Resume cloud transport"148+ : snapshot.sync.mode === "local-only"149+ ? "Transport pause unavailable in local-only"150+ : "Pause cloud transport";151+ offline.disabled = busy || snapshot.sync.mode === "local-only";152+ };153+ const refresh = async () => {154+ const generation = ++refreshGeneration;155+ const snapshot = await adapter.readSnapshot();156+ if (active && generation === refreshGeneration) render(snapshot);157+ };158+ const runAction = async (name: string, action: () => Promise<void>) => {159+ if (busy) return;160+ setBusy(true);161+ status.textContent = `${name}…`;162+ try {163+ await action();164+ status.textContent = `${name} complete.`;165+ await refresh();166+ } catch {167+ status.textContent =168+ `${name} failed. Run deno task doctor, apply the first action, then retry.`;169+ } finally {170+ setBusy(false);171+ }172+ };173+174+ toggle.addEventListener("click", () => {175+ body.hidden = !body.hidden;176+ toggle.textContent = body.hidden ? "Show" : "Hide";177+ toggle.setAttribute("aria-expanded", String(!body.hidden));178+ });179+ offline.addEventListener("click", () => {180+ const nextPaused = current?.sync.transport !== "paused by inspector";181+ void runAction(182+ nextPaused ? "Cloud transport pause" : "Cloud transport resume",183+ () => adapter.setTransportPaused(nextPaused),184+ );185+ });186+ restart.addEventListener("click", () => void runAction("Client restart", adapter.restartClient));187+ clear.addEventListener("click", () => {188+ const confirmed = globalThis.confirm?.(189+ "Clear this development replica? The device-local identity is preserved, but unsynced local data is deleted.",190+ ) === true;191+ if (confirmed) void runAction("Local replica clear", adapter.clearLocalReplica);192+ });193+194+ const unsubscribe = adapter.subscribe(() => void refresh());195+ void refresh().catch(() => {196+ status.textContent =197+ "Inspector snapshot failed. Run deno task doctor, apply the first action, then reload.";198+ });199+ return {200+ element: host,201+ dispose() {202+ if (!active) return;203+ active = false;204+ unsubscribe();205+ host.remove();206+ },207+ };208+}
A apps/reference/src/_lofi/inspector_test.ts +41 -0
@@ -0,0 +1,41 @@
1+import { inspectorRows, type InspectorSnapshot } from "./inspector.ts";2+3+const test = (globalThis as unknown as {4+ Deno: { test(name: string, body: () => void | Promise<void>): void };5+}).Deno.test;6+7+function assert(condition: unknown, message: string): asserts condition {8+ if (!condition) throw new Error(message);9+}10+11+const snapshot: InspectorSnapshot = {12+ identity: { state: "device-local key active", backup: "blocked by alpha security review" },13+ storage: { driver: "persistent open", persistence: "granted", fallback: "none" },14+ sync: {15+ mode: "managed configured",16+ transport: "live detail unavailable",17+ pendingLocalWrites: 1,18+ pendingGlobalWrites: 2,19+ lastWrite: "local",20+ },21+ runtime: {22+ clients: 1,23+ consumers: 2,24+ vendorSubscriptions: 1,25+ mutationListeners: 1,26+ mutationErrors: 0,27+ },28+ multiTab: {29+ role: "unavailable",30+ detail: "Jazz alpha.53 exposes no supported leader/follower signal",31+ },32+};33+34+test("inspector rows retain truthful unavailable and pending states", () => {35+ const rows = Object.fromEntries(inspectorRows(snapshot).map((row) => [row.label, row.value]));36+ assert(rows.Transport === "live detail unavailable", "transport precision was fabricated");37+ assert(rows["Pending lofi local"] === "1", "local pending work was omitted");38+ assert(rows["Pending lofi global"] === "2", "global pending work was omitted");39+ assert(rows["Multi-tab role"] === "unavailable", "multi-tab role was fabricated");40+ assert(!JSON.stringify(rows).includes("secret"), "inspector rows exposed a secret-shaped field");41+});
M apps/reference/src/_lofi/probe.ts +111 -34
@@ -1,43 +1,120 @@
1-import {2- getRuntime,3- getRuntimeDiagnostics,4- recreateRuntime,5- runtimeRecreatedEvent,6- shutdownRuntime,7-} from "./runtime.ts";8-9-export type ReferenceProbe = {10- diagnostics: typeof getRuntimeDiagnostics;11- create(text: string): Promise<void>;12- update(id: string, text: string): Promise<void>;13- setCompleted(id: string, completed: boolean): Promise<void>;14- delete(id: string): Promise<void>;15- disconnect(): Promise<void>;16- reconnect(): Promise<void>;17- recreate(): Promise<void>;18- shutdown(): Promise<void>;19-};1+import { serverUrl } from "./config.ts";2+import { readDeviceCapabilityReport } from "./device-capabilities.ts";3+import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";4+import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";5+6+export type LofiDevelopmentBridge = InspectorAdapter;207 218 declare global {229 interface Window {23- __LOFI_REFERENCE__?: ReferenceProbe;10+ __LOFI_INSPECTOR__?: LofiDevelopmentBridge;2411 }2512 }2613 27-if (typeof document !== "undefined") {28- (globalThis as typeof globalThis & Window).__LOFI_REFERENCE__ = {29- diagnostics: getRuntimeDiagnostics,30- create: async (text) => await (await getRuntime()).checklist.create(text),31- update: async (id, text) => await (await getRuntime()).checklist.update(id, text),32- setCompleted: async (id, completed) =>33- await (await getRuntime()).checklist.setCompleted(id, completed),34- delete: async (id) => await (await getRuntime()).checklist.delete(id),35- disconnect: async () => await (await getRuntime()).db.disconnect(),36- reconnect: async () => await (await getRuntime()).db.reconnect(),37- recreate: async () => {38- await recreateRuntime();39- globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));14+const stateListeners = new Set<() => void>();15+let transportPaused = false;16+17+function reloadBrowserClient(): Promise<void> {18+ globalThis.location.reload();19+ // The current document must not report completion while its replacement is20+ // still booting. Navigation destroys this pending promise with the page.21+ return new Promise(() => undefined);22+}23+24+function notifyState(): void {25+ for (const listener of stateListeners) listener();26+}27+28+async function readSnapshot(): Promise<InspectorSnapshot> {29+ const [runtime, device] = await Promise.all([30+ getRuntime(),31+ readDeviceCapabilityReport(),32+ ]);33+ const diagnostics = getRuntimeDiagnostics();34+ const authMode = runtime.db.getAuthState().authMode;35+ return {36+ identity: {37+ state: authMode === "local-first"38+ ? "device-local key active"39+ : authMode === "external"40+ ? "external"41+ : authMode === "anonymous"42+ ? "anonymous"43+ : "unavailable",44+ backup: "blocked by alpha security review",45+ },46+ storage: {47+ driver: diagnostics.storageState === "persistent-driver-open"48+ ? "persistent open"49+ : diagnostics.storageState === "persistent-requested"50+ ? "persistent requested"51+ : "failed",52+ persistence: device.persistentPermission === "not-granted"53+ ? "not granted"54+ : device.persistentPermission,55+ fallback: diagnostics.storageState === "failed" ? "unavailable" : "none",56+ },57+ sync: {58+ mode: serverUrl ? "managed configured" : "local-only",59+ transport: serverUrl60+ ? transportPaused ? "paused by inspector" : "live detail unavailable"61+ : "not configured",62+ pendingLocalWrites: diagnostics.pendingLocalWrites,63+ pendingGlobalWrites: diagnostics.pendingGlobalWrites,64+ lastWrite: diagnostics.lastWriteDurability,65+ },66+ runtime: {67+ clients: diagnostics.activeClients,68+ consumers: diagnostics.activeConsumers,69+ vendorSubscriptions: diagnostics.activeVendorSubscriptions,70+ mutationListeners: diagnostics.activeMutationListeners,71+ mutationErrors: diagnostics.mutationErrors,72+ },73+ multiTab: {74+ role: "unavailable",75+ detail: "Jazz alpha.53 exposes no supported leader/follower signal",4076 },41- shutdown: shutdownRuntime,4277 };4378 }79+80+const bridge: LofiDevelopmentBridge = {81+ readSnapshot,82+ subscribe(listener) {83+ stateListeners.add(listener);84+ const unsubscribeRuntime = subscribeRuntimeDiagnostics(listener);85+ let active = true;86+ return () => {87+ if (!active) return;88+ active = false;89+ stateListeners.delete(listener);90+ unsubscribeRuntime();91+ };92+ },93+ async setTransportPaused(paused) {94+ if (!serverUrl) {95+ throw new Error("Cloud transport pause is unavailable in local-only mode");96+ }97+ const runtime = await getRuntime();98+ if (paused) await runtime.db.disconnect();99+ else await runtime.db.reconnect();100+ transportPaused = paused;101+ notifyState();102+ },103+ restartClient() {104+ return reloadBrowserClient();105+ },106+ async clearLocalReplica() {107+ const runtime = await getRuntime();108+ await runtime.db.deleteClientStorage();109+ return await reloadBrowserClient();110+ },111+};112+113+if (typeof document !== "undefined") {114+ (globalThis as typeof globalThis & Window).__LOFI_INSPECTOR__ = bridge;115+ const mounted = mountInspector(bridge);116+ import.meta.hot?.dispose(() => {117+ mounted.dispose();118+ delete (globalThis as typeof globalThis & Window).__LOFI_INSPECTOR__;119+ });120+}
M apps/reference/src/_lofi/runtime.ts +30 -1
@@ -23,6 +23,7 @@ export const runtimeRecreatedEvent = "lofi:runtime-recreated";
2323 type RuntimeSlot = {2424 client: SerializedResourceState<Db>;2525 diagnostics: RuntimeDiagnostics;26+ diagnosticListeners: Set<() => void>;2627 };2728 2829 const slotName = "__LOFI_ALPHA53_RUNTIME__";
@@ -42,12 +43,20 @@ function slot(): RuntimeSlot {
4243 totalMutationListeners: 0,4344 unsubscribeCalls: 0,4445 localWaitCalls: 0,46+ pendingLocalWrites: 0,47+ pendingGlobalWrites: 0,48+ lastWriteDurability: "none",4549 mutationErrors: 0,4650 },51+ diagnosticListeners: new Set(),4752 };4853 return browserGlobal[slotName];4954 }5055 56+function notifyDiagnostics(state = slot()): void {57+ for (const listener of state.diagnosticListeners) listener();58+}59+5160 async function createClient(state: RuntimeSlot): Promise<Db> {5261 try {5362 assertDurableBrowser();
@@ -56,6 +65,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
5665 state.diagnostics.storageState = "persistent-driver-open";5766 state.diagnostics.clientsCreated += 1;5867 state.diagnostics.activeClients += 1;68+ notifyDiagnostics(state);5969 return db;6070 } catch (error) {6171 state.diagnostics.storageState = "failed";
@@ -66,6 +76,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
6676 async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {6777 await db.shutdown();6878 state.diagnostics.activeClients -= 1;79+ notifyDiagnostics(state);6980 }7081 7182 const adapter = new AdapterLifecycle<Db, LofiRuntime>();
@@ -73,10 +84,16 @@ let recreationPromise: Promise<LofiRuntime> | null = null;
7384 let shutdownPromise: Promise<void> | null = null;7485 7586 function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {76- const checklist = new ChecklistStore(db, state.diagnostics);87+ const checklist = new ChecklistStore(88+ db,89+ state.diagnostics,90+ undefined,91+ () => notifyDiagnostics(state),92+ );7793 const stopMutationErrors = db.onMutationError((event) => checklist.reportMutationError(event));7894 state.diagnostics.activeMutationListeners += 1;7995 state.diagnostics.totalMutationListeners += 1;96+ notifyDiagnostics(state);8097 let active = true;8198 const runtime: LofiRuntime = {8299 db,
@@ -88,6 +105,7 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
88105 checklist.close();89106 stopMutationErrors();90107 state.diagnostics.activeMutationListeners -= 1;108+ notifyDiagnostics(state);91109 return Promise.resolve();92110 },93111 };
@@ -107,6 +125,17 @@ export function getRuntimeDiagnostics(): RuntimeDiagnostics {
107125 return { ...slot().diagnostics };108126 }109127 128+export function subscribeRuntimeDiagnostics(listener: () => void): () => void {129+ const listeners = slot().diagnosticListeners;130+ listeners.add(listener);131+ let active = true;132+ return () => {133+ if (!active) return;134+ active = false;135+ listeners.delete(listener);136+ };137+}138+110139 export function recreateRuntime(): Promise<LofiRuntime> {111140 if (recreationPromise) return recreationPromise;112141 const state = slot();
A apps/reference/tests/testing-contract_test.ts +23 -0
M deno.json +1 -0
A docs/decisions/0006-bound-layer3-inspection-and-testing.md +80 -0
@@ -0,0 +1,80 @@
1+# 0006: Bound Layer 3 inspection and testing to truthful observable seams2+3+Status: accepted for M2 implementation\4+Date: 2026-07-15\5+Issues: #10, #126+7+## Decision8+9+Layer 3 graduates two developer surfaces:10+11+- a visible development-only inspector backed by a provisional internal snapshot/action adapter;12+- readiness-first Playwright helpers exported from the single package at `@nzip/lofi/testing`.13+14+The inspector is generated runtime machinery, not a public framework facade. `./testing` is the only15+new public package export in this layer. Broader `core`, `ui`, `sync`, `auth`, and `pwa` extraction16+still requires the M4 evidence described by ADR 0005.17+18+The inspector may report only signals lofi can observe:19+20+- device-local identity and the blocked alpha passkey-backup state;21+- requested/open/failed persistent storage plus browser persistence permission;22+- local-only or managed-configured mode;23+- lofi-wrapped per-write local/global durability and mutation failures; and24+- active client, consumer, vendor-subscription, and mutation-listener counts.25+26+The Jazz alpha pin exposes no supported live connection state, vendor queue depth, or multi-tab27+leader/follower role. Those fields remain visibly unavailable. Configuration is never presented as28+connectivity, and checklist writes are not mislabeled as the vendor's complete pending queue.29+30+Inspector transport pause/resume calls Jazz `Db.disconnect()`/`reconnect()` only when managed sync31+is configured and labels the action as a cloud-transport test seam. Real browser offline control is32+owned by `@nzip/lofi/testing` through Playwright `BrowserContext.setOffline()`. Client restart uses33+a full document reload: Jazz alpha.53 does not reliably reopen the same browser-broker namespace34+after an in-document `Db.shutdown()`/`createDb()` cycle, while a document lifecycle restart retains35+the locally durable replica. Replica clear uses `Db.deleteClientStorage()` behind a destructive36+confirmation, then reloads the document; it clears the local OPFS namespace while preserving the37+localStorage identity and warns that unsynced data may be lost.38+39+## Two-client identity rule40+41+The current permissions are creator-only. Two distinct user identities therefore cannot both read42+and edit one row, so claiming distinct-identity convergence would be false. The convergence fixture43+uses two isolated browser contexts and separate OPFS replicas with the same restored identity. The44+first context's Playwright storage state is passed directly to the second context in memory. It is45+never written to reports, traces, snapshots, logs, or screenshots because it contains the bearer46+identity secret.47+48+A truly distinct-user fixture belongs with sharing/invitation and permission-model work, not this49+testing layer.50+51+## Verification boundary52+53+Local acceptance proves readiness predicates, offline restoration in `finally`, restart/reload,54+artifact redaction, inspector actions, and production exclusion. Closing the convergence contract55+also requires a cloud-configured generated-project journey:56+57+1. seed a row and wait for global durability;58+2. boot the second isolated replica with the in-memory restored identity;59+3. take both browser contexts offline;60+4. make orthogonal edits, then reload/recreate while work remains local;61+5. restore network control in `finally` and wait for both visible snapshots to converge; and62+6. remove fixture rows and wait for global durability.63+64+Only the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair reaches the child application. Server secrets65+remain blank, and no configured value is retained in evidence.66+67+Production builds must contain no lofi inspector marker, global bridge, route, or chunk. Jazz's raw68+vendor inspector remains disabled.69+70+## Deferred physical-device evidence71+72+Playwright does not graduate installed-PWA lifecycle, storage eviction, airplane-mode cold launch,73+real passkey custody, or mobile background/foreground recovery. Those remain explicit M3 physical-74+device gates.75+76+## Why77+78+This boundary gives developers useful controls and deterministic tests without inventing vendor79+precision, leaking identity material, weakening creator-only permissions, or prematurely turning the80+M1 runtime into a general framework API.
M docs/devx-contract.md +9 -0
M docs/m2-abstraction-leaks.md +13 -8
@@ -6,14 +6,19 @@ Last reviewed: 2026-07-15
66 This ledger keeps the Layer 1 reference application honest. A listed leak is not a supported lofi77 API; it is a concrete input to the layer that owns removing or productizing it.88 9-| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10-| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |11-| Repository-root wrappers still own the checkout journey; generated commands now live in `@nzip/lofi`. | The generated path is implemented without repository-relative paths, but this leak remains listed until exact-head generated golden evidence is retained. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12-| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13-| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14-| Runtime cardinality and lifecycle evidence use a development-only global probe. | Product UI must stay clean while the browser journey still needs deterministic evidence. | #10 | The inspector exposes the same truthful signals through a supported development surface and is absent from production. |15-| The checkout golden path owns process/browser orchestration at repository scope. | Layer 1 proves the journey before defining a reusable testing package. | #12 | Offline and multi-client helpers are callable from generated-project tests with readiness-based APIs. |16-| Cloud/global settlement is observable per write, but live socket state is not. | Jazz alpha.53 exposes mutation durability but no supported connection-state signal. | #10, #12 | Inspector/tests distinguish configured mode, local durability, global durability, and unavailable live detail. |9+| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10+| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |11+| Repository-root wrappers still own the checkout journey; generated commands now live in `@nzip/lofi`. | The generated path is implemented without repository-relative paths, but this leak remains listed until exact-head generated golden evidence is retained. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12+| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13+| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14+| Live socket state, vendor queue depth, and multi-tab role remain unavailable. | Jazz alpha.53 exposes mutation durability but no supported signal for these details; ADR 0006 accepts explicit unavailability instead of fabricated precision. | Jazz baseline review | A later Jazz pin exposes supported signals, or the unavailable boundary remains an intentional product contract. |15+16+## Graduated in Layer 317+18+| Former leak | Graduated surface | Evidence |19+| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |20+| Runtime cardinality and lifecycle evidence used a development-only probe. | The generated dev-only inspector owns redacted diagnostics and safe actions; production builds contain no inspector marker, bridge, route, or chunk. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), inspector unit coverage, and generated golden restart/clear/production-exclusion assertions in `tools/golden_path.ts`. |21+| Browser orchestration existed only in the repository checkout journey. | `@nzip/lofi/testing` exports readiness, offline restoration, two-client identity isolation, convergence coordination, and sanitized failure artifacts. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), generated subpath-resolution coverage, real Chromium integration, and the cloud two-replica convergence gate. |1722 1823 Deleting an entry requires retained evidence in the owning issue or a decision record accepting the1924 behavior as an intentional public boundary.
M jsr.json +2 -0
M package/create_core.ts +1 -0
M package/create_core_test.ts +4 -2
M package/starter_template.ts +8 -0
@@ -16,6 +16,8 @@ import deviceCapabilities from "../apps/reference/src/_lofi/device-capabilities.
1616 import deviceCapabilitiesTest from "../apps/reference/src/_lofi/device-capabilities_test.ts" with {1717 type: "text",1818 };19+import inspector from "../apps/reference/src/_lofi/inspector.ts" with { type: "text" };20+import inspectorTest from "../apps/reference/src/_lofi/inspector_test.ts" with { type: "text" };1921 import probe from "../apps/reference/src/_lofi/probe.ts" with { type: "text" };2022 import pwa from "../apps/reference/src/_lofi/pwa.ts" with { type: "text" };2123 import pwaTest from "../apps/reference/src/_lofi/pwa_test.ts" with { type: "text" };
@@ -57,6 +59,9 @@ import uiContract from "../apps/reference/src/ui-contract.ts" with { type: "text
5759 import authorBoundaryTest from "../apps/reference/tests/author-boundary_test.ts" with {5860 type: "text",5961 };62+import testingContractTest from "../apps/reference/tests/testing-contract_test.ts" with {63+ type: "text",64+};6065 import tsconfig from "../apps/reference/tsconfig.json" with { type: "text" };6166 6267 /** Every source-controlled file copied from the validated reference app into a new project. */
@@ -73,6 +78,8 @@ export const STARTER_TEMPLATE: Readonly<Record<string, string>> = {
7378 "src/_lofi/config.ts": config,7479 "src/_lofi/device-capabilities.ts": deviceCapabilities,7580 "src/_lofi/device-capabilities_test.ts": deviceCapabilitiesTest,81+ "src/_lofi/inspector.ts": inspector,82+ "src/_lofi/inspector_test.ts": inspectorTest,7683 "src/_lofi/probe.ts": probe,7784 "src/_lofi/pwa.ts": pwa,7885 "src/_lofi/pwa_test.ts": pwaTest,
@@ -96,5 +103,6 @@ export const STARTER_TEMPLATE: Readonly<Record<string, string>> = {
96103 "src/styles/global.css": globalStyles,97104 "src/ui-contract.ts": uiContract,98105 "tests/author-boundary_test.ts": authorBoundaryTest,106+ "tests/testing-contract_test.ts": testingContractTest,99107 "tsconfig.json": tsconfig,100108 };
M package/testdata/starter.snapshot.json +15 -12
@@ -1,29 +1,31 @@
11 {22 ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",33 ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",4- "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",54 "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",6- "deno.json": "11183a356cb65dc17394945622fffd16828c5f47fb88388451b61850c620d3f8",5+ "deno.json": "1285b00d154c19fe9104d7b13eff503cd95d628574d409177cdfbf7d652a98ac",76 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",87 "public/manifest.webmanifest": "d23457fe8df68a16181fde1d4ecca4b69d3b050b5fa3bf8f4051cb05bde1d3d6",98 "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",10- "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",11- "src/_lofi/ReferenceShell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",9+ "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",1210 "src/_lofi/boot.ts": "dd66bd1e5ddb88599a0cbadb2c2cf932993734843c7e5cf41d28b389560a32c7",13- "src/_lofi/checklist-store.ts": "8e9b36bc82e431ef63997915407992aa52be2a9606a023546d83b224e28cfd0e",14- "src/_lofi/checklist-store_test.ts": "00a7bbf786b90e6bd765e3fe401331233a6fad29a9d473dc4b1f0d5146e4dbc2",11+ "src/_lofi/checklist-store_test.ts": "0c0ebc75afa8702ad13fec9eaace14763a6d3ef52c3afa283ebfae33f90929d9",12+ "src/_lofi/checklist-store.ts": "3a221882b72be6652b3ccfd5d1b058703ad84ae955c12515a301cec0f8708cd7",1513 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",16- "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",1714 "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",18- "src/_lofi/probe.ts": "72c8a80561a6b5650d943456e1ce36f08d883fc1008fddee4614a2b75a8f06e9",19- "src/_lofi/pwa.ts": "185a0668528b49eae2dd0aa905a5a2e491b3787da335d491ffecaf8bfadec430",15+ "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",16+ "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",17+ "src/_lofi/inspector_test.ts": "05de6636a3ff5ff7f918f989ac919cde653fafd81cbd58c8f0c371badb554a07",18+ "src/_lofi/inspector.ts": "19fbe4f2d300ca4886ad90f16f6539cc0ad04ba1f38ac28bd9dec269c3f872bd",19+ "src/_lofi/probe.ts": "9bb284f4543e631acbfb22bb661b5cfc0798d561b0e95ee09796b36ebb5865b0",2020 "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",21- "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",21+ "src/_lofi/pwa.ts": "185a0668528b49eae2dd0aa905a5a2e491b3787da335d491ffecaf8bfadec430",22+ "src/_lofi/ReferenceShell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",2223 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",23- "src/_lofi/runtime.ts": "7ebbc54c66df6c92f6ecb2e6e24e131c4c1922044a86aa0fd168cdaf88a5eb19",24+ "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",25+ "src/_lofi/runtime.ts": "4fa51fae73fa69aec95e6f9a3d750844bb841a9ae8b787e03a16b8d3a3ba3a22",2426 "src/_lofi/test-assert.ts": "38549889964a4e92a0d63ee514c746cae5a43e6160cea4f2075a9e61543d3744",25- "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",2627 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",28+ "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",2729 "src/_lofi/use-checklist.ts": "a6d2818d390d228ad2a3db554b7cb043b60f7e9a2e48636ba19247c666343eaf",2830 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",2931 "src/app.ts": "3fd3646054f11ef7a1c080445d089eba45d58df7934aeaa58edab7c965c65523",
@@ -38,5 +40,6 @@
3840 "src/styles/global.css": "7cbe243842f3bca32038d99a4872afe30c346cf7137387ce41861c9333a1f5c0",3941 "src/ui-contract.ts": "fc40d20dff6ba870c0de038839e66cb61d4349031e4a7748d963ca182e8bbec0",4042 "tests/author-boundary_test.ts": "7ea79031ed4ae7d7966bef2e456e02efe7d93d82c8f0bee219d885d300260ed2",43+ "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",4144 "tsconfig.json": "fe2e5acc66101ed42d893d7e7063d07d298927621b351adcfb1ebde6c3a0a2ac"4245 }
A package/testing/browser_error.ts +20 -0
A package/testing/convergence.ts +126 -0
@@ -0,0 +1,126 @@
1+import type { BrowserTestClient } from "./fixture.ts";2+import type { ValueFreeState } from "./safety.ts";3+4+export interface OfflineTestClient {5+ readonly offline: boolean;6+ goOffline(): Promise<void>;7+ goOnline(): Promise<void>;8+}9+10+export interface OfflineTestFixture<Client extends OfflineTestClient> {11+ readonly clients: readonly [Client, Client];12+ readonly first: Client;13+ readonly second: Client;14+ goOffline(): Promise<void>;15+ goOnline(): Promise<void>;16+ captureFailure(17+ label: string,18+ snapshot?: (client: Client) => Promise<ValueFreeState>,19+ ): Promise<unknown>;20+}21+22+export interface ConcurrentOfflineScenario<23+ Edit,24+ Client extends OfflineTestClient = BrowserTestClient,25+> {26+ readonly edits: readonly [Edit, Edit];27+ /** Overall defensive deadline. Defaults to 60 seconds. */28+ readonly timeoutMs?: number;29+ /** App-owned, deterministic readiness assertion (locator/waitForFunction/etc.). */30+ readonly ready: (client: Client, signal: AbortSignal) => Promise<void>;31+ readonly apply: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;32+ readonly locallyApplied: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;33+ /** Use this hook for page/client restart while offline work is pending. */34+ readonly whilePending?: (35+ fixture: OfflineTestFixture<Client>,36+ signal: AbortSignal,37+ ) => Promise<void>;38+ /** Must resolve only when both app views have converged, or reject on timeout. */39+ readonly converged: (40+ fixture: OfflineTestFixture<Client>,41+ signal: AbortSignal,42+ ) => Promise<void>;43+ readonly snapshot?: (client: Client) => Promise<ValueFreeState>;44+ readonly failureLabel?: string;45+}46+47+export class ConvergenceScenarioError extends Error {48+ override readonly name = "ConvergenceScenarioError";49+50+ constructor(readonly stage: string, options?: ErrorOptions) {51+ super(`Concurrent offline convergence failed during ${stage}`, options);52+ }53+}54+55+/**56+ * Coordinates the transport lifecycle while the app owns edits, assertions and57+ * conflict semantics. Both offline edits are started in the same microtask.58+ */59+export async function runConcurrentOfflineConvergence<Edit, Client extends OfflineTestClient>(60+ fixture: OfflineTestFixture<Client>,61+ scenario: ConcurrentOfflineScenario<Edit, Client>,62+): Promise<void> {63+ const timeoutMs = scenario.timeoutMs ?? 60_000;64+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {65+ throw new TypeError("convergence timeoutMs must be a positive finite number");66+ }67+ const controller = new AbortController();68+ const timeout = setTimeout(69+ () => controller.abort(new Error(`convergence exceeded its ${timeoutMs}ms deadline`)),70+ timeoutMs,71+ );72+ const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {73+ if (controller.signal.aborted) throw controller.signal.reason;74+ let onAbort!: () => void;75+ const aborted = new Promise<never>((_resolve, reject) => {76+ onAbort = () => reject(controller.signal.reason);77+ controller.signal.addEventListener("abort", onAbort, { once: true });78+ });79+ try {80+ return await Promise.race([operation, aborted]);81+ } finally {82+ controller.signal.removeEventListener("abort", onAbort);83+ }84+ };85+ let stage = "readiness";86+ try {87+ await withinDeadline(88+ Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),89+ );90+ stage = "going offline";91+ await withinDeadline(fixture.goOffline());92+ stage = "concurrent edits";93+ await withinDeadline(94+ Promise.all([95+ scenario.apply(fixture.first, scenario.edits[0], controller.signal),96+ scenario.apply(fixture.second, scenario.edits[1], controller.signal),97+ ]),98+ );99+ stage = "local persistence";100+ await withinDeadline(101+ Promise.all([102+ scenario.locallyApplied(fixture.first, scenario.edits[0], controller.signal),103+ scenario.locallyApplied(fixture.second, scenario.edits[1], controller.signal),104+ ]),105+ );106+ stage = "pending-work hook";107+ if (scenario.whilePending) {108+ await withinDeadline(scenario.whilePending(fixture, controller.signal));109+ }110+ stage = "reconnection";111+ await withinDeadline(fixture.goOnline());112+ stage = "convergence";113+ await withinDeadline(scenario.converged(fixture, controller.signal));114+ } catch (error) {115+ await fixture.captureFailure(116+ scenario.failureLabel ?? `offline-convergence-${stage}`,117+ scenario.snapshot,118+ ).catch(() => undefined);119+ throw new ConvergenceScenarioError(stage, { cause: error });120+ } finally {121+ clearTimeout(timeout);122+ await Promise.allSettled(123+ fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),124+ );125+ }126+}
A package/testing/convergence_test.ts +133 -0
@@ -0,0 +1,133 @@
1+import {2+ ConvergenceScenarioError,3+ type OfflineTestClient,4+ type OfflineTestFixture,5+ runConcurrentOfflineConvergence,6+} from "./convergence.ts";7+import type { ValueFreeState } from "./safety.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+class FakeClient implements OfflineTestClient {14+ offline = false;15+ readonly events: string[] = [];16+17+ constructor(readonly name: string) {}18+19+ goOffline(): Promise<void> {20+ this.offline = true;21+ this.events.push("offline");22+ return Promise.resolve();23+ }24+25+ goOnline(): Promise<void> {26+ this.offline = false;27+ this.events.push("online");28+ return Promise.resolve();29+ }30+}31+32+class FakeFixture implements OfflineTestFixture<FakeClient> {33+ readonly first = new FakeClient("first");34+ readonly second = new FakeClient("second");35+ readonly clients = [this.first, this.second] as const;36+ captures: Array<{ label: string; states?: ValueFreeState[] }> = [];37+38+ async goOffline(): Promise<void> {39+ await Promise.all(this.clients.map((client) => client.goOffline()));40+ }41+42+ async goOnline(): Promise<void> {43+ await Promise.all(this.clients.map((client) => client.goOnline()));44+ }45+46+ async captureFailure(47+ label: string,48+ snapshot?: (client: FakeClient) => Promise<ValueFreeState>,49+ ): Promise<void> {50+ this.captures.push({51+ label,52+ states: snapshot ? await Promise.all(this.clients.map(snapshot)) : undefined,53+ });54+ }55+}56+57+Deno.test("offline convergence starts edits concurrently and leaves both clients online", async () => {58+ const fixture = new FakeFixture();59+ const entered: string[] = [];60+ let release!: () => void;61+ const bothEntered = new Promise<void>((resolve) => (release = resolve));62+63+ await runConcurrentOfflineConvergence(fixture, {64+ edits: ["alpha", "beta"],65+ ready: () => Promise.resolve(),66+ async apply(client, edit) {67+ assert(client.offline, `${client.name} edit ran online`);68+ entered.push(`${client.name}:${edit}`);69+ if (entered.length === 2) release();70+ await bothEntered;71+ },72+ locallyApplied: () => Promise.resolve(),73+ whilePending(current) {74+ assert(current.clients.every((client) => client.offline), "pending hook ran online");75+ return Promise.resolve();76+ },77+ converged(current) {78+ assert(current.clients.every((client) => !client.offline), "convergence ran offline");79+ return Promise.resolve();80+ },81+ });82+83+ assert(entered.join(",") === "first:alpha,second:beta", `edits were not paired: ${entered}`);84+ assert(fixture.clients.every((client) => !client.offline), "clients were left offline");85+ assert(fixture.captures.length === 0, "successful scenario captured failure artifacts");86+});87+88+Deno.test("offline convergence captures the failing stage and restores connectivity", async () => {89+ const fixture = new FakeFixture();90+ try {91+ await runConcurrentOfflineConvergence(fixture, {92+ edits: [1, 2],93+ ready: () => Promise.resolve(),94+ apply: () => Promise.reject(new Error("edit failed")),95+ locallyApplied: () => Promise.resolve(),96+ converged: () => Promise.resolve(),97+ snapshot: (client) => Promise.resolve({ offline: client.offline }),98+ });99+ throw new Error("expected scenario failure");100+ } catch (error) {101+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);102+ assert(error.stage === "concurrent edits", `unexpected stage: ${error.stage}`);103+ }104+ assert(fixture.clients.every((client) => !client.offline), "failure left clients offline");105+ assert(fixture.captures.length === 1, "failure artifacts were not requested once");106+ assert(107+ fixture.captures[0].label.includes("concurrent edits"),108+ `failure label lost the stage: ${fixture.captures[0].label}`,109+ );110+});111+112+Deno.test("offline convergence bounds a never-settling app predicate", async () => {113+ const fixture = new FakeFixture();114+ try {115+ await runConcurrentOfflineConvergence(fixture, {116+ edits: [1, 2],117+ timeoutMs: 10,118+ ready: () => Promise.resolve(),119+ apply: () => Promise.resolve(),120+ locallyApplied: () => Promise.resolve(),121+ converged: () => new Promise(() => undefined),122+ });123+ throw new Error("expected convergence deadline failure");124+ } catch (error) {125+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);126+ assert(error.stage === "convergence", `unexpected stage: ${error.stage}`);127+ assert(128+ error.cause instanceof Error && error.cause.message.includes("10ms deadline"),129+ `deadline cause was not retained: ${error.cause}`,130+ );131+ }132+ assert(fixture.clients.every((client) => !client.offline), "deadline left clients offline");133+});
A package/testing/fixture.ts +386 -0
@@ -0,0 +1,386 @@
1+import {2+ type Browser,3+ type BrowserContext,4+ type BrowserContextOptions,5+ chromium,6+ type ConsoleMessage,7+ type Locator,8+ type Page,9+ type Request,10+} from "npm:playwright@1.61.1";11+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";12+import {13+ artifactName,14+ assertValueFreeState,15+ redactDiagnosticText,16+ type ValueFreeState,17+} from "./safety.ts";18+import { sanitizeTraceArchive } from "./trace.ts";19+20+type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;21+export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {22+ /** Identity state is managed in memory by the fixture. File paths are forbidden. */23+ storageState?: never;24+};25+26+export type ClientName = "first" | "second";27+28+export interface BrowserDiagnostic {29+ readonly client: ClientName;30+ readonly kind: "console" | "page-error" | "request-failed";31+ readonly level?: string;32+ readonly message: string;33+ readonly url?: string;34+}35+36+export type IdentityOptions =37+ | {38+ readonly mode: "shared";39+ /** Runs before tracing starts. The resulting state is cloned only in memory. */40+ readonly preparePrimary: (client: BrowserTestClient) => Promise<void>;41+ }42+ | {43+ readonly mode: "isolated";44+ /** Runs independently for each client before tracing starts. */45+ readonly prepare?: (client: BrowserTestClient) => Promise<void>;46+ };47+48+export interface FailureArtifactOptions {49+ readonly directory: string;50+ /** Literal credential values to remove from retained diagnostics. */51+ readonly secretValues?: readonly string[];52+ /** Locators containing private UI values that screenshots should mask. */53+ readonly mask?: (client: BrowserTestClient) => readonly Locator[];54+}55+56+export interface TwoClientFixtureOptions {57+ readonly baseURL: string;58+ readonly identity: IdentityOptions;59+ readonly browser?: Browser;60+ readonly context?: SafeContextOptions;61+ readonly artifacts?: FailureArtifactOptions;62+ /** Defaults to true. Tracing begins only after identity preparation. */63+ readonly traceOnFailure?: boolean;64+}65+66+export { BrowserUnavailableError };67+68+function validateContextOptions(options: SafeContextOptions | undefined): void {69+ if (options && "storageState" in options) {70+ throw new TypeError(71+ "context.storageState is not accepted; use an identity mode so state stays in memory",72+ );73+ }74+}75+76+export class BrowserTestClient {77+ readonly #diagnostics: BrowserDiagnostic[] = [];78+ #context: BrowserContext;79+ #page: Page;80+ #offline = false;81+ #recording = false;82+ #closed = false;83+84+ constructor(85+ readonly name: ClientName,86+ context: BrowserContext,87+ page: Page,88+ readonly baseURL: string,89+ private readonly createContext: (state?: MemoryStorageState) => Promise<BrowserContext>,90+ private readonly redact: (value: string) => string,91+ private readonly traceOnFailure: boolean,92+ ) {93+ this.#context = context;94+ this.#page = page;95+ }96+97+ get context(): BrowserContext {98+ return this.#context;99+ }100+101+ get page(): Page {102+ return this.#page;103+ }104+105+ get offline(): boolean {106+ return this.#offline;107+ }108+109+ get diagnostics(): readonly BrowserDiagnostic[] {110+ return this.#diagnostics;111+ }112+113+ async startRecording(): Promise<void> {114+ if (this.#recording || this.#closed) return;115+ this.#attachDiagnostics(this.#page);116+ if (this.traceOnFailure) {117+ await this.#context.tracing.start({ screenshots: false, snapshots: false, sources: false });118+ }119+ this.#recording = true;120+ }121+122+ async goOffline(): Promise<void> {123+ await this.#context.setOffline(true);124+ this.#offline = true;125+ }126+127+ async goOnline(): Promise<void> {128+ await this.#context.setOffline(false);129+ this.#offline = false;130+ }131+132+ /** Reload the current page without replacing its browser-side client state. */133+ async reloadPage(): Promise<Page> {134+ await this.#page.reload({ waitUntil: "domcontentloaded" });135+ return this.#page;136+ }137+138+ /** Close and recreate the page inside the same identity/context. */139+ async restartPage(): Promise<Page> {140+ const previous = this.#page;141+ await previous.close();142+ this.#page = await this.#context.newPage();143+ this.#attachDiagnostics(this.#page);144+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });145+ return this.#page;146+ }147+148+ /**149+ * Restart the whole context, preserving identity and IndexedDB only in memory.150+ * A new context starts online because service-worker/cache state is not part of151+ * Playwright storageState; use restartPage for an offline PWA restart.152+ */153+ async restartClient(options: { preserveIdentity?: boolean } = {}): Promise<Page> {154+ const preserveIdentity = options.preserveIdentity ?? true;155+ let state: MemoryStorageState | undefined;156+ if (preserveIdentity) {157+ state = await this.#context.storageState({ indexedDB: true });158+ }159+ await this.#discardTrace();160+ await this.#context.close();161+ this.#context = await this.createContext(state ? structuredClone(state) : undefined);162+ state = undefined;163+ this.#page = await this.#context.newPage();164+ this.#offline = false;165+ this.#recording = false;166+ await this.startRecording();167+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });168+ return this.#page;169+ }170+171+ async captureTrace(path: string): Promise<boolean> {172+ if (!this.#recording || !this.traceOnFailure) return false;173+ await this.#context.tracing.stop({ path });174+ this.#recording = false;175+ try {176+ await sanitizeTraceArchive(path, this.redact);177+ } catch (error) {178+ await Deno.remove(path).catch(() => undefined);179+ throw error;180+ }181+ return true;182+ }183+184+ async close(): Promise<void> {185+ if (this.#closed) return;186+ this.#closed = true;187+ await this.#discardTrace();188+ await this.#context.close();189+ }190+191+ #attachDiagnostics(page: Page): void {192+ page.on("console", (message: ConsoleMessage) => {193+ this.#diagnostics.push({194+ client: this.name,195+ kind: "console",196+ level: message.type(),197+ message: this.redact(message.text()),198+ url: this.redact(message.location().url),199+ });200+ });201+ page.on("pageerror", (error: Error) => {202+ this.#diagnostics.push({203+ client: this.name,204+ kind: "page-error",205+ message: this.redact(`${error.name}: ${error.message}`),206+ });207+ });208+ page.on("requestfailed", (request: Request) => {209+ this.#diagnostics.push({210+ client: this.name,211+ kind: "request-failed",212+ message: this.redact(request.failure()?.errorText ?? "request failed"),213+ url: this.redact(request.url()),214+ });215+ });216+ }217+218+ async #discardTrace(): Promise<void> {219+ if (!this.#recording || !this.traceOnFailure) return;220+ await this.#context.tracing.stop().catch(() => undefined);221+ this.#recording = false;222+ }223+}224+225+export interface FailureArtifacts {226+ readonly directory: string;227+ readonly files: readonly string[];228+}229+230+export class TwoClientFixture {231+ readonly clients: readonly [BrowserTestClient, BrowserTestClient];232+ #closed = false;233+234+ constructor(235+ readonly browser: Browser,236+ readonly ownsBrowser: boolean,237+ first: BrowserTestClient,238+ second: BrowserTestClient,239+ private readonly artifacts: FailureArtifactOptions | undefined,240+ ) {241+ this.clients = [first, second];242+ }243+244+ get first(): BrowserTestClient {245+ return this.clients[0];246+ }247+248+ get second(): BrowserTestClient {249+ return this.clients[1];250+ }251+252+ async goOffline(): Promise<void> {253+ await Promise.all(this.clients.map((client) => client.goOffline()));254+ }255+256+ async goOnline(): Promise<void> {257+ await Promise.all(this.clients.map((client) => client.goOnline()));258+ }259+260+ async captureFailure(261+ label: string,262+ snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,263+ ): Promise<FailureArtifacts | undefined> {264+ if (!this.artifacts) return undefined;265+ const directory = `${this.artifacts.directory}/${artifactName(label)}`;266+ await Deno.mkdir(directory, { recursive: true });267+ const files: string[] = [];268+269+ for (const client of this.clients) {270+ const screenshot = `${directory}/${client.name}.png`;271+ await client.page.screenshot({272+ path: screenshot,273+ fullPage: true,274+ mask: this.artifacts.mask ? [...this.artifacts.mask(client)] : undefined,275+ });276+ files.push(screenshot);277+278+ const trace = `${directory}/${client.name}.trace.zip`;279+ if (await client.captureTrace(trace)) files.push(trace);280+ }281+282+ const diagnostics = `${directory}/diagnostics.json`;283+ await Deno.writeTextFile(284+ diagnostics,285+ `${JSON.stringify(this.clients.flatMap((client) => client.diagnostics), null, 2)}\n`,286+ );287+ files.push(diagnostics);288+289+ if (snapshot) {290+ const states: Record<ClientName, ValueFreeState> = {291+ first: await snapshot(this.first),292+ second: await snapshot(this.second),293+ };294+ assertValueFreeState(states);295+ const statePath = `${directory}/state-shape.json`;296+ await Deno.writeTextFile(statePath, `${JSON.stringify(states, null, 2)}\n`);297+ files.push(statePath);298+ }299+ return { directory, files };300+ }301+302+ async close(): Promise<void> {303+ if (this.#closed) return;304+ this.#closed = true;305+ const results = await Promise.allSettled(this.clients.map((client) => client.close()));306+ if (this.ownsBrowser) await this.browser.close();307+ const rejected = results.find((result) => result.status === "rejected");308+ if (rejected?.status === "rejected") throw rejected.reason;309+ }310+}311+312+export async function createTwoClientFixture(313+ options: TwoClientFixtureOptions,314+): Promise<TwoClientFixture> {315+ validateContextOptions(options.context);316+ let browser = options.browser;317+ const ownsBrowser = !browser;318+ if (!browser) {319+ try {320+ browser = await chromium.launch({ headless: true });321+ } catch (error) {322+ rethrowBrowserLaunchError(error);323+ }324+ }325+326+ const contextOptions = { ...options.context, baseURL: options.baseURL };327+ const createContext = (state?: MemoryStorageState) =>328+ browser.newContext({329+ ...contextOptions,330+ storageState: state ? structuredClone(state) : undefined,331+ });332+ const redact = (value: string) =>333+ redactDiagnosticText(value, options.artifacts?.secretValues ?? []);334+ const traceOnFailure = options.traceOnFailure ?? true;335+ let first: BrowserTestClient | undefined;336+ let second: BrowserTestClient | undefined;337+338+ try {339+ const firstContext = await createContext();340+ const firstPage = await firstContext.newPage();341+ first = new BrowserTestClient(342+ "first",343+ firstContext,344+ firstPage,345+ options.baseURL,346+ createContext,347+ redact,348+ traceOnFailure,349+ );350+ await firstPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });351+352+ let sharedState: MemoryStorageState | undefined;353+ if (options.identity.mode === "shared") {354+ await options.identity.preparePrimary(first);355+ sharedState = await firstContext.storageState({ indexedDB: true });356+ } else if (options.identity.prepare) {357+ await options.identity.prepare(first);358+ }359+360+ const secondContext = await createContext(361+ sharedState ? structuredClone(sharedState) : undefined,362+ );363+ sharedState = undefined;364+ const secondPage = await secondContext.newPage();365+ second = new BrowserTestClient(366+ "second",367+ secondContext,368+ secondPage,369+ options.baseURL,370+ createContext,371+ redact,372+ traceOnFailure,373+ );374+ await secondPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });375+ if (options.identity.mode === "isolated" && options.identity.prepare) {376+ await options.identity.prepare(second);377+ }378+379+ await Promise.all([first.startRecording(), second.startRecording()]);380+ return new TwoClientFixture(browser, ownsBrowser, first, second, options.artifacts);381+ } catch (error) {382+ await Promise.allSettled([first?.close(), second?.close()].filter(Boolean));383+ if (ownsBrowser) await browser.close().catch(() => undefined);384+ throw error;385+ }386+}
A package/testing/fixture_integration.ts +171 -0
@@ -0,0 +1,171 @@
1+import {2+ BrowserUnavailableError,3+ createTwoClientFixture,4+ type TwoClientFixture,5+} from "./fixture.ts";6+import { waitForReady } from "./readiness.ts";7+import { readTraceArchive } from "./trace.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+Deno.test("real Chromium fixture isolates or shares identity in memory and captures safe failures", async () => {14+ const artifacts = await Deno.makeTempDir({ dir: "." });15+ const server = Deno.serve(16+ { hostname: "127.0.0.1", port: 0, onListen() {} },17+ () =>18+ new Response(19+ `<!doctype html>20+ <html><body data-ready="yes"><main>fixture</main></body></html>`,21+ { headers: { "content-type": "text/html; charset=utf-8" } },22+ ),23+ );24+ const address = server.addr as Deno.NetAddr;25+ const baseURL = `http://127.0.0.1:${address.port}/`;26+ let shared: TwoClientFixture | undefined;27+ let isolated: TwoClientFixture | undefined;28+29+ try {30+ try {31+ shared = await createTwoClientFixture({32+ baseURL,33+ identity: {34+ mode: "shared",35+ async preparePrimary(client) {36+ await client.page.evaluate(() => localStorage.setItem("identity", "shared-person"));37+ },38+ },39+ artifacts: { directory: artifacts, secretValues: ["literal-secret"] },40+ });41+ } catch (error) {42+ if (error instanceof BrowserUnavailableError) return;43+ throw error;44+ }45+46+ await Promise.all(47+ shared.clients.map((client) =>48+ waitForReady(49+ client.page,50+ (value) => document.body.dataset.ready === value,51+ "yes",52+ { description: `${client.name} boot` },53+ )54+ ),55+ );56+ const sharedIdentities = await Promise.all(57+ shared.clients.map((client) => client.page.evaluate(() => localStorage.getItem("identity"))),58+ );59+ assert(60+ sharedIdentities.join(",") === "shared-person,shared-person",61+ `shared state was not cloned: ${sharedIdentities}`,62+ );63+64+ await shared.first.reloadPage();65+ assert(66+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",67+ "page reload lost identity",68+ );69+70+ await shared.goOffline();71+ await Promise.all(72+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === false)),73+ );74+ await shared.goOnline();75+ await Promise.all(76+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === true)),77+ );78+79+ await shared.first.restartPage();80+ assert(81+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",82+ "page restart lost identity",83+ );84+ await shared.second.restartClient();85+ assert(86+ await shared.second.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",87+ "client restart lost in-memory identity",88+ );89+90+ isolated = await createTwoClientFixture({91+ baseURL,92+ browser: shared.browser,93+ identity: {94+ mode: "isolated",95+ async prepare(client) {96+ await client.page.evaluate(97+ (identity) => localStorage.setItem("identity", identity),98+ `${client.name}-person`,99+ );100+ },101+ },102+ traceOnFailure: false,103+ });104+ const isolatedIdentities = await Promise.all(105+ isolated.clients.map((client) =>106+ client.page.evaluate(() => localStorage.getItem("identity"))107+ ),108+ );109+ assert(110+ isolatedIdentities.join(",") === "first-person,second-person",111+ `isolated identities crossed: ${isolatedIdentities}`,112+ );113+ await isolated.close();114+ isolated = undefined;115+116+ const consoleEvent = shared.first.page.waitForEvent("console", {117+ predicate: (message) => message.type() === "error",118+ });119+ await shared.first.page.evaluate(() => console.error("token=literal-secret"));120+ await consoleEvent;121+ const pageError = shared.first.page.waitForEvent("pageerror");122+ await shared.first.page.evaluate(() => {123+ queueMicrotask(() => {124+ throw new Error("secret=literal-secret");125+ });126+ });127+ await pageError;128+ const requestFailed = shared.first.page.waitForEvent("requestfailed");129+ await shared.first.page.evaluate(() =>130+ fetch("http://127.0.0.1:1/private?token=literal-secret").catch(() => undefined)131+ );132+ await requestFailed;133+134+ const captured = await shared.captureFailure(135+ "integration failure",136+ (client) => Promise.resolve({ online: !client.offline, itemCount: 0 }),137+ );138+ assert(captured, "failure artifacts were not created");139+ assert(captured.files.some((path) => path.endsWith(".png")), "screenshots were omitted");140+ assert(141+ captured.files.filter((path) => path.endsWith(".trace.zip")).length === 2,142+ "one trace per client was not retained",143+ );144+ assert(145+ captured.files.every((path) => !path.toLowerCase().includes("storage")),146+ `storage state leaked into artifact names: ${captured.files}`,147+ );148+ const diagnostics = await Deno.readTextFile(`${captured.directory}/diagnostics.json`);149+ assert(!diagnostics.includes("literal-secret"), diagnostics);150+ assert(diagnostics.includes("[redacted]"), diagnostics);151+ for (const tracePath of captured.files.filter((path) => path.endsWith(".trace.zip"))) {152+ const entries = await readTraceArchive(tracePath);153+ for (const bytes of Object.values(entries)) {154+ assert(155+ !new TextDecoder().decode(bytes).includes("literal-secret"),156+ `${tracePath} retained a secret`,157+ );158+ }159+ }160+161+ const browser = shared.browser;162+ await shared.close();163+ shared = undefined;164+ assert(!browser.isConnected(), "owned Chromium process remained connected after close");165+ } finally {166+ await isolated?.close().catch(() => undefined);167+ await shared?.close().catch(() => undefined);168+ await server.shutdown().catch(() => undefined);169+ await Deno.remove(artifacts, { recursive: true });170+ }171+});
A package/testing/mod.ts +22 -0
A package/testing/readiness.ts +40 -0
A package/testing/readiness_test.ts +48 -0
@@ -0,0 +1,48 @@
1+import type { Page } from "npm:playwright@1.61.1";2+import { ReadinessError, waitForReady } from "./readiness.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+Deno.test("waitForReady delegates polling and timeout to Playwright", async () => {9+ let call: unknown[] | undefined;10+ const page = {11+ waitForFunction(...args: unknown[]) {12+ call = args;13+ return Promise.resolve({});14+ },15+ } as unknown as Page;16+ const predicate = (expected: string) => document.body.dataset.ready === expected;17+18+ await waitForReady(page, predicate, "yes", {19+ description: "reference app boot",20+ timeoutMs: 321,21+ polling: 17,22+ });23+24+ assert(call?.[0] === predicate, "predicate was replaced");25+ assert(call?.[1] === "yes", "predicate argument was replaced");26+ assert(27+ JSON.stringify(call?.[2]) === JSON.stringify({ polling: 17, timeout: 321 }),28+ `unexpected Playwright options: ${JSON.stringify(call?.[2])}`,29+ );30+});31+32+Deno.test("waitForReady adds the app condition to timeout failures", async () => {33+ const cause = new Error("playwright timeout");34+ const page = {35+ waitForFunction() {36+ return Promise.reject(cause);37+ },38+ } as unknown as Page;39+40+ try {41+ await waitForReady(page, () => false, undefined, { description: "sync indicator" });42+ throw new Error("expected readiness to fail");43+ } catch (error) {44+ assert(error instanceof ReadinessError, `unexpected error: ${error}`);45+ assert(error.message.includes("sync indicator"), "condition was missing from the error");46+ assert(error.cause === cause, "Playwright failure was not retained as the cause");47+ }48+});
A package/testing/safety.ts +73 -0
@@ -0,0 +1,73 @@
1+export type ValueFreeState =2+ | null3+ | boolean4+ | number5+ | { readonly [key: string]: ValueFreeState }6+ | readonly ValueFreeState[];7+8+const JSON_SECRET_ASSIGNMENT =9+ /(\"(?:password|passwd|secret|token|authorization|cookie|credential|session)\"\s*:\s*)\"(?:\\.|[^\"])*\"/gi;10+const SECRET_ASSIGNMENT =11+ /\b(password|passwd|secret|token|authorization|cookie|credential|session)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi;12+const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;13+const URL_VALUE = /https?:\/\/[^\s"'<>]+/gi;14+15+function safeUrl(value: string): string {16+ try {17+ const url = new URL(value);18+ url.username = "";19+ url.password = "";20+ url.search = "";21+ url.hash = "";22+ return url.toString();23+ } catch {24+ return value;25+ }26+}27+28+/** Redacts common credential forms before diagnostics are retained in memory. */29+export function redactDiagnosticText(30+ value: string,31+ secretValues: readonly string[] = [],32+): string {33+ let redacted = value34+ .replaceAll(URL_VALUE, (url) => safeUrl(url))35+ .replaceAll(JSON_SECRET_ASSIGNMENT, '$1"[redacted]"')36+ .replaceAll(BEARER_VALUE, "Bearer [redacted]")37+ .replaceAll(SECRET_ASSIGNMENT, (_match, key: string) => `${key}=[redacted]`);38+ for (const secret of secretValues) {39+ if (secret.length > 0) redacted = redacted.replaceAll(secret, "[redacted]");40+ }41+ return redacted;42+}43+44+/**45+ * State artifacts intentionally reject strings and non-finite numbers. Callers46+ * can record shape, counts and booleans, but not user values or credentials.47+ */48+export function assertValueFreeState(49+ value: unknown,50+ path = "state",51+): asserts value is ValueFreeState {52+ if (value === null || typeof value === "boolean") return;53+ if (typeof value === "number" && Number.isFinite(value)) return;54+ if (typeof value === "string") {55+ throw new TypeError(`${path} contains a string value; state artifacts must be value-free`);56+ }57+ if (Array.isArray(value)) {58+ value.forEach((entry, index) => assertValueFreeState(entry, `${path}[${index}]`));59+ return;60+ }61+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {62+ for (const [key, entry] of Object.entries(value)) {63+ assertValueFreeState(entry, `${path}.${key}`);64+ }65+ return;66+ }67+ throw new TypeError(`${path} contains an unsupported value`);68+}69+70+export function artifactName(value: string): string {71+ const safe = value.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "");72+ return safe || "failure";73+}
A package/testing/safety_test.ts +81 -0
@@ -0,0 +1,81 @@
1+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";2+import { assertValueFreeState, redactDiagnosticText } from "./safety.ts";3+import { readTraceArchive, sanitizeTraceArchive, writeTraceArchive } from "./trace.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+Deno.test("value-free snapshots retain shape and reject values", () => {10+ assertValueFreeState({ online: false, itemCount: 2, clients: [true, null] });11+ for (12+ const invalid of [13+ { title: "private value" },14+ { value: Number.NaN },15+ new Date(),16+ ]17+ ) {18+ let rejected = false;19+ try {20+ assertValueFreeState(invalid);21+ } catch {22+ rejected = true;23+ }24+ assert(rejected, `unsafe state was accepted: ${String(invalid)}`);25+ }26+});27+28+Deno.test("missing Chromium reports the exact install command", () => {29+ const cause = new Error("browser executable doesn't exist");30+ try {31+ rethrowBrowserLaunchError(cause);32+ } catch (error) {33+ assert(error instanceof BrowserUnavailableError, `unexpected error: ${error}`);34+ assert(35+ error.message.includes("deno run -A npm:playwright@1.61.1 install chromium"),36+ error.message,37+ );38+ assert(error.cause === cause, "browser launch failure was not retained");39+ return;40+ }41+ throw new Error("missing browser error was not thrown");42+});43+44+Deno.test("diagnostic redaction strips URL, JSON and literal credentials", () => {45+ const input =46+ 'request https://user:pass@example.test/path?token=query#fragment {"authorization":"Bearer auth-value"} token=plain literal-value';47+ const output = redactDiagnosticText(input, ["literal-value"]);48+ assert(!output.includes("user:pass"), output);49+ assert(!output.includes("query"), output);50+ assert(!output.includes("fragment"), output);51+ assert(!output.includes("auth-value"), output);52+ assert(!output.includes("plain"), output);53+ assert(!output.includes("literal-value"), output);54+ assert(output.includes("https://example.test/path"), output);55+});56+57+Deno.test("trace archives are sanitized without losing their structure", async () => {58+ const directory = await Deno.makeTempDir({ dir: "." });59+ const path = `${directory}/trace.zip`;60+ try {61+ const trace =62+ '{"type":"console","text":"token=trace-secret","authorization":"Bearer auth-secret"}\n';63+ await Deno.writeFile(64+ path,65+ writeTraceArchive({66+ "trace.trace": new TextEncoder().encode(trace),67+ "resources/private.dat": new TextEncoder().encode("literal-private-resource"),68+ }),69+ );70+ await sanitizeTraceArchive(path, (value) => redactDiagnosticText(value, ["trace-secret"]));71+ const entries = await readTraceArchive(path);72+ const result = new TextDecoder().decode(entries["trace.trace"]);73+ assert(!result.includes("trace-secret"), result);74+ assert(!result.includes("auth-secret"), result);75+ assert(result.includes("[redacted]"), result);76+ JSON.parse(result);77+ assert(!("resources/private.dat" in entries), "private trace resource was retained");78+ } finally {79+ await Deno.remove(directory, { recursive: true });80+ }81+});
A package/testing/trace.ts +140 -0
@@ -0,0 +1,140 @@
1+const LOCAL_FILE = 0x04034b50;2+const CENTRAL_FILE = 0x02014b50;3+const END_OF_CENTRAL_DIRECTORY = 0x06054b50;4+const UTF8 = new TextEncoder();5+const TEXT = new TextDecoder();6+7+function concatenate(parts: readonly Uint8Array[]): Uint8Array {8+ const length = parts.reduce((total, part) => total + part.length, 0);9+ const result = new Uint8Array(length);10+ let offset = 0;11+ for (const part of parts) {12+ result.set(part, offset);13+ offset += part.length;14+ }15+ return result;16+}17+18+function header(length: number): { bytes: Uint8Array; view: DataView } {19+ const bytes = new Uint8Array(length);20+ return { bytes, view: new DataView(bytes.buffer) };21+}22+23+function crc32(bytes: Uint8Array): number {24+ let crc = 0xffffffff;25+ for (const byte of bytes) {26+ crc ^= byte;27+ for (let bit = 0; bit < 8; bit++) {28+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);29+ }30+ }31+ return (crc ^ 0xffffffff) >>> 0;32+}33+34+function endOffset(bytes: Uint8Array): number {35+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);36+ for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset--) {37+ if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) return offset;38+ }39+ throw new Error("Playwright trace is not a ZIP archive");40+}41+42+async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {43+ const input = new Blob([bytes.slice()]).stream();44+ const output = input.pipeThrough(new DecompressionStream("deflate-raw"));45+ return new Uint8Array(await new Response(output).arrayBuffer());46+}47+48+export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {49+ const bytes = await Deno.readFile(path);50+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);51+ const end = endOffset(bytes);52+ const count = view.getUint16(end + 10, true);53+ let centralOffset = view.getUint32(end + 16, true);54+ const entries: Record<string, Uint8Array> = {};55+56+ for (let index = 0; index < count; index++) {57+ if (view.getUint32(centralOffset, true) !== CENTRAL_FILE) {58+ throw new Error("Playwright trace has an invalid central directory");59+ }60+ const flags = view.getUint16(centralOffset + 8, true);61+ if (flags & 1) throw new Error("Encrypted trace entries cannot be sanitized");62+ const method = view.getUint16(centralOffset + 10, true);63+ const compressedSize = view.getUint32(centralOffset + 20, true);64+ const nameLength = view.getUint16(centralOffset + 28, true);65+ const extraLength = view.getUint16(centralOffset + 30, true);66+ const commentLength = view.getUint16(centralOffset + 32, true);67+ const localOffset = view.getUint32(centralOffset + 42, true);68+ const name = TEXT.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));69+ if (view.getUint32(localOffset, true) !== LOCAL_FILE) {70+ throw new Error(`Playwright trace entry ${name} has an invalid local header`);71+ }72+ const localNameLength = view.getUint16(localOffset + 26, true);73+ const localExtraLength = view.getUint16(localOffset + 28, true);74+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;75+ const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);76+ if (method === 0) entries[name] = compressed.slice();77+ else if (method === 8) entries[name] = await inflateRaw(compressed);78+ else throw new Error(`Playwright trace entry ${name} uses unsupported compression ${method}`);79+ centralOffset += 46 + nameLength + extraLength + commentLength;80+ }81+ return entries;82+}83+84+export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {85+ const localParts: Uint8Array[] = [];86+ const centralParts: Uint8Array[] = [];87+ let localOffset = 0;88+89+ for (const [name, contents] of Object.entries(entries)) {90+ const nameBytes = UTF8.encode(name);91+ const checksum = crc32(contents);92+ const local = header(30);93+ local.view.setUint32(0, LOCAL_FILE, true);94+ local.view.setUint16(4, 20, true);95+ local.view.setUint32(14, checksum, true);96+ local.view.setUint32(18, contents.length, true);97+ local.view.setUint32(22, contents.length, true);98+ local.view.setUint16(26, nameBytes.length, true);99+ localParts.push(local.bytes, nameBytes, contents);100+101+ const central = header(46);102+ central.view.setUint32(0, CENTRAL_FILE, true);103+ central.view.setUint16(4, 20, true);104+ central.view.setUint16(6, 20, true);105+ central.view.setUint32(16, checksum, true);106+ central.view.setUint32(20, contents.length, true);107+ central.view.setUint32(24, contents.length, true);108+ central.view.setUint16(28, nameBytes.length, true);109+ central.view.setUint32(42, localOffset, true);110+ centralParts.push(central.bytes, nameBytes);111+ localOffset += local.bytes.length + nameBytes.length + contents.length;112+ }113+114+ const central = concatenate(centralParts);115+ const end = header(22);116+ end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);117+ end.view.setUint16(8, Object.keys(entries).length, true);118+ end.view.setUint16(10, Object.keys(entries).length, true);119+ end.view.setUint32(12, central.length, true);120+ end.view.setUint32(16, localOffset, true);121+ return concatenate([...localParts, central, end.bytes]);122+}123+124+/**125+ * Sanitize an intentionally snapshot-free trace archive. Response/source126+ * resources are omitted because they can contain arbitrary private bytes; the127+ * action and network metadata remain useful for failure diagnosis.128+ */129+export async function sanitizeTraceArchive(130+ path: string,131+ redact: (value: string) => string,132+): Promise<void> {133+ const entries = await readTraceArchive(path);134+ const sanitized: Record<string, Uint8Array> = {};135+ for (const [name, bytes] of Object.entries(entries)) {136+ if (name.startsWith("resources/")) continue;137+ sanitized[name] = UTF8.encode(redact(TEXT.decode(bytes)));138+ }139+ await Deno.writeFile(path, writeTraceArchive(sanitized));140+}
M tools/golden_path.ts +477 -38
@@ -1,6 +1,16 @@
11 import { join, resolve } from "node:path";2+import {3+ type BrowserTestClient,4+ createTwoClientFixture,5+ runConcurrentOfflineConvergence,6+} from "@nzip/lofi/testing";27 import { type Browser, type BrowserContext, chromium, type Page } from "playwright";3-import { environmentNames, validateEnvironment } from "./env_contract.ts";8+import {9+ clientEnvironmentNames,10+ environmentNames,11+ serverEnvironmentNames,12+ validateEnvironment,13+} from "./env_contract.ts";414 import {515 artifactPaths,616 type CapturedProcess,
@@ -21,6 +31,8 @@ import {
2131 } from "./golden_path_core.ts";2232 import { loadEnvironment } from "./load_env.ts";2333 import { checklistUi } from "../apps/reference/src/ui-contract.ts";34+import { redactDiagnosticText } from "../package/testing/safety.ts";35+import { readTraceArchive, sanitizeTraceArchive } from "../package/testing/trace.ts";2436 import { assert } from "./assert.ts";2537 2638 export type GoldenPathCommands = {
@@ -76,6 +88,10 @@ const disposableBody = "Golden path disposable item";
7688 const offlineDevelopmentBody = "Golden path development offline item";7789 const offlineProductionBody = "Golden path production offline item";7890 const offlineProductionSyncedBody = "Golden path production offline item synced";91+const replicaClearBody = "Golden path replica-clear disposable item";92+const convergenceTextSeedBody = "Golden path two-client text item";93+const convergenceEditedBody = "Golden path two-client text item edited";94+const convergenceCompletionBody = "Golden path two-client completion item";7995 8096 function taskArgs(task: string, extra: string[] = []): string[] {8197 return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];
@@ -127,7 +143,6 @@ async function addItem(page: Page, island: string, body: string): Promise<void>
127143 { cause: error },128144 );129145 }130- await textbox.fill(body);131146 const add = section.getByRole("button", { name: checklistUi.addFrom(island) });132147 try {133148 await add.waitFor({ state: "visible" });
@@ -139,6 +154,7 @@ async function addItem(page: Page, island: string, body: string): Promise<void>
139154 { cause: error },140155 );141156 }157+ await textbox.fill(body);142158 await add.click();143159 }144160
@@ -155,27 +171,97 @@ async function assertRuntimeCardinality(
155171 islandLabels: readonly string[],156172 ): Promise<void> {157173 assert(islandLabels.length === 2, "golden path cardinality requires two mounted islands");158- await page.waitForFunction(() => {159- const probe = (globalThis as typeof globalThis & {160- __LOFI_REFERENCE__?: {161- diagnostics(): {162- storageState: string;163- activeClients: number;164- activeConsumers: number;165- activeVendorSubscriptions: number;166- activeMutationListeners: number;167- };174+ await page.waitForFunction(async () => {175+ const inspector = (globalThis as typeof globalThis & {176+ __LOFI_INSPECTOR__?: {177+ readSnapshot(): Promise<{178+ storage: { driver: string };179+ runtime: {180+ clients: number;181+ consumers: number;182+ vendorSubscriptions: number;183+ mutationListeners: number;184+ };185+ }>;168186 };169- }).__LOFI_REFERENCE__;170- const diagnostics = probe?.diagnostics();171- return diagnostics?.storageState === "persistent-driver-open" &&172- diagnostics.activeClients === 1 &&173- diagnostics.activeConsumers === 2 &&174- diagnostics.activeVendorSubscriptions === 1 &&175- diagnostics.activeMutationListeners === 1;187+ }).__LOFI_INSPECTOR__;188+ const snapshot = await inspector?.readSnapshot();189+ return snapshot?.storage.driver === "persistent open" &&190+ snapshot.runtime.clients === 1 &&191+ snapshot.runtime.consumers === 2 &&192+ snapshot.runtime.vendorSubscriptions === 1 &&193+ snapshot.runtime.mutationListeners === 1;176194 });177195 }178196 197+async function restartClientThroughInspector(198+ page: Page,199+ islandLabels: readonly string[],200+ retainedItem: string,201+ timeoutMs: number,202+): Promise<void> {203+ const identity = await armIdentityComparison(page);204+ const before = await page.evaluate(() => ({205+ timeOrigin: performance.timeOrigin,206+ url: location.href,207+ }));208+ const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });209+ await page.getByRole("button", { name: "Restart client" }).click();210+ await navigated;211+ const after = await page.evaluate(() => ({212+ timeOrigin: performance.timeOrigin,213+ url: location.href,214+ }));215+ assert(after.timeOrigin !== before.timeOrigin, "inspector restart did not replace the document");216+ assert(after.url === before.url, "inspector restart changed the application URL");217+ assert(218+ await identityComparisonMatches(page, identity.token),219+ "inspector restart changed the device identity",220+ );221+ await waitForItem(page, islandLabels, retainedItem);222+ for (const label of islandLabels) {223+ const island = page.locator("[data-island]").filter({ hasText: label });224+ await island.getByRole("status").filter({ hasText: "1 item(s)" }).waitFor({225+ state: "visible",226+ timeout: timeoutMs,227+ });228+ }229+ await assertRuntimeCardinality(page, islandLabels);230+}231+232+async function armIdentityComparison(page: Page): Promise<{ token: string; present: boolean }> {233+ const token = crypto.randomUUID();234+ const present = await page.evaluate(async (comparisonToken) => {235+ const identity = JSON.stringify(236+ Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),237+ );238+ const digest = new Uint8Array(239+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity)),240+ );241+ const hex = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");242+ (globalThis as typeof globalThis & { name: string }).name =243+ `lofi-golden:${comparisonToken}:${hex}`;244+ return identity !== "[]";245+ }, token);246+ return { token, present };247+}248+249+async function identityComparisonMatches(page: Page, token: string): Promise<boolean> {250+ return await page.evaluate(async (comparisonToken) => {251+ const browserGlobal = globalThis as typeof globalThis & { name: string };252+ const expected = browserGlobal.name;253+ browserGlobal.name = "";254+ const identity = JSON.stringify(255+ Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),256+ );257+ const digest = new Uint8Array(258+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity)),259+ );260+ const hex = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");261+ return expected === `lofi-golden:${comparisonToken}:${hex}`;262+ }, token);263+}264+179265 async function exerciseHmr(180266 page: Page,181267 projectRoot: string,
@@ -212,18 +298,63 @@ async function exerciseHmr(
212298 213299 async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {214300 await page.evaluate(async (shouldConnect) => {215- const probe = (globalThis as typeof globalThis & {216- __LOFI_REFERENCE__?: {217- disconnect(): Promise<void>;218- reconnect(): Promise<void>;219- };220- }).__LOFI_REFERENCE__;221- if (!probe) throw new Error("development reference probe is unavailable");222- if (shouldConnect) await probe.reconnect();223- else await probe.disconnect();301+ const inspector = (globalThis as typeof globalThis & {302+ __LOFI_INSPECTOR__?: { setTransportPaused(paused: boolean): Promise<void> };303+ }).__LOFI_INSPECTOR__;304+ if (!inspector) throw new Error("development inspector is unavailable");305+ await inspector.setTransportPaused(!shouldConnect);224306 }, connected);225307 }226308 309+async function exerciseReplicaClear(310+ browser: Browser,311+ url: string,312+ islandLabels: readonly string[],313+ timeoutMs: number,314+): Promise<void> {315+ const context = await browser.newContext();316+ try {317+ const page = await context.newPage();318+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });319+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({320+ state: "visible",321+ });322+ await assertRuntimeCardinality(page, islandLabels);323+ await addItem(page, islandLabels[0], replicaClearBody);324+ await waitForItem(page, islandLabels, replicaClearBody);325+ await waitForLocalDurability(page);326+ const identity = await armIdentityComparison(page);327+ assert(identity.present, "replica-clear fixture did not create an identity state");328+ const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });329+ page.once("dialog", (dialog) => dialog.accept());330+ await page.getByRole("button", { name: "Clear local replica" }).click();331+ await navigated;332+ await waitForItemAbsent(page, islandLabels, replicaClearBody);333+ assert(334+ await identityComparisonMatches(page, identity.token),335+ "replica clear changed the device-local identity",336+ );337+ } finally {338+ await context.close();339+ }340+}341+342+async function inspectorMarkerFiles(root: string): Promise<string[]> {343+ const matches: string[] = [];344+ async function visit(path: string): Promise<void> {345+ for await (const entry of Deno.readDir(path)) {346+ const child = join(path, entry.name);347+ if (entry.isDirectory) await visit(child);348+ else if (entry.isFile) {349+ const content = await Deno.readTextFile(child).catch(() => "");350+ if (content.includes("lofi-development-inspector")) matches.push(child);351+ }352+ }353+ }354+ await visit(root);355+ return matches.sort();356+}357+227358 async function observeReconnectSettlement(228359 page: Page,229360 timeoutMs: number,
@@ -244,7 +375,7 @@ async function observeReconnectSettlement(
244375 const failure = page.getByRole("status").filter({ hasText: checklistUi.writeFailed }).first();245376 await global.or(failure).waitFor({246377 state: "visible",247- timeout: Math.min(timeoutMs, 10_000),378+ timeout: timeoutMs,248379 });249380 if (await failure.isVisible()) {250381 throw new Error((await failure.textContent()) ?? "Write failed");
@@ -252,7 +383,7 @@ async function observeReconnectSettlement(
252383 return {253384 name: "development reconnect settlement",254385 status: "passed",255- detail: "The configured development server exposed global settlement after network return.",386+ detail: "The configured server exposed global settlement after reconnection.",256387 };257388 } catch {258389 throw new Error(
@@ -278,7 +409,10 @@ async function journeyEnvironment(
278409 );279410 }280411 const allowlisted = { ...isolated };281- for (const name of environmentNames) allowlisted[name] = configured[name]?.trim() ?? "";412+ for (const name of clientEnvironmentNames) {413+ allowlisted[name] = configured[name]?.trim() ?? "";414+ }415+ for (const name of serverEnvironmentNames) allowlisted[name] = "";282416 return await installNodeSentinel(allowlisted, artifactRoot);283417 }284418
@@ -332,6 +466,11 @@ async function assertCompleted(page: Page, body: string): Promise<void> {
332466 const name = checklistUi.complete(body);333467 const checkboxes = page.getByRole("checkbox", { name });334468 await checkboxes.first().waitFor({ state: "visible" });469+ await page.waitForFunction((accessibleName) => {470+ const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]471+ .filter((element) => element.getAttribute("aria-label") === accessibleName);472+ return matches.length === 2 && matches.every((element) => element.checked);473+ }, name);335474 assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);336475 assert(337476 await checkboxes.evaluateAll((elements) =>
@@ -366,6 +505,118 @@ async function waitForProductionPwa(page: Page, timeoutMs: number): Promise<void
366505 });367506 }368507 508+async function waitForChecklistReady(page: Page, timeoutMs: number): Promise<void> {509+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({510+ state: "visible",511+ timeout: timeoutMs,512+ });513+ await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({514+ state: "visible",515+ timeout: timeoutMs,516+ });517+}518+519+async function valueFreeClientState(client: BrowserTestClient) {520+ const renderedRows = await client.page.locator("[data-task-id]").count();521+ const completedControls = await client.page.locator('input[type="checkbox"]:checked').count();522+ return { offline: client.offline, renderedRows, completedControls };523+}524+525+async function exerciseTwoClientCloudConvergence(526+ browser: Browser,527+ url: string,528+ islandLabels: readonly string[],529+ timeoutMs: number,530+ artifactRoot: string,531+ redactValues: readonly string[],532+): Promise<void> {533+ const fixture = await createTwoClientFixture({534+ baseURL: url,535+ browser,536+ identity: {537+ mode: "shared",538+ async preparePrimary(client) {539+ await waitForProductionPwa(client.page, timeoutMs);540+ await waitForChecklistReady(client.page, timeoutMs);541+ },542+ },543+ artifacts: {544+ directory: join(artifactRoot, "two-client-convergence"),545+ secretValues: redactValues,546+ },547+ });548+ try {549+ await Promise.all(fixture.clients.map(async (client) => {550+ await waitForProductionPwa(client.page, timeoutMs);551+ await waitForChecklistReady(client.page, timeoutMs);552+ }));553+ await addItem(fixture.first.page, islandLabels[0], convergenceTextSeedBody);554+ await waitForItem(fixture.first.page, islandLabels, convergenceTextSeedBody);555+ await addItem(fixture.first.page, islandLabels[0], convergenceCompletionBody);556+ await waitForItem(fixture.first.page, islandLabels, convergenceCompletionBody);557+ await observeReconnectSettlement(fixture.first.page, timeoutMs, true);558+ await waitForItem(fixture.second.page, islandLabels, convergenceTextSeedBody);559+ await waitForItem(fixture.second.page, islandLabels, convergenceCompletionBody);560+561+ type ConvergenceEdit = { kind: "text" } | { kind: "completion" };562+ await runConcurrentOfflineConvergence<ConvergenceEdit, BrowserTestClient>(fixture, {563+ edits: [{ kind: "text" }, { kind: "completion" }],564+ async ready(client) {565+ await waitForProductionPwa(client.page, timeoutMs);566+ await waitForItem(client.page, islandLabels, convergenceTextSeedBody);567+ await waitForItem(client.page, islandLabels, convergenceCompletionBody);568+ },569+ async apply(client, edit) {570+ if (edit.kind === "text") {571+ await updateItem(client.page, convergenceTextSeedBody, convergenceEditedBody);572+ } else {573+ await completeItem(client.page, convergenceCompletionBody);574+ }575+ },576+ async locallyApplied(client, edit) {577+ if (edit.kind === "text") {578+ await waitForItem(client.page, islandLabels, convergenceEditedBody);579+ } else {580+ await assertCompleted(client.page, convergenceCompletionBody);581+ }582+ await waitForLocalDurability(client.page);583+ },584+ async whilePending(current) {585+ await current.first.reloadPage();586+ await waitForProductionPwa(current.first.page, timeoutMs);587+ await waitForItem(current.first.page, islandLabels, convergenceEditedBody);588+589+ await current.second.restartPage();590+ await waitForProductionPwa(current.second.page, timeoutMs);591+ await waitForItem(current.second.page, islandLabels, convergenceCompletionBody);592+ await assertCompleted(current.second.page, convergenceCompletionBody);593+ },594+ async converged(current) {595+ for (const client of current.clients) {596+ await waitForItem(client.page, islandLabels, convergenceEditedBody);597+ await assertCompleted(client.page, convergenceCompletionBody);598+ }599+ },600+ snapshot: valueFreeClientState,601+ failureLabel: "cloud-two-client-convergence",602+ });603+604+ for (const body of [convergenceEditedBody, convergenceCompletionBody]) {605+ await deleteItem(fixture.first.page, body);606+ await waitForItemAbsent(fixture.first.page, islandLabels, body);607+ await observeReconnectSettlement(fixture.first.page, timeoutMs, true);608+ await waitForItemAbsent(fixture.second.page, islandLabels, body);609+ }610+ } catch (error) {611+ await fixture.captureFailure("cloud-two-client-cleanup", valueFreeClientState).catch(() => {612+ // The original convergence or cleanup failure remains authoritative.613+ });614+ throw error;615+ } finally {616+ await fixture.close();617+ }618+}619+369620 async function screenshotFailure(page: Page | null, path: string): Promise<void> {370621 if (!page || page.isClosed()) return;371622 try {
@@ -375,6 +626,49 @@ async function screenshotFailure(page: Page | null, path: string): Promise<void>
375626 }376627 }377628 629+function byteSequencePresent(haystack: Uint8Array, needle: Uint8Array): boolean {630+ if (needle.length === 0 || haystack.length < needle.length) return false;631+ outer:632+ for (let offset = 0; offset <= haystack.length - needle.length; offset++) {633+ for (let index = 0; index < needle.length; index++) {634+ if (haystack[offset + index] !== needle[index]) continue outer;635+ }636+ return true;637+ }638+ return false;639+}640+641+async function sanitizeRetainedTrace(642+ path: string,643+ configuredValues: readonly string[],644+): Promise<void> {645+ await sanitizeTraceArchive(646+ path,647+ (value) => redactDiagnosticText(value, configuredValues),648+ );649+ const entries = await readTraceArchive(path);650+ assert(651+ Object.keys(entries).every((name) => !name.startsWith("resources/")),652+ "sanitized trace retained a response/source resource",653+ );654+ const encoder = new TextEncoder();655+ for (const configured of configuredValues.filter(Boolean)) {656+ const needle = encoder.encode(configured);657+ assert(658+ Object.values(entries).every((bytes) => !byteSequencePresent(bytes, needle)),659+ "sanitized trace retained a configured environment value",660+ );661+ }662+ const traceText = Object.entries(entries)663+ .filter(([name]) => name.endsWith(".trace"))664+ .map(([, bytes]) => new TextDecoder().decode(bytes))665+ .join("\n");666+ assert(667+ !/"identity"\s*:\s*"[^"\n]+"/.test(traceText),668+ "sanitized trace retained an identity-valued Playwright result",669+ );670+}671+378672 function commandFailure(record: CommandRecord): Error {379673 return new Error(380674 `${record.name} failed with exit ${record.exitCode}; rerun ${Deno.execPath()} ${
@@ -425,7 +719,14 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
425719 const commands = { ...defaultCommands, ...options.commands };426720 const islandLabels = options.islandLabels ?? ["North island", "South island"];427721 const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);428- const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);722+ const configuredEnvironment = await loadEnvironment(join(sourceRoot, ".env"));723+ const redactValues = [724+ ...new Set(725+ environmentNames.flatMap((name) => [configuredEnvironment[name], environment[name]]).filter(726+ Boolean,727+ ),728+ ),729+ ];429730 const commit = await sourceRevision(revisionRoot, environment);430731 const commandRecords: CommandRecord[] = [...options.initialCommands ?? []];431732 const assertions: JourneyAssertion[] = [];
@@ -496,6 +797,19 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
496797 status: "passed",497798 detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",498799 });800+ if (environmentMode === "cloud-from-root") {801+ assert(802+ clientEnvironmentNames.every((name) => Boolean(environment[name])) &&803+ serverEnvironmentNames.every((name) => environment[name] === ""),804+ "cloud child environment did not isolate server credentials",805+ );806+ assertions.push({807+ name: "cloud credential isolation",808+ status: "passed",809+ detail:810+ "The application child received only the public Jazz pair; server credentials were explicitly blank.",811+ });812+ }499813 500814 if (commands.doctor) {501815 const doctor = await runCapturedCommand({
@@ -526,7 +840,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
526840 browserVersion = browser.version();527841 report.runtime.browser = browserVersion;528842 context = await browser.newContext();529- await context.tracing.start({ screenshots: true, snapshots: true, sources: true });843+ await context.tracing.start({ screenshots: false, snapshots: false, sources: false });530844 page = await context.newPage();531845 532846 const port = reserveLocalPort();
@@ -552,6 +866,38 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
552866 await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({553867 state: "visible",554868 });869+ const inspector = page.locator('[data-lofi-inspector="lofi-development-inspector"]');870+ await inspector.waitFor({ state: "attached" });871+ const inspectorSnapshot = await page.evaluate(async () => {872+ const bridge = (globalThis as typeof globalThis & {873+ __LOFI_INSPECTOR__?: { readSnapshot(): Promise<unknown> };874+ }).__LOFI_INSPECTOR__;875+ if (!bridge) throw new Error("development inspector bridge is unavailable");876+ return await bridge.readSnapshot();877+ });878+ const serializedInspector = JSON.stringify(inspectorSnapshot);879+ assert(880+ serializedInspector.includes("live detail unavailable") ||881+ serializedInspector.includes("not configured"),882+ "inspector must name unavailable transport precision",883+ );884+ assert(885+ !/secret|token|password/i.test(serializedInspector),886+ "inspector snapshot exposed secrets",887+ );888+ if (environmentMode === "isolated-local") {889+ assert(890+ await page.getByRole("button", { name: "Transport pause unavailable in local-only" })891+ .isDisabled(),892+ "local-only inspector must disable cloud transport controls",893+ );894+ }895+ assertions.push({896+ name: "development inspector",897+ status: "passed",898+ detail:899+ "The development-only surface exposed value-free storage, durability, runtime, and explicitly unavailable vendor signals.",900+ });555901 devReadyMs = Math.round(performance.now() - devStarted);556902 assertions.push({557903 name: "development readiness",
@@ -578,6 +924,13 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
578924 "The local write survived reload and both islands kept one shared runtime subscription.",579925 });580926 927+ await restartClientThroughInspector(page, islandLabels, retainedBody, timeoutMs);928+ assertions.push({929+ name: "inspector client restart",930+ status: "passed",931+ detail: "The development-only client restart retained data and restored runtime cardinality.",932+ });933+581934 const hmr = await exerciseHmr(582935 page,583936 projectRoot,
@@ -609,21 +962,36 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
609962 610963 await addItem(page, islandLabels[0], disposableBody);611964 await waitForItem(page, islandLabels, disposableBody);965+ if (environmentMode === "cloud-from-root") {966+ await observeReconnectSettlement(page, timeoutMs, true);967+ }612968 await deleteItem(page, disposableBody);613969 await waitForItemAbsent(page, islandLabels, disposableBody);970+ if (environmentMode === "cloud-from-root") {971+ const online = await observeReconnectSettlement(page, timeoutMs, true);972+ assertions.push({973+ ...online,974+ name: "development online settlement",975+ detail:976+ "The online insert and delete reached global durability before the inspector paused transport.",977+ });978+ }614979 assertions.push({615980 name: "accessible CRUD",616981 status: "passed",617982 detail: "Create, update, complete, and delete worked through named author-facing controls.",618983 });619984 620- if (environmentMode === "cloud-from-root") await setReferenceConnection(page, false);621- await context.setOffline(true);985+ if (environmentMode === "cloud-from-root") {986+ await setReferenceConnection(page, false);987+ } else {988+ await context.setOffline(true);989+ }622990 await addItem(page, islandLabels[1], offlineDevelopmentBody);623991 await waitForItem(page, islandLabels, offlineDevelopmentBody);624992 await waitForLocalDurability(page);625- await context.setOffline(false);626993 if (environmentMode === "cloud-from-root") await setReferenceConnection(page, true);994+ else await context.setOffline(false);627995 assertions.push(628996 await observeReconnectSettlement(page, timeoutMs, environmentMode === "cloud-from-root"),629997 );
@@ -632,9 +1000,21 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
6321000 assertions.push({6331001 name: "development offline write",6341002 status: "passed",635- detail: "A loaded page accepted a local write offline and retained it after network return.",1003+ detail: environmentMode === "cloud-from-root"1004+ ? "A loaded page accepted a local write while Jazz transport was paused and retained it after transport resumed."1005+ : "A loaded page accepted a local write offline and retained it after network return.",6361006 });6371007 1008+ if (environmentMode === "isolated-local") {1009+ await exerciseReplicaClear(browser, devUrl, islandLabels, timeoutMs);1010+ assertions.push({1011+ name: "inspector replica clear",1012+ status: "passed",1013+ detail:1014+ "A confirmation-gated clear removed an isolated OPFS replica while preserving its in-memory-compared identity state.",1015+ });1016+ }1017+6381018 const devStderrPath = dev.stderrPath;6391019 await dev.stop();6401020 dev = null;
@@ -674,6 +1054,20 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
6741054 status: "passed",6751055 detail: "check, test, and build completed through Deno tasks.",6761056 });1057+ const productionRoot = join(1058+ projectRoot,1059+ options.source === "create" ? "dist" : "apps/reference/dist",1060+ );1061+ const inspectorFiles = await inspectorMarkerFiles(productionRoot);1062+ assert(1063+ inspectorFiles.length === 0,1064+ `production build contains development inspector markers: ${inspectorFiles.join(", ")}`,1065+ );1066+ assertions.push({1067+ name: "production inspector exclusion",1068+ status: "passed",1069+ detail: "The production bundle contains no development-inspector marker.",1070+ });6771071 6781072 preview = startReadyProcess({6791073 cwd: projectRoot,
@@ -689,6 +1083,10 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
6891083 const previewUrl = await preview.ready;6901084 assert(new URL(previewUrl).origin === new URL(devUrl).origin, "preview must reuse dev origin");6911085 await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });1086+ assert(1087+ await page.locator('[data-lofi-inspector="lofi-development-inspector"]').count() === 0,1088+ "production preview mounted the development inspector",1089+ );6921090 await waitForItem(page, islandLabels, updatedBody);6931091 await waitForProductionPwa(page, timeoutMs);6941092 await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });
@@ -720,6 +1118,23 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
7201118 detail: "The service-worker shell and OPFS data survived cold offline reload and editing.",7211119 });7221120 1121+ if (environmentMode === "cloud-from-root") {1122+ await exerciseTwoClientCloudConvergence(1123+ browser,1124+ previewUrl,1125+ islandLabels,1126+ timeoutMs,1127+ artifacts.root,1128+ redactValues,1129+ );1130+ assertions.push({1131+ name: "cloud two-client convergence",1132+ status: "passed",1133+ detail:1134+ "Two isolated OPFS replicas shared one in-memory-restored identity, retained orthogonal offline edits through reload/page restart, converged after reconnect, and cleaned up globally.",1135+ });1136+ }1137+7231138 if (environmentMode === "cloud-from-root") {7241139 for (const body of [updatedBody, offlineDevelopmentBody, offlineProductionSyncedBody]) {7251140 await deleteItem(page, body);
@@ -746,7 +1161,31 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
7461161 } finally {7471162 if (context) {7481163 await context.setOffline(false).catch(() => undefined);749- await context.tracing.stop({ path: artifacts.trace }).catch(() => undefined);1164+ try {1165+ await context.tracing.stop({ path: artifacts.trace });1166+ await sanitizeRetainedTrace(artifacts.trace, redactValues);1167+ assertions.push({1168+ name: "trace artifact safety",1169+ status: "passed",1170+ detail:1171+ "The snapshot-free retained trace omitted response/source resources, identity-valued results, and configured environment values.",1172+ });1173+ } catch (error) {1174+ await Deno.remove(artifacts.trace).catch(() => undefined);1175+ const traceFailure = new Error(1176+ `retained trace could not be made value-free: ${1177+ error instanceof Error ? error.message : String(error)1178+ }`,1179+ { cause: error },1180+ );1181+ if (!failure) failure = traceFailure;1182+ report.status = "failed";1183+ assertions.push({1184+ name: "trace artifact safety",1185+ status: "failed",1186+ detail: traceFailure.message,1187+ });1188+ }7501189 }7511190 await preview?.stop().catch(() => undefined);7521191 await dev?.stop().catch(() => undefined);