Files
Signage/tests/browser/contributed-renderer.test.tsx
T
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

368 lines
11 KiB
TypeScript

// @vitest-environment happy-dom
import { act } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
import { rendererInputFor } from "../../src/browser/scene-request.js";
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost } from "./support/fake-host.js";
import {
createFixtureRenderer,
createReadiness,
type FixtureRendererCounters,
} from "./support/fixture-renderer.js";
/**
* A provider's live React renderer, on a Signage slide.
*
* The renderer here is test-only and is built with the platform's own `defineModuleContribution`, so
* what these prove is the real contract: Signage hands it identity, timing, and an opaque payload,
* mounts it through the host, waits for it to say it is ready, updates it in place, and takes it
* down deterministically — and never learns anything about what it draws.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const SELECTION = {
availableUntilMs: null,
deadlineAtMs: 20_000,
durationMs: 20_000,
id: "sel-trivia",
itemId: "question-1",
itemRevision: "1",
payload: { answer: "Bethlehem", question: "Where was Jesus born?", revealAfterMs: 2000 },
presentation: null,
providerId: "fixture.trivia",
renderer: {
contributionId: "fixture.trivia.slide",
contributionVersion: "1.0.0",
kind: "contribution" as const,
},
revision: 1,
slotId: "slot-trivia",
stale: false,
startedAtMs: 0,
withdrawn: false,
};
const CONTEXT = {
audienceKey: "default",
localNowMs: 1_000_000,
paused: false,
remainingMs: null,
serverNowMs: 5000,
};
async function settle(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
describe("a provider's contributed renderer", () => {
let root: HTMLElement;
let counters: FixtureRendererCounters;
let stage: SceneStage;
function mountFixture(readiness?: ReturnType<typeof createReadiness>) {
const fixture = createFixtureRenderer({ counters, ...(readiness ? { readiness } : {}) });
return createSceneStage({
crossfadeMs: 0,
mountContribution: async ({ container, input }) =>
(await fixture.definition.mount(container, {
contributionId: fixture.definition.id,
input: input as never,
})) ?? {},
root,
setTimer: (handler) => {
handler();
return undefined;
},
});
}
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
counters = { mounts: 0, unmounts: 0, updates: 0 };
});
it("is not shown until it says it is ready", async () => {
const readiness = createReadiness();
stage = mountFixture(readiness);
const showing = stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
await settle();
// Mounted, rendering, and still invisible: the stage waits for the renderer's own promise.
expect(counters.mounts).toBe(1);
const layers = [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
expect(layers.map((layer) => layer.style.opacity)).toEqual(["0", "0"]);
readiness.ready();
await act(async () => {
await showing;
});
expect(layers.filter((layer) => layer.style.opacity === "1")).toHaveLength(1);
});
it("runs its own multi-stage presentation inside one Signage slide", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Where was Jesus born?",
);
// The answer has not been revealed yet: that is the renderer's own timing, inside one slot whose
// deadline Signage set and the renderer cannot extend.
expect(root.querySelector("[data-testid='fixture-trivia-answer']")).toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2100));
});
expect(root.querySelector("[data-testid='fixture-trivia-answer']")?.textContent).toBe(
"Bethlehem",
);
// Still one mount, one slide. Signage never learned that anything happened.
expect(counters.mounts).toBe(1);
expect(counters.updates).toBe(0);
});
it("computes its countdown from the clock anchor, not the device's clock", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, {
...CONTEXT,
// A television whose clock is a year out. The anchor is what makes this right.
localNowMs: Date.now(),
serverNowMs: 5000,
}),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
// Deadline 20s, server now 5s: fifteen seconds left, whatever the device believes.
expect(root.querySelector("[data-testid='fixture-trivia-remaining']")?.textContent).toBe("15s");
});
it("renders a different audience differently, from one logical selection", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, { ...CONTEXT, audienceKey: "online" }),
key: "sel-trivia:online",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-audience']")?.textContent).toBe(
"online",
);
});
it("takes new content in place, without remounting or restarting its stage", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(
{
...SELECTION,
payload: { answer: "Bethlehem", question: "Cancelled", revealAfterMs: 2000 },
revision: 2,
},
CONTEXT,
),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Cancelled",
);
expect(counters.mounts).toBe(1);
expect(counters.updates).toBe(1);
expect(counters.unmounts).toBe(0);
});
it("mounts and unmounts repeatedly without leaking", async () => {
stage = mountFixture();
for (let index = 0; index < 4; index += 1) {
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor({ ...SELECTION, id: `sel-${index}` }, CONTEXT),
key: `sel-${index}:default`,
kind: "contribution",
});
});
await settle();
}
await act(async () => {
await stage.destroy();
});
// Four mounts, four unmounts, and nothing left in the DOM.
expect(counters.mounts).toBe(4);
expect(counters.unmounts).toBe(4);
expect(root.querySelector("[data-testid='fixture-trivia']")).toBeNull();
});
it("keeps working from cached state with no network at all", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const fixture = createFixtureRenderer({ counters });
const host = createFakeHost({
contributions: [
{ capability: "signage.slide-renderer", id: "fixture.trivia.slide", version: "1.0.0" },
],
mount: async (options) => {
const instance = await fixture.definition.mount(options.container, {
contributionId: options.contributionId,
input: options.input,
});
return {
contributionId: options.contributionId,
mounted: true,
unmount: async () => {
await instance?.unmount?.();
},
update: async (input) => {
await instance?.update?.(input);
},
version: "1.0.0",
};
},
permissions: [],
routes: [],
});
const state: DisplayChannelStateView = {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current: SELECTION,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: SELECTION.id,
currentSlotId: SELECTION.slotId,
deadlineAtMs: 20_000,
pendingTimelineId: null,
remainingMs: 20_000,
revision: 1,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [SELECTION],
serverNowMs: 5000,
staticHold: null,
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 1,
slots: [
{
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 20_000,
id: "slot-trivia",
position: 0,
},
],
},
};
// One successful online state, persisted.
const online = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await act(async () => {
await online.start();
await online.apply(state);
});
await settle();
await act(async () => {
await online.stop();
});
// A fresh controller with the same local storage and no server at all.
const offlineRoot = document.createElement("div");
document.body.append(offlineRoot);
const offline = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root: offlineRoot,
setTimer: () => undefined,
store,
telemetry: false,
});
await act(async () => {
await offline.start();
});
await settle();
// The renderer is on screen, from its cached artifact and this display's cached payload — with
// no request made and nothing invented.
expect(offlineRoot.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Where was Jesus born?",
);
expect(host.requests).toEqual([]);
expect(offline.diagnostics().offline).toBe(true);
await act(async () => {
await offline.stop();
});
});
});