Files
Signage/tests/browser/display.test.ts
ekklesiaandClaude Opus 5.5 f1b7d67aa5
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 18s
CI / integration (pull_request) Successful in 3m32s
fix(display): define the display-server messages once, in src/shared
The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.

src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.

Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.

- The routes parse with the shared parsers, and a refusal names the
  field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
  display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
  registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
  server sends them (no viewerName, no stale, partial media) now match
  the real responses and are typed as the protocol.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:25:21 -05:00

475 lines
16 KiB
TypeScript

// @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> = {}): 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<void> {
for (let index = 0; index < 10; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function build(options: {
readonly host: ReturnType<typeof createFakeHost>;
readonly media?: ReturnType<typeof createMemoryMediaCache>;
readonly store?: ReturnType<typeof createMemoryStore>;
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<string, unknown>;
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();
});
});