feat: initial Signage 0.1.0
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user