feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
// @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> = {}): 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<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();
});
});