1import {2 createStorageForkGuard,3 describeStorageFork,4 type StorageForkDiagnostics,5 type StorageForkGuardDependencies,6 type StorageForkState,7} from "./storage-fork.ts";8import { assert } from "./test-assert.ts";910class FakeStorage {11 readonly values = new Map<string, string>();12 getItem(key: string): string | null {13 return this.values.get(key) ?? null;14 }15 setItem(key: string, value: string): void {16 this.values.set(key, value);17 }18 keys(): readonly string[] {19 return [...this.values.keys()];20 }21}2223class FakeCookieJar {24 readonly values = new Map<string, string>();25 lastWritten: string | null = null;2627 read(): string {28 return [...this.values].map(([name, value]) => `${name}=${value}`).join("; ");29 }30 write(cookie: string): void {31 this.lastWritten = cookie;32 const [pair, ...attributes] = cookie.split(";").map((part) => part.trim());33 const separator = pair.indexOf("=");34 const name = pair.slice(0, separator);35 const maxAge = attributes.find((attribute) => attribute.toLowerCase().startsWith("max-age="));36 if (maxAge && Number(maxAge.slice("max-age=".length)) <= 0) this.values.delete(name);37 else this.values.set(name, pair.slice(separator + 1));38 }39}4041class FakeDiagnostics {42 #state: StorageForkDiagnostics = {43 storageState: "persistent-requested",44 localWaitCalls: 0,45 journaledPendingWrites: 0,46 lastWriteDurability: "none",47 };48 readonly #subscribers = new Set<() => void>();4950 get = (): StorageForkDiagnostics => this.#state;51 subscribe = (listener: () => void): () => void => {52 this.#subscribers.add(listener);53 return () => this.#subscribers.delete(listener);54 };55 mutate(update: Partial<StorageForkDiagnostics>): void {56 this.#state = { ...this.#state, ...update };57 for (const subscriber of this.#subscribers) subscriber();58 }59}6061const appId = "test-app";62const markerKey = `lofi:local-data:${appId}`;63const cookieName = `lofi-fork-${appId}`;6465function guardUnderTest(options: {66 production?: boolean;67 storage?: FakeStorage;68 cookies?: FakeCookieJar;69 standalone?: boolean;70 syncing?: boolean;71 diagnostics?: FakeDiagnostics;72 probeLocalRows?: () => Promise<boolean>;73 storageEvents?: EventTarget;74 overrides?: Partial<StorageForkGuardDependencies>;75} = {}) {76 const storage = options.storage ?? new FakeStorage();77 const cookies = options.cookies ?? new FakeCookieJar();78 const diagnostics = options.diagnostics ?? new FakeDiagnostics();79 const guard = createStorageForkGuard({80 production: () => options.production ?? true,81 appId: () => appId,82 storage: () => storage,83 storageKeys: () => storage.keys(),84 readCookies: () => cookies.read(),85 writeCookie: (cookie) => cookies.write(cookie),86 cookiePath: () => "/",87 secureContext: () => true,88 environment: () => ({89 displayModeStandalone: options.standalone ?? false,90 navigatorStandalone: false,91 }),92 syncing: () => options.syncing ?? false,93 diagnostics: () => diagnostics,94 probeLocalRows: options.probeLocalRows ?? (() => Promise.resolve(false)),95 storageEvents: () => options.storageEvents,96 ...options.overrides,97 });98 return { guard, storage, cookies, diagnostics };99}100101function waitForState(102 guard: ReturnType<typeof createStorageForkGuard>,103 predicate: (state: StorageForkState) => boolean,104): Promise<StorageForkState> {105 return new Promise((resolve) => {106 const unsubscribe = guard.subscribe((state) => {107 if (!predicate(state)) return;108 unsubscribe();109 resolve(state);110 });111 });112}113114function storageEvent(key: string): Event {115 return Object.assign(new Event("storage"), { key });116}117118Deno.test("development leaves the guard unarmed and writes no cookie", () => {119 const { guard, cookies } = guardUnderTest({ production: false });120 guard.start();121 const state = guard.getState();122 assert(123 state.state === "unarmed" && state.reason === "development",124 "a development boot did not stay unarmed",125 );126 assert(cookies.lastWritten === null, "a development boot touched cookies");127});128129Deno.test("no local data settles idle and deletes a stale flag cookie", () => {130 const cookies = new FakeCookieJar();131 cookies.values.set(cookieName, "1");132 const { guard } = guardUnderTest({ cookies });133 guard.start();134 assert(guard.getState().state === "idle", "an empty browser context was not idle");135 assert(!cookies.values.has(cookieName), "the stale flag cookie was not deleted");136});137138Deno.test("a persisted marker without sync is at risk and refreshes the cookie", () => {139 const storage = new FakeStorage();140 storage.setItem(markerKey, "1");141 const { guard, cookies } = guardUnderTest({ storage });142 guard.start();143 assert(guard.getState().state === "browser-data-at-risk", "marked local data was not at risk");144 assert(cookies.values.get(cookieName) === "1", "the flag cookie was not written");145 const written = cookies.lastWritten ?? "";146 for (const attribute of ["Path=/", "SameSite=Lax", "Secure", "Max-Age=31536000"]) {147 assert(written.includes(attribute), `the flag cookie lost its ${attribute} attribute`);148 }149});150151Deno.test("observed write activity persists the marker and flips to at risk", () => {152 const { guard, storage, cookies, diagnostics } = guardUnderTest();153 guard.start();154 assert(guard.getState().state === "idle", "a fresh context did not start idle");155 diagnostics.mutate({ localWaitCalls: 1 });156 assert(guard.getState().state === "browser-data-at-risk", "write activity did not raise risk");157 assert(storage.getItem(markerKey) === "1", "write activity did not persist the marker");158 assert(cookies.values.get(cookieName) === "1", "write activity did not set the flag cookie");159});160161Deno.test("electing sync settles idle and deletes the flag cookie", () => {162 const storage = new FakeStorage();163 storage.setItem(markerKey, "1");164 let syncing = false;165 const { guard, cookies, diagnostics } = guardUnderTest({166 storage,167 overrides: { syncing: () => syncing },168 });169 guard.start();170 assert(guard.getState().state === "browser-data-at-risk", "unsynced data was not at risk");171 syncing = true;172 // Election recreates the runtime, which notifies the diagnostics feed.173 diagnostics.mutate({});174 assert(guard.getState().state === "idle", "electing sync did not settle idle");175 assert(!cookies.values.has(cookieName), "electing sync did not delete the flag cookie");176});177178Deno.test("a cross-tab storage event for this app re-evaluates the verdict", () => {179 const storage = new FakeStorage();180 storage.setItem(markerKey, "1");181 const storageEvents = new EventTarget();182 let syncing = false;183 const { guard } = guardUnderTest({184 storage,185 storageEvents,186 overrides: { syncing: () => syncing },187 });188 guard.start();189 assert(guard.getState().state === "browser-data-at-risk", "unsynced data was not at risk");190 syncing = true;191 storageEvents.dispatchEvent(storageEvent(`lofi:sync-elected:${appId}`));192 assert(guard.getState().state === "idle", "a cross-tab election was not observed");193});194195Deno.test("a standalone launch into a fresh container with the flag detects the fork", () => {196 const cookies = new FakeCookieJar();197 cookies.values.set(cookieName, "1");198 const { guard } = guardUnderTest({ cookies, standalone: true });199 guard.start();200 const state = guard.getState();201 assert(state.state === "fork-detected", "the inherited flag cookie was not detected");202 assert(203 state.state === "fork-detected" && state.message.includes("still in Safari"),204 "the fork notice does not say where the data lives",205 );206 assert(cookies.values.has(cookieName), "detection cleared the cookie before dismissal");207});208209Deno.test("a standalone launch without the flag evaluates normally", () => {210 const { guard } = guardUnderTest({ standalone: true });211 guard.start();212 assert(guard.getState().state === "idle", "a fresh standalone launch was not idle");213});214215Deno.test("a used standalone container ignores a stale flag cookie", () => {216 const storage = new FakeStorage();217 storage.setItem(`lofi:schema-version:${appId}`, "{}");218 const cookies = new FakeCookieJar();219 cookies.values.set(cookieName, "1");220 const { guard } = guardUnderTest({ storage, cookies, standalone: true });221 guard.start();222 assert(guard.getState().state !== "fork-detected", "a used container reported a fork");223});224225Deno.test("a flag cookie for a different app never detects a fork", () => {226 const cookies = new FakeCookieJar();227 cookies.values.set("lofi-fork-other-app", "1");228 const { guard } = guardUnderTest({ cookies, standalone: true });229 guard.start();230 assert(guard.getState().state === "idle", "another app's cookie leaked across");231});232233Deno.test("dismissing a detected fork settles idle and deletes the cookie", () => {234 const cookies = new FakeCookieJar();235 cookies.values.set(cookieName, "1");236 const { guard } = guardUnderTest({ cookies, standalone: true });237 guard.start();238 assert(guard.getState().state === "fork-detected", "the fork was not detected");239 guard.dismissFork();240 assert(guard.getState().state === "idle", "dismissal did not settle idle");241 assert(!cookies.values.has(cookieName), "dismissal did not delete the flag cookie");242 guard.dismissFork();243 assert(guard.getState().state === "idle", "a repeated dismissal changed the verdict");244});245246Deno.test("start is idempotent", () => {247 const { guard, cookies } = guardUnderTest();248 guard.start();249 const first = cookies.lastWritten;250 guard.start();251 assert(guard.getState().state === "idle", "a second start changed the verdict");252 assert(cookies.lastWritten === first, "a second start rewrote cookies");253});254255Deno.test("throwing browser surfaces settle idle instead of crashing", () => {256 const guard = createStorageForkGuard({257 production: () => true,258 appId: () => appId,259 storage: () => {260 throw new Error("blocked");261 },262 storageKeys: () => {263 throw new Error("blocked");264 },265 readCookies: () => {266 throw new Error("blocked");267 },268 writeCookie: () => {269 throw new Error("blocked");270 },271 environment: () => ({ displayModeStandalone: false, navigatorStandalone: false }),272 syncing: () => false,273 diagnostics: () => new FakeDiagnostics(),274 probeLocalRows: () => Promise.resolve(false),275 storageEvents: () => undefined,276 });277 guard.start();278 assert(guard.getState().state === "idle", "blocked storage did not settle idle");279});280281Deno.test("the row probe runs once the persistent driver opens and marks found data", async () => {282 const probes: true[] = [];283 const probeCount = () => probes.length;284 const { guard, storage, diagnostics } = guardUnderTest({285 probeLocalRows: () => {286 probes.push(true);287 return Promise.resolve(true);288 },289 });290 const atRisk = waitForState(guard, (state) => state.state === "browser-data-at-risk");291 guard.start();292 assert(probeCount() === 0, "the probe ran before the persistent driver opened");293 diagnostics.mutate({ storageState: "persistent-driver-open" });294 assert(probeCount() === 1, "the open driver did not trigger the probe");295 await atRisk;296 assert(storage.getItem(markerKey) === "1", "found rows did not persist the marker");297 diagnostics.mutate({});298 assert(probeCount() === 1, "the probe ran more than once");299});300301Deno.test("describeStorageFork labels every state for diagnostic surfaces", () => {302 const labels = [303 describeStorageFork({ state: "unarmed", reason: "development" }),304 describeStorageFork({ state: "unarmed", reason: "inactive" }),305 describeStorageFork({ state: "idle" }),306 describeStorageFork({ state: "browser-data-at-risk" }),307 describeStorageFork({ state: "fork-detected", message: "" }),308 ];309 assert(new Set(labels).size === labels.length, "fork states share a label");310 for (const label of labels) assert(label.length > 0, "a fork state lacks a label");311});