// @vitest-environment happy-dom import { 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 { 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: "channels/chan-1/display-state", 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(); }); });