// @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 { 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 } : {}), }; } const SESSION = { channelId: "chan-1", channelKey: "default", groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", }; 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: "channels/chan-1/display-state", 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: "channels/chan-1/display-state", 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("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: "channels/chan-1/display-state", 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: "channels/chan-1/display-state", 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(); }); });