import { beforeEach, describe, expect, it, vi } from "vitest"; import { classifyDisplayRequest, DISPLAY_ARTIFACT_CACHE, DISPLAY_SHELL_CACHE, type DisplayCacheLike, type DisplayCacheStorageLike, type DisplayOfflineEnvironment, type DisplayOfflineStatus, handleDisplayFetch, pruneSupersededArtifacts, pruneSupersededCaches, } from "../src/display/offline-cache.js"; /** * The display's offline behaviour (ADR 0043), tested against the real caching logic rather than * against a service worker a test runner cannot host. * * That is the whole reason `offline-cache.ts` touches no global: every capability arrives as an * environment, so a fake cache and a controllable `fetch` exercise the exact code the worker runs. * `sw.ts` is the thin wiring that supplies the real ones. */ const origin = "https://ekklesia.example.test"; /** A faithful enough `Cache`: keyed by URL, storing whole responses, with real `keys()` ordering. */ function fakeCache(): DisplayCacheLike & { readonly entries: Map } { const entries = new Map(); return { entries, async delete(request) { return entries.delete(typeof request === "string" ? request : request.url); }, async keys() { return [...entries.keys()].map((url) => new Request(url)); }, async match(request) { return entries.get(typeof request === "string" ? request : request.url); }, async put(request, response) { entries.set(typeof request === "string" ? request : request.url, response); }, }; } interface Harness { readonly environment: DisplayOfflineEnvironment; readonly caches: Map>; readonly statuses: DisplayOfflineStatus[]; online: boolean; /** Responses the "server" will give, by URL. Absent means a 404. */ readonly served: Map Response>; readonly requested: string[]; } function harness(): Harness { const caches = new Map>(); const statuses: DisplayOfflineStatus[] = []; const served = new Map Response>(); const requested: string[] = []; const state: Harness = { caches, environment: { caches: { async delete(name) { return caches.delete(name); }, async keys() { return [...caches.keys()]; }, async open(name) { const existing = caches.get(name) ?? fakeCache(); caches.set(name, existing); return existing; }, } satisfies DisplayCacheStorageLike, async fetch(request) { requested.push(request.url); if (!state.online) { throw new TypeError("Failed to fetch"); } const responder = served.get(request.url); return responder ? responder() : new Response("missing", { status: 404 }); }, notify: (status) => statuses.push(status), }, online: true, requested, served, statuses, }; return state; } function code(body: string): Response { return new Response(body, { headers: { "content-type": "text/javascript; charset=utf-8" }, status: 200, }); } const page = `${origin}/display/signage`; const script = `${origin}/assets/ekklesia-display/app.js`; const artifact = (version: string) => `${origin}/api/v1/module-ui/signage/${version}/display`; const contribution = (version: string) => `${origin}/api/v1/module-ui/announcements/${version}/contributions/announcements.signage-slide`; /** * A navigation request, as a service worker actually receives one. * * `mode: "navigate"` cannot be passed to the `Request` constructor — the spec forbids it, precisely * so page code cannot forge a navigation — so the property is defined on the instance instead. That * is what a browser hands `event.request` for a top-level load. */ function navigation(url: string): Request { const request = new Request(url); Object.defineProperty(request, "mode", { value: "navigate" }); return request; } let live: Harness; beforeEach(() => { live = harness(); live.served.set(page, () => new Response("display", { status: 200 })); live.served.set(script, () => code("export const host = 1;")); live.served.set(artifact("1.0.0"), () => code("export default { version: '1.0.0' };")); live.served.set(contribution("2.0.0"), () => code("export default { slide: true };")); }); async function visit(url: string, request = new Request(url)): Promise { return handleDisplayFetch(request, origin, live.environment); } describe("what the display worker will and will not cache", () => { it.each([ ["the display page", page, "shell"], ["the fixed host script", script, "shell"], ["a module display artifact", artifact("1.0.0"), "artifact"], ["a contributed renderer", contribution("2.0.0"), "artifact"], ])("caches %s", (_label, url, kind) => { const request = kind === "shell" && url === page ? navigation(url) : new Request(url); expect(classifyDisplayRequest(request, origin)).toBe(kind); }); it.each([ ["the display descriptor", `${origin}/api/v1/module-displays/signage`], ["the principal endpoint", `${origin}/api/v1/principal`], ["a module API call", `${origin}/api/v1/modules/signage/slides`], ["a realtime stream", `${origin}/api/v1/module-realtime/signage/state`], ["a pairing request", `${origin}/api/v1/devices/pairing`], ["an auth route", `${origin}/api/auth/sign-in/email`], ["the Dashboard page", `${origin}/`], ["the worker itself", `${origin}/display/sw.js`], ["a signed object URL", "https://objects.example.test/bucket/slide.jpg?X-Amz-Signature=opaque"], ["any other origin", "https://evil.example.test/api/v1/module-ui/signage/1.0.0/display"], ])("never caches %s", (_label, url) => { // An allowlist, so a platform endpoint added later is *not* cached by default rather than // silently becoming a stored permission decision. expect(classifyDisplayRequest(new Request(url), origin)).toBeUndefined(); }); it("never caches a non-GET request or a query-bearing URL", () => { expect( classifyDisplayRequest(new Request(artifact("1.0.0"), { method: "POST" }), origin), ).toBeUndefined(); expect(classifyDisplayRequest(new Request(`${artifact("1.0.0")}?t=1`), origin)).toBeUndefined(); }); it("passes an unrecognized request through untouched, storing nothing", async () => { const descriptor = `${origin}/api/v1/module-displays/signage`; live.served.set(descriptor, () => Response.json({ module: {} })); const handled = await visit(descriptor); // `undefined` means "do what the browser would have done anyway" — the worker's presence must // not change how any other request behaves. expect(handled).toBeUndefined(); expect(live.requested).toEqual([]); expect(live.caches.size).toBe(0); }); }); describe("first online load", () => { it("serves everything from the network and stores the shell and artifacts", async () => { await visit(page, navigation(page)); await visit(script); await visit(artifact("1.0.0")); await visit(contribution("2.0.0")); expect(live.requested).toEqual([page, script, artifact("1.0.0"), contribution("2.0.0")]); expect([...(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.keys() ?? [])]).toEqual([ page, script, ]); expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ artifact("1.0.0"), contribution("2.0.0"), ]); expect(live.statuses.every((status) => !status.usingFallback)).toBe(true); }); it("stores nothing for a failed, redirected, or not-modified response", async () => { for (const [url, response] of [ [artifact("1.0.0"), new Response(null, { status: 304 })], [contribution("2.0.0"), new Response("nope", { status: 404 })], [script, new Response("gone", { status: 503 })], ] as const) { live.served.set(url, () => response.clone()); await visit(url); } // A screen must never later replay an API restart's 503 as though it were the artifact. expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size ?? 0).toBe(0); expect(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.size ?? 0).toBe(0); }); }); describe("offline reload after one successful visit", () => { it("serves the shell from cache and reports the fallback", async () => { await visit(page, navigation(page)); await visit(script); live.online = false; live.statuses.length = 0; const reloadedPage = await visit(page, navigation(page)); const reloadedScript = await visit(script); expect(await reloadedPage?.text()).toBe("display"); expect(await reloadedScript?.text()).toBe("export const host = 1;"); // A kiosk that reboots during an outage boots into Ekklesia's own shell rather than the // browser's error page — which is the entire point of this phase. expect(live.statuses).toEqual([ { kind: "shell", usingFallback: true }, { kind: "shell", usingFallback: true }, ]); }); it("falls back to the last validated artifact and contributed renderer", async () => { await visit(artifact("1.0.0")); await visit(contribution("2.0.0")); live.online = false; expect(await (await visit(artifact("1.0.0")))?.text()).toBe( "export default { version: '1.0.0' };", ); expect(await (await visit(contribution("2.0.0")))?.text()).toBe( "export default { slide: true };", ); expect(live.statuses.filter((status) => status.usingFallback)).toHaveLength(2); }); it("fails honestly when nothing was ever cached for that exact URL", async () => { live.online = false; // A first visit during an outage, or a version this screen has never run. There is no honest // answer, so the failure propagates and the page renders its own state rather than a fabricated // one. await expect(visit(artifact("9.9.9"))).rejects.toBeInstanceOf(TypeError); expect(live.statuses).toEqual([{ kind: "artifact", usingFallback: true }]); }); it("does not fall back across versions: a cached 1.0.0 never answers for 1.1.0", async () => { await visit(artifact("1.0.0")); live.online = false; await expect(visit(artifact("1.1.0"))).rejects.toBeInstanceOf(TypeError); }); }); describe("version upgrade and pruning", () => { it("keeps only the current version of a module's artifacts", async () => { await visit(artifact("1.0.0")); live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); await visit(artifact("1.1.0")); // Version-keyed URLs mean a new build lands beside the old one; without pruning an unattended // screen accumulates every version it has ever run. expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ artifact("1.1.0"), ]); }); it("keeps every artifact belonging to the version it just stored", async () => { const primary = `${origin}/api/v1/module-ui/signage/1.0.0`; live.served.set(primary, () => code("export default { primary: true };")); await visit(artifact("1.0.0")); await visit(primary); // A module's display artifact and its other artifacts share one version and are separate URLs; // dropping either would half-cache a working display. expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size).toBe(2); }); it("never prunes another module's artifacts", async () => { await visit(artifact("1.0.0")); await visit(contribution("2.0.0")); live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); await visit(artifact("1.1.0")); expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ contribution("2.0.0"), artifact("1.1.0"), ]); }); it("leaves an unrecognized artifact URL shape alone rather than guessing", async () => { const cache = fakeCache(); await cache.put(`${origin}/api/v1/module-ui/signage`, code("odd")); expect(await pruneSupersededArtifacts(cache, `${origin}/api/v1/module-ui/signage`)).toEqual([]); expect(cache.entries.size).toBe(1); }); it("deletes superseded cache generations and keeps unrelated ones", async () => { await live.environment.caches.open("ekklesia-display-shell-v0"); await live.environment.caches.open("some-other-application-cache"); await live.environment.caches.open(DISPLAY_SHELL_CACHE); const removed = await pruneSupersededCaches(live.environment); expect(removed).toEqual(["ekklesia-display-shell-v0"]); expect([...live.caches.keys()]).toEqual(["some-other-application-cache", DISPLAY_SHELL_CACHE]); }); }); describe("reconnecting to current server code", () => { it("prefers the network on every request, so a screen is never trapped on obsolete code", async () => { await visit(artifact("1.0.0")); live.served.set(artifact("1.0.0"), () => code("export default { rebuilt: true };")); const afterRebuild = await visit(artifact("1.0.0")); // Network first, always: the newer bytes win, and they replace the stored copy. expect(await afterRebuild?.text()).toBe("export default { rebuilt: true };"); expect( await live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.get(artifact("1.0.0"))?.text(), ).toBe("export default { rebuilt: true };"); }); it("reports that it is no longer falling back once the network returns", async () => { await visit(script); live.online = false; await visit(script); live.online = true; live.statuses.length = 0; await visit(script); expect(live.statuses).toEqual([{ usingFallback: false }]); }); }); describe("what the cache can never do", () => { it("cannot answer for the descriptor, so a revoked device stays revoked when online", async () => { const descriptor = `${origin}/api/v1/module-displays/signage`; live.served.set(descriptor, () => Response.json({ module: {} }, { status: 200 })); await visit(descriptor); // It was never stored while online... expect(live.caches.size).toBe(0); // ...so once the server revokes the device, the refusal is what the display sees. There is no // cached copy of the previous success anywhere to fall back to. live.served.set(descriptor, () => Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }), ); expect(await visit(descriptor)).toBeUndefined(); }); it("stores no credential, permission decision, or live state even after many visits", async () => { for (const url of [ `${origin}/api/v1/principal`, `${origin}/api/v1/module-displays/signage`, `${origin}/api/v1/modules/signage/slides`, `${origin}/api/v1/module-realtime/signage/state`, `${origin}/api/auth/sign-out`, ]) { live.served.set(url, () => Response.json({ secret: "do-not-store" })); await visit(url); await visit(url); } const stored = [...live.caches.values()].flatMap((cache) => [...cache.entries.keys()]); expect(stored).toEqual([]); }); it("mounts nothing on its own: it only ever answers a request the page made", async () => { // The worker has no notion of a display, a module, or a version — it classifies URLs. Every // decision about *what* to mount stays with the page, which resolves a fresh descriptor first. const fetchSpy = vi.fn(live.environment.fetch); const environment: DisplayOfflineEnvironment = { ...live.environment, fetch: fetchSpy }; await pruneSupersededCaches(environment); expect(fetchSpy).not.toHaveBeenCalled(); }); });