// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type OfflineSnapshot, resolveOffline, serverNowFrom, snapshotFrom, } from "../../src/browser/offline.js"; import type { SceneSelection } from "../../src/browser/scene-request.js"; import { createBrowserMediaCache, createMemoryMediaCache, createMemoryStore, signageMediaKey, } from "../../src/browser/storage.js"; import { createDisplayController, type DisplayChannelStateView, } from "../../src/display/controller.js"; import type { DisplaySelection, ViewerSessionResponse } from "../../src/shared/display-protocol.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js"; /** * Offline continuation: what a screen does when its building's uplink dies. * * Every case here is one an unattended television actually meets — a router reboot during a service, * a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is * always the same one: the display continues *deterministically from the last authoritative anchor*, * replaying only what the server already resolved, and never becomes a second authority. */ const SCENE = { backgroundColor: "#000000", backgroundImage: false, backgroundMode: "none" as const, blurPixels: 0, darkenOpacity: 0, fit: "fill" as const, shadow: false, }; function selection(overrides: Partial & { id: string }): SceneSelection { return { availableUntilMs: null, deadlineAtMs: null, durationMs: 5000, itemId: `item-${overrides.id}`, itemRevision: "rev-1", payload: { kind: "native-image" }, presentation: null, providerId: "signage.native-images", renderer: { kind: "native-image" }, revision: 1, scene: SCENE, slotId: null, startedAtMs: null, withdrawn: false, ...overrides, }; } function slot(id: string, position: number, overrides: Record = {}) { return { eligibleFromMs: null, eligibleUntilMs: null, enabled: true, estimatedDurationMs: 5000, id, position, ...overrides, }; } function snapshot(overrides: Partial = {}): OfflineSnapshot { return snapshotFrom({ channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, localNowMs: 1_000_000, playback: { blackout: false, currentSelectionId: "sel-a", currentSlotId: "slot-a", deadlineAtMs: 5000, pendingTimelineId: null, remainingMs: 5000, revision: 3, startedAtMs: 0, transport: "playing", ...overrides.playback, }, selections: overrides.selections ?? [ selection({ id: "sel-c", slotId: "slot-c" }), selection({ id: "sel-b", slotId: "slot-b" }), selection({ id: "sel-a", slotId: "slot-a" }), ], serverNowMs: 0, staticHold: overrides.staticHold ?? null, timeline: overrides.timeline === undefined ? { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 2, slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)], } : overrides.timeline, }); } describe("offline continuation", () => { it("reconstructs the server's clock, however wrong the device's is", () => { const persisted = snapshot(); // The device's clock jumped forward two hours while the screen was off. The anchor is what makes // the evaluation right anyway: only the *elapsed* local time matters. expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000); expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000); }); it("continues to the next slot at the deadline, using what the server already resolved", () => { const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() }); expect(evaluation.resolution).toMatchObject({ kind: "selection", startedAtMs: 5000, }); if (evaluation.resolution.kind !== "selection") { throw new Error("Expected a selection."); } // The next slot in the pattern, rendered from its own cached answer. Nothing was invented. expect(evaluation.resolution.selection.id).toBe("sel-b"); expect(evaluation.nextBoundaryMs).toBe(10_000); }); it("catches up a week in one evaluation rather than replaying every slide", () => { const week = 7 * 24 * 60 * 60 * 1000; const started = Date.now(); const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() }); expect(evaluation.resolution.kind).toBe("selection"); if (evaluation.resolution.kind !== "selection") { throw new Error("Expected a selection."); } // Landed inside the current slot, on the server's own phase — and the arithmetic took no // measurable time, because it is arithmetic. expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week); expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000); expect(Date.now() - started).toBeLessThan(200); }); it("stays exactly where it was while paused, with its remainder preserved", () => { const paused = snapshot({ playback: { blackout: false, currentSelectionId: "sel-a", currentSlotId: "slot-a", deadlineAtMs: null, pendingTimelineId: null, remainingMs: 3000, revision: 4, startedAtMs: null, transport: "paused", }, }); const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused }); // Ten minutes later, on the same slide. Frozen means frozen, offline as much as online. expect(evaluation.resolution).toMatchObject({ kind: "paused" }); if (evaluation.resolution.kind !== "paused") { throw new Error("Expected a paused resolution."); } expect(evaluation.resolution.selection?.id).toBe("sel-a"); expect(paused.playback.remainingMs).toBe(3000); }); it("keeps a static hold held, and does not let local age release it", () => { const held = snapshot({ staticHold: selection({ id: "static-1" }) }); const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held }); expect(evaluation.resolution).toMatchObject({ kind: "static" }); }); it("releases a static hold only when the held image genuinely expired", () => { const held = snapshot({ staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }), }); const before = resolveOffline({ atMs: 9000, snapshot: held }); const after = resolveOffline({ atMs: 11_000, snapshot: held }); expect(before.resolution.kind).toBe("static"); // An explicit expiry the server told it about — never a guess made locally. expect(after.resolution.kind).toBe("selection"); }); it("skips content the server said was withdrawn or expired", () => { const persisted = snapshot({ selections: [ selection({ id: "sel-c", slotId: "slot-c" }), selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }), selection({ id: "sel-a", slotId: "slot-a" }), ], }); const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); // Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an // empty provider slot — no gap, no error, and certainly no fabricated replacement. expect(evaluation.resolution).toMatchObject({ kind: "selection" }); if (evaluation.resolution.kind !== "selection") { throw new Error("Expected a selection."); } expect(evaluation.resolution.selection.id).toBe("sel-c"); }); it("holds the last good frame rather than continuing into a timeline it never received", () => { const pending = snapshot({ playback: { blackout: false, currentSelectionId: "sel-a", currentSlotId: "slot-a", deadlineAtMs: 5000, pendingTimelineId: "timeline-2", remainingMs: 0, revision: 5, startedAtMs: 0, transport: "playing", }, }); const evaluation = resolveOffline({ atMs: 6000, snapshot: pending }); // Going deeper into the old timeline would show content the operator has already replaced. expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" }); }); it("holds when no slot has a cached answer, rather than inventing one", () => { const persisted = snapshot({ selections: [] }); const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); expect(evaluation.resolution).toMatchObject({ kind: "hold" }); }); it("goes black only when the cached timeline genuinely has nothing eligible", () => { const persisted = snapshot({ timeline: { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 2, slots: [slot("slot-a", 0, { enabled: false })], }, }); const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); expect(evaluation.resolution).toEqual({ kind: "empty" }); }); it("persists no delivery URL, because a stored one is a credential that stops working", () => { const persisted = snapshotFrom({ channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, localNowMs: 0, playback: { blackout: false, currentSelectionId: "sel-a", currentSlotId: "slot-a", deadlineAtMs: 5000, pendingTimelineId: null, remainingMs: 5000, revision: 1, startedAtMs: 0, transport: "playing", }, selections: [ { ...selection({ id: "sel-a", slotId: "slot-a" }), media: { display: { url: "https://cdn.invalid/a?sig=secret" }, thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" }, }, }, ], serverNowMs: 0, staticHold: null, timeline: null, }); expect(JSON.stringify(persisted)).not.toContain("sig=secret"); expect(JSON.stringify(persisted)).not.toContain("https://"); }); }); describe("a display that loses its network", () => { let root: HTMLElement; beforeEach(() => { root = document.createElement("div"); document.body.append(root); }); async function settle(): Promise { for (let index = 0; index < 12; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } /** A native selection exactly as the server sends one in channel state. */ function nativeSelection(id: string, slotId: string): DisplaySelection { return { ...selection({ id, slotId }), media: { display: { expiresAtMs: 900_000, height: 1080, url: `https://cdn.invalid/${id}?sig=a`, width: 1920, }, thumbnail: { expiresAtMs: 900_000, height: 270, url: `https://cdn.invalid/${id}-t?sig=a`, width: 480, }, }, stale: false, }; } function state(currentId: string): DisplayChannelStateView { const selections = [ nativeSelection("sel-c", "slot-c"), nativeSelection("sel-b", "slot-b"), nativeSelection("sel-a", "slot-a"), ]; const current = selections.find((candidate) => candidate.id === currentId); return { channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, current: current ?? null, playback: { activeTimelineId: "timeline-1", blackout: false, currentSelectionId: currentId, currentSlotId: current?.slotId ?? null, deadlineAtMs: 5000, pendingTimelineId: null, remainingMs: 5000, revision: 3, selectionRevision: 1, startedAtMs: 0, transport: "playing", }, selections, serverNowMs: 0, staticHold: null, timeline: { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 2, slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)], }, }; } it("keeps rendering from cached bytes and cached answers when the server goes away", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); let reachable = true; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse({ channelId: "chan-1", channelKey: "default", groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", viewerName: "Foyer", } satisfies ViewerSessionResponse), }, { method: "GET", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => { if (!reachable) { throw new Error("network is gone"); } return jsonResponse(state("sel-a")); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, ], }); let clock = 1_000_000; // The armed timer is captured rather than run, so the test decides when the safety-net refresh // and the offline evaluation happen — no real time passes. let armed: (() => void) | undefined; const controller = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media, newViewerKey: () => "viewer-fixed-key-0001", now: () => clock, root, setTimer: (handler) => { armed = handler; return handler; }, store, }); await controller.start(); await settle(); expect(controller.diagnostics().renderedSelectionId).toBe("sel-a"); expect(controller.diagnostics().offline).toBe(false); expect(controller.diagnostics().offlineReady).toBe(true); // The uplink dies, and the current slot's deadline passes. reachable = false; clock += 6000; armed?.(); await settle(); const diagnostics = controller.diagnostics(); // Local continuation took over: the next slot in the pattern, from bytes and answers this screen // already had. Nothing was invented, and the slate never appeared. expect(diagnostics.offline).toBe(true); expect(diagnostics.renderedSelectionId).toBe("sel-b"); expect(root.dataset.slate).toBe("false"); await controller.stop(); }); it("puts the last known good frame up before the first request returns", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); await media.store( signageMediaKey({ itemId: "item-sel-a", itemRevision: "rev-1", providerId: "signage.native-images", rendition: "display", }), "https://cdn.invalid/sel-a?sig=a", ); await store.write("last-known-good", snapshot()); let requested = 0; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => { requested += 1; throw new Error("network is gone"); }, }, ], }); const controller = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media, now: () => 1_000_000, root, setTimer: () => undefined, store, }); await controller.start(); await settle(); // A frame is up, from persisted state, with no server involved — and the slate never appeared. expect(controller.diagnostics().renderedSelectionId).toBe("sel-a"); expect(root.dataset.slate).toBe("false"); expect(requested).toBe(1); await controller.stop(); }); it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); await store.write("last-known-good", snapshot()); for (const id of ["sel-a", "sel-b", "sel-c"]) { await media.store( signageMediaKey({ itemId: `item-${id}`, itemRevision: "rev-1", providerId: "signage.native-images", rendition: "display", }), `https://cdn.invalid/${id}?sig=a`, ); } const host = createFakeHost({ permissions: [], routes: [] }); const controller = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media, now: () => 1_000_000, root, setTimer: () => undefined, store, telemetry: false, }); await controller.start(); await settle(); const offlineFrame = controller.diagnostics().renderedSelectionId; expect(offlineFrame).toBe("sel-a"); expect(controller.diagnostics().offline).toBe(true); // The server comes back and says something different. It wins immediately. await controller.apply(state("sel-c")); await settle(); const diagnostics = controller.diagnostics(); expect(diagnostics.renderedSelectionId).toBe("sel-c"); expect(diagnostics.offline).toBe(false); // Straight to what the server says. Nothing in between was replayed, and the slate never showed. expect(root.dataset.slate).toBe("false"); await controller.stop(); }); it("prunes superseded bytes only after the new state is usable", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); const pruned: readonly string[][] = []; const order: string[] = []; const wrapped = { ...media, prune: (keep: readonly string[]) => { order.push("prune"); (pruned as string[][]).push([...keep]); return media.prune(keep); }, store: (key: string, url: string) => { order.push(`store:${key}`); return media.store(key, url); }, }; const host = createFakeHost({ permissions: [], routes: [] }); const controller = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media: wrapped, now: () => 1_000_000, root, setTimer: () => undefined, store, telemetry: false, }); await controller.start(); await controller.apply(state("sel-a")); await settle(); // Every store happens before the prune: a screen never has less than it had. expect(order.at(-1)).toBe("prune"); expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3); await controller.stop(); }); }); /** * Stage 0's real kiosk: two native images cycling, then the laptop's network switched off for a * minute. The offline note appeared, but the wall stayed on one image until the network returned. * * The page was served over plain `http://` on a LAN address, which a browser does not treat as a * secure context, so there was no Cache Storage. The media cache's fallback then remembered only each * slide's signed delivery URL. Offline, the next slide's URL could not be fetched, the slide was * marked unusable, and local continuation kept landing on the one image already on screen. * * This runs the whole controller on a clock that only moves when the test says so, across several * slide intervals, with every timer it arms honoured in order. */ describe("a running display that loses its network for several slides", () => { const SLOT_MS = 5000; const realCreate = URL.createObjectURL; const realRevoke = URL.revokeObjectURL; let root: HTMLElement; beforeEach(() => { root = document.createElement("div"); document.body.append(root); let next = 0; URL.createObjectURL = () => { next += 1; return `blob:display/${next}`; }; URL.revokeObjectURL = () => undefined; }); afterEach(() => { URL.createObjectURL = realCreate; URL.revokeObjectURL = realRevoke; }); async function settle(): Promise { for (let index = 0; index < 12; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } /** A clock and timer queue the controller and its stage both run on. */ function createClock(startMs: number) { let nowMs = startMs; let nextHandle = 0; const timers = new Map void }>(); return { clearTimer: (handle: unknown) => { timers.delete(handle as number); }, now: () => nowMs, setTimer: (handler: () => void, delayMs: number) => { nextHandle += 1; timers.set(nextHandle, { atMs: nowMs + Math.max(0, delayMs), handler }); return nextHandle; }, /** Moves time forward, running every timer that falls due on the way, in order. */ async advance(ms: number): Promise { const targetMs = nowMs + ms; for (;;) { let due: [number, { readonly atMs: number; readonly handler: () => void }] | undefined; for (const entry of timers) { if (entry[1].atMs <= targetMs && (!due || entry[1].atMs < due[1].atMs)) { due = entry; } } if (!due) { break; } timers.delete(due[0]); nowMs = due[1].atMs; due[1].handler(); await settle(); } nowMs = targetMs; await settle(); }, }; } /** Which of the two images a selection is. */ function slideOf(selectionId: string | undefined): string | undefined { return selectionId?.split("-")[1]; } /** The server's answer at `atMs`: two images alternating, each play its own selection, and a * freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */ function serverState(atMs: number): DisplayChannelStateView { const cycle = Math.floor(atMs / SLOT_MS); const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2] .filter((c) => c >= 0) .map((c) => ({ ...selection({ id: `sel-${slide(c)}-${c}`, itemId: `item-${slide(c)}`, slotId: `slot-${slide(c)}`, }), media: { display: { expiresAtMs: atMs + 900_000, height: 1080, url: `http://files.example.test/${slide(c)}?sig=${atMs}`, width: 1920, }, thumbnail: { expiresAtMs: atMs + 900_000, height: 270, url: `http://files.example.test/${slide(c)}-t?sig=${atMs}`, width: 480, }, }, stale: false, })); const current = history[0] as DisplaySelection; const startedAtMs = cycle * SLOT_MS; return { channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, current, playback: { activeTimelineId: "timeline-1", blackout: false, currentSelectionId: current.id, currentSlotId: current.slotId, deadlineAtMs: startedAtMs + SLOT_MS, pendingTimelineId: null, remainingMs: startedAtMs + SLOT_MS - atMs, revision: cycle, selectionRevision: cycle, startedAtMs, transport: "playing", }, selections: history, serverNowMs: atMs, staticHold: null, timeline: { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 1, slots: [ slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), ], }, }; } it("keeps advancing through its cached images on an origin with no Cache Storage", async () => { const network = { reachable: true }; // Mid-slide, so every sample below lands halfway through one. const clock = createClock(12_500); const unreachable = () => new TypeError("Failed to fetch"); const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse({ channelId: "chan-1", channelKey: "default", groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", viewerName: "Foyer", } satisfies ViewerSessionResponse), }, { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, respond: () => { if (!network.reachable) { throw unreachable(); } return jsonResponse(serverState(clock.now())); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => { if (!network.reachable) { throw unreachable(); } return jsonResponse({}); }, }, ], }); // A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else. const media = createBrowserMediaCache({ fetch: async () => { if (!network.reachable) { throw unreachable(); } return new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" }, }); }, location: { origin: "http://192.168.1.20:3000" }, } as unknown as typeof globalThis); const controller = createDisplayController({ clearTimer: clock.clearTimer, context: host.context, // What a browser does: bytes this page holds always decode, and a remote URL needs a network. decodeImage: (image) => (image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable ? Promise.resolve() : Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")), media, newViewerKey: () => "viewer-fixed-key-0001", now: clock.now, root, setTimer: clock.setTimer, store: createMemoryStore(), }); await controller.start(); await settle(); // Online for a full cycle, so both images have been shown and fetched. const online: (string | undefined)[] = [slideOf(controller.diagnostics().renderedSelectionId)]; for (let interval = 0; interval < 2; interval += 1) { await clock.advance(SLOT_MS); online.push(slideOf(controller.diagnostics().renderedSelectionId)); } expect(online).toEqual(["a", "b", "a"]); expect(controller.diagnostics().offline).toBe(false); // The network goes away for six slides. network.reachable = false; const offline: (string | undefined)[] = []; for (let interval = 0; interval < 6; interval += 1) { await clock.advance(SLOT_MS); offline.push(slideOf(controller.diagnostics().renderedSelectionId)); expect(controller.diagnostics().offline).toBe(true); } // Still cycling, on the server's own phase, from bytes the page already held. expect(offline).toEqual(["b", "a", "b", "a", "b", "a"]); expect(controller.diagnostics().unusableSelectionIds).toEqual([]); expect(root.dataset.slate).toBe("false"); // The network returns. The server is authoritative again with no reload. network.reachable = true; await clock.advance(70_000); const diagnostics = controller.diagnostics(); expect(diagnostics.offline).toBe(false); expect(diagnostics.renderedSelectionId).toMatch(/^sel-[ab]-\d+$/u); expect(Number(diagnostics.renderedSelectionId?.split("-")[2])).toBeGreaterThan(10); await controller.stop(); }); });