clone

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

feat(inspector): add truthful development diagnostics

Dami committed 8050279
commit
805027900707bac43008b87180185cfe684a38a5
parent
cd3481ce06804d9402fe58bb9d42b93a6c877533
author
Dami <dami.etoile@gmail.com>
10 files changed, +655 -80
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++++-
Mdocs/decisions/0006-bound-layer3-inspection-and-testing.md8+-
Mpackage/starter_template.ts4+
Mpackage/testdata/starter.snapshot.json24++--
Mtools/golden_path.ts216++++++++++++++++++++++++++----
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();
M docs/decisions/0006-bound-layer3-inspection-and-testing.md +5 -3
M package/starter_template.ts +4 -0
M package/testdata/starter.snapshot.json +13 -11
@@ -1,29 +1,31 @@
11 {22 ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",33 ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",4- "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",54 "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",65 "deno.json": "11183a356cb65dc17394945622fffd16828c5f47fb88388451b61850c620d3f8",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",
M tools/golden_path.ts +189 -27
@@ -76,6 +76,7 @@ const disposableBody = "Golden path disposable item";
7676 const offlineDevelopmentBody = "Golden path development offline item";7777 const offlineProductionBody = "Golden path production offline item";7878 const offlineProductionSyncedBody = "Golden path production offline item synced";79+const replicaClearBody = "Golden path replica-clear disposable item";7980 8081 function taskArgs(task: string, extra: string[] = []): string[] {8182 return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];
@@ -127,7 +128,6 @@ async function addItem(page: Page, island: string, body: string): Promise<void>
127128 { cause: error },128129 );129130 }130- await textbox.fill(body);131131 const add = section.getByRole("button", { name: checklistUi.addFrom(island) });132132 try {133133 await add.waitFor({ state: "visible" });
@@ -139,6 +139,7 @@ async function addItem(page: Page, island: string, body: string): Promise<void>
139139 { cause: error },140140 );141141 }142+ await textbox.fill(body);142143 await add.click();143144 }144145
@@ -155,27 +156,66 @@ async function assertRuntimeCardinality(
155156 islandLabels: readonly string[],156157 ): Promise<void> {157158 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- };159+ await page.waitForFunction(async () => {160+ const inspector = (globalThis as typeof globalThis & {161+ __LOFI_INSPECTOR__?: {162+ readSnapshot(): Promise<{163+ storage: { driver: string };164+ runtime: {165+ clients: number;166+ consumers: number;167+ vendorSubscriptions: number;168+ mutationListeners: number;169+ };170+ }>;168171 };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;172+ }).__LOFI_INSPECTOR__;173+ const snapshot = await inspector?.readSnapshot();174+ return snapshot?.storage.driver === "persistent open" &&175+ snapshot.runtime.clients === 1 &&176+ snapshot.runtime.consumers === 2 &&177+ snapshot.runtime.vendorSubscriptions === 1 &&178+ snapshot.runtime.mutationListeners === 1;176179 });177180 }178181 182+async function restartClientThroughInspector(183+ page: Page,184+ islandLabels: readonly string[],185+ retainedItem: string,186+ timeoutMs: number,187+): Promise<void> {188+ const before = await page.evaluate(() => ({189+ timeOrigin: performance.timeOrigin,190+ url: location.href,191+ identity: JSON.stringify(192+ Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),193+ ),194+ }));195+ const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });196+ await page.getByRole("button", { name: "Restart client" }).click();197+ await navigated;198+ const after = await page.evaluate(() => ({199+ timeOrigin: performance.timeOrigin,200+ url: location.href,201+ identity: JSON.stringify(202+ Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),203+ ),204+ }));205+ assert(after.timeOrigin !== before.timeOrigin, "inspector restart did not replace the document");206+ assert(after.url === before.url, "inspector restart changed the application URL");207+ assert(after.identity === before.identity, "inspector restart changed the device identity");208+ await waitForItem(page, islandLabels, retainedItem);209+ for (const label of islandLabels) {210+ const island = page.locator("[data-island]").filter({ hasText: label });211+ await island.getByRole("status").filter({ hasText: "1 item(s)" }).waitFor({212+ state: "visible",213+ timeout: timeoutMs,214+ });215+ }216+ await assertRuntimeCardinality(page, islandLabels);217+}218+179219 async function exerciseHmr(180220 page: Page,181221 projectRoot: string,
@@ -212,18 +252,73 @@ async function exerciseHmr(
212252 213253 async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {214254 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();255+ const inspector = (globalThis as typeof globalThis & {256+ __LOFI_INSPECTOR__?: { setTransportPaused(paused: boolean): Promise<void> };257+ }).__LOFI_INSPECTOR__;258+ if (!inspector) throw new Error("development inspector is unavailable");259+ await inspector.setTransportPaused(!shouldConnect);224260 }, connected);225261 }226262 263+async function exerciseReplicaClear(264+ browser: Browser,265+ url: string,266+ islandLabels: readonly string[],267+ timeoutMs: number,268+): Promise<void> {269+ const context = await browser.newContext();270+ try {271+ const page = await context.newPage();272+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });273+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({274+ state: "visible",275+ });276+ await assertRuntimeCardinality(page, islandLabels);277+ await addItem(page, islandLabels[0], replicaClearBody);278+ await waitForItem(page, islandLabels, replicaClearBody);279+ await waitForLocalDurability(page);280+ const identityBefore = await page.evaluate(() =>281+ JSON.stringify(282+ Object.entries(globalThis.localStorage).sort(([left], [right]) =>283+ left.localeCompare(right)284+ ),285+ )286+ );287+ assert(identityBefore !== "[]", "replica-clear fixture did not create an identity state");288+ const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });289+ page.once("dialog", (dialog) => dialog.accept());290+ await page.getByRole("button", { name: "Clear local replica" }).click();291+ await navigated;292+ await waitForItemAbsent(page, islandLabels, replicaClearBody);293+ const identityAfter = await page.evaluate(() =>294+ JSON.stringify(295+ Object.entries(globalThis.localStorage).sort(([left], [right]) =>296+ left.localeCompare(right)297+ ),298+ )299+ );300+ assert(identityBefore === identityAfter, "replica clear changed the device-local identity");301+ } finally {302+ await context.close();303+ }304+}305+306+async function inspectorMarkerFiles(root: string): Promise<string[]> {307+ const matches: string[] = [];308+ async function visit(path: string): Promise<void> {309+ for await (const entry of Deno.readDir(path)) {310+ const child = join(path, entry.name);311+ if (entry.isDirectory) await visit(child);312+ else if (entry.isFile) {313+ const content = await Deno.readTextFile(child).catch(() => "");314+ if (content.includes("lofi-development-inspector")) matches.push(child);315+ }316+ }317+ }318+ await visit(root);319+ return matches.sort();320+}321+227322 async function observeReconnectSettlement(228323 page: Page,229324 timeoutMs: number,
@@ -552,6 +647,38 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
552647 await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({553648 state: "visible",554649 });650+ const inspector = page.locator('[data-lofi-inspector="lofi-development-inspector"]');651+ await inspector.waitFor({ state: "attached" });652+ const inspectorSnapshot = await page.evaluate(async () => {653+ const bridge = (globalThis as typeof globalThis & {654+ __LOFI_INSPECTOR__?: { readSnapshot(): Promise<unknown> };655+ }).__LOFI_INSPECTOR__;656+ if (!bridge) throw new Error("development inspector bridge is unavailable");657+ return await bridge.readSnapshot();658+ });659+ const serializedInspector = JSON.stringify(inspectorSnapshot);660+ assert(661+ serializedInspector.includes("live detail unavailable") ||662+ serializedInspector.includes("not configured"),663+ "inspector must name unavailable transport precision",664+ );665+ assert(666+ !/secret|token|password/i.test(serializedInspector),667+ "inspector snapshot exposed secrets",668+ );669+ if (environmentMode === "isolated-local") {670+ assert(671+ await page.getByRole("button", { name: "Transport pause unavailable in local-only" })672+ .isDisabled(),673+ "local-only inspector must disable cloud transport controls",674+ );675+ }676+ assertions.push({677+ name: "development inspector",678+ status: "passed",679+ detail:680+ "The development-only surface exposed value-free storage, durability, runtime, and explicitly unavailable vendor signals.",681+ });555682 devReadyMs = Math.round(performance.now() - devStarted);556683 assertions.push({557684 name: "development readiness",
@@ -578,6 +705,13 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
578705 "The local write survived reload and both islands kept one shared runtime subscription.",579706 });580707 708+ await restartClientThroughInspector(page, islandLabels, retainedBody, timeoutMs);709+ assertions.push({710+ name: "inspector client restart",711+ status: "passed",712+ detail: "The development-only client restart retained data and restored runtime cardinality.",713+ });714+581715 const hmr = await exerciseHmr(582716 page,583717 projectRoot,
@@ -635,6 +769,16 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
635769 detail: "A loaded page accepted a local write offline and retained it after network return.",636770 });637771 772+ if (environmentMode === "isolated-local") {773+ await exerciseReplicaClear(browser, devUrl, islandLabels, timeoutMs);774+ assertions.push({775+ name: "inspector replica clear",776+ status: "passed",777+ detail:778+ "A confirmation-gated clear removed an isolated OPFS replica while preserving its in-memory-compared identity state.",779+ });780+ }781+638782 const devStderrPath = dev.stderrPath;639783 await dev.stop();640784 dev = null;
@@ -674,6 +818,20 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
674818 status: "passed",675819 detail: "check, test, and build completed through Deno tasks.",676820 });821+ const productionRoot = join(822+ projectRoot,823+ options.source === "create" ? "dist" : "apps/reference/dist",824+ );825+ const inspectorFiles = await inspectorMarkerFiles(productionRoot);826+ assert(827+ inspectorFiles.length === 0,828+ `production build contains development inspector markers: ${inspectorFiles.join(", ")}`,829+ );830+ assertions.push({831+ name: "production inspector exclusion",832+ status: "passed",833+ detail: "The production bundle contains no development-inspector marker.",834+ });677835 678836 preview = startReadyProcess({679837 cwd: projectRoot,
@@ -689,6 +847,10 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
689847 const previewUrl = await preview.ready;690848 assert(new URL(previewUrl).origin === new URL(devUrl).origin, "preview must reuse dev origin");691849 await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });850+ assert(851+ await page.locator('[data-lofi-inspector="lofi-development-inspector"]').count() === 0,852+ "production preview mounted the development inspector",853+ );692854 await waitForItem(page, islandLabels, updatedBody);693855 await waitForProductionPwa(page, timeoutMs);694856 await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });