// @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://"); }); /** * Stage 0's real timeline: **one** provider-source slot, which the server answers with the next * image of the provider's rotation on every play. Offline, replaying only that slot's newest answer * meant replaying the image already on screen — every boundary "advanced" to the same picture. */ function rotatingSnapshot(newestFirstItems: readonly string[]): OfflineSnapshot { const selections = newestFirstItems.map((item, index) => selection({ id: `sel-${index}`, itemId: `item-${item}`, slotId: "slot-source" }), ); return snapshot({ playback: { blackout: false, currentSelectionId: "sel-0", currentSlotId: "slot-source", deadlineAtMs: 5000, pendingTimelineId: null, remainingMs: 5000, revision: 3, startedAtMs: 0, transport: "playing", }, selections, timeline: { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 2, slots: [slot("slot-source", 0)], }, }); } function itemAt(persisted: OfflineSnapshot, atMs: number): string | undefined { const { resolution } = resolveOffline({ atMs, snapshot: persisted }); return resolution.kind === "selection" ? resolution.selection.itemId : undefined; } it("continues a source slot's rotation through the answers it already has", () => { // On screen: B. Before it, A, then B, then A — the provider's two-image rotation. const persisted = rotatingSnapshot(["b", "a", "b", "a"]); expect(itemAt(persisted, 2500)).toBe("item-b"); expect([7500, 12_500, 17_500, 22_500, 27_500].map((at) => itemAt(persisted, at))).toEqual([ "item-a", "item-b", "item-a", "item-b", "item-a", ]); }); it("keeps the provider's own order across a longer rotation", () => { // Played C, A, B, C, A, B — B is on screen, so C comes next, then A, then B. const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); expect([7500, 12_500, 17_500, 22_500].map((at) => itemAt(persisted, at))).toEqual([ "item-c", "item-a", "item-b", "item-c", ]); }); it("catches a rotation up across a week in one evaluation", () => { const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); // Mid-way through the slot's 120,961st play since the anchor. const week = 7 * 24 * 60 * 60 * 1000 + 7500; const started = Date.now(); // A three-image rotation that resumes at C shows C on every play numbered 1 mod 3. expect(itemAt(persisted, week)).toBe("item-c"); expect(itemAt(persisted, week + 5000)).toBe("item-a"); expect(Date.now() - started).toBeLessThan(200); }); it("skips an image it cannot render and keeps rotating through the rest", () => { const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); const unusable = new Set(["sel-1"]); const items = [7500, 12_500, 17_500].map((atMs) => { const { resolution } = resolveOffline({ atMs, snapshot: persisted, unusableSelectionIds: unusable, }); return resolution.kind === "selection" ? resolution.selection.itemId : undefined; }); // A's newest answer is unusable, so A is not shown — and no older answer of A is dug up instead. expect(items).toEqual(["item-c", "item-b", "item-c"]); }); }); 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 two timeline shapes a pair of alternating images can take. Two pinned slots, one image * each; or Stage 0's real one — a single provider-source slot the server answers with the next * image of the rotation on every play. */ type TimelineShape = "two pinned slots" | "one provider-source slot"; /** 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, shape: TimelineShape): DisplayChannelStateView { const cycle = Math.floor(atMs / SLOT_MS); const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); const slotFor = (c: number) => shape === "two pinned slots" ? `slot-${slide(c)}` : "slot-source"; 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: slotFor(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: shape === "two pinned slots" ? [ slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), ] : [slot("slot-source", 0, { estimatedDurationMs: SLOT_MS })], }, }; } it.each([ // Wi-Fi off on a plain-http LAN origin: no API, no storage, and no Cache Storage. { outage: "network", shape: "two pinned slots" }, { outage: "network", shape: "one provider-source slot" }, // The API process stopped while the storage server stays up. { outage: "api", shape: "one provider-source slot" }, ] as const)("keeps cycling through its cached images with the $outage down: $shape", async ({ outage, shape, }) => { const api = { reachable: true }; const storage = { 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 (!api.reachable) { throw unreachable(); } return jsonResponse(serverState(clock.now(), shape)); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => { if (!api.reachable) { throw unreachable(); } return jsonResponse({}); }, }, ], }); // A plain-http LAN origin: no `caches`, but `fetch` — which needs the storage server. const media = createBrowserMediaCache({ fetch: async () => { if (!storage.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 storage. decodeImage: (image) => (image.getAttribute("src") ?? "").startsWith("blob:") || storage.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 outage lasts six slides. api.reachable = false; storage.reachable = outage !== "network"; 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"); // Everything returns. The server is authoritative again with no reload. api.reachable = true; storage.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(); }); });