diff --git a/tests/browser/display-recovery.test.ts b/tests/browser/display-recovery.test.ts new file mode 100644 index 0000000..cdef776 --- /dev/null +++ b/tests/browser/display-recovery.test.ts @@ -0,0 +1,306 @@ +// @vitest-environment happy-dom +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createMemoryMediaCache, + createMemoryStore, + type SignageLocalStore, +} from "../../src/browser/storage.js"; +import { + createDisplayController, + type DisplayChannelStateView, +} from "../../src/display/controller.js"; +import { createFakeHost, type FakeHost, jsonResponse } from "./support/fake-host.js"; + +/** + * A display never reaches a dead end. + * + * Every network path a display depends on — registering, fetching state, realtime — can fail once, + * as a kiosk that boots before its network settles or an API that restarts will make it. Each must + * be tried again, with backoff, until it works, and the display must then be exactly as live as if + * nothing had happened. None of it may need a person to reload a television. + * + * Time is virtual: every timer the controller schedules goes through the queue below. + */ + +const SESSION = { + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", + viewerName: "Foyer", +}; + +function selection(id: string) { + return { + availableUntilMs: null, + deadlineAtMs: 5000, + durationMs: 5000, + id, + itemId: `image-${id}`, + itemRevision: "rev-1", + media: { + display: { + expiresAtMs: 900_000, + height: 1080, + url: `https://cdn.invalid/${id}`, + width: 1920, + }, + thumbnail: { + expiresAtMs: 900_000, + height: 270, + url: `https://cdn.invalid/${id}-t`, + width: 480, + }, + }, + payload: { kind: "native-image" }, + presentation: null, + providerId: "signage.native-images", + renderer: { kind: "native-image" as const }, + revision: 1, + scene: { + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none" as const, + blurPixels: 0, + darkenOpacity: 0, + fit: "fill" as const, + shadow: false, + }, + slotId: "slot-1", + stale: false, + startedAtMs: 0, + withdrawn: false, + }; +} + +function channelState(revision = 3): DisplayChannelStateView { + const current = selection("sel-1"); + 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: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision, + selectionRevision: 1, + startedAtMs: 0, + transport: "playing", + }, + selections: [current], + serverNowMs: 0, + staticHold: null, + timeline: null, + }; +} + +/** Server behaviour that a test can change mid-run. */ +interface Server { + sessionFailures: number; + stateFailing: boolean; + sessionRequests: number; + stateRequests: number; +} + +describe("a display recovers from every failed request without a reload", () => { + let clock: number; + let timers: Map void }>; + let nextTimer: number; + let root: HTMLElement; + let local: SignageLocalStore; + let server: Server; + let host: FakeHost; + let subscriptions: { onStatus?: (status: string) => void }[]; + + beforeEach(() => { + clock = 0; + timers = new Map(); + nextTimer = 1; + root = document.createElement("div"); + document.body.append(root); + local = createMemoryStore(); + server = { sessionFailures: 0, sessionRequests: 0, stateFailing: false, stateRequests: 0 }; + subscriptions = []; + host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + server.sessionRequests += 1; + if (server.sessionFailures > 0) { + server.sessionFailures -= 1; + throw new TypeError("Failed to fetch"); + } + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => { + server.stateRequests += 1; + if (server.stateFailing) { + throw new TypeError("Failed to fetch"); + } + return jsonResponse(channelState()); + }, + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + Object.assign(host.context, { + realtime: { + subscribe: (_channel: string, handlers: { onStatus?: (status: string) => void }) => { + subscriptions.push(handlers); + return { close: () => undefined }; + }, + } satisfies Pick as never, + }); + }); + + async function settle(): Promise { + for (let index = 0; index < 20; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + /** Moves virtual time forward, firing every timer that falls due, in order. */ + async function advance(ms: number): Promise { + const end = clock + ms; + for (;;) { + const due = [...timers] + .filter(([, timer]) => timer.at <= end) + .sort((left, right) => left[1].at - right[1].at)[0]; + if (!due) { + break; + } + timers.delete(due[0]); + clock = due[1].at; + due[1].run(); + await settle(); + } + clock = end; + } + + function build() { + return createDisplayController({ + clearTimer: (handle) => timers.delete(handle as number), + context: host.context, + decodeImage: () => Promise.resolve(), + media: createMemoryMediaCache(), + newViewerKey: () => "viewer-fixed-key-0001", + now: () => clock, + root, + setTimer: (run, delayMs) => { + const id = nextTimer; + nextTimer += 1; + timers.set(id, { at: clock + delayMs, run }); + return id; + }, + store: local, + }); + } + + /** One ordinary live run, so the display has a persisted snapshot to continue from, exactly as a + * screen that has been on the wall for a while does. */ + async function runOnceLive(): Promise { + const display = build(); + await display.start(); + await settle(); + expect(root.dataset.offline).toBe("false"); + await display.stop(); + timers.clear(); + } + + it("registers once the server answers, after its first registration failed", async () => { + await runOnceLive(); + server.sessionFailures = 1; + const sessionsBefore = server.sessionRequests; + + const display = build(); + await display.start(); + await settle(); + // Continuing from its own state while it cannot register: the honest banner. + expect(root.dataset.offline).toBe("true"); + + await advance(60_000); + + expect(server.sessionRequests - sessionsBefore).toBeGreaterThanOrEqual(2); + expect(root.dataset.offline).toBe("false"); + expect(subscriptions.length).toBeGreaterThanOrEqual(2); + await display.stop(); + }); + + it("keeps retrying registration with backoff, and never gives up", async () => { + await runOnceLive(); + server.sessionFailures = 8; + const sessionsBefore = server.sessionRequests; + + const display = build(); + await display.start(); + await settle(); + // An hour of virtual time: enough for every backoff step, and the cap, to have been reached. + await advance(60 * 60_000); + + expect(server.sessionRequests - sessionsBefore).toBe(9); + expect(root.dataset.offline).toBe("false"); + await display.stop(); + }); + + it("retries a failed state refresh until the server answers, with no realtime nudge", async () => { + const display = build(); + await display.start(); + await settle(); + expect(root.dataset.offline).toBe("false"); + + // The slot's deadline refresh fails, as it would mid-restart. Realtime says nothing. + server.stateFailing = true; + await advance(6_000); + expect(root.dataset.offline).toBe("true"); + const failedRequests = server.stateRequests; + + server.stateFailing = false; + await advance(120_000); + + expect(server.stateRequests).toBeGreaterThan(failedRequests); + expect(root.dataset.offline).toBe("false"); + await display.stop(); + }); + + it("subscribes again when the host reports its realtime stream closed for good", async () => { + const display = build(); + await display.start(); + await settle(); + expect(subscriptions).toHaveLength(1); + + subscriptions[0]?.onStatus?.("closed"); + await settle(); + await advance(60_000); + + expect(subscriptions.length).toBeGreaterThanOrEqual(2); + await display.stop(); + }); + + it("schedules nothing after it is stopped", async () => { + await runOnceLive(); + server.sessionFailures = 3; + const display = build(); + await display.start(); + await settle(); + await display.stop(); + const sessionsAtStop = server.sessionRequests; + + await advance(60 * 60_000); + + expect(server.sessionRequests).toBe(sessionsAtStop); + }); +});