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
+366
View File
@@ -0,0 +1,366 @@
// @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",
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();
});
});
});
+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();
});
});
+493
View File
@@ -0,0 +1,493 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
/**
* The management UI's behaviour, mounted as React rather than inspected as source.
*
* The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read
* a screen, press a control, be told why a save was refused — against a host that answers only the
* relative paths Signage actually asks for.
*/
// React needs this to run effects the way a browser does.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
async function render(element: React.ReactElement): Promise<void> {
await act(async () => {
root.render(element);
});
// Let the resource loads settle.
for (let index = 0; index < 6; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
async function click(element: Element | null | undefined): Promise<void> {
if (!element) {
throw new Error("Expected an element to click.");
}
await act(async () => {
(element as HTMLElement).click();
});
for (let index = 0; index < 6; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
/**
* Types into a controlled React input the way a person does.
*
* Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on
* the element and would treat the change as already handled. Going through the prototype's setter is
* what makes React see it.
*/
async function type(input: HTMLInputElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function buttonNamed(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
const GROUP = {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: "sel-1",
currentSlotId: "slot-1",
deadlineAtMs: 1_700_000_005_000,
groupId: "group-1",
pendingTimelineId: null,
remainingMs: 4000,
revision: 7,
scheduledActivationAtMs: null,
scheduledTimelineId: null,
selectionRevision: 1,
serverNowMs: 1_700_000_001_000,
startedAtMs: 1_700_000_000_000,
staticAssetId: null,
transport: "playing" as const,
},
revision: 1,
};
const PLAYBACK_DETAIL = {
current: {
deadlineAtMs: 1_700_000_005_000,
durationMs: 5000,
id: "sel-1",
itemId: "welcome",
itemRevision: "1",
providerId: "fixture.announcements",
rendererContributionId: "fixture.announcements.slide",
revision: 1,
stale: false,
startedAtMs: 1_700_000_000_000,
withdrawn: false,
},
history: [
{
id: "sel-0",
itemId: "giving",
providerId: "fixture.announcements",
sequence: 1,
startedAtMs: 1_699_999_990_000,
},
],
snapshot: GROUP.playback,
};
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
describe("Signage dashboard", () => {
it("shows live transport for the default channel group and drives it with one command each", async () => {
const commands: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "POST",
path: /^playback\/group-1\//u,
respond: ({ path }) => {
commands.push(path);
return jsonResponse(GROUP.playback);
},
},
],
});
await render(<SignageDashboard context={host.context} />);
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
"welcome",
);
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
await click(buttonNamed("Pause"));
await click(buttonNamed("Next"));
await click(buttonNamed("Blackout"));
expect(commands).toEqual([
"playback/group-1/pause",
"playback/group-1/next",
"playback/group-1/blackout",
]);
});
it("offers no transport controls to an operator who may only view", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
],
});
await render(<SignageDashboard context={host.context} />);
// The current slide is still legible; the buttons that would be refused are simply not offered.
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
});
it("shows Signage's own sanitized reason when a save is refused", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{
method: "POST",
path: "timelines",
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
),
);
const input = container.querySelector("input[type='text']") as HTMLInputElement;
await type(input, "Sunday morning");
await click(buttonNamed("Create"));
const alert = container.querySelector("[role='alert']");
expect(alert?.textContent).toBe("The request is not valid.");
// The server's own fixed message, and nothing else: no status code, no URL, no request detail.
expect(alert?.textContent).not.toMatch(/400|http|timelines/u);
});
it("says a timeline has no slots rather than showing an empty table", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{
method: "GET",
path: "timelines",
respond: () =>
jsonResponse({
timelines: [
{
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
},
],
}),
},
{
method: "GET",
path: "timelines/timeline-1",
respond: () =>
jsonResponse({
slots: [],
sources: [],
timeline: {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
},
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
),
);
await click(buttonNamed("Sunday morning"));
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
expect(container.textContent).toContain("This timeline has no slots");
});
it("reports a provider whose renderer the platform did not publish", async () => {
const host = createFakeHost({
// The platform published nothing against Signage's renderer capability — the contributing
// module is absent, disabled, or outside the compatible range.
contributions: [],
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{
method: "GET",
path: "providers",
respond: () =>
jsonResponse({
providers: [
{
consecutiveFailures: 0,
contractVersion: "1.0.0",
defaultDurationMs: 8000,
lastFailureAt: null,
lastFailureCode: null,
lastSuccessAt: "2026-08-30T15:00:00.000Z",
providerId: "fixture.announcements",
providerVersion: "1.0.0",
registeredAt: "2026-08-30T14:00:00.000Z",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
},
},
],
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
),
);
expect(container.textContent).toContain("Renderer not published");
// Which is a fact only the browser holds: the server cannot know what the host published.
});
it("says Signage works without providers rather than looking broken", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
),
);
expect(container.textContent).toContain("No content providers registered");
expect(container.textContent).toContain("Signage works on its own with native images");
});
it("shows the display roster with online state and offline readiness", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{
method: "GET",
path: "viewers",
respond: () =>
jsonResponse({
viewers: [
{
cachedAssetCount: 12,
channelId: "chan-1",
deviceId: null,
id: "viewer-1",
lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null },
lastSeenAtMs: Date.now() - 4000,
name: "Lobby north",
offlineReady: true,
online: true,
reportedContributionId: null,
reportedProviderId: "fixture.announcements",
reportedSelectionId: "sel-1",
reportedTimelineId: "timeline-1",
},
],
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
);
expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached");
// A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
});
it("uploads one request per file and reports each one's own outcome", async () => {
const uploaded: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{
method: "POST",
path: /^images\?/u,
respond: ({ path }) => {
uploaded.push(path);
return path.includes("broken")
? errorResponse(
"SIGNAGE_IMAGE_MALFORMED",
"The uploaded file is not a decodable image.",
422,
)
: jsonResponse({ deduplicated: false, image: {} }, 201);
},
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
);
const input = container.querySelector(
"[data-testid='signage-upload-input']",
) as HTMLInputElement;
// happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a
// real picker fires, with file-shaped values.
Object.defineProperty(input, "files", {
configurable: true,
value: [
{ name: "welcome.png", size: 1024, type: "image/png" },
{ name: "broken.png", size: 1024, type: "image/png" },
],
});
await act(async () => {
input.dispatchEvent(new Event("change", { bubbles: true }));
});
for (let index = 0; index < 10; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
// Two files, two requests — each with its own name in the query string and the bytes as the body.
expect(uploaded).toHaveLength(2);
expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true);
const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done");
expect(uploads?.textContent).toContain("failed");
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
});
it("stays on screen when one panel's data cannot be loaded", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () =>
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
},
],
});
await render(<SignageDashboard context={host.context} />);
expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain("The operation could not be completed.");
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
});
});
+170
View File
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { parseModuleUiExport } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import { createFakeHost, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
/**
* The end-to-end proof for Signage's browser artifacts:
*
* React/TSX source -> `pnpm build` -> the artifacts the manifest declares -> `parseModuleUiExport`
* -> `mount(container, context)` -> `unmount()`
*
* Both artifacts are rebuilt from source here rather than read from a checked-in file, so this can
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
*/
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
const moduleDirectory = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
let identity: { id: string; version: string };
let uiArtifactPath: string;
let uiArtifactHref: string;
let displayArtifactPath: string;
let displayArtifactHref: string;
async function flush(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("Signage browser artifacts", () => {
beforeAll(async () => {
const manifest = parseModuleManifest(
JSON.parse(
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
) as unknown,
);
identity = { id: manifest.id, version: manifest.version };
const result = await buildModuleUi({ moduleDirectory });
const ui = result.artifacts.find((artifact) => artifact.kind === "ui");
const display = result.artifacts.find((artifact) => artifact.kind === "display");
if (!ui || !display) {
throw new Error("The Signage build must produce both declared browser artifacts.");
}
uiArtifactPath = ui.outputFile;
uiArtifactHref = pathToFileURL(ui.outputFile).href;
displayArtifactPath = display.outputFile;
displayArtifactHref = pathToFileURL(display.outputFile).href;
}, 180_000);
it("produces self-contained artifacts that bake in no Ekklesia deployment detail", async () => {
const sources = await Promise.all([
readFile(uiArtifactPath, "utf8"),
readFile(displayArtifactPath, "utf8"),
]);
for (const source of sources) {
// React is bundled, so each artifact resolves nothing at run time (ADR 0023).
expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(source).toContain("export {");
expect(source).not.toContain("/api/v1/modules");
expect(source).not.toContain("/api/v1/module-ui");
expect(source).not.toContain("process.env.NODE_ENV");
// Nothing this release excludes may reach a browser artifact.
for (const forbidden of ["mqtt", "socket.io", "homeassistant", "sqlite"]) {
expect(source.toLowerCase()).not.toContain(forbidden);
}
}
});
it("mounts the management UI, asks for relative paths only, and unmounts", async () => {
const namespace = (await import(uiArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
document.body.append(container);
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
],
});
const unmount = await definition.mount(container, host.context);
await flush();
expect(container.textContent).toContain("Playback");
// Relative to Signage's own API root, always. A route table keyed on Ekklesia's private prefix
// would never have matched.
for (const request of host.requests) {
expect(request.path.startsWith("api/")).toBe(false);
expect(request.path.startsWith("/")).toBe(false);
}
// The desktop-only gate is a CSS decision, so both branches are always in the accessibility tree.
expect(container.querySelector("[data-testid='signage-narrow-notice']")).not.toBeNull();
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
container.remove();
});
it("renders a denied state from the host's presentation-only permission hint", async () => {
const namespace = (await import(uiArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
const host = createFakeHost({ permissions: [], routes: [] });
const unmount = await definition.mount(container, host.context);
await flush();
expect(container.textContent).toContain("No access to Signage");
// A hidden control is a usability choice, not enforcement: Signage simply did not ask.
expect(host.requests).toEqual([]);
await (unmount as () => Promise<void>)();
});
it("mounts the display through the same ABI and shows the safe slate", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
const host = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
const unmount = await definition.mount(container, host.context);
await flush();
const slate = container.querySelector("[data-testid='signage-slate']");
expect(slate?.textContent).toBe("Content unavailable");
// The slate must never carry an exception, URL, identifier, or payload of any kind.
expect(slate?.textContent).not.toMatch(/https?:|\/api\/|[0-9a-f]{8}-[0-9a-f]{4}/u);
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
});
it("carries each artifact's own stylesheet inside the one declared file", async () => {
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
expect(ui.styles).toContain(".signage-narrow-notice");
expect(ui.styles).toContain(".ek-panel");
// Signage references the platform's global theme and never declares part of it (ADR 0039).
expect(ui.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
expect(display.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
// Two artifacts of one module: separate roots, separate stylesheets.
expect(display.styles).toContain(".signage-slate");
expect(display.styles).not.toContain(".signage-narrow-notice");
// No sidecar stylesheet is emitted beside either artifact.
await expect(readFile(path.join(path.dirname(uiArtifactPath), "index.css"))).rejects.toThrow();
});
it("sets its own box model, because an isolated root inherits none", async () => {
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
// The host installs base rules and the platform's components, and neither declares `box-sizing`.
// Under the initial `content-box` a control that is `width: 100%` is laid out wider than the
// field around it by its own padding and border, and slides under whatever sits beside it. Each
// artifact says so for the tree it owns; nothing outside that tree is affected.
for (const styles of [ui.styles, display.styles]) {
expect(styles).toMatch(/\*,\s*\*::before,\s*\*::after\s*\{[^}]*box-sizing:\s*border-box/u);
}
});
});
+562
View File
@@ -0,0 +1,562 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
type OfflineSnapshot,
resolveOffline,
serverNowFrom,
snapshotFrom,
} from "../../src/browser/offline.js";
import type { SceneSelection } from "../../src/browser/scene-request.js";
import {
createMemoryMediaCache,
createMemoryStore,
signageMediaKey,
} from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
/**
* Offline continuation: what a screen does when its building's uplink dies.
*
* Every case here is one an unattended television actually meets — a router reboot during a service,
* a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is
* always the same one: the display continues *deterministically from the last authoritative anchor*,
* replaying only what the server already resolved, and never becomes a second authority.
*/
const SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function selection(overrides: Partial<SceneSelection> & { id: string }): SceneSelection {
return {
availableUntilMs: null,
deadlineAtMs: null,
durationMs: 5000,
itemId: `item-${overrides.id}`,
itemRevision: "rev-1",
payload: { kind: "native-image" },
presentation: null,
providerId: "signage.native-images",
renderer: { kind: "native-image" },
revision: 1,
scene: SCENE,
slotId: null,
startedAtMs: null,
withdrawn: false,
...overrides,
};
}
function slot(id: string, position: number, overrides: Record<string, unknown> = {}) {
return {
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 5000,
id,
position,
...overrides,
};
}
function snapshot(overrides: Partial<OfflineSnapshot> = {}): OfflineSnapshot {
return snapshotFrom({
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
localNowMs: 1_000_000,
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 3,
startedAtMs: 0,
transport: "playing",
...overrides.playback,
},
selections: overrides.selections ?? [
selection({ id: "sel-c", slotId: "slot-c" }),
selection({ id: "sel-b", slotId: "slot-b" }),
selection({ id: "sel-a", slotId: "slot-a" }),
],
serverNowMs: 0,
staticHold: overrides.staticHold ?? null,
timeline:
overrides.timeline === undefined
? {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
}
: overrides.timeline,
});
}
describe("offline continuation", () => {
it("reconstructs the server's clock, however wrong the device's is", () => {
const persisted = snapshot();
// The device's clock jumped forward two hours while the screen was off. The anchor is what makes
// the evaluation right anyway: only the *elapsed* local time matters.
expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000);
expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000);
});
it("continues to the next slot at the deadline, using what the server already resolved", () => {
const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() });
expect(evaluation.resolution).toMatchObject({
kind: "selection",
startedAtMs: 5000,
});
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
// The next slot in the pattern, rendered from its own cached answer. Nothing was invented.
expect(evaluation.resolution.selection.id).toBe("sel-b");
expect(evaluation.nextBoundaryMs).toBe(10_000);
});
it("catches up a week in one evaluation rather than replaying every slide", () => {
const week = 7 * 24 * 60 * 60 * 1000;
const started = Date.now();
const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() });
expect(evaluation.resolution.kind).toBe("selection");
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
// Landed inside the current slot, on the server's own phase — and the arithmetic took no
// measurable time, because it is arithmetic.
expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week);
expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000);
expect(Date.now() - started).toBeLessThan(200);
});
it("stays exactly where it was while paused, with its remainder preserved", () => {
const paused = snapshot({
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: null,
pendingTimelineId: null,
remainingMs: 3000,
revision: 4,
startedAtMs: null,
transport: "paused",
},
});
const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused });
// Ten minutes later, on the same slide. Frozen means frozen, offline as much as online.
expect(evaluation.resolution).toMatchObject({ kind: "paused" });
if (evaluation.resolution.kind !== "paused") {
throw new Error("Expected a paused resolution.");
}
expect(evaluation.resolution.selection?.id).toBe("sel-a");
expect(paused.playback.remainingMs).toBe(3000);
});
it("keeps a static hold held, and does not let local age release it", () => {
const held = snapshot({ staticHold: selection({ id: "static-1" }) });
const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held });
expect(evaluation.resolution).toMatchObject({ kind: "static" });
});
it("releases a static hold only when the held image genuinely expired", () => {
const held = snapshot({
staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }),
});
const before = resolveOffline({ atMs: 9000, snapshot: held });
const after = resolveOffline({ atMs: 11_000, snapshot: held });
expect(before.resolution.kind).toBe("static");
// An explicit expiry the server told it about — never a guess made locally.
expect(after.resolution.kind).toBe("selection");
});
it("skips content the server said was withdrawn or expired", () => {
const persisted = snapshot({
selections: [
selection({ id: "sel-c", slotId: "slot-c" }),
selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }),
selection({ id: "sel-a", slotId: "slot-a" }),
],
});
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
// Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an
// empty provider slot — no gap, no error, and certainly no fabricated replacement.
expect(evaluation.resolution).toMatchObject({ kind: "selection" });
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
expect(evaluation.resolution.selection.id).toBe("sel-c");
});
it("holds the last good frame rather than continuing into a timeline it never received", () => {
const pending = snapshot({
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: "timeline-2",
remainingMs: 0,
revision: 5,
startedAtMs: 0,
transport: "playing",
},
});
const evaluation = resolveOffline({ atMs: 6000, snapshot: pending });
// Going deeper into the old timeline would show content the operator has already replaced.
expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" });
});
it("holds when no slot has a cached answer, rather than inventing one", () => {
const persisted = snapshot({ selections: [] });
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
expect(evaluation.resolution).toMatchObject({ kind: "hold" });
});
it("goes black only when the cached timeline genuinely has nothing eligible", () => {
const persisted = snapshot({
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0, { enabled: false })],
},
});
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
expect(evaluation.resolution).toEqual({ kind: "empty" });
});
it("persists no delivery URL, because a stored one is a credential that stops working", () => {
const persisted = snapshotFrom({
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
localNowMs: 0,
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [
{
...selection({ id: "sel-a", slotId: "slot-a" }),
media: {
display: { url: "https://cdn.invalid/a?sig=secret" },
thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" },
},
},
],
serverNowMs: 0,
staticHold: null,
timeline: null,
});
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
expect(JSON.stringify(persisted)).not.toContain("https://");
});
});
describe("a display that loses its network", () => {
let root: HTMLElement;
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
});
async function settle(): Promise<void> {
for (let index = 0; index < 12; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function nativeSelection(id: string, slotId: string): SceneSelection {
return {
...selection({ id, slotId }),
media: {
display: { url: `https://cdn.invalid/${id}?sig=a` },
thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
},
} as SceneSelection;
}
function state(currentId: string): DisplayChannelStateView {
const selections = [
nativeSelection("sel-c", "slot-c"),
nativeSelection("sel-b", "slot-b"),
nativeSelection("sel-a", "slot-a"),
];
const current = selections.find((candidate) => candidate.id === currentId);
return {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current: current ?? null,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: currentId,
currentSlotId: current?.slotId ?? null,
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 3,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections,
serverNowMs: 0,
staticHold: null,
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
},
};
}
it("keeps rendering from cached bytes and cached answers when the server goes away", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
let reachable = true;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () =>
jsonResponse({
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
}),
},
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => {
if (!reachable) {
throw new Error("network is gone");
}
return jsonResponse(state("sel-a"));
},
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
let clock = 1_000_000;
// The armed timer is captured rather than run, so the test decides when the safety-net refresh
// and the offline evaluation happen — no real time passes.
let armed: (() => void) | undefined;
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
newViewerKey: () => "viewer-fixed-key-0001",
now: () => clock,
root,
setTimer: (handler) => {
armed = handler;
return handler;
},
store,
});
await controller.start();
await settle();
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
expect(controller.diagnostics().offline).toBe(false);
expect(controller.diagnostics().offlineReady).toBe(true);
// The uplink dies, and the current slot's deadline passes.
reachable = false;
clock += 6000;
armed?.();
await settle();
const diagnostics = controller.diagnostics();
// Local continuation took over: the next slot in the pattern, from bytes and answers this screen
// already had. Nothing was invented, and the slate never appeared.
expect(diagnostics.offline).toBe(true);
expect(diagnostics.renderedSelectionId).toBe("sel-b");
expect(root.dataset.slate).toBe("false");
await controller.stop();
});
it("puts the last known good frame up before the first request returns", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
await media.store(
signageMediaKey({
itemId: "item-sel-a",
itemRevision: "rev-1",
providerId: "signage.native-images",
rendition: "display",
}),
"https://cdn.invalid/sel-a?sig=a",
);
await store.write("last-known-good", snapshot());
let requested = 0;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
requested += 1;
throw new Error("network is gone");
},
},
],
});
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
});
await controller.start();
await settle();
// A frame is up, from persisted state, with no server involved — and the slate never appeared.
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
expect(root.dataset.slate).toBe("false");
expect(requested).toBe(1);
await controller.stop();
});
it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
await store.write("last-known-good", snapshot());
for (const id of ["sel-a", "sel-b", "sel-c"]) {
await media.store(
signageMediaKey({
itemId: `item-${id}`,
itemRevision: "rev-1",
providerId: "signage.native-images",
rendition: "display",
}),
`https://cdn.invalid/${id}?sig=a`,
);
}
const host = createFakeHost({ permissions: [], routes: [] });
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await controller.start();
await settle();
const offlineFrame = controller.diagnostics().renderedSelectionId;
expect(offlineFrame).toBe("sel-a");
expect(controller.diagnostics().offline).toBe(true);
// The server comes back and says something different. It wins immediately.
await controller.apply(state("sel-c"));
await settle();
const diagnostics = controller.diagnostics();
expect(diagnostics.renderedSelectionId).toBe("sel-c");
expect(diagnostics.offline).toBe(false);
// Straight to what the server says. Nothing in between was replayed, and the slate never showed.
expect(root.dataset.slate).toBe("false");
await controller.stop();
});
it("prunes superseded bytes only after the new state is usable", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const pruned: readonly string[][] = [];
const order: string[] = [];
const wrapped = {
...media,
prune: (keep: readonly string[]) => {
order.push("prune");
(pruned as string[][]).push([...keep]);
return media.prune(keep);
},
store: (key: string, url: string) => {
order.push(`store:${key}`);
return media.store(key, url);
},
};
const host = createFakeHost({ permissions: [], routes: [] });
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media: wrapped,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await controller.start();
await controller.apply(state("sel-a"));
await settle();
// Every store happens before the prune: a screen never has less than it had.
expect(order.at(-1)).toBe("prune");
expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3);
await controller.stop();
});
});
+412
View File
@@ -0,0 +1,412 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import type { SceneRequest } from "../../src/browser/scene.js";
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
import { rendererInputFor, sceneRequestFor } from "../../src/browser/scene-request.js";
/**
* The transition, exercised rather than asserted.
*
* Every collaborator is injected — the decode, the timer, the contribution mount — so what these
* prove is the sequence itself: readiness is awaited, the outgoing layer is never faded, a stale
* completion is discarded, and a failure leaves the last good frame exactly where it was.
*/
const SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function image(key: string, url = `https://cdn.invalid/${key}.webp`): SceneRequest {
return { alt: "", key, kind: "native-image", scene: SCENE, url };
}
describe("scene stage", () => {
let root: HTMLElement;
let decodes: { url: string; resolve: () => void; reject: (error: Error) => void }[];
let stage: SceneStage;
function layers(): HTMLElement[] {
return [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
}
function opacities(): string[] {
return layers().map((layer) => layer.style.opacity);
}
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function resolveDecodes(): void {
while (decodes.length > 0) {
decodes.shift()?.resolve();
}
}
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
decodes = [];
stage = createSceneStage({
crossfadeMs: 1000,
decodeImage: (element) =>
new Promise<void>((resolve, reject) => {
decodes.push({
reject,
resolve,
url: element.getAttribute("src") ?? "",
});
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
});
it("creates exactly two layers, once, and never rebuilds them", async () => {
const before = layers();
expect(before).toHaveLength(2);
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const second = stage.show(image("b"));
await settle();
resolveDecodes();
await second;
// The same two elements. A renderer that rebuilt them per slide is the `v1` defect.
expect(layers()[0]).toBe(before[0]);
expect(layers()[1]).toBe(before[1]);
});
it("waits for a real decode before anything becomes visible", async () => {
const shown = stage.show(image("a"));
await settle();
// The image is in the DOM and the decode has not resolved: nothing is visible yet.
expect(decodes).toHaveLength(1);
expect(opacities()).toEqual(["0", "0"]);
resolveDecodes();
await shown;
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("never fades the outgoing layer, so the black root cannot show through", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const outgoing = layers().find((layer) => layer.style.opacity === "1") as HTMLElement;
const second = stage.show(image("b"));
await settle();
// The incoming layer is prepared; the outgoing one is still fully opaque, underneath.
expect(outgoing.style.opacity).toBe("1");
resolveDecodes();
await second;
// Only after the fade completes does the old layer go to zero — and it is now behind, and empty.
expect(outgoing.style.opacity).toBe("0");
expect(outgoing.style.zIndex).toBe("1");
});
it("keeps the last good frame when incoming content cannot be decoded", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const goodKey = stage.currentKey();
const failing = stage.show(image("broken"));
await settle();
decodes.shift()?.reject(new Error("decode failed"));
const outcome = await failing;
expect(outcome).toEqual({ code: "SLIDE_DECODE_FAILED", kind: "failed" });
// The wall still shows what it showed. A broken slide costs one slide.
expect(stage.currentKey()).toBe(goodKey);
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("discards completion from a superseded transition", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const slow = stage.show(image("b"));
await settle();
const fast = stage.show(image("c"));
await settle();
resolveDecodes();
const [slowOutcome, fastOutcome] = await Promise.all([slow, fast]);
// The newer transition owns the layers; the older one changes nothing.
expect(slowOutcome.kind).toBe("superseded");
expect(fastOutcome.kind).toBe("shown");
expect(stage.currentKey()).toBe("c");
});
it("updates a mounted renderer in place rather than remounting it", async () => {
const updates: unknown[] = [];
let mounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => {
mounts += 1;
return Promise.resolve({
unmount: () => undefined,
update: (input: unknown) => {
updates.push(input);
},
});
},
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const input = rendererInputFor(
{
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "advent",
itemRevision: "1",
payload: { headline: "Sunday" },
presentation: null,
providerId: "fixture.announcements",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
},
{ audienceKey: "default", localNowMs: 0, paused: false, remainingMs: null, serverNowMs: 0 },
);
const request: SceneRequest = {
contributionId: "fixture.announcements.slide",
input,
key: "sel-1:1:default:r",
kind: "contribution",
};
await contributionStage.show(request);
const updated = await contributionStage.show({
...request,
input: { ...input, payload: { headline: "Cancelled" } },
});
// Same key, new input: one mount, one update, no transition — which is what lets a long-running
// screen change content without restarting animations or reloading media.
expect(updated).toEqual({ kind: "updated" });
expect(mounts).toBe(1);
expect(updates).toHaveLength(1);
await contributionStage.destroy();
});
it("keeps the last good frame when a renderer refuses to mount", async () => {
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => Promise.reject(new Error("renderer exploded")),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const outcome = await contributionStage.show({
contributionId: "fixture.announcements.slide",
input: {} as never,
key: "sel-9",
kind: "contribution",
});
expect(outcome).toEqual({ code: "RENDERER_MOUNT_FAILED", kind: "failed" });
expect(contributionStage.currentKey()).toBeUndefined();
await contributionStage.destroy();
});
it("unmounts a renderer deterministically when its layer is reused", async () => {
let unmounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () =>
Promise.resolve({
unmount: () => {
unmounts += 1;
},
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "one",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "two",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "three",
kind: "contribution",
});
await contributionStage.destroy();
// Three mounts, three unmounts: repeated mount and unmount leaks nothing.
expect(unmounts).toBe(3);
});
it("reports blackout and slate as states of the root, so one slate implementation renders both", () => {
stage.setBlackout(true);
expect(root.dataset.blackout).toBe("true");
stage.setBlackout(false);
expect(root.dataset.blackout).toBe("false");
stage.setSlate(true);
expect(root.dataset.slate).toBe("true");
});
});
describe("scene requests", () => {
const base = {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "welcome",
itemRevision: "abc",
payload: { headline: "Welcome" },
presentation: null,
providerId: "fixture.announcements",
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
};
const context = {
audienceKey: "default",
localNowMs: 1000,
paused: false,
remainingMs: null,
serverNowMs: 9000,
};
it("gives a renderer both clocks, so a television with a wrong clock still counts correctly", () => {
const input = rendererInputFor(
{
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
},
context,
);
expect(input.clock).toEqual({ localNowMs: 1000, serverNowMs: 9000 });
expect(input.timing).toMatchObject({ deadlineAtMs: 5000, paused: false, startedAtMs: 0 });
expect(input.contract).toBe("signage.slide-renderer");
// Everything the renderer is given, and nothing else: no API client, no principal, no
// credential, no URL.
expect(Object.keys(input).sort()).toEqual([
"audienceKey",
"clock",
"contract",
"generation",
"itemId",
"itemRevision",
"payload",
"presentation",
"providerId",
"timing",
"version",
]);
});
it("changes the key when the payload revision or pause state changes, and not when the clock does", () => {
const selection = {
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
} as const;
const first = rendererInputFor(selection, context).generation;
const reAnchored = rendererInputFor(selection, {
...context,
localNowMs: 99_999,
serverNowMs: 99_999,
}).generation;
const revised = rendererInputFor({ ...selection, revision: 2 }, context).generation;
const paused = rendererInputFor(selection, { ...context, paused: true }).generation;
// A reconnect re-anchors the clock. Remounting a renderer for that would restart every
// animation on the wall for no reason.
expect(reAnchored).toBe(first);
expect(revised).not.toBe(first);
expect(paused).not.toBe(first);
});
it("declines to build a scene for a renderer the platform did not publish", () => {
const request = sceneRequestFor(
{
...base,
renderer: {
contributionId: "missing.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
},
context,
{ canMountContribution: () => false },
);
// Ordinary rather than exceptional: the contributing module is absent, disabled, or outside
// Signage's compatible range, and the caller keeps its last good frame.
expect(request).toBeUndefined();
});
it("declines to build a native scene with no delivery URL", () => {
const request = sceneRequestFor({ ...base, renderer: { kind: "native-image" } }, context, {
canMountContribution: () => true,
});
expect(request).toBeUndefined();
});
});
+122
View File
@@ -0,0 +1,122 @@
import type {
ModuleContributionDescriptor,
ModuleUiApiClient,
ModuleUiContributions,
ModuleUiHostContext,
MountedModuleContribution,
MountModuleContributionOptions,
} from "@ekklesia/module-ui-sdk";
/**
* A host, as a module's artifact actually experiences one.
*
* Requests are matched on the **relative** path a module asks for, which is what proves the artifact
* never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would
* simply never match.
*/
export interface FakeHostRoute {
readonly method: string;
readonly path: string | RegExp;
readonly respond: (request: {
readonly path: string;
readonly init: RequestInit | undefined;
}) => Response | Promise<Response>;
}
export interface FakeHost {
readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[];
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
export function errorResponse(code: string, message: string, status = 400): Response {
return jsonResponse({ code, message }, status);
}
export interface FakeHostOptions {
readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[];
readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: (
options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution;
}
export function createFakeHost(options: FakeHostOptions): FakeHost {
const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = [];
const api: ModuleUiApiClient = {
async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized });
for (const route of options.routes) {
const matches =
typeof route.path === "string"
? route.path === normalized.split("?")[0]
: route.path.test(normalized);
if (route.method === method && matches) {
return route.respond({ init, path: normalized });
}
}
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
},
};
const contributions: ModuleUiContributions = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
if (options.mount) {
return options.mount(mountOptions);
}
return {
contributionId: mountOptions.contributionId,
mounted: true,
unmount: () => Promise.resolve(),
update: () => Promise.resolve(),
version: "1.0.0",
};
},
};
return {
context: {
api,
contributions,
moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) },
principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
mounted,
requests,
};
}
/** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.0",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
backgroundDarkened: false,
backgroundMode: "blurred",
blurPixels: 20,
},
rendererInputVersion: "1.0.0",
timezone: "America/Chicago",
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
});
+130
View File
@@ -0,0 +1,130 @@
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js";
/**
* A **test-only** browser renderer, standing in for the contribution a real content module will
* publish.
*
* It is not an installed module and has no manifest: it lives under `tests/`, and the only way it
* reaches a screen is a test handing it to the stage the way the platform's contribution loader
* would. It is built with the platform's own `defineModuleContribution`, so what it proves is the
* actual contract rather than a convenient approximation.
*
* It deliberately does everything a demanding real renderer would:
*
* - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it;
* - presents **internal multiple stages** — a question, then its answer — inside one Signage slide,
* which Signage neither knows about nor times;
* - renders time-based text from the input's clock anchor rather than the device's clock;
* - renders differently per audience;
* - updates in place, without remounting;
* - unmounts deterministically, releasing its timer and its React root.
*
* It receives no API client, no realtime, no principal, no storage, and no credential — because the
* contract gives a renderer none of those. Everything it draws comes from the consumer's input.
*/
export interface TriviaPayload {
readonly question?: unknown;
readonly answer?: unknown;
readonly revealAfterMs?: unknown;
}
function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement {
const payload = input.payload as TriviaPayload;
const question = typeof payload.question === "string" ? payload.question : "…";
const answer = typeof payload.answer === "string" ? payload.answer : "";
const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000;
const [revealed, setRevealed] = useState(false);
// The authoritative now, computed from both clocks. A television's own clock is routinely wrong;
// this is what makes a countdown on two screens agree.
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000));
// `generation` is the dependency that matters and the linter cannot see why: it changes when the
// slide's content changes, which is exactly when this stage should restart — and it does *not*
// change when the clock is re-anchored on reconnect, which is exactly when it should not.
// biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point.
useEffect(() => {
if (input.timing.paused) {
return;
}
// Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides
// what happens *within* it. It cannot extend that deadline and does not try.
setRevealed(false);
const handle = setTimeout(() => setRevealed(true), revealAfterMs);
return () => clearTimeout(handle);
}, [input.generation, input.timing.paused, revealAfterMs]);
return (
<section className="fixture-trivia" data-testid="fixture-trivia">
<h1 data-testid="fixture-trivia-question">{question}</h1>
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
</section>
);
}
/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness"
* is a property that can be observed rather than assumed. */
export interface RendererReadiness {
readonly whenReady: Promise<void>;
ready(): void;
}
export function createReadiness(): RendererReadiness {
let settle: (() => void) | undefined;
const whenReady = new Promise<void>((resolve) => {
settle = resolve;
});
return {
ready(): void {
settle?.();
},
whenReady,
};
}
export interface FixtureRendererCounters {
mounts: number;
updates: number;
unmounts: number;
}
export function createFixtureRenderer(options: {
readonly readiness?: RendererReadiness;
readonly counters?: FixtureRendererCounters;
}) {
const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 };
const definition = defineModuleContribution({
id: "fixture.trivia.slide",
version: "1.0.0",
async mount(container, context) {
counters.mounts += 1;
const root: Root = createRoot(container as Element);
root.render(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
// Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for
// a font resolves only when it is honestly ready, and the stage's fade waits for it.
if (options.readiness) {
await options.readiness.whenReady;
}
return {
unmount() {
counters.unmounts += 1;
root.unmount();
},
update(input) {
counters.updates += 1;
root.render(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
},
};
},
});
return { counters, definition };
}