// @vitest-environment happy-dom import { beforeEach, describe, expect, it } from "vitest"; import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; import { createDisplayController, type DisplayChannelStateView, type DisplayController, } from "../../src/display/controller.js"; import type { ViewerSessionResponse } from "../../src/shared/display-protocol.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js"; /** * The display, driven end to end against a host: register, fetch state, render, report. * * Every collaborator is injected, so what these prove is the display's own decisions — which frame * it keeps when something fails, what it caches and under what key, what it tells the server, and * what it never puts on a wall. */ const NATIVE_SCENE = { backgroundColor: "#000000", backgroundImage: false, backgroundMode: "none" as const, blurPixels: 0, darkenOpacity: 0, fit: "fill" as const, shadow: false, }; function nativeSelection(id: string, itemRevision = "rev-1") { return { availableUntilMs: null as number | null, deadlineAtMs: 5000, durationMs: 5000, id, itemId: `image-${id}`, itemRevision, 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, }, }, payload: { kind: "native-image" }, presentation: null, providerId: "signage.native-images", renderer: { kind: "native-image" as const }, revision: 1, scene: NATIVE_SCENE, slotId: "slot-1", stale: false, startedAtMs: 0, withdrawn: false, }; } function contributionSelection(id: string, revision = 1) { return { availableUntilMs: null, deadlineAtMs: 5000, durationMs: 5000, id, itemId: "advent", itemRevision: String(revision), payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" }, presentation: null, providerId: "fixture.announcements", renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0", kind: "contribution" as const, }, revision, slotId: "slot-1", stale: false, startedAtMs: 0, withdrawn: false, }; } function state(overrides: Partial = {}): DisplayChannelStateView { // `??` would fall through on an explicit `null`, which is exactly the case these tests care about. const current = "current" in overrides ? overrides.current : nativeSelection("sel-1"); return { channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, current, playback: { activeTimelineId: "timeline-1" as string | null, blackout: false, currentSelectionId: current?.id ?? null, currentSlotId: current?.slotId ?? null, deadlineAtMs: 5000 as number | null, pendingTimelineId: null as string | null, remainingMs: 5000 as number | null, revision: 3, selectionRevision: current?.revision ?? null, startedAtMs: 0 as number | null, transport: "playing", ...overrides.playback, }, selections: overrides.selections ?? (current ? [current] : []), serverNowMs: 1000, staticHold: overrides.staticHold ?? null, timeline: overrides.timeline ?? null, ...(overrides.channel ? { channel: overrides.channel } : {}), }; } /** Typed as the shared protocol message, so a stub cannot drift from what the server sends. */ const SESSION: ViewerSessionResponse = { channelId: "chan-1", channelKey: "default", groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", viewerName: "Foyer", }; describe("display controller", () => { let root: HTMLElement; beforeEach(() => { root = document.createElement("div"); document.body.append(root); }); async function settle(): Promise { for (let index = 0; index < 10; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } function build(options: { readonly host: ReturnType; readonly media?: ReturnType; readonly store?: ReturnType; readonly telemetry?: boolean; }): DisplayController { return createDisplayController({ context: options.host.context, decodeImage: () => Promise.resolve(), media: options.media ?? createMemoryMediaCache(), newViewerKey: () => "viewer-fixed-key-0001", root, // No real timers: the refresh schedule is exercised by calling `apply` directly. setTimer: () => undefined, store: options.store ?? createMemoryStore(), ...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }), }); } it("registers with a persistent identity and keeps it across restarts", async () => { const store = createMemoryStore(); const keys: unknown[] = []; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: ({ init }) => { keys.push(JSON.parse(String(init?.body))); return jsonResponse(SESSION); }, }, { method: "GET", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, ], }); const first = build({ host, store }); await first.start(); await settle(); await first.stop(); const second = build({ host, store }); await second.start(); await settle(); await second.stop(); // The same key both times: a television that reboots is the same screen, not a new one. expect(keys).toEqual([ { viewerKey: "viewer-fixed-key-0001" }, { viewerKey: "viewer-fixed-key-0001" }, ]); }); it("renders the server's current selection and reports what it is showing", async () => { const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, ], }); const controller = build({ host }); await controller.start(); await settle(); const diagnostics = controller.diagnostics(); expect(diagnostics.renderedSelectionId).toBe("sel-1"); expect(diagnostics.slate).toBe(false); expect(root.querySelectorAll(".signage-layer")).toHaveLength(2); await controller.stop(); }); it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => { const media = createMemoryMediaCache(); const stored: string[] = []; const wrapped = { ...media, store: (key: string, url: string) => { stored.push(key); return media.store(key, url); }, }; const host = createFakeHost({ permissions: [], routes: [] }); const controller = build({ host, media: wrapped, telemetry: false }); await controller.start(); await controller.apply(state()); // The same bytes, a new signature — exactly what the next refresh carries. const resigned = nativeSelection("sel-1"); Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" }); await controller.apply(state({ current: resigned })); await settle(); // One store, because the key is built from provider, item, and item revision — never from the // URL, which changes every time. expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]); await controller.stop(); }); it("updates a mounted renderer in place when only the payload changed", async () => { const mounts: unknown[] = []; const updates: unknown[] = []; const host = createFakeHost({ contributions: [ { capability: "signage.slide-renderer", id: "fixture.announcements.slide", version: "1.0.0", }, ], mount: (options) => { mounts.push(options.contributionId); return { contributionId: options.contributionId, mounted: true, unmount: () => Promise.resolve(), update: (input) => { updates.push(input); return Promise.resolve(); }, version: "1.0.0", }; }, permissions: [], routes: [], }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state({ current: contributionSelection("sel-1", 1) })); await controller.apply(state({ current: contributionSelection("sel-1", 2) })); await settle(); // One mount, one update: a cancelled event changes the slide without restarting its slot. expect(mounts).toEqual(["fixture.announcements.slide"]); expect(updates).toHaveLength(1); await controller.stop(); }); it("keeps the last good frame and records a code when a renderer is not published", async () => { const host = createFakeHost({ contributions: [], permissions: [], routes: [] }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state()); const good = controller.diagnostics().renderedSelectionId; await controller.apply(state({ current: contributionSelection("sel-2", 1) })); await settle(); const diagnostics = controller.diagnostics(); // Nothing changed on screen, and the reason is a bounded code — never a message on a wall. expect(diagnostics.renderedSelectionId).toBe(good); expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE"); expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]); await controller.stop(); }); it("does not retry a selection it already knows it cannot render", async () => { let mountAttempts = 0; const host = createFakeHost({ contributions: [ { capability: "signage.slide-renderer", id: "fixture.announcements.slide", version: "1.0.0", }, ], mount: () => { mountAttempts += 1; return Promise.reject(new Error("renderer exploded")); }, permissions: [], routes: [], }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state({ current: contributionSelection("sel-2", 1) })); await controller.apply(state({ current: contributionSelection("sel-2", 1) })); await controller.apply(state({ current: contributionSelection("sel-2", 1) })); await settle(); // One attempt. A broken item never becomes a tight retry loop on an unattended screen. expect(mountAttempts).toBe(1); await controller.stop(); }); it("shows the slate only when it has nothing at all, and never over a good frame", async () => { const host = createFakeHost({ permissions: [], routes: [] }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state({ current: null, selections: [] })); await settle(); expect(root.dataset.slate).toBe("true"); expect(controller.diagnostics().slate).toBe(true); await controller.apply(state()); await settle(); expect(root.dataset.slate).toBe("false"); // Content disappears again — but a good frame is up, so the wall keeps it rather than flashing // the slate at a congregation. await controller.apply(state({ current: null, selections: [] })); await settle(); expect(root.dataset.slate).toBe("false"); expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); it("covers the output for a blackout while the frame underneath stays prepared", async () => { const host = createFakeHost({ permissions: [], routes: [] }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state()); await controller.apply(state({ playback: { blackout: true } as never })); await settle(); expect(root.dataset.blackout).toBe("true"); // The hidden timeline is still what it was: ending the blackout shows this frame, not a slate. expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); it("restores a blackout before the server answers, so a reload never flashes content", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); const online = createFakeHost({ permissions: [], routes: [] }); const before = build({ host: online, media, store, telemetry: false }); await before.start(); await before.apply(state({ playback: { blackout: true } as never })); await settle(); await before.stop(); // A kiosk reloads mid-blackout and its first request has not been answered yet. root.dataset.blackout = "false"; const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] }); Object.assign(pending.context.api, { request: () => new Promise(() => undefined) }); const after = build({ host: pending, media, store }); void after.start(); await settle(); expect(root.dataset.blackout).toBe("true"); await after.stop(); }); it("reports a bounded, sanitized heartbeat and nothing else", async () => { const beats: unknown[] = []; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: ({ init }) => { beats.push(JSON.parse(String(init?.body))); return jsonResponse({ acknowledged: true, recorded: true }); }, }, ], }); const controller = build({ host }); await controller.start(); await settle(); expect(beats.length).toBeGreaterThan(0); const beat = beats[0] as Record; expect(Object.keys(beat).sort()).toEqual([ "cachedAssetCount", "error", "offlineReady", "reportedContributionId", "reportedPlaybackRevision", "reportedProviderId", "reportedSelectionId", "reportedTimelineId", ]); // Identity, counts, and a code. No URL, no payload, no exception, no credential. const serialized = JSON.stringify(beat); expect(serialized).not.toContain("https://"); expect(serialized).not.toContain("payload"); await controller.stop(); }); it("keeps the frame when the server cannot be reached, and says so quietly", async () => { const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, ], }); const controller = build({ host }); await controller.start(); await settle(); const before = controller.diagnostics().renderedSelectionId; // Every request now fails, exactly as a dropped uplink does. Object.assign(host.context.api, { request: () => Promise.reject(new Error("network is gone")), }); await controller.apply(state()); await settle(); expect(controller.diagnostics().renderedSelectionId).toBe(before); // Nothing about the failure is on screen: the slate is for having no content, not no network. expect(root.dataset.slate).toBe("false"); await controller.stop(); }); it("registers no viewer and sends no heartbeat when telemetry is off", async () => { const host = createFakeHost({ permissions: [], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) }, ], }); const controller = build({ host, telemetry: false }); await controller.start(); await controller.apply(state()); await settle(); // The management preview renders exactly what a wall renders and appears in no roster. expect(host.requests).toEqual([]); expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); it("follows the server when an administrator moves it to another channel", async () => { const moved = { groupId: "group-2", id: "chan-2", key: "online" }; let stateFetches = 0; let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, respond: () => { stateFetches += 1; // Registered on chan-1; moved by an administrator right after its first fetch. return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved })); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }), }, ], }); Object.assign(host.context.realtime, { subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { realtimeHandlers = handlers; return { close: () => undefined }; }, }); const controller = build({ host }); await controller.start(); await settle(); // The heartbeat named a different channel, so the display refetched and adopted it. expect(stateFetches).toBe(2); expect(controller.diagnostics().channelId).toBe("chan-2"); // A change in the old group no longer concerns it; a change in the new one does. realtimeHandlers?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }], }); await settle(); expect(stateFetches).toBe(2); realtimeHandlers?.onUpdate?.({ groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }], }); await settle(); expect(stateFetches).toBe(3); await controller.stop(); }); it("registers again, exactly once, when it was removed from the roster", async () => { let sessions = 0; let subscriptions = 0; let closes = 0; let removed = false; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => { sessions += 1; removed = false; return jsonResponse(SESSION); }, }, { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, respond: () => removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse({ acknowledged: true }), }, ], }); let onUpdate: ((state: unknown) => void) | undefined; Object.assign(host.context.realtime, { subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { subscriptions += 1; onUpdate = handlers.onUpdate; return { close: () => (closes += 1) }; }, }); const controller = build({ host }); await controller.start(); await settle(); expect(sessions).toBe(1); // Removed from the roster; a realtime nudge and a heartbeat both discover it at once. removed = true; onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] }); onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] }); await settle(); expect(sessions).toBe(2); // One live subscription: the old one closed, exactly one new one opened. expect(subscriptions - closes).toBe(1); expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => { let sessions = 0; const host = createFakeHost({ permissions: ["signage.display.view"], routes: [ { method: "POST", path: "viewers/session", respond: () => { sessions += 1; return jsonResponse(SESSION); }, }, { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, // A proxy, or a server without this route — not "this viewer is gone". respond: () => jsonResponse({ code: "NOT_FOUND" }, 404), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, ], }); const controller = build({ host }); await controller.start(); await settle(); await settle(); expect(sessions).toBe(1); await controller.stop(); }); });