feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it } from "vitest";
import { ingestNativeImage } from "../src/server/images/service.js";
import {
createPlaybackService,
type SignagePlaybackService,
} from "../src/server/playback/service.js";
import {
createNativeImageProvider,
SIGNAGE_NATIVE_LIBRARY_SOURCE,
} from "../src/server/providers/native-provider.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
import { makeImage } from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
/**
* The scenarios a church would check before trusting this on a wall.
*
* Most of them are proven in the suite that owns the behaviour — provider policy in
* `providers.test.ts`, transport in `playback.test.ts`, transitions in `browser/scene.test.ts`. What
* lives here is the handful that cross those seams, plus the two sweeps that are only meaningful
* across the whole module.
*/
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourceDirectory = path.join(path.dirname(moduleDirectory), "src");
/**
* Strips comments before scanning.
*
* These sweeps are about what the code *does*, and this module's comments deliberately name the
* things it refuses to use — "never reads `process.env`", "no MQTT" — which is exactly the prose
* that makes the refusal legible. Scanning it would punish saying so.
*/
function withoutComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//gu, " ").replace(/(^|[^:])\/\/.*$/gmu, "$1");
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await sourceFiles(full)));
} else if (/\.(?:ts|tsx|css)$/u.test(entry.name)) {
files.push(full);
}
}
return files;
}
describe("Signage on its own, with no content module installed", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let clock: number;
let counter: number;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(async () => {
clock = Date.parse("2026-08-30T15:00:00.000Z");
counter = 0;
store = createMemorySignageStore({ now: () => new Date(clock) });
storage = createFakeStorage({ now: () => new Date(clock) });
// Nothing but Signage's own image provider. No Announcements, no Trivia, no Scripture.
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
registry.capability.register(
createNativeImageProvider({
defaultDurationMs: SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs,
now: () => new Date(clock),
store,
}).registration,
);
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: SIGNAGE_DEFAULT_SETTINGS,
store,
});
await service.ensureDefaultGroup();
});
async function upload(name: string): Promise<string> {
const result = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: name,
expiresAt: null,
filename: `${name}.png`,
storage,
store,
});
return result.image.id;
}
it("plays a loop of native images, with nothing else installed", async () => {
const first = await upload("welcome");
// A second image must have different bytes, or content addressing would deduplicate it.
const second = (
await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 900, width: 1600 }),
displayName: "giving",
expiresAt: null,
filename: "giving.png",
storage,
store,
})
).image.id;
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
let timeline = await createTimeline({ name: "Lobby loop", newId, store });
timeline = await addProviderSource({
label: "All Signage images",
newId,
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
sourceId: SIGNAGE_NATIVE_LIBRARY_SOURCE,
store,
timelineId: timeline.timeline.id,
});
const source = timeline.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
const played: (string | undefined)[] = [];
for (let index = 0; index < 3; index += 1) {
const snapshot = await service.snapshot(groupId);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
played.push(selection?.itemId);
clock += SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs;
await service.reconcile(groupId, "timer");
}
// Signage works out of the box: its own provider, its own images, its own rotation.
expect(played).toEqual([first, second, first]);
});
it("gives two displays that connected at different moments the same slot and deadline", async () => {
await upload("welcome");
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
const timeline = await createTimeline({ name: "Lobby loop", newId, store });
await addSlot({
newId,
pinned: { itemId: await upload("second"), providerId: SIGNAGE_NATIVE_PROVIDER_ID },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
// One display asks now; another asks two seconds later, mid-slot.
const early = await service.snapshot(groupId);
clock += 2000;
const late = await service.snapshot(groupId);
// Both read the same persisted absolute instants. There is no countdown to drift, so two screens
// that joined at different moments transition together to the millisecond.
expect(late.currentSelectionId).toBe(early.currentSelectionId);
expect(late.startedAtMs).toBe(early.startedAtMs);
expect(late.deadlineAtMs).toBe(early.deadlineAtMs);
// And what each of them has left is computed from that one deadline, not from when it arrived.
expect((early.remainingMs ?? 0) - (late.remainingMs ?? 0)).toBe(2000);
});
});
describe("what this release deliberately does not contain", () => {
it("names no excluded technology anywhere in its source", async () => {
const files = await sourceFiles(sourceDirectory);
const contents = await Promise.all(
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
);
// Every one of these is explicitly out of scope for this release. A single mention would mean
// something crept in that an operator would then have to support.
const forbidden = [
"mqtt",
"home-assistant",
"homeassistant",
"socket.io",
"socketio",
"sqlite",
"better-auth",
"@aws-sdk",
"s3client",
"minio",
"seaweedfs",
"nginx",
"docker-compose",
];
for (const { file, text } of contents) {
const lowered = text.toLowerCase();
for (const term of forbidden) {
expect(`${path.relative(sourceDirectory, file)}:${term}`).toBe(
lowered.includes(term)
? `IMPOSSIBLE:${term}`
: `${path.relative(sourceDirectory, file)}:${term}`,
);
}
}
});
it("hardcodes no platform URL, origin, or environment variable", async () => {
const files = await sourceFiles(sourceDirectory);
const contents = await Promise.all(
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
);
for (const { file, text } of contents) {
const relative = path.relative(sourceDirectory, file);
// Ekklesia's module API prefix is its private deployment detail; a module that wrote it would
// be unhostable anywhere else.
expect(`${relative}:${text.includes("/api/v1/")}`).toBe(`${relative}:false`);
// Configuration reaches Signage through one contract, and it is not the environment.
expect(`${relative}:${/process\.env/u.test(text)}`).toBe(`${relative}:false`);
// Only comments and documentation may mention a real host; nothing may fetch one.
const fetchesHost = /(?:fetch|request)\(\s*["'`]https?:\/\//u.test(text);
expect(`${relative}:${fetchesHost}`).toBe(`${relative}:false`);
}
});
it("declares no dependency this release excludes", async () => {
const manifest = JSON.parse(
await readFile(path.join(path.dirname(moduleDirectory), "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
const names = [
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.devDependencies ?? {}),
];
for (const forbidden of [
"mqtt",
"socket.io",
"socket.io-client",
"better-auth",
"better-sqlite3",
"@aws-sdk/client-s3",
"minio",
"pg",
]) {
expect(names).not.toContain(forbidden);
}
// The database is reached only through the platform's facade; there is no client of Signage's own.
expect(names).toContain("drizzle-orm");
});
});
+366
View File
@@ -0,0 +1,366 @@
// @vitest-environment happy-dom
import { act } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
import { rendererInputFor } from "../../src/browser/scene-request.js";
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost } from "./support/fake-host.js";
import {
createFixtureRenderer,
createReadiness,
type FixtureRendererCounters,
} from "./support/fixture-renderer.js";
/**
* A provider's live React renderer, on a Signage slide.
*
* The renderer here is test-only and is built with the platform's own `defineModuleContribution`, so
* what these prove is the real contract: Signage hands it identity, timing, and an opaque payload,
* mounts it through the host, waits for it to say it is ready, updates it in place, and takes it
* down deterministically — and never learns anything about what it draws.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const SELECTION = {
availableUntilMs: null,
deadlineAtMs: 20_000,
durationMs: 20_000,
id: "sel-trivia",
itemId: "question-1",
itemRevision: "1",
payload: { answer: "Bethlehem", question: "Where was Jesus born?", revealAfterMs: 2000 },
presentation: null,
providerId: "fixture.trivia",
renderer: {
contributionId: "fixture.trivia.slide",
contributionVersion: "1.0.0",
kind: "contribution" as const,
},
revision: 1,
slotId: "slot-trivia",
startedAtMs: 0,
withdrawn: false,
};
const CONTEXT = {
audienceKey: "default",
localNowMs: 1_000_000,
paused: false,
remainingMs: null,
serverNowMs: 5000,
};
async function settle(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
describe("a provider's contributed renderer", () => {
let root: HTMLElement;
let counters: FixtureRendererCounters;
let stage: SceneStage;
function mountFixture(readiness?: ReturnType<typeof createReadiness>) {
const fixture = createFixtureRenderer({ counters, ...(readiness ? { readiness } : {}) });
return createSceneStage({
crossfadeMs: 0,
mountContribution: async ({ container, input }) =>
(await fixture.definition.mount(container, {
contributionId: fixture.definition.id,
input: input as never,
})) ?? {},
root,
setTimer: (handler) => {
handler();
return undefined;
},
});
}
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
counters = { mounts: 0, unmounts: 0, updates: 0 };
});
it("is not shown until it says it is ready", async () => {
const readiness = createReadiness();
stage = mountFixture(readiness);
const showing = stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
await settle();
// Mounted, rendering, and still invisible: the stage waits for the renderer's own promise.
expect(counters.mounts).toBe(1);
const layers = [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
expect(layers.map((layer) => layer.style.opacity)).toEqual(["0", "0"]);
readiness.ready();
await act(async () => {
await showing;
});
expect(layers.filter((layer) => layer.style.opacity === "1")).toHaveLength(1);
});
it("runs its own multi-stage presentation inside one Signage slide", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Where was Jesus born?",
);
// The answer has not been revealed yet: that is the renderer's own timing, inside one slot whose
// deadline Signage set and the renderer cannot extend.
expect(root.querySelector("[data-testid='fixture-trivia-answer']")).toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2100));
});
expect(root.querySelector("[data-testid='fixture-trivia-answer']")?.textContent).toBe(
"Bethlehem",
);
// Still one mount, one slide. Signage never learned that anything happened.
expect(counters.mounts).toBe(1);
expect(counters.updates).toBe(0);
});
it("computes its countdown from the clock anchor, not the device's clock", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, {
...CONTEXT,
// A television whose clock is a year out. The anchor is what makes this right.
localNowMs: Date.now(),
serverNowMs: 5000,
}),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
// Deadline 20s, server now 5s: fifteen seconds left, whatever the device believes.
expect(root.querySelector("[data-testid='fixture-trivia-remaining']")?.textContent).toBe("15s");
});
it("renders a different audience differently, from one logical selection", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, { ...CONTEXT, audienceKey: "online" }),
key: "sel-trivia:online",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-audience']")?.textContent).toBe(
"online",
);
});
it("takes new content in place, without remounting or restarting its stage", async () => {
stage = mountFixture();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(SELECTION, CONTEXT),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor(
{
...SELECTION,
payload: { answer: "Bethlehem", question: "Cancelled", revealAfterMs: 2000 },
revision: 2,
},
CONTEXT,
),
key: "sel-trivia:default",
kind: "contribution",
});
});
await settle();
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Cancelled",
);
expect(counters.mounts).toBe(1);
expect(counters.updates).toBe(1);
expect(counters.unmounts).toBe(0);
});
it("mounts and unmounts repeatedly without leaking", async () => {
stage = mountFixture();
for (let index = 0; index < 4; index += 1) {
await act(async () => {
await stage.show({
contributionId: "fixture.trivia.slide",
input: rendererInputFor({ ...SELECTION, id: `sel-${index}` }, CONTEXT),
key: `sel-${index}:default`,
kind: "contribution",
});
});
await settle();
}
await act(async () => {
await stage.destroy();
});
// Four mounts, four unmounts, and nothing left in the DOM.
expect(counters.mounts).toBe(4);
expect(counters.unmounts).toBe(4);
expect(root.querySelector("[data-testid='fixture-trivia']")).toBeNull();
});
it("keeps working from cached state with no network at all", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const fixture = createFixtureRenderer({ counters });
const host = createFakeHost({
contributions: [
{ capability: "signage.slide-renderer", id: "fixture.trivia.slide", version: "1.0.0" },
],
mount: async (options) => {
const instance = await fixture.definition.mount(options.container, {
contributionId: options.contributionId,
input: options.input,
});
return {
contributionId: options.contributionId,
mounted: true,
unmount: async () => {
await instance?.unmount?.();
},
update: async (input) => {
await instance?.update?.(input);
},
version: "1.0.0",
};
},
permissions: [],
routes: [],
});
const state: DisplayChannelStateView = {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current: SELECTION,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: SELECTION.id,
currentSlotId: SELECTION.slotId,
deadlineAtMs: 20_000,
pendingTimelineId: null,
remainingMs: 20_000,
revision: 1,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [SELECTION],
serverNowMs: 5000,
staticHold: null,
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 1,
slots: [
{
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 20_000,
id: "slot-trivia",
position: 0,
},
],
},
};
// One successful online state, persisted.
const online = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await act(async () => {
await online.start();
await online.apply(state);
});
await settle();
await act(async () => {
await online.stop();
});
// A fresh controller with the same local storage and no server at all.
const offlineRoot = document.createElement("div");
document.body.append(offlineRoot);
const offline = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root: offlineRoot,
setTimer: () => undefined,
store,
telemetry: false,
});
await act(async () => {
await offline.start();
});
await settle();
// The renderer is on screen, from its cached artifact and this display's cached payload — with
// no request made and nothing invented.
expect(offlineRoot.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
"Where was Jesus born?",
);
expect(host.requests).toEqual([]);
expect(offline.diagnostics().offline).toBe(true);
await act(async () => {
await offline.stop();
});
});
});
+471
View File
@@ -0,0 +1,471 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
type DisplayController,
} from "../../src/display/controller.js";
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
/**
* The display, driven end to end against a host: register, fetch state, render, report.
*
* Every collaborator is injected, so what these prove is the display's own decisions — which frame
* it keeps when something fails, what it caches and under what key, what it tells the server, and
* what it never puts on a wall.
*/
const NATIVE_SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function nativeSelection(id: string, itemRevision = "rev-1") {
return {
availableUntilMs: null as number | null,
deadlineAtMs: 5000,
durationMs: 5000,
id,
itemId: `image-${id}`,
itemRevision,
media: {
display: {
expiresAtMs: 900_000,
height: 1080,
url: `https://cdn.invalid/${id}?sig=a`,
width: 1920,
},
thumbnail: {
expiresAtMs: 900_000,
height: 270,
url: `https://cdn.invalid/${id}-t?sig=a`,
width: 480,
},
},
payload: { kind: "native-image" },
presentation: null,
providerId: "signage.native-images",
renderer: { kind: "native-image" as const },
revision: 1,
scene: NATIVE_SCENE,
slotId: "slot-1",
stale: false,
startedAtMs: 0,
withdrawn: false,
};
}
function contributionSelection(id: string, revision = 1) {
return {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id,
itemId: "advent",
itemRevision: String(revision),
payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" },
presentation: null,
providerId: "fixture.announcements",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
kind: "contribution" as const,
},
revision,
slotId: "slot-1",
stale: false,
startedAtMs: 0,
withdrawn: false,
};
}
function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannelStateView {
// `??` would fall through on an explicit `null`, which is exactly the case these tests care about.
const current = "current" in overrides ? overrides.current : nativeSelection("sel-1");
return {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current,
playback: {
activeTimelineId: "timeline-1" as string | null,
blackout: false,
currentSelectionId: current?.id ?? null,
currentSlotId: current?.slotId ?? null,
deadlineAtMs: 5000 as number | null,
pendingTimelineId: null as string | null,
remainingMs: 5000 as number | null,
revision: 3,
selectionRevision: current?.revision ?? null,
startedAtMs: 0 as number | null,
transport: "playing",
...overrides.playback,
},
selections: overrides.selections ?? (current ? [current] : []),
serverNowMs: 1000,
staticHold: overrides.staticHold ?? null,
timeline: overrides.timeline ?? null,
...(overrides.channel ? { channel: overrides.channel } : {}),
};
}
const SESSION = {
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
};
describe("display controller", () => {
let root: HTMLElement;
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
});
async function settle(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function build(options: {
readonly host: ReturnType<typeof createFakeHost>;
readonly media?: ReturnType<typeof createMemoryMediaCache>;
readonly store?: ReturnType<typeof createMemoryStore>;
readonly telemetry?: boolean;
}): DisplayController {
return createDisplayController({
context: options.host.context,
decodeImage: () => Promise.resolve(),
media: options.media ?? createMemoryMediaCache(),
newViewerKey: () => "viewer-fixed-key-0001",
root,
// No real timers: the refresh schedule is exercised by calling `apply` directly.
setTimer: () => undefined,
store: options.store ?? createMemoryStore(),
...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }),
});
}
it("registers with a persistent identity and keeps it across restarts", async () => {
const store = createMemoryStore();
const keys: unknown[] = [];
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: ({ init }) => {
keys.push(JSON.parse(String(init?.body)));
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
const first = build({ host, store });
await first.start();
await settle();
await first.stop();
const second = build({ host, store });
await second.start();
await settle();
await second.stop();
// The same key both times: a television that reboots is the same screen, not a new one.
expect(keys).toEqual([
{ viewerKey: "viewer-fixed-key-0001" },
{ viewerKey: "viewer-fixed-key-0001" },
]);
});
it("renders the server's current selection and reports what it is showing", async () => {
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
const controller = build({ host });
await controller.start();
await settle();
const diagnostics = controller.diagnostics();
expect(diagnostics.renderedSelectionId).toBe("sel-1");
expect(diagnostics.slate).toBe(false);
expect(root.querySelectorAll(".signage-layer")).toHaveLength(2);
await controller.stop();
});
it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => {
const media = createMemoryMediaCache();
const stored: string[] = [];
const wrapped = {
...media,
store: (key: string, url: string) => {
stored.push(key);
return media.store(key, url);
},
};
const host = createFakeHost({ permissions: [], routes: [] });
const controller = build({ host, media: wrapped, telemetry: false });
await controller.start();
await controller.apply(state());
// The same bytes, a new signature — exactly what the next refresh carries.
const resigned = nativeSelection("sel-1");
Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" });
await controller.apply(state({ current: resigned }));
await settle();
// One store, because the key is built from provider, item, and item revision — never from the
// URL, which changes every time.
expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]);
await controller.stop();
});
it("updates a mounted renderer in place when only the payload changed", async () => {
const mounts: unknown[] = [];
const updates: unknown[] = [];
const host = createFakeHost({
contributions: [
{
capability: "signage.slide-renderer",
id: "fixture.announcements.slide",
version: "1.0.0",
},
],
mount: (options) => {
mounts.push(options.contributionId);
return {
contributionId: options.contributionId,
mounted: true,
unmount: () => Promise.resolve(),
update: (input) => {
updates.push(input);
return Promise.resolve();
},
version: "1.0.0",
};
},
permissions: [],
routes: [],
});
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state({ current: contributionSelection("sel-1", 1) }));
await controller.apply(state({ current: contributionSelection("sel-1", 2) }));
await settle();
// One mount, one update: a cancelled event changes the slide without restarting its slot.
expect(mounts).toEqual(["fixture.announcements.slide"]);
expect(updates).toHaveLength(1);
await controller.stop();
});
it("keeps the last good frame and records a code when a renderer is not published", async () => {
const host = createFakeHost({ contributions: [], permissions: [], routes: [] });
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state());
const good = controller.diagnostics().renderedSelectionId;
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
await settle();
const diagnostics = controller.diagnostics();
// Nothing changed on screen, and the reason is a bounded code — never a message on a wall.
expect(diagnostics.renderedSelectionId).toBe(good);
expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE");
expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]);
await controller.stop();
});
it("does not retry a selection it already knows it cannot render", async () => {
let mountAttempts = 0;
const host = createFakeHost({
contributions: [
{
capability: "signage.slide-renderer",
id: "fixture.announcements.slide",
version: "1.0.0",
},
],
mount: () => {
mountAttempts += 1;
return Promise.reject(new Error("renderer exploded"));
},
permissions: [],
routes: [],
});
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
await settle();
// One attempt. A broken item never becomes a tight retry loop on an unattended screen.
expect(mountAttempts).toBe(1);
await controller.stop();
});
it("shows the slate only when it has nothing at all, and never over a good frame", async () => {
const host = createFakeHost({ permissions: [], routes: [] });
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state({ current: null, selections: [] }));
await settle();
expect(root.dataset.slate).toBe("true");
expect(controller.diagnostics().slate).toBe(true);
await controller.apply(state());
await settle();
expect(root.dataset.slate).toBe("false");
// Content disappears again — but a good frame is up, so the wall keeps it rather than flashing
// the slate at a congregation.
await controller.apply(state({ current: null, selections: [] }));
await settle();
expect(root.dataset.slate).toBe("false");
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
it("covers the output for a blackout while the frame underneath stays prepared", async () => {
const host = createFakeHost({ permissions: [], routes: [] });
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state());
await controller.apply(state({ playback: { blackout: true } as never }));
await settle();
expect(root.dataset.blackout).toBe("true");
// The hidden timeline is still what it was: ending the blackout shows this frame, not a slate.
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
const beats: unknown[] = [];
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => jsonResponse(state()),
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: ({ init }) => {
beats.push(JSON.parse(String(init?.body)));
return jsonResponse({ acknowledged: true, recorded: true });
},
},
],
});
const controller = build({ host });
await controller.start();
await settle();
expect(beats.length).toBeGreaterThan(0);
const beat = beats[0] as Record<string, unknown>;
expect(Object.keys(beat).sort()).toEqual([
"cachedAssetCount",
"error",
"offlineReady",
"reportedContributionId",
"reportedPlaybackRevision",
"reportedProviderId",
"reportedSelectionId",
"reportedTimelineId",
]);
// Identity, counts, and a code. No URL, no payload, no exception, no credential.
const serialized = JSON.stringify(beat);
expect(serialized).not.toContain("https://");
expect(serialized).not.toContain("payload");
await controller.stop();
});
it("keeps the frame when the server cannot be reached, and says so quietly", async () => {
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
const controller = build({ host });
await controller.start();
await settle();
const before = controller.diagnostics().renderedSelectionId;
// Every request now fails, exactly as a dropped uplink does.
Object.assign(host.context.api, {
request: () => Promise.reject(new Error("network is gone")),
});
await controller.apply(state());
await settle();
expect(controller.diagnostics().renderedSelectionId).toBe(before);
// Nothing about the failure is on screen: the slate is for having no content, not no network.
expect(root.dataset.slate).toBe("false");
await controller.stop();
});
it("registers no viewer and sends no heartbeat when telemetry is off", async () => {
const host = createFakeHost({
permissions: [],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{ method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) },
],
});
const controller = build({ host, telemetry: false });
await controller.start();
await controller.apply(state());
await settle();
// The management preview renders exactly what a wall renders and appears in no roster.
expect(host.requests).toEqual([]);
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
});
+493
View File
@@ -0,0 +1,493 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
/**
* The management UI's behaviour, mounted as React rather than inspected as source.
*
* The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read
* a screen, press a control, be told why a save was refused — against a host that answers only the
* relative paths Signage actually asks for.
*/
// React needs this to run effects the way a browser does.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
async function render(element: React.ReactElement): Promise<void> {
await act(async () => {
root.render(element);
});
// Let the resource loads settle.
for (let index = 0; index < 6; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
async function click(element: Element | null | undefined): Promise<void> {
if (!element) {
throw new Error("Expected an element to click.");
}
await act(async () => {
(element as HTMLElement).click();
});
for (let index = 0; index < 6; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
/**
* Types into a controlled React input the way a person does.
*
* Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on
* the element and would treat the change as already handled. Going through the prototype's setter is
* what makes React see it.
*/
async function type(input: HTMLInputElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function buttonNamed(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
const GROUP = {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: "sel-1",
currentSlotId: "slot-1",
deadlineAtMs: 1_700_000_005_000,
groupId: "group-1",
pendingTimelineId: null,
remainingMs: 4000,
revision: 7,
scheduledActivationAtMs: null,
scheduledTimelineId: null,
selectionRevision: 1,
serverNowMs: 1_700_000_001_000,
startedAtMs: 1_700_000_000_000,
staticAssetId: null,
transport: "playing" as const,
},
revision: 1,
};
const PLAYBACK_DETAIL = {
current: {
deadlineAtMs: 1_700_000_005_000,
durationMs: 5000,
id: "sel-1",
itemId: "welcome",
itemRevision: "1",
providerId: "fixture.announcements",
rendererContributionId: "fixture.announcements.slide",
revision: 1,
stale: false,
startedAtMs: 1_700_000_000_000,
withdrawn: false,
},
history: [
{
id: "sel-0",
itemId: "giving",
providerId: "fixture.announcements",
sequence: 1,
startedAtMs: 1_699_999_990_000,
},
],
snapshot: GROUP.playback,
};
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
describe("Signage dashboard", () => {
it("shows live transport for the default channel group and drives it with one command each", async () => {
const commands: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "POST",
path: /^playback\/group-1\//u,
respond: ({ path }) => {
commands.push(path);
return jsonResponse(GROUP.playback);
},
},
],
});
await render(<SignageDashboard context={host.context} />);
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
"welcome",
);
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
await click(buttonNamed("Pause"));
await click(buttonNamed("Next"));
await click(buttonNamed("Blackout"));
expect(commands).toEqual([
"playback/group-1/pause",
"playback/group-1/next",
"playback/group-1/blackout",
]);
});
it("offers no transport controls to an operator who may only view", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
],
});
await render(<SignageDashboard context={host.context} />);
// The current slide is still legible; the buttons that would be refused are simply not offered.
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
});
it("shows Signage's own sanitized reason when a save is refused", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{
method: "POST",
path: "timelines",
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
),
);
const input = container.querySelector("input[type='text']") as HTMLInputElement;
await type(input, "Sunday morning");
await click(buttonNamed("Create"));
const alert = container.querySelector("[role='alert']");
expect(alert?.textContent).toBe("The request is not valid.");
// The server's own fixed message, and nothing else: no status code, no URL, no request detail.
expect(alert?.textContent).not.toMatch(/400|http|timelines/u);
});
it("says a timeline has no slots rather than showing an empty table", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{
method: "GET",
path: "timelines",
respond: () =>
jsonResponse({
timelines: [
{
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
},
],
}),
},
{
method: "GET",
path: "timelines/timeline-1",
respond: () =>
jsonResponse({
slots: [],
sources: [],
timeline: {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
},
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
),
);
await click(buttonNamed("Sunday morning"));
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
expect(container.textContent).toContain("This timeline has no slots");
});
it("reports a provider whose renderer the platform did not publish", async () => {
const host = createFakeHost({
// The platform published nothing against Signage's renderer capability — the contributing
// module is absent, disabled, or outside the compatible range.
contributions: [],
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{
method: "GET",
path: "providers",
respond: () =>
jsonResponse({
providers: [
{
consecutiveFailures: 0,
contractVersion: "1.0.0",
defaultDurationMs: 8000,
lastFailureAt: null,
lastFailureCode: null,
lastSuccessAt: "2026-08-30T15:00:00.000Z",
providerId: "fixture.announcements",
providerVersion: "1.0.0",
registeredAt: "2026-08-30T14:00:00.000Z",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
},
},
],
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
),
);
expect(container.textContent).toContain("Renderer not published");
// Which is a fact only the browser holds: the server cannot know what the host published.
});
it("says Signage works without providers rather than looking broken", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
),
);
expect(container.textContent).toContain("No content providers registered");
expect(container.textContent).toContain("Signage works on its own with native images");
});
it("shows the display roster with online state and offline readiness", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{
method: "GET",
path: "viewers",
respond: () =>
jsonResponse({
viewers: [
{
cachedAssetCount: 12,
channelId: "chan-1",
deviceId: null,
id: "viewer-1",
lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null },
lastSeenAtMs: Date.now() - 4000,
name: "Lobby north",
offlineReady: true,
online: true,
reportedContributionId: null,
reportedProviderId: "fixture.announcements",
reportedSelectionId: "sel-1",
reportedTimelineId: "timeline-1",
},
],
}),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
);
expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached");
// A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
});
it("uploads one request per file and reports each one's own outcome", async () => {
const uploaded: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{
method: "POST",
path: /^images\?/u,
respond: ({ path }) => {
uploaded.push(path);
return path.includes("broken")
? errorResponse(
"SIGNAGE_IMAGE_MALFORMED",
"The uploaded file is not a decodable image.",
422,
)
: jsonResponse({ deduplicated: false, image: {} }, 201);
},
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
);
const input = container.querySelector(
"[data-testid='signage-upload-input']",
) as HTMLInputElement;
// happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a
// real picker fires, with file-shaped values.
Object.defineProperty(input, "files", {
configurable: true,
value: [
{ name: "welcome.png", size: 1024, type: "image/png" },
{ name: "broken.png", size: 1024, type: "image/png" },
],
});
await act(async () => {
input.dispatchEvent(new Event("change", { bubbles: true }));
});
for (let index = 0; index < 10; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
// Two files, two requests — each with its own name in the query string and the bytes as the body.
expect(uploaded).toHaveLength(2);
expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true);
const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done");
expect(uploads?.textContent).toContain("failed");
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
});
it("stays on screen when one panel's data cannot be loaded", async () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () =>
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
},
],
});
await render(<SignageDashboard context={host.context} />);
expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain("The operation could not be completed.");
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
});
});
+170
View File
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { parseModuleUiExport } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import { createFakeHost, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
/**
* The end-to-end proof for Signage's browser artifacts:
*
* React/TSX source -> `pnpm build` -> the artifacts the manifest declares -> `parseModuleUiExport`
* -> `mount(container, context)` -> `unmount()`
*
* Both artifacts are rebuilt from source here rather than read from a checked-in file, so this can
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
*/
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
const moduleDirectory = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
let identity: { id: string; version: string };
let uiArtifactPath: string;
let uiArtifactHref: string;
let displayArtifactPath: string;
let displayArtifactHref: string;
async function flush(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("Signage browser artifacts", () => {
beforeAll(async () => {
const manifest = parseModuleManifest(
JSON.parse(
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
) as unknown,
);
identity = { id: manifest.id, version: manifest.version };
const result = await buildModuleUi({ moduleDirectory });
const ui = result.artifacts.find((artifact) => artifact.kind === "ui");
const display = result.artifacts.find((artifact) => artifact.kind === "display");
if (!ui || !display) {
throw new Error("The Signage build must produce both declared browser artifacts.");
}
uiArtifactPath = ui.outputFile;
uiArtifactHref = pathToFileURL(ui.outputFile).href;
displayArtifactPath = display.outputFile;
displayArtifactHref = pathToFileURL(display.outputFile).href;
}, 180_000);
it("produces self-contained artifacts that bake in no Ekklesia deployment detail", async () => {
const sources = await Promise.all([
readFile(uiArtifactPath, "utf8"),
readFile(displayArtifactPath, "utf8"),
]);
for (const source of sources) {
// React is bundled, so each artifact resolves nothing at run time (ADR 0023).
expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(source).toContain("export {");
expect(source).not.toContain("/api/v1/modules");
expect(source).not.toContain("/api/v1/module-ui");
expect(source).not.toContain("process.env.NODE_ENV");
// Nothing this release excludes may reach a browser artifact.
for (const forbidden of ["mqtt", "socket.io", "homeassistant", "sqlite"]) {
expect(source.toLowerCase()).not.toContain(forbidden);
}
}
});
it("mounts the management UI, asks for relative paths only, and unmounts", async () => {
const namespace = (await import(uiArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
document.body.append(container);
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
],
});
const unmount = await definition.mount(container, host.context);
await flush();
expect(container.textContent).toContain("Playback");
// Relative to Signage's own API root, always. A route table keyed on Ekklesia's private prefix
// would never have matched.
for (const request of host.requests) {
expect(request.path.startsWith("api/")).toBe(false);
expect(request.path.startsWith("/")).toBe(false);
}
// The desktop-only gate is a CSS decision, so both branches are always in the accessibility tree.
expect(container.querySelector("[data-testid='signage-narrow-notice']")).not.toBeNull();
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
container.remove();
});
it("renders a denied state from the host's presentation-only permission hint", async () => {
const namespace = (await import(uiArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
const host = createFakeHost({ permissions: [], routes: [] });
const unmount = await definition.mount(container, host.context);
await flush();
expect(container.textContent).toContain("No access to Signage");
// A hidden control is a usability choice, not enforcement: Signage simply did not ask.
expect(host.requests).toEqual([]);
await (unmount as () => Promise<void>)();
});
it("mounts the display through the same ABI and shows the safe slate", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, identity);
const container = document.createElement("div");
const host = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
const unmount = await definition.mount(container, host.context);
await flush();
const slate = container.querySelector("[data-testid='signage-slate']");
expect(slate?.textContent).toBe("Content unavailable");
// The slate must never carry an exception, URL, identifier, or payload of any kind.
expect(slate?.textContent).not.toMatch(/https?:|\/api\/|[0-9a-f]{8}-[0-9a-f]{4}/u);
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
});
it("carries each artifact's own stylesheet inside the one declared file", async () => {
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
expect(ui.styles).toContain(".signage-narrow-notice");
expect(ui.styles).toContain(".ek-panel");
// Signage references the platform's global theme and never declares part of it (ADR 0039).
expect(ui.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
expect(display.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
// Two artifacts of one module: separate roots, separate stylesheets.
expect(display.styles).toContain(".signage-slate");
expect(display.styles).not.toContain(".signage-narrow-notice");
// No sidecar stylesheet is emitted beside either artifact.
await expect(readFile(path.join(path.dirname(uiArtifactPath), "index.css"))).rejects.toThrow();
});
it("sets its own box model, because an isolated root inherits none", async () => {
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
// The host installs base rules and the platform's components, and neither declares `box-sizing`.
// Under the initial `content-box` a control that is `width: 100%` is laid out wider than the
// field around it by its own padding and border, and slides under whatever sits beside it. Each
// artifact says so for the tree it owns; nothing outside that tree is affected.
for (const styles of [ui.styles, display.styles]) {
expect(styles).toMatch(/\*,\s*\*::before,\s*\*::after\s*\{[^}]*box-sizing:\s*border-box/u);
}
});
});
+562
View File
@@ -0,0 +1,562 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
type OfflineSnapshot,
resolveOffline,
serverNowFrom,
snapshotFrom,
} from "../../src/browser/offline.js";
import type { SceneSelection } from "../../src/browser/scene-request.js";
import {
createMemoryMediaCache,
createMemoryStore,
signageMediaKey,
} from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
/**
* Offline continuation: what a screen does when its building's uplink dies.
*
* Every case here is one an unattended television actually meets — a router reboot during a service,
* a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is
* always the same one: the display continues *deterministically from the last authoritative anchor*,
* replaying only what the server already resolved, and never becomes a second authority.
*/
const SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function selection(overrides: Partial<SceneSelection> & { id: string }): SceneSelection {
return {
availableUntilMs: null,
deadlineAtMs: null,
durationMs: 5000,
itemId: `item-${overrides.id}`,
itemRevision: "rev-1",
payload: { kind: "native-image" },
presentation: null,
providerId: "signage.native-images",
renderer: { kind: "native-image" },
revision: 1,
scene: SCENE,
slotId: null,
startedAtMs: null,
withdrawn: false,
...overrides,
};
}
function slot(id: string, position: number, overrides: Record<string, unknown> = {}) {
return {
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 5000,
id,
position,
...overrides,
};
}
function snapshot(overrides: Partial<OfflineSnapshot> = {}): OfflineSnapshot {
return snapshotFrom({
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
localNowMs: 1_000_000,
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 3,
startedAtMs: 0,
transport: "playing",
...overrides.playback,
},
selections: overrides.selections ?? [
selection({ id: "sel-c", slotId: "slot-c" }),
selection({ id: "sel-b", slotId: "slot-b" }),
selection({ id: "sel-a", slotId: "slot-a" }),
],
serverNowMs: 0,
staticHold: overrides.staticHold ?? null,
timeline:
overrides.timeline === undefined
? {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
}
: overrides.timeline,
});
}
describe("offline continuation", () => {
it("reconstructs the server's clock, however wrong the device's is", () => {
const persisted = snapshot();
// The device's clock jumped forward two hours while the screen was off. The anchor is what makes
// the evaluation right anyway: only the *elapsed* local time matters.
expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000);
expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000);
});
it("continues to the next slot at the deadline, using what the server already resolved", () => {
const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() });
expect(evaluation.resolution).toMatchObject({
kind: "selection",
startedAtMs: 5000,
});
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
// The next slot in the pattern, rendered from its own cached answer. Nothing was invented.
expect(evaluation.resolution.selection.id).toBe("sel-b");
expect(evaluation.nextBoundaryMs).toBe(10_000);
});
it("catches up a week in one evaluation rather than replaying every slide", () => {
const week = 7 * 24 * 60 * 60 * 1000;
const started = Date.now();
const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() });
expect(evaluation.resolution.kind).toBe("selection");
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
// Landed inside the current slot, on the server's own phase — and the arithmetic took no
// measurable time, because it is arithmetic.
expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week);
expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000);
expect(Date.now() - started).toBeLessThan(200);
});
it("stays exactly where it was while paused, with its remainder preserved", () => {
const paused = snapshot({
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: null,
pendingTimelineId: null,
remainingMs: 3000,
revision: 4,
startedAtMs: null,
transport: "paused",
},
});
const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused });
// Ten minutes later, on the same slide. Frozen means frozen, offline as much as online.
expect(evaluation.resolution).toMatchObject({ kind: "paused" });
if (evaluation.resolution.kind !== "paused") {
throw new Error("Expected a paused resolution.");
}
expect(evaluation.resolution.selection?.id).toBe("sel-a");
expect(paused.playback.remainingMs).toBe(3000);
});
it("keeps a static hold held, and does not let local age release it", () => {
const held = snapshot({ staticHold: selection({ id: "static-1" }) });
const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held });
expect(evaluation.resolution).toMatchObject({ kind: "static" });
});
it("releases a static hold only when the held image genuinely expired", () => {
const held = snapshot({
staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }),
});
const before = resolveOffline({ atMs: 9000, snapshot: held });
const after = resolveOffline({ atMs: 11_000, snapshot: held });
expect(before.resolution.kind).toBe("static");
// An explicit expiry the server told it about — never a guess made locally.
expect(after.resolution.kind).toBe("selection");
});
it("skips content the server said was withdrawn or expired", () => {
const persisted = snapshot({
selections: [
selection({ id: "sel-c", slotId: "slot-c" }),
selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }),
selection({ id: "sel-a", slotId: "slot-a" }),
],
});
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
// Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an
// empty provider slot — no gap, no error, and certainly no fabricated replacement.
expect(evaluation.resolution).toMatchObject({ kind: "selection" });
if (evaluation.resolution.kind !== "selection") {
throw new Error("Expected a selection.");
}
expect(evaluation.resolution.selection.id).toBe("sel-c");
});
it("holds the last good frame rather than continuing into a timeline it never received", () => {
const pending = snapshot({
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: "timeline-2",
remainingMs: 0,
revision: 5,
startedAtMs: 0,
transport: "playing",
},
});
const evaluation = resolveOffline({ atMs: 6000, snapshot: pending });
// Going deeper into the old timeline would show content the operator has already replaced.
expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" });
});
it("holds when no slot has a cached answer, rather than inventing one", () => {
const persisted = snapshot({ selections: [] });
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
expect(evaluation.resolution).toMatchObject({ kind: "hold" });
});
it("goes black only when the cached timeline genuinely has nothing eligible", () => {
const persisted = snapshot({
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0, { enabled: false })],
},
});
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
expect(evaluation.resolution).toEqual({ kind: "empty" });
});
it("persists no delivery URL, because a stored one is a credential that stops working", () => {
const persisted = snapshotFrom({
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
localNowMs: 0,
playback: {
blackout: false,
currentSelectionId: "sel-a",
currentSlotId: "slot-a",
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [
{
...selection({ id: "sel-a", slotId: "slot-a" }),
media: {
display: { url: "https://cdn.invalid/a?sig=secret" },
thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" },
},
},
],
serverNowMs: 0,
staticHold: null,
timeline: null,
});
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
expect(JSON.stringify(persisted)).not.toContain("https://");
});
});
describe("a display that loses its network", () => {
let root: HTMLElement;
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
});
async function settle(): Promise<void> {
for (let index = 0; index < 12; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function nativeSelection(id: string, slotId: string): SceneSelection {
return {
...selection({ id, slotId }),
media: {
display: { url: `https://cdn.invalid/${id}?sig=a` },
thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
},
} as SceneSelection;
}
function state(currentId: string): DisplayChannelStateView {
const selections = [
nativeSelection("sel-c", "slot-c"),
nativeSelection("sel-b", "slot-b"),
nativeSelection("sel-a", "slot-a"),
];
const current = selections.find((candidate) => candidate.id === currentId);
return {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current: current ?? null,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: currentId,
currentSlotId: current?.slotId ?? null,
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision: 3,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections,
serverNowMs: 0,
staticHold: null,
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 2,
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
},
};
}
it("keeps rendering from cached bytes and cached answers when the server goes away", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
let reachable = true;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () =>
jsonResponse({
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
}),
},
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => {
if (!reachable) {
throw new Error("network is gone");
}
return jsonResponse(state("sel-a"));
},
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
let clock = 1_000_000;
// The armed timer is captured rather than run, so the test decides when the safety-net refresh
// and the offline evaluation happen — no real time passes.
let armed: (() => void) | undefined;
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
newViewerKey: () => "viewer-fixed-key-0001",
now: () => clock,
root,
setTimer: (handler) => {
armed = handler;
return handler;
},
store,
});
await controller.start();
await settle();
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
expect(controller.diagnostics().offline).toBe(false);
expect(controller.diagnostics().offlineReady).toBe(true);
// The uplink dies, and the current slot's deadline passes.
reachable = false;
clock += 6000;
armed?.();
await settle();
const diagnostics = controller.diagnostics();
// Local continuation took over: the next slot in the pattern, from bytes and answers this screen
// already had. Nothing was invented, and the slate never appeared.
expect(diagnostics.offline).toBe(true);
expect(diagnostics.renderedSelectionId).toBe("sel-b");
expect(root.dataset.slate).toBe("false");
await controller.stop();
});
it("puts the last known good frame up before the first request returns", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
await media.store(
signageMediaKey({
itemId: "item-sel-a",
itemRevision: "rev-1",
providerId: "signage.native-images",
rendition: "display",
}),
"https://cdn.invalid/sel-a?sig=a",
);
await store.write("last-known-good", snapshot());
let requested = 0;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
requested += 1;
throw new Error("network is gone");
},
},
],
});
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
});
await controller.start();
await settle();
// A frame is up, from persisted state, with no server involved — and the slate never appeared.
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
expect(root.dataset.slate).toBe("false");
expect(requested).toBe(1);
await controller.stop();
});
it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
await store.write("last-known-good", snapshot());
for (const id of ["sel-a", "sel-b", "sel-c"]) {
await media.store(
signageMediaKey({
itemId: `item-${id}`,
itemRevision: "rev-1",
providerId: "signage.native-images",
rendition: "display",
}),
`https://cdn.invalid/${id}?sig=a`,
);
}
const host = createFakeHost({ permissions: [], routes: [] });
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await controller.start();
await settle();
const offlineFrame = controller.diagnostics().renderedSelectionId;
expect(offlineFrame).toBe("sel-a");
expect(controller.diagnostics().offline).toBe(true);
// The server comes back and says something different. It wins immediately.
await controller.apply(state("sel-c"));
await settle();
const diagnostics = controller.diagnostics();
expect(diagnostics.renderedSelectionId).toBe("sel-c");
expect(diagnostics.offline).toBe(false);
// Straight to what the server says. Nothing in between was replayed, and the slate never showed.
expect(root.dataset.slate).toBe("false");
await controller.stop();
});
it("prunes superseded bytes only after the new state is usable", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const pruned: readonly string[][] = [];
const order: string[] = [];
const wrapped = {
...media,
prune: (keep: readonly string[]) => {
order.push("prune");
(pruned as string[][]).push([...keep]);
return media.prune(keep);
},
store: (key: string, url: string) => {
order.push(`store:${key}`);
return media.store(key, url);
},
};
const host = createFakeHost({ permissions: [], routes: [] });
const controller = createDisplayController({
context: host.context,
decodeImage: () => Promise.resolve(),
media: wrapped,
now: () => 1_000_000,
root,
setTimer: () => undefined,
store,
telemetry: false,
});
await controller.start();
await controller.apply(state("sel-a"));
await settle();
// Every store happens before the prune: a screen never has less than it had.
expect(order.at(-1)).toBe("prune");
expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3);
await controller.stop();
});
});
+412
View File
@@ -0,0 +1,412 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import type { SceneRequest } from "../../src/browser/scene.js";
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
import { rendererInputFor, sceneRequestFor } from "../../src/browser/scene-request.js";
/**
* The transition, exercised rather than asserted.
*
* Every collaborator is injected — the decode, the timer, the contribution mount — so what these
* prove is the sequence itself: readiness is awaited, the outgoing layer is never faded, a stale
* completion is discarded, and a failure leaves the last good frame exactly where it was.
*/
const SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function image(key: string, url = `https://cdn.invalid/${key}.webp`): SceneRequest {
return { alt: "", key, kind: "native-image", scene: SCENE, url };
}
describe("scene stage", () => {
let root: HTMLElement;
let decodes: { url: string; resolve: () => void; reject: (error: Error) => void }[];
let stage: SceneStage;
function layers(): HTMLElement[] {
return [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
}
function opacities(): string[] {
return layers().map((layer) => layer.style.opacity);
}
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function resolveDecodes(): void {
while (decodes.length > 0) {
decodes.shift()?.resolve();
}
}
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
decodes = [];
stage = createSceneStage({
crossfadeMs: 1000,
decodeImage: (element) =>
new Promise<void>((resolve, reject) => {
decodes.push({
reject,
resolve,
url: element.getAttribute("src") ?? "",
});
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
});
it("creates exactly two layers, once, and never rebuilds them", async () => {
const before = layers();
expect(before).toHaveLength(2);
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const second = stage.show(image("b"));
await settle();
resolveDecodes();
await second;
// The same two elements. A renderer that rebuilt them per slide is the `v1` defect.
expect(layers()[0]).toBe(before[0]);
expect(layers()[1]).toBe(before[1]);
});
it("waits for a real decode before anything becomes visible", async () => {
const shown = stage.show(image("a"));
await settle();
// The image is in the DOM and the decode has not resolved: nothing is visible yet.
expect(decodes).toHaveLength(1);
expect(opacities()).toEqual(["0", "0"]);
resolveDecodes();
await shown;
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("never fades the outgoing layer, so the black root cannot show through", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const outgoing = layers().find((layer) => layer.style.opacity === "1") as HTMLElement;
const second = stage.show(image("b"));
await settle();
// The incoming layer is prepared; the outgoing one is still fully opaque, underneath.
expect(outgoing.style.opacity).toBe("1");
resolveDecodes();
await second;
// Only after the fade completes does the old layer go to zero — and it is now behind, and empty.
expect(outgoing.style.opacity).toBe("0");
expect(outgoing.style.zIndex).toBe("1");
});
it("keeps the last good frame when incoming content cannot be decoded", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const goodKey = stage.currentKey();
const failing = stage.show(image("broken"));
await settle();
decodes.shift()?.reject(new Error("decode failed"));
const outcome = await failing;
expect(outcome).toEqual({ code: "SLIDE_DECODE_FAILED", kind: "failed" });
// The wall still shows what it showed. A broken slide costs one slide.
expect(stage.currentKey()).toBe(goodKey);
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("discards completion from a superseded transition", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const slow = stage.show(image("b"));
await settle();
const fast = stage.show(image("c"));
await settle();
resolveDecodes();
const [slowOutcome, fastOutcome] = await Promise.all([slow, fast]);
// The newer transition owns the layers; the older one changes nothing.
expect(slowOutcome.kind).toBe("superseded");
expect(fastOutcome.kind).toBe("shown");
expect(stage.currentKey()).toBe("c");
});
it("updates a mounted renderer in place rather than remounting it", async () => {
const updates: unknown[] = [];
let mounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => {
mounts += 1;
return Promise.resolve({
unmount: () => undefined,
update: (input: unknown) => {
updates.push(input);
},
});
},
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const input = rendererInputFor(
{
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "advent",
itemRevision: "1",
payload: { headline: "Sunday" },
presentation: null,
providerId: "fixture.announcements",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
},
{ audienceKey: "default", localNowMs: 0, paused: false, remainingMs: null, serverNowMs: 0 },
);
const request: SceneRequest = {
contributionId: "fixture.announcements.slide",
input,
key: "sel-1:1:default:r",
kind: "contribution",
};
await contributionStage.show(request);
const updated = await contributionStage.show({
...request,
input: { ...input, payload: { headline: "Cancelled" } },
});
// Same key, new input: one mount, one update, no transition — which is what lets a long-running
// screen change content without restarting animations or reloading media.
expect(updated).toEqual({ kind: "updated" });
expect(mounts).toBe(1);
expect(updates).toHaveLength(1);
await contributionStage.destroy();
});
it("keeps the last good frame when a renderer refuses to mount", async () => {
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => Promise.reject(new Error("renderer exploded")),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const outcome = await contributionStage.show({
contributionId: "fixture.announcements.slide",
input: {} as never,
key: "sel-9",
kind: "contribution",
});
expect(outcome).toEqual({ code: "RENDERER_MOUNT_FAILED", kind: "failed" });
expect(contributionStage.currentKey()).toBeUndefined();
await contributionStage.destroy();
});
it("unmounts a renderer deterministically when its layer is reused", async () => {
let unmounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () =>
Promise.resolve({
unmount: () => {
unmounts += 1;
},
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "one",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "two",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "three",
kind: "contribution",
});
await contributionStage.destroy();
// Three mounts, three unmounts: repeated mount and unmount leaks nothing.
expect(unmounts).toBe(3);
});
it("reports blackout and slate as states of the root, so one slate implementation renders both", () => {
stage.setBlackout(true);
expect(root.dataset.blackout).toBe("true");
stage.setBlackout(false);
expect(root.dataset.blackout).toBe("false");
stage.setSlate(true);
expect(root.dataset.slate).toBe("true");
});
});
describe("scene requests", () => {
const base = {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "welcome",
itemRevision: "abc",
payload: { headline: "Welcome" },
presentation: null,
providerId: "fixture.announcements",
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
};
const context = {
audienceKey: "default",
localNowMs: 1000,
paused: false,
remainingMs: null,
serverNowMs: 9000,
};
it("gives a renderer both clocks, so a television with a wrong clock still counts correctly", () => {
const input = rendererInputFor(
{
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
},
context,
);
expect(input.clock).toEqual({ localNowMs: 1000, serverNowMs: 9000 });
expect(input.timing).toMatchObject({ deadlineAtMs: 5000, paused: false, startedAtMs: 0 });
expect(input.contract).toBe("signage.slide-renderer");
// Everything the renderer is given, and nothing else: no API client, no principal, no
// credential, no URL.
expect(Object.keys(input).sort()).toEqual([
"audienceKey",
"clock",
"contract",
"generation",
"itemId",
"itemRevision",
"payload",
"presentation",
"providerId",
"timing",
"version",
]);
});
it("changes the key when the payload revision or pause state changes, and not when the clock does", () => {
const selection = {
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
} as const;
const first = rendererInputFor(selection, context).generation;
const reAnchored = rendererInputFor(selection, {
...context,
localNowMs: 99_999,
serverNowMs: 99_999,
}).generation;
const revised = rendererInputFor({ ...selection, revision: 2 }, context).generation;
const paused = rendererInputFor(selection, { ...context, paused: true }).generation;
// A reconnect re-anchors the clock. Remounting a renderer for that would restart every
// animation on the wall for no reason.
expect(reAnchored).toBe(first);
expect(revised).not.toBe(first);
expect(paused).not.toBe(first);
});
it("declines to build a scene for a renderer the platform did not publish", () => {
const request = sceneRequestFor(
{
...base,
renderer: {
contributionId: "missing.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
},
context,
{ canMountContribution: () => false },
);
// Ordinary rather than exceptional: the contributing module is absent, disabled, or outside
// Signage's compatible range, and the caller keeps its last good frame.
expect(request).toBeUndefined();
});
it("declines to build a native scene with no delivery URL", () => {
const request = sceneRequestFor({ ...base, renderer: { kind: "native-image" } }, context, {
canMountContribution: () => true,
});
expect(request).toBeUndefined();
});
});
+122
View File
@@ -0,0 +1,122 @@
import type {
ModuleContributionDescriptor,
ModuleUiApiClient,
ModuleUiContributions,
ModuleUiHostContext,
MountedModuleContribution,
MountModuleContributionOptions,
} from "@ekklesia/module-ui-sdk";
/**
* A host, as a module's artifact actually experiences one.
*
* Requests are matched on the **relative** path a module asks for, which is what proves the artifact
* never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would
* simply never match.
*/
export interface FakeHostRoute {
readonly method: string;
readonly path: string | RegExp;
readonly respond: (request: {
readonly path: string;
readonly init: RequestInit | undefined;
}) => Response | Promise<Response>;
}
export interface FakeHost {
readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[];
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
export function errorResponse(code: string, message: string, status = 400): Response {
return jsonResponse({ code, message }, status);
}
export interface FakeHostOptions {
readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[];
readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: (
options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution;
}
export function createFakeHost(options: FakeHostOptions): FakeHost {
const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = [];
const api: ModuleUiApiClient = {
async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized });
for (const route of options.routes) {
const matches =
typeof route.path === "string"
? route.path === normalized.split("?")[0]
: route.path.test(normalized);
if (route.method === method && matches) {
return route.respond({ init, path: normalized });
}
}
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
},
};
const contributions: ModuleUiContributions = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
if (options.mount) {
return options.mount(mountOptions);
}
return {
contributionId: mountOptions.contributionId,
mounted: true,
unmount: () => Promise.resolve(),
update: () => Promise.resolve(),
version: "1.0.0",
};
},
};
return {
context: {
api,
contributions,
moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) },
principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
mounted,
requests,
};
}
/** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.0",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
backgroundDarkened: false,
backgroundMode: "blurred",
blurPixels: 20,
},
rendererInputVersion: "1.0.0",
timezone: "America/Chicago",
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
});
+130
View File
@@ -0,0 +1,130 @@
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js";
/**
* A **test-only** browser renderer, standing in for the contribution a real content module will
* publish.
*
* It is not an installed module and has no manifest: it lives under `tests/`, and the only way it
* reaches a screen is a test handing it to the stage the way the platform's contribution loader
* would. It is built with the platform's own `defineModuleContribution`, so what it proves is the
* actual contract rather than a convenient approximation.
*
* It deliberately does everything a demanding real renderer would:
*
* - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it;
* - presents **internal multiple stages** — a question, then its answer — inside one Signage slide,
* which Signage neither knows about nor times;
* - renders time-based text from the input's clock anchor rather than the device's clock;
* - renders differently per audience;
* - updates in place, without remounting;
* - unmounts deterministically, releasing its timer and its React root.
*
* It receives no API client, no realtime, no principal, no storage, and no credential — because the
* contract gives a renderer none of those. Everything it draws comes from the consumer's input.
*/
export interface TriviaPayload {
readonly question?: unknown;
readonly answer?: unknown;
readonly revealAfterMs?: unknown;
}
function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement {
const payload = input.payload as TriviaPayload;
const question = typeof payload.question === "string" ? payload.question : "…";
const answer = typeof payload.answer === "string" ? payload.answer : "";
const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000;
const [revealed, setRevealed] = useState(false);
// The authoritative now, computed from both clocks. A television's own clock is routinely wrong;
// this is what makes a countdown on two screens agree.
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000));
// `generation` is the dependency that matters and the linter cannot see why: it changes when the
// slide's content changes, which is exactly when this stage should restart — and it does *not*
// change when the clock is re-anchored on reconnect, which is exactly when it should not.
// biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point.
useEffect(() => {
if (input.timing.paused) {
return;
}
// Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides
// what happens *within* it. It cannot extend that deadline and does not try.
setRevealed(false);
const handle = setTimeout(() => setRevealed(true), revealAfterMs);
return () => clearTimeout(handle);
}, [input.generation, input.timing.paused, revealAfterMs]);
return (
<section className="fixture-trivia" data-testid="fixture-trivia">
<h1 data-testid="fixture-trivia-question">{question}</h1>
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
</section>
);
}
/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness"
* is a property that can be observed rather than assumed. */
export interface RendererReadiness {
readonly whenReady: Promise<void>;
ready(): void;
}
export function createReadiness(): RendererReadiness {
let settle: (() => void) | undefined;
const whenReady = new Promise<void>((resolve) => {
settle = resolve;
});
return {
ready(): void {
settle?.();
},
whenReady,
};
}
export interface FixtureRendererCounters {
mounts: number;
updates: number;
unmounts: number;
}
export function createFixtureRenderer(options: {
readonly readiness?: RendererReadiness;
readonly counters?: FixtureRendererCounters;
}) {
const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 };
const definition = defineModuleContribution({
id: "fixture.trivia.slide",
version: "1.0.0",
async mount(container, context) {
counters.mounts += 1;
const root: Root = createRoot(container as Element);
root.render(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
// Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for
// a font resolves only when it is honestly ready, and the stage's fade waits for it.
if (options.readiness) {
await options.readiness.whenReady;
}
return {
unmount() {
counters.unmounts += 1;
root.unmount();
},
update(input) {
counters.updates += 1;
root.render(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
},
};
},
});
return { counters, definition };
}
+502
View File
@@ -0,0 +1,502 @@
import sharp from "sharp";
import { beforeEach, describe, expect, it } from "vitest";
import { SignageError } from "../src/server/errors.js";
import { SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS } from "../src/server/images/limits.js";
import { processSignageImage, sha256Hex } from "../src/server/images/processing.js";
import {
deleteNativeImage,
ingestNativeImage,
updateNativeImage,
} from "../src/server/images/service.js";
import { readBoundedBody } from "../src/server/images/streaming.js";
import { isSixteenByNine, resolveNativeScene } from "../src/shared/native-images.js";
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
import {
bytesOf,
GIF_DOCUMENT_BASE64,
makeAnimatedGif,
makeAnimatedWebp,
makeImage,
PDF_DOCUMENT,
SVG_DOCUMENT,
} from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(SignageError);
const signageError = error as SignageError;
expect(signageError.code).toBe(code);
return signageError;
}
throw new Error(`Expected ${code}, but the operation succeeded.`);
}
describe("native image processing", () => {
it("accepts still PNG, JPEG, and WebP and reports the format the bytes actually are", async () => {
for (const format of ["png", "jpeg", "webp"] as const) {
const processed = await processSignageImage(
await makeImage({ format, height: 720, width: 1280 }),
);
expect(processed.format).toBe(format);
expect(processed.width).toBe(1280);
expect(processed.height).toBe(720);
expect(processed.renditions.map((rendition) => rendition.kind)).toEqual([
"display",
"thumbnail",
]);
}
});
it("refuses SVG, PDF, GIF, animation, and anything that is not an image at all", async () => {
await expectSignageCode(
processSignageImage(bytesOf(SVG_DOCUMENT)),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
await expectSignageCode(processSignageImage(bytesOf(PDF_DOCUMENT)), "SIGNAGE_IMAGE_MALFORMED");
await expectSignageCode(
processSignageImage(new Uint8Array(Buffer.from(GIF_DOCUMENT_BASE64, "base64"))),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
await expectSignageCode(
processSignageImage(bytesOf("this is a text file, not a slide")),
"SIGNAGE_IMAGE_MALFORMED",
);
// Animation is detected before format, so an animated GIF reports why it is really refused.
await expectSignageCode(
processSignageImage(await makeAnimatedWebp()),
"SIGNAGE_IMAGE_ANIMATED",
);
await expectSignageCode(processSignageImage(await makeAnimatedGif()), "SIGNAGE_IMAGE_ANIMATED");
});
it("refuses an image whose decoded pixels exceed the budget, whatever the file size is", async () => {
// A decompression bomb is small on disk and enormous decoded, which is exactly why the budget
// is measured in pixels rather than in bytes.
const bomb = await makeImage({ format: "png", height: 9000, width: 9000 });
expect(bomb.byteLength).toBeLessThan(SIGNAGE_IMAGE_MAX_BYTES);
expect(9000 * 9000).toBeGreaterThan(SIGNAGE_IMAGE_MAX_PIXELS);
const error = await expectSignageCode(processSignageImage(bomb), "SIGNAGE_IMAGE_PIXEL_LIMIT");
// The caller learns the limit Signage declared, and nothing about the decoder that enforced it.
expect(error.detail).toEqual({ maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
expect(error.message).not.toMatch(/sharp|vips|pixel limit exceeded/iu);
});
it("fits renditions inside their bounds, and never crops or upscales", async () => {
const tall = await processSignageImage(
await makeImage({ format: "png", height: 2400, width: 1000 }),
);
const display = tall.renditions.find((rendition) => rendition.kind === "display");
// Inside-fit: the long edge reaches the bound, the short edge is proportional, nothing is cut.
expect(display?.height).toBe(1080);
expect(display?.width).toBe(450);
const small = await processSignageImage(
await makeImage({ format: "png", height: 90, width: 160 }),
);
const smallDisplay = small.renditions.find((rendition) => rendition.kind === "display");
// Never upscaled: blowing a small image up to 1920 makes it soft, not better.
expect(smallDisplay?.width).toBe(160);
expect(smallDisplay?.height).toBe(90);
});
it("strips metadata from renditions and flattens transparency against black", async () => {
const transparent = await processSignageImage(
await makeImage({ alpha: true, format: "png", height: 200, width: 400 }),
);
const display = transparent.renditions.find((rendition) => rendition.kind === "display");
if (!display) {
throw new Error("Expected a display rendition.");
}
const metadata = await sharp(display.bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.hasAlpha).toBe(false);
// EXIF, GPS coordinates, and camera serial numbers never travel with a published rendition.
expect(metadata.exif).toBeUndefined();
expect(metadata.icc).toBeUndefined();
expect(metadata.xmp).toBeUndefined();
// The original's own facts are preserved in PostgreSQL instead.
expect(transparent.hasAlpha).toBe(true);
});
it("is deterministic, so a retry of the same bytes produces the same renditions", async () => {
const bytes = await makeImage({ format: "png", height: 600, width: 800 });
const first = await processSignageImage(bytes);
const second = await processSignageImage(bytes);
expect(first.contentSha256).toBe(second.contentSha256);
expect(first.contentSha256).toBe(sha256Hex(bytes));
expect(first.renditions.map((rendition) => rendition.contentSha256)).toEqual(
second.renditions.map((rendition) => rendition.contentSha256),
);
});
});
describe("native image presentation", () => {
it("lets a true 16:9 image fill the frame, within a documented tolerance", () => {
expect(isSixteenByNine(1920, 1080)).toBe(true);
expect(isSixteenByNine(1280, 720)).toBe(true);
// A one-or-two-pixel export artefact is still 16:9 to a person looking at the wall.
expect(isSixteenByNine(1918, 1080)).toBe(true);
expect(isSixteenByNine(1080, 1080)).toBe(false);
expect(isSixteenByNine(2560, 1080)).toBe(false);
expect(isSixteenByNine(0, 0)).toBe(false);
});
it("shows a 16:9 image whole with no background, blur, or shadow", () => {
const scene = resolveNativeScene(
{ height: 1080, width: 1920 },
{
backgroundColor: "#112233",
backgroundDarkened: true,
backgroundMode: "blurred",
blurPixels: 20,
foregroundShadow: true,
},
);
// An image that already fits needs none of the treatments that exist to frame one that does not.
expect(scene).toEqual({
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none",
blurPixels: 0,
darkenOpacity: 0,
fit: "fill",
shadow: false,
});
});
it("shows every other ratio whole, on the background the slot asked for", () => {
const blurred = resolveNativeScene(
{ height: 1080, width: 1080 },
{
backgroundColor: "#112233",
backgroundDarkened: true,
backgroundMode: "blurred",
blurPixels: 24,
foregroundShadow: true,
},
);
expect(blurred).toMatchObject({
backgroundImage: true,
blurPixels: 24,
darkenOpacity: 0.25,
fit: "contain",
shadow: true,
});
const solid = resolveNativeScene(
{ height: 1080, width: 1080 },
{
backgroundColor: "#112233",
backgroundDarkened: false,
backgroundMode: "solid",
blurPixels: 24,
foregroundShadow: true,
},
);
// A solid background is not blurred, and an undarkened one is not dimmed.
expect(solid).toMatchObject({
backgroundColor: "#112233",
backgroundImage: false,
blurPixels: 0,
darkenOpacity: 0,
fit: "contain",
});
});
});
describe("bounded upload reading", () => {
function streamedRequest(chunks: readonly Uint8Array[], contentLength?: number): Request {
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Request("http://localhost/images?filename=a.png", {
body,
// Required by Node's fetch for a streaming request body.
duplex: "half",
headers: contentLength === undefined ? {} : { "content-length": String(contentLength) },
method: "POST",
});
}
it("refuses a declared length over the limit before the body is even touched", async () => {
let bodyAccesses = 0;
// A stand-in rather than a real `Request`, and `body` is a getter: a `ReadableStream` pulls its
// first chunk as soon as it is constructed, so counting reads would measure the stream rather
// than the check. Counting *accesses to the body* measures exactly the property under test.
const request = {
get body(): ReadableStream<Uint8Array> {
bodyAccesses += 1;
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16));
controller.close();
},
});
},
headers: new Headers({ "content-length": String(SIGNAGE_IMAGE_MAX_BYTES + 1) }),
} as unknown as Request;
await expectSignageCode(
readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES),
"SIGNAGE_IMAGE_TOO_LARGE",
);
expect(bodyAccesses).toBe(0);
});
it("counts what actually arrives, so a missing or lying declared length bounds nothing", async () => {
const chunk = new Uint8Array(1024);
const chunks = Array.from({ length: 8 }, () => chunk);
// No `content-length` at all, and a total over a small limit.
await expectSignageCode(
readBoundedBody(streamedRequest(chunks), 4096),
"SIGNAGE_IMAGE_TOO_LARGE",
);
// A declared length that lies about being small.
await expectSignageCode(
readBoundedBody(streamedRequest(chunks, 10), 4096),
"SIGNAGE_IMAGE_TOO_LARGE",
);
});
it("reassembles a chunked body exactly", async () => {
const image = await makeImage({ format: "png", height: 90, width: 160 });
const half = Math.floor(image.byteLength / 2);
const bytes = await readBoundedBody(
streamedRequest([image.slice(0, half), image.slice(half)]),
SIGNAGE_IMAGE_MAX_BYTES,
);
expect(sha256Hex(bytes)).toBe(sha256Hex(image));
});
it("refuses an empty body rather than treating it as an image", async () => {
await expectSignageCode(
readBoundedBody(streamedRequest([]), SIGNAGE_IMAGE_MAX_BYTES),
"SIGNAGE_INVALID_REQUEST",
);
});
});
describe("native image ingest", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
let counter: number;
beforeEach(() => {
store = createMemorySignageStore();
storage = createFakeStorage();
counter = 0;
});
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
async function ingest(bytes: Uint8Array, filename = "welcome.png") {
return ingestNativeImage({
bytes,
displayName: filename,
expiresAt: null,
filename,
newId,
storage,
store,
});
}
it("archives the original unchanged and stores both renditions beside it", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const result = await ingest(bytes);
expect(result.deduplicated).toBe(false);
expect([...storage.objects.keys()].sort()).toEqual([
`native-images/assets/${result.image.id}/display.webp`,
`native-images/assets/${result.image.id}/original.png`,
`native-images/assets/${result.image.id}/thumbnail.webp`,
]);
// Byte for byte. It is the church's file, and Signage is not entitled to rewrite it.
const archived = storage.objects.get(`native-images/assets/${result.image.id}/original.png`);
expect(sha256Hex(archived?.bytes ?? new Uint8Array())).toBe(sha256Hex(bytes));
expect(result.image.originalContentType).toBe("image/png");
expect(result.image.renditions.map((rendition) => rendition.contentType)).toEqual([
"image/webp",
"image/webp",
]);
});
it("returns the existing asset for identical bytes, and writes nothing new", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes, "welcome.png");
const objectCount = storage.objects.size;
const second = await ingest(bytes, "welcome-copy.png");
expect(second.deduplicated).toBe(true);
expect(second.image.id).toBe(first.image.id);
expect(storage.objects.size).toBe(objectCount);
expect(store.images.size).toBe(1);
});
it("writes nothing at all when the bytes are not an image Signage accepts", async () => {
await expectSignageCode(
ingest(bytesOf(SVG_DOCUMENT), "logo.svg"),
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
);
expect(storage.objects.size).toBe(0);
expect(store.images.size).toBe(0);
});
it("removes every object it wrote when a later write fails", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
storage.failPut(`assets/00000000-0000-4000-8000-000000000001/thumbnail.webp`);
await expectSignageCode(ingest(bytes), "SIGNAGE_STORAGE_UNAVAILABLE");
// An aborted ingest leaves no orphan bytes and no half-formed asset.
expect(storage.objects.size).toBe(0);
expect(store.images.size).toBe(0);
});
it("treats a race with an identical upload as a success rather than a failure", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes);
// The second ingest is forced past the dedup check by clearing the hash lookup once, exactly as
// a concurrent commit would: the write happens, and the unique index refuses the insert.
const originalLookup = store.findReadyImageByContentHash.bind(store);
let lookups = 0;
store.findReadyImageByContentHash = (hash: string) => {
lookups += 1;
return lookups === 1 ? Promise.resolve(undefined) : originalLookup(hash);
};
const second = await ingest(bytes);
expect(second.deduplicated).toBe(true);
expect(second.image.id).toBe(first.image.id);
// The losing attempt cleaned up after itself.
expect(storage.objects.size).toBe(3);
});
it("refuses bytes past Signage's own limit even if the caller read them", async () => {
await expectSignageCode(
ingestNativeImage({
bytes: new Uint8Array(SIGNAGE_IMAGE_MAX_BYTES + 1),
displayName: "huge.png",
expiresAt: null,
filename: "huge.png",
storage,
store,
}),
"SIGNAGE_IMAGE_TOO_LARGE",
);
});
});
describe("native image maintenance", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
async function seed() {
return ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: "welcome.png",
expiresAt: null,
filename: "welcome.png",
storage,
store,
});
}
beforeEach(() => {
store = createMemorySignageStore();
storage = createFakeStorage();
});
it("writes nothing for an exact no-op", async () => {
const { image } = await seed();
let writes = 0;
const original = store.updateImage.bind(store);
store.updateImage = (id, changes) => {
writes += 1;
return original(id, changes);
};
const unchanged = await updateNativeImage({
assetId: image.id,
displayName: image.displayName,
expiresAt: null,
store,
});
expect(unchanged.displayName).toBe(image.displayName);
expect(writes).toBe(0);
});
it("renames and expires an image, and treats a null expiration as permanent", async () => {
const { image } = await seed();
const expires = new Date("2027-01-01T00:00:00.000Z");
const renamed = await updateNativeImage({
assetId: image.id,
displayName: "Welcome slide",
expiresAt: expires,
store,
});
expect(renamed.displayName).toBe("Welcome slide");
expect(renamed.expiresAt?.toISOString()).toBe(expires.toISOString());
const permanent = await updateNativeImage({ assetId: image.id, expiresAt: null, store });
expect(permanent.expiresAt).toBeNull();
});
it("deletes an unreferenced image and every object it owned", async () => {
const { image } = await seed();
await deleteNativeImage({ assetId: image.id, storage, store });
expect(store.images.size).toBe(0);
expect(storage.objects.size).toBe(0);
});
it("refuses to delete an image a slot or a live static hold still refers to", async () => {
const { image } = await seed();
store.addImageReference(image.id, "slot");
store.addImageReference(image.id, "static-hold");
const error = await expectSignageCode(
deleteNativeImage({ assetId: image.id, storage, store }),
"SIGNAGE_ASSET_IN_USE",
);
// Counts an operator can act on — never a slot id, a timeline name, or a channel group's name.
expect(error.detail).toEqual({ heldByChannelGroups: 1, referencedBySlots: 1 });
expect(store.images.size).toBe(1);
expect(storage.objects.size).toBe(3);
});
it("refuses an unknown image without saying whether it ever existed", async () => {
await expectSignageCode(
deleteNativeImage({
assetId: "11111111-1111-4111-8111-111111111111",
storage,
store,
}),
"SIGNAGE_NOT_FOUND",
);
});
});
+290
View File
@@ -0,0 +1,290 @@
import { readFile } from "node:fs/promises";
import { EMPTY_MODULE_CONFIGURATION, parseModuleManifest } from "@ekklesia/module-sdk";
import { describe, expect, it } from "vitest";
import moduleRuntime from "../src/server/index.js";
import { readSignageSettings } from "../src/server/settings.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import {
fakeConfiguration,
fakeHandlerContext,
findRoute,
registerModule,
} from "./support/module-harness.js";
/** Signage's own copy of Core's overlap rule: two routes of one module under one method must never
* be able to match the same request path, because they may carry different permissions. */
function overlappingRoutes(routes: readonly { method: string; path: string }[]): readonly string[] {
const segmentsOf = (path: string) => (path === "/" ? [] : path.slice(1).split("/"));
const collisions: string[] = [];
for (let left = 0; left < routes.length; left += 1) {
for (let right = left + 1; right < routes.length; right += 1) {
const first = routes[left];
const second = routes[right];
if (!first || !second || first.method !== second.method) {
continue;
}
const a = segmentsOf(first.path);
const b = segmentsOf(second.path);
if (a.length !== b.length) {
continue;
}
const overlaps = a.every((segment, index) => {
const other = b[index] as string;
return segment.startsWith(":") || other.startsWith(":") || segment === other;
});
if (overlaps) {
collisions.push(`${first.method} ${first.path} ~ ${second.path}`);
}
}
}
return collisions;
}
async function readManifest() {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
return parseModuleManifest(JSON.parse(await readFile(manifestUrl, "utf8")) as unknown);
}
describe("Signage module manifest", () => {
it("declares the extension seams other modules reach Signage through", async () => {
const manifest = await readManifest();
// The backend seam and the browser seam are versioned separately on purpose: a provider's
// registration contract and its renderer's input contract have different lifecycles, and
// versioning them together would force every provider to rebuild whenever either moved.
expect(manifest.capabilities).toEqual([
{ id: "signage.content-providers", version: "1.0.0" },
{ id: "signage.slide-renderer", version: "1.0.0" },
{ id: "signage.playback", version: "1.0.0" },
{ id: "signage.displays", version: "1.0.0" },
]);
});
it("declares the module's product identity at the current manifest version", async () => {
const manifest = await readManifest();
expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.0");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
});
it("declares every Signage permission, and none of them publicly", async () => {
const manifest = await readManifest();
expect(manifest.permissions.map((permission) => permission.id)).toEqual([
"signage.view",
"signage.assets.manage",
"signage.timelines.manage",
"signage.playback.control",
"signage.displays.manage",
"signage.display.view",
]);
// Nothing Signage owns is readable by an unauthenticated caller: a timeline names a church's
// schedule, and a display is a screen someone paired.
expect(manifest.permissions.every((permission) => permission.baseline === "restricted")).toBe(
true,
);
for (const permission of manifest.permissions) {
expect(permission.description.length).toBeGreaterThan(0);
}
});
it("declares one logical storage bucket and names no storage service anywhere", async () => {
const manifest = await readManifest();
const raw = await readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8");
expect(manifest.storage.buckets).toEqual([
{
id: "native-images",
description:
"Archived original uploads and generated renditions for Signage's native images.",
},
]);
// A module that configured its own object store would be welded to one deployment (ADR 0030).
for (const forbidden of ["s3", "seaweed", "minio", "endpoint", "accessKey", "region"]) {
expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase());
}
});
it("declares both browser artifacts, with the display gated on a non-public permission", async () => {
const manifest = await readManifest();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "signage.display.view",
});
});
it("declares only operator settings, every one of them with a safe default and no secret", async () => {
const manifest = await readManifest();
expect(manifest.configuration.map((entry) => entry.key)).toEqual([
"timezone",
"nativeImageDurationSeconds",
"crossfadeMilliseconds",
"defaultBackgroundMode",
"defaultBackgroundColor",
"defaultBackgroundDarkened",
"defaultBlurPixels",
"providerTimeoutMilliseconds",
"playbackHistoryLimit",
"viewerHeartbeatWriteSeconds",
]);
// Nothing Signage needs configured has no safe default, so nothing can block it from starting.
expect(manifest.configuration.every((entry) => entry.required === false)).toBe(true);
expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false);
});
});
describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" });
});
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
const registration = await registerModule();
expect(registration.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([
"DELETE /channel-groups/:groupId",
"DELETE /channels/:channelId",
"DELETE /images/:imageId",
"DELETE /timelines/:timelineId",
"DELETE /timelines/:timelineId/slots/:slotId",
"DELETE /timelines/:timelineId/sources/:sourceId",
"DELETE /viewers/:viewerId",
"GET /channel-groups",
"GET /channels/:channelId/display-state",
"GET /images",
"GET /images/:imageId",
"GET /playback/:groupId",
"GET /providers",
"GET /providers/:providerId/items",
"GET /providers/:providerId/sources",
"GET /settings",
"GET /timelines",
"GET /timelines/:timelineId",
"GET /viewers",
"PATCH /images/:imageId",
"PATCH /timelines/:timelineId",
"PATCH /timelines/:timelineId/slots/:slotId",
"PATCH /viewers/:viewerId",
"POST /channel-groups",
"POST /channel-groups/:groupId/channels",
"POST /images",
"POST /playback/:groupId/activate",
"POST /playback/:groupId/blackout",
"POST /playback/:groupId/next",
"POST /playback/:groupId/pause",
"POST /playback/:groupId/previous",
"POST /playback/:groupId/reload",
"POST /playback/:groupId/resume",
"POST /playback/:groupId/static",
"POST /timelines",
"POST /timelines/:timelineId/duplicate",
"POST /timelines/:timelineId/slots",
"POST /timelines/:timelineId/sources",
"POST /viewers/:viewerId/heartbeat",
"POST /viewers/session",
"PUT /timelines/:timelineId/slot-order",
]);
// Every route makes the body decision consciously, names a permission Signage's own manifest
// declares, and cannot collide with another — Core refuses a module whose routes overlap, and
// discovering that at API startup is discovering it in production.
for (const route of registration.routes) {
expect(route.permission.startsWith("signage.")).toBe(true);
expect(Number.isSafeInteger(route.maxBodyBytes)).toBe(true);
}
expect(overlappingRoutes(registration.routes)).toEqual([]);
// Every declared capability is registered during `register`, because Core refuses a module that
// declares one and does not.
expect([...registration.provided.keys()].sort()).toEqual([
"signage.content-providers",
"signage.displays",
"signage.playback",
"signage.slide-renderer",
]);
// Two realtime channels, each gated on a permission Signage owns: what a display needs, and
// what the dashboard needs, are different audiences and different permissions.
expect(registration.channels).toEqual([
{ channel: "display-state", permission: "signage.display.view" },
{ channel: "management-state", permission: "signage.view" },
]);
const upload = findRoute(registration, "POST", "/images");
expect(upload).toMatchObject({
bodyMode: "streamed",
maxBodyBytes: 26_214_400,
permission: "signage.assets.manage",
});
// Exactly one route streams. Everything else takes a small buffered body, or none at all.
expect(registration.routes.filter((route) => route.bodyMode === "streamed")).toHaveLength(1);
expect(findRoute(registration, "GET", "/settings").maxBodyBytes).toBe(0);
});
it("serves its own effective settings, and never a configuration value it did not choose to expose", async () => {
const route = findRoute(await registerModule(), "GET", "/settings");
const response = await route.handler(
new Request("http://localhost/settings"),
fakeHandlerContext({
config: fakeConfiguration({
crossfadeMilliseconds: 400,
nativeImageDurationSeconds: 12,
timezone: "America/Denver",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
crossfadeMs: 400,
moduleVersion: "0.1.0",
nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0",
timezone: "America/Denver",
// The dashboard is told the limits the server enforces rather than hardcoding them: a UI that
// believed a stale limit would offer an upload the server refuses.
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
});
});
});
describe("Signage settings", () => {
it("falls back to exactly the defaults the manifest declares", async () => {
const manifest = await readManifest();
const settings = readSignageSettings(EMPTY_MODULE_CONFIGURATION);
const declared = new Map(manifest.configuration.map((entry) => [entry.key, entry]));
expect(settings).toEqual(SIGNAGE_DEFAULT_SETTINGS);
// The one place these two could drift is exactly the place a stale default would be invisible.
expect(declared.get("timezone")).toMatchObject({ default: settings.timezone });
expect(declared.get("nativeImageDurationSeconds")).toMatchObject({
default: settings.nativeImageDurationMs / 1000,
});
expect(declared.get("crossfadeMilliseconds")).toMatchObject({ default: settings.crossfadeMs });
expect(declared.get("defaultBlurPixels")).toMatchObject({
default: settings.nativePresentation.blurPixels,
});
expect(declared.get("playbackHistoryLimit")).toMatchObject({
default: settings.playbackHistoryLimit,
});
});
it("refuses a background colour that is not a plain six-digit hex value", () => {
const settings = readSignageSettings(
fakeConfiguration({ defaultBackgroundColor: "red; background: url(x)" }),
);
expect(settings.nativePresentation.backgroundColor).toBe("#000000");
});
it("refuses a background mode Signage does not implement", () => {
const settings = readSignageSettings(fakeConfiguration({ defaultBackgroundMode: "rainbow" }));
expect(settings.nativePresentation.backgroundMode).toBe("blurred");
});
});
+622
View File
@@ -0,0 +1,622 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
createPlaybackService,
type PlaybackSnapshot,
type SignagePlaybackService,
} from "../src/server/playback/service.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import type { StoredChannelGroupDetail, StoredTimelineDetail } from "../src/server/store/types.js";
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import {
announcementsFixture,
type FixtureProvider,
scriptureFixture,
triviaFixture,
} from "./support/fixture-provider.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
const START = Date.parse("2026-08-30T15:00:00.000Z");
describe("authoritative playback", () => {
let store: MemorySignageStore;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let announcements: FixtureProvider;
let trivia: FixtureProvider;
let scripture: FixtureProvider;
let clock: number;
let counter: number;
let published: { snapshot: PlaybackSnapshot; reason: string }[];
/** Timers are captured rather than run: a test drives the clock, so nothing depends on real time
* and a reconciliation is never racing a test's assertions. */
let armed: { handler: () => void; delayMs: number } | undefined;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(() => {
clock = START;
counter = 0;
published = [];
armed = undefined;
store = createMemorySignageStore({ now: () => new Date(clock) });
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
announcements = announcementsFixture();
trivia = triviaFixture();
scripture = scriptureFixture();
registry.capability.register(announcements.registration);
registry.capability.register(trivia.registration);
registry.capability.register(scripture.registration);
service = createPlaybackService({
clearTimer: () => {
armed = undefined;
},
newId,
now: () => clock,
onSnapshot: (snapshot, reason) => published.push({ reason, snapshot }),
registry,
setTimer: (handler, delayMs) => {
armed = { delayMs, handler };
return armed;
},
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
store,
});
});
/** Advances the clock and fires whatever timer was armed for that moment, the way a real one
* would. Nothing here reaches a real timer, so a long outage costs nothing but arithmetic. */
async function tick(ms: number): Promise<void> {
clock += ms;
const pending = armed;
armed = undefined;
if (pending) {
pending.handler();
// Let the reconciliation the timer started settle.
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function seedGroup(): Promise<StoredChannelGroupDetail> {
await service.start();
return service.ensureDefaultGroup();
}
/** The pattern from the product definition: three announcements, one trivia, two announcements,
* one scripture — the five announcement occurrences sharing one source. */
async function seedPattern(): Promise<StoredTimelineDetail> {
let detail = await createTimeline({ name: "Sunday morning", newId, store });
const timelineId = detail.timeline.id;
for (const [providerId, sourceId, label] of [
["fixture.announcements", "active-slides", "Announcements"],
["fixture.trivia", "open-questions", "Trivia"],
["fixture.scripture", "reading-plan", "Scripture"],
] as const) {
detail = await addProviderSource({ label, newId, providerId, sourceId, store, timelineId });
}
const [a, t, s] = detail.sources;
if (!a || !t || !s) {
throw new Error("Expected three sources.");
}
for (const source of [a, a, a, t, a, a, s]) {
detail = await addSlot({ newId, source: { sourceId: source.id }, store, timelineId });
}
return detail;
}
async function currentItem(groupId: string): Promise<string | null> {
const snapshot = await service.snapshot(groupId);
if (snapshot.currentSelectionId === null) {
return null;
}
const selection = await store.findSelection(snapshot.currentSelectionId);
return selection?.itemId ?? null;
}
it("creates exactly one default channel group with one default channel, and nothing else", async () => {
const group = await seedGroup();
expect(group.channels).toHaveLength(1);
expect(group.channels[0]).toMatchObject({ isDefault: true, key: "default" });
// No `in-person`, no `online`. Those are an administrator's decision, not an install-time one.
expect(group.channels.map((channel) => channel.key)).toEqual(["default"]);
// Calling again is idempotent: an installation has one default group, not one per call.
const again = await service.ensureDefaultGroup();
expect(again.group.id).toBe(group.group.id);
});
it("plays the operator's pattern, asking each provider once per slot occurrence", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const played: (string | null)[] = [await currentItem(group.group.id)];
for (let index = 0; index < 6; index += 1) {
await tick(5000);
played.push(await currentItem(group.group.id));
}
// Three announcements, one trivia, two announcements, one scripture — with the five
// announcement occurrences walking **one** rotation, because they share one selection scope.
expect(played).toEqual(["welcome", "greeting", "giving", "q1", "kids", "welcome", "psalm-23"]);
expect(announcements.served).toEqual(["welcome", "greeting", "giving", "kids", "welcome"]);
expect(trivia.served).toEqual(["q1"]);
expect(scripture.served).toEqual(["psalm-23"]);
});
it("resolves one logical selection for every channel in the group, and one variant each", async () => {
const group = await seedGroup();
await store.addChannel({
groupId: group.group.id,
id: newId(),
isDefault: false,
key: "online",
name: "Online",
});
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
// The second announcement is the one with an audience-specific variant.
await tick(5000);
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
expect(selection?.itemId).toBe("greeting");
// Asked once — never once per channel — which is what keeps two channels on the same slide.
expect(
announcements.resolveRequests.filter((request) => request.audienceKeys.includes("online")),
).toHaveLength(announcements.resolveRequests.length);
expect(selection?.variants.map((variant) => variant.audienceKey).sort()).toEqual([
"default",
"online",
]);
expect(
selection?.variants.find((variant) => variant.audienceKey === "online")?.payload,
).toEqual({ headline: "Watching from home" });
// Both channels read the same start and the same deadline. There is one clock.
expect(snapshot.startedAtMs).not.toBeNull();
expect(snapshot.deadlineAtMs).toBe((snapshot.startedAtMs ?? 0) + 5000);
});
it("falls back to the default variant for an audience the provider said nothing about", async () => {
const group = await seedGroup();
await store.addChannel({
groupId: group.group.id,
id: newId(),
isDefault: false,
key: "in-person",
name: "In person",
});
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
// "welcome" has no audience-specific payload at all. That is the contract, not a gap.
expect(selection?.variants).toHaveLength(1);
expect(selection?.variants[0]).toMatchObject({ audienceKey: "default", isDefault: true });
});
it("chains each slot from the previous deadline, so a late timer costs nothing twice", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const first = await service.snapshot(group.group.id);
// The timer fires 900 ms late, as a busy process's timer does.
await tick(5900);
const second = await service.snapshot(group.group.id);
// The new slot starts at the old slot's deadline — not at "now" — so the lateness is absorbed
// once instead of stretching the loop by 900 ms on every slide for the rest of the day.
expect(second.startedAtMs).toBe(first.deadlineAtMs);
expect(second.deadlineAtMs).toBe((first.deadlineAtMs ?? 0) + 5000);
});
it("catches up a long outage with arithmetic, not by replaying every missed slide", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const callsBefore =
announcements.resolveRequests.length +
trivia.resolveRequests.length +
scripture.resolveRequests.length;
// A week offline. Seven slots at five seconds each is more than a hundred thousand slides.
clock += 7 * 24 * 60 * 60 * 1000;
await service.reconcile(group.group.id, "startup");
const callsAfter =
announcements.resolveRequests.length +
trivia.resolveRequests.length +
scripture.resolveRequests.length;
// Exactly one provider call: the slot actually landed on. A replay would have burned the
// providers' rotations and taken longer than the outage.
expect(callsAfter - callsBefore).toBe(1);
const snapshot = await service.snapshot(group.group.id);
expect(snapshot.currentSelectionId).not.toBeNull();
// And the slot it landed on is genuinely current rather than in the past.
expect(snapshot.deadlineAtMs).toBeGreaterThan(clock);
});
it("skips a slot whose provider has nothing right now, immediately and without an error", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
trivia.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
await tick(5000);
await tick(5000);
// The fourth slot is Trivia, which has nothing. It is skipped in the same instant.
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
await tick(5000);
const afterTrivia = await service.snapshot(group.group.id);
const next = await store.findSelection(afterTrivia.currentSelectionId ?? "");
expect(selection?.itemId).toBe("giving");
// Straight past Trivia to the next announcement. No gap, no black frame, no error.
expect(next?.itemId).toBe("kids");
expect(next?.startedAt?.getTime()).toBe(selection?.deadlineAt?.getTime());
expect(
registry.health().find((p) => p.providerId === "fixture.trivia")?.consecutiveFailures,
).toBe(0);
});
it("goes idle rather than inventing content when nothing at all is available", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
announcements.setItems([]);
trivia.setItems([]);
scripture.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
const snapshot = await service.snapshot(group.group.id);
expect(snapshot.transport).toBe("idle");
expect(snapshot.currentSelectionId).toBeNull();
// The display shows its slate. Signage did not fabricate a slide to fill the gap.
});
it("freezes on pause and continues the same slide from its remainder on resume", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const item = await currentItem(group.group.id);
clock += 2000;
const paused = await service.pause(group.group.id);
expect(paused.transport).toBe("paused");
expect(paused.remainingMs).toBe(3000);
expect(paused.deadlineAtMs).toBeNull();
// Two minutes of a paused screen must not advance anything.
clock += 120_000;
expect(await currentItem(group.group.id)).toBe(item);
const resumed = await service.resume(group.group.id);
expect(resumed.transport).toBe("playing");
expect(await currentItem(group.group.id)).toBe(item);
// Three seconds left, and the reconstructed window is still a whole slot long.
expect(resumed.deadlineAtMs).toBe(clock + 3000);
expect((resumed.deadlineAtMs ?? 0) - (resumed.startedAtMs ?? 0)).toBe(5000);
});
it("holds a static image while preserving the cursor underneath, and continues on release", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const item = await currentItem(group.group.id);
const imageId = newId();
store.images.set(imageId, {
contentSha256: "a".repeat(64),
createdAt: new Date(clock),
displayName: "Hold",
encodedHeight: 1080,
encodedWidth: 1920,
expiresAt: null,
hasAlpha: false,
height: 1080,
id: imageId,
orientation: null,
originalByteSize: 1,
originalContentType: "image/png",
originalFilename: "hold.png",
originalObjectKey: "assets/hold/original.png",
renditions: [],
status: "ready",
width: 1920,
});
clock += 1000;
const held = await service.setStaticHold(group.group.id, imageId);
expect(held.transport).toBe("static");
expect(held.staticAssetId).toBe(imageId);
// The normal cursor is preserved, not discarded.
expect(held.remainingMs).toBe(4000);
expect(held.currentSelectionId).not.toBeNull();
clock += 60_000;
const released = await service.setStaticHold(group.group.id, null);
expect(released.transport).toBe("playing");
// The same slide continues with the remainder it had, rather than the loop restarting.
expect(await currentItem(group.group.id)).toBe(item);
expect(released.deadlineAtMs).toBe(clock + 4000);
});
it("keeps the hidden timeline running through a blackout", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const dark = await service.setBlackout(group.group.id, true);
expect(dark.blackout).toBe(true);
// Blackout covers output. It does not pause, reset, or stop resolving.
expect(dark.transport).toBe("playing");
expect(dark.deadlineAtMs).not.toBeNull();
await tick(5000);
const later = await service.snapshot(group.group.id);
expect(later.blackout).toBe(true);
expect(await currentItem(group.group.id)).toBe("greeting");
const lit = await service.setBlackout(group.group.id, false);
// Clearing it lands exactly where the hidden timeline had got to.
expect(lit.blackout).toBe(false);
expect(await currentItem(group.group.id)).toBe("greeting");
});
it("walks actual resolved playback with previous, including past a skipped slot", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
trivia.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
await tick(5000);
await tick(5000);
await tick(5000);
expect(await currentItem(group.group.id)).toBe("kids");
const back = await service.previous(group.group.id);
// Trivia was never resolved, so it is not in history. "Previous" means the slide that was
// actually on screen, which is the third announcement.
expect(await currentItem(group.group.id)).toBe("giving");
expect(back.deadlineAtMs).toBe(clock + 5000);
await service.previous(group.group.id);
// And again, further back — not oscillating between two slides.
expect(await currentItem(group.group.id)).toBe("greeting");
});
it("ends the current slot immediately on next, and still applies eligibility and wraparound", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
clock += 1000;
const skipped = await service.next(group.group.id);
expect(await currentItem(group.group.id)).toBe("greeting");
expect(skipped.startedAtMs).toBe(clock);
expect(skipped.deadlineAtMs).toBe(clock + 5000);
});
it("keeps the last known good item and marks it stale when a provider fails", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
announcements.failNext(5);
await service.reload(group.group.id);
const after = await service.snapshot(group.group.id);
const selection = await store.findSelection(after.currentSelectionId ?? "");
// The slide stays up, and the deadline is untouched: a failure is not a reason to blank a wall.
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
expect(selection?.stale).toBe(true);
expect(selection?.itemId).toBe("welcome");
expect(await currentItem(group.group.id)).toBe("welcome");
});
it("updates the current slide in place when a provider invalidates it, without restarting the slot", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
const beforeSelection = await store.findSelection(before.currentSelectionId ?? "");
announcements.reviseItem("welcome", { headline: "Cancelled" });
clock += 1500;
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const after = await service.snapshot(group.group.id);
const afterSelection = await store.findSelection(after.currentSelectionId ?? "");
// Same slide, same slot, same deadline — new payload and a new revision, which is exactly what
// updates a mounted React renderer in place.
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
expect(after.selectionRevision).toBe((before.selectionRevision ?? 0) + 1);
expect(afterSelection?.variants[0]?.payload).toEqual({ headline: "Cancelled" });
expect(beforeSelection?.variants[0]?.payload).toEqual({ headline: "Welcome" });
});
it("does not bump a revision when an invalidation changed nothing", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const after = await service.snapshot(group.group.id);
// A client polling on revision must not be woken by nothing.
expect(after.selectionRevision).toBe(before.selectionRevision);
});
it("marks an explicitly withdrawn item and removes it at the next boundary, not mid-slide", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
announcements.withdraw("welcome");
clock += 1000;
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const during = await service.snapshot(group.group.id);
const selection = await store.findSelection(during.currentSelectionId ?? "");
// Still on screen, still on its own deadline — marked, not cut.
expect(during.currentSelectionId).toBe(before.currentSelectionId);
expect(during.deadlineAtMs).toBe(before.deadlineAtMs);
expect(selection?.withdrawn).toBe(true);
await tick(4000);
// Gone at the boundary — and *which* item follows is the provider's decision, made from a pool
// that no longer contains the withdrawn one. Signage neither chose it nor knows why.
const next = await currentItem(group.group.id);
expect(next).not.toBe("welcome");
expect(["greeting", "giving", "kids"]).toContain(next);
});
it("activates immediately, or at the next slot boundary, exactly as asked", async () => {
const group = await seedGroup();
const first = await seedPattern();
const second = await createTimeline({ name: "Evening", newId, store });
const withSource = await addProviderSource({
label: "Scripture",
newId,
providerId: "fixture.scripture",
sourceId: "reading-plan",
store,
timelineId: second.timeline.id,
});
const source = withSource.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: second.timeline.id,
});
await service.activate(group.group.id, first.timeline.id, "now");
expect(await currentItem(group.group.id)).toBe("welcome");
clock += 1000;
const queued = await service.activate(group.group.id, second.timeline.id, "next-boundary");
// Queued, and the current slide is not cut short.
expect(queued.pendingTimelineId).toBe(second.timeline.id);
expect(queued.activeTimelineId).toBe(first.timeline.id);
expect(await currentItem(group.group.id)).toBe("welcome");
await tick(4000);
const switched = await service.snapshot(group.group.id);
expect(switched.activeTimelineId).toBe(second.timeline.id);
expect(switched.pendingTimelineId).toBeNull();
expect(await currentItem(group.group.id)).toBe("psalm-23");
});
it("applies a scheduled activation at its own instant, not when the timer happened to fire", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
const at = new Date(clock + 60_000);
const scheduled = await service.activate(group.group.id, timeline.timeline.id, { at });
expect(scheduled.scheduledTimelineId).toBe(timeline.timeline.id);
expect(scheduled.activeTimelineId).toBeNull();
// The callback arrives two seconds late, as a real one does.
clock += 62_000;
const after = await service.reconcile(group.group.id, "timer");
expect(after.activeTimelineId).toBe(timeline.timeline.id);
expect(after.scheduledTimelineId).toBeNull();
expect(await currentItem(group.group.id)).toBe("welcome");
});
it("refuses a scheduled activation in the past rather than firing it instantly", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await expect(
service.activate(group.group.id, timeline.timeline.id, { at: new Date(clock - 1000) }),
).rejects.toMatchObject({ code: "SIGNAGE_INVALID_REQUEST" });
});
it("keeps history bounded and still lets previous work", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
for (let index = 0; index < 30; index += 1) {
await tick(5000);
}
const history = await store.listSelections(group.group.id, 100);
expect(history.length).toBeLessThanOrEqual(20);
const back = await service.previous(group.group.id);
expect(back.currentSelectionId).not.toBeNull();
});
it("recovers a restart onto the item that was already showing, not a new one", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
const callsBefore = announcements.resolveRequests.length;
// A restart two seconds into a five-second slide: reconciliation runs with the persisted state.
clock += 2000;
const after = await service.reconcile(group.group.id, "startup");
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
// And no provider was asked, so no rotation was consumed by a restart.
expect(announcements.resolveRequests.length).toBe(callsBefore);
});
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const latest = published.at(-1);
expect(latest?.reason).toBe("activated");
const serialized = JSON.stringify(latest?.snapshot);
// Compact enough for the platform's realtime ceiling many times over, and carrying identity and
// revisions rather than content: large state is fetched over authorized HTTP keyed by revision.
expect(serialized.length).toBeLessThan(1024);
expect(serialized).not.toContain("Welcome");
expect(serialized).not.toContain("headline");
});
it("serializes commands so two at once cannot interleave", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const [first, second] = await Promise.all([
service.next(group.group.id),
service.next(group.group.id),
]);
// Two "next" presses land on two different slides, in order — never on the same one, and never
// on a state neither command asked for.
expect(first.currentSelectionId).not.toBe(second.currentSelectionId);
expect(second.revision).toBeGreaterThan(first.revision);
});
});
+516
View File
@@ -0,0 +1,516 @@
import { beforeEach, describe, expect, it } from "vitest";
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "../src/server/providers/contract.js";
import { createNativeImageProvider } from "../src/server/providers/native-provider.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import { SignageProviderError } from "../src/server/providers/validation.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
import { createFakeStorage } from "./support/fake-storage.js";
import {
announcementsFixture,
createFixtureProvider,
scriptureFixture,
triviaFixture,
} from "./support/fixture-provider.js";
import { makeImage } from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
const AT = "2026-08-27T15:00:00.000Z";
function request(overrides: Record<string, unknown> = {}) {
return {
at: AT,
audienceKeys: ["default"],
channelGroupId: "group-1",
selectionScopeId: "scope-1",
sourceId: "active-slides",
timelineId: "timeline-1",
timezone: "America/Chicago",
...overrides,
} as never;
}
describe("content provider registry", () => {
let registry: SignageProviderRegistry;
let invalidations: { providerId: string; itemId?: string; sourceId?: string }[];
beforeEach(() => {
invalidations = [];
registry = createProviderRegistry({
onInvalidate: (notice) => invalidations.push(notice),
timeoutMs: 200,
});
});
it("registers a provider, reports it as healthy, and unregisters it cleanly", () => {
const announcements = announcementsFixture();
const handle = registry.capability.register(announcements.registration);
expect(registry.capability.contractVersion).toBe(SIGNAGE_PROVIDER_CONTRACT_VERSION);
expect(registry.has("fixture.announcements")).toBe(true);
expect(registry.health()).toEqual([
{
consecutiveFailures: 0,
contractVersion: "1.0.0",
defaultDurationMs: 5000,
lastFailureAt: null,
lastFailureCode: null,
lastSuccessAt: null,
providerId: "fixture.announcements",
providerVersion: "1.0.0",
registeredAt: expect.any(String),
renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0" },
},
]);
handle.unregister();
expect(registry.has("fixture.announcements")).toBe(false);
expect(registry.health()).toEqual([]);
});
it("unregisters idempotently, so a rollback after a partial start is safe", () => {
const handle = registry.capability.register(announcementsFixture().registration);
handle.unregister();
handle.unregister();
// A late invalidation from a module being torn down is ordinary, not an error.
expect(() => handle.invalidate({ itemId: "welcome" })).not.toThrow();
expect(invalidations).toEqual([]);
// And the same provider may register again — a module restored after being disabled.
expect(() => registry.capability.register(announcementsFixture().registration)).not.toThrow();
});
it("refuses a second live registration of the same provider id", () => {
registry.capability.register(announcementsFixture().registration);
expect(() => registry.capability.register(announcementsFixture().registration)).toThrowError(
expect.objectContaining({ code: "SIGNAGE_PROVIDER_DUPLICATE" }),
);
});
it("rejects a malformed registration with a sanitized code carrying none of the value", () => {
const cases: readonly [unknown, string][] = [
[undefined, "SIGNAGE_PROVIDER_REGISTRATION_INVALID"],
["announcements", "SIGNAGE_PROVIDER_REGISTRATION_INVALID"],
[
{ ...announcementsFixture().registration, providerId: "" },
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
],
[
{ ...announcementsFixture().registration, providerVersion: "one" },
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
],
[
{ ...announcementsFixture().registration, resolveNext: "not a function" },
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
],
[
{ ...announcementsFixture().registration, defaultDurationMs: Number.NaN },
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
],
[
{ ...announcementsFixture().registration, renderer: { contributionId: "x" } },
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
],
[
{ ...announcementsFixture().registration, contractVersion: "2.0.0" },
"SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
],
];
for (const [value, code] of cases) {
let thrown: unknown;
try {
registry.capability.register(value);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(SignageProviderError);
const error = thrown as SignageProviderError;
expect(error.code).toBe(code);
// The message is fixed, and nothing of the rejected registration is in it.
expect(error.message).not.toMatch(/announcements|function|NaN|2\.0\.0/u);
}
expect(registry.health()).toEqual([]);
});
it("separates a contract-version mismatch from a malformed registration, so an operator can act", () => {
const outdated = createFixtureProvider({
contractVersion: "2.0.0",
items: [{ id: "a", label: "A", payload: {} }],
policy: "cycle",
providerId: "fixture.future",
sourceId: "s",
sourceLabel: "S",
});
expect(() => registry.capability.register(outdated.registration)).toThrowError(
expect.objectContaining({
code: "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
providerId: "fixture.future",
}),
);
});
it("passes invalidation through as a notification, never as a command", () => {
const handle = registry.capability.register(announcementsFixture().registration);
handle.invalidate({ itemId: "greeting" });
handle.invalidate({ sourceId: "active-slides" });
handle.invalidate();
expect(invalidations).toEqual([
{ itemId: "greeting", providerId: "fixture.announcements" },
{ providerId: "fixture.announcements", sourceId: "active-slides" },
{ providerId: "fixture.announcements" },
]);
});
});
describe("provider selection", () => {
let registry: SignageProviderRegistry;
beforeEach(() => {
registry = createProviderRegistry({ timeoutMs: 200 });
});
it("supplies a provider with a scope, a time, and an audience — and nothing else", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
await registry.resolveNext(
"fixture.announcements",
request({ audienceKeys: ["default", "online"] }),
);
expect(announcements.resolveRequests).toHaveLength(1);
expect(Object.keys(announcements.resolveRequests[0] ?? {}).sort()).toEqual([
"at",
"audienceKeys",
"channelGroupId",
"selectionScopeId",
"sourceId",
"timelineId",
"timezone",
]);
// No principal, no session, no database, no storage, no URL, no permission.
});
it("lets an announcements-shaped provider cycle its own active set forever", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
for (let index = 0; index < 6; index += 1) {
await registry.resolveNext("fixture.announcements", request());
}
// Signage asked six times and got the provider's own rotation, wrapping where the provider
// decided to wrap. Signage implements no repeat policy at all.
expect(announcements.served).toEqual([
"welcome",
"greeting",
"giving",
"kids",
"welcome",
"greeting",
]);
});
it("lets trivia and scripture refuse to repeat until their pools are exhausted", async () => {
const trivia = triviaFixture();
const scripture = scriptureFixture();
registry.capability.register(trivia.registration);
registry.capability.register(scripture.registration);
for (let index = 0; index < 4; index += 1) {
await registry.resolveNext("fixture.trivia", request({ sourceId: "open-questions" }));
await registry.resolveNext("fixture.scripture", request({ sourceId: "reading-plan" }));
}
// Every question appears once before any repeats; the fourth call starts a new pass.
expect(trivia.served).toEqual(["q1", "q2", "q3", "q1"]);
expect(scripture.served).toEqual(["psalm-23", "john-3", "psalm-23", "john-3"]);
});
it("keeps one rotation per selection scope, and two scopes independent", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" }));
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" }));
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "atrium" }));
// Two source instances an operator deliberately created as separate rotations stay separate.
expect(announcements.served).toEqual(["welcome", "greeting", "welcome"]);
});
it("carries one logical selection with a variant per audience, and a required default", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
await registry.resolveNext("fixture.announcements", request());
const outcome = await registry.resolveNext("fixture.announcements", request());
expect(outcome.kind).toBe("resolved");
if (outcome.kind !== "resolved" || !outcome.resolution) {
throw new Error("Expected a resolution.");
}
expect(outcome.resolution.itemId).toBe("greeting");
// One resolution, two renderings. The provider was asked once.
expect([...outcome.resolution.variants.keys()].sort()).toEqual(["default", "online"]);
expect(outcome.resolution.variants.get("online")).toEqual({ headline: "Watching from home" });
expect(outcome.resolution.variants.get("default")).toEqual({ headline: "Good morning" });
expect(announcements.resolveRequests).toHaveLength(2);
});
it("treats an empty pool as nothing right now, not as a failure", async () => {
const trivia = triviaFixture();
trivia.setItems([]);
registry.capability.register(trivia.registration);
const outcome = await registry.resolveNext(
"fixture.trivia",
request({ sourceId: "open-questions" }),
);
expect(outcome).toEqual({ kind: "resolved", resolution: null });
// A skip is not a failure, so nothing about provider health changes.
expect(registry.health()[0]?.consecutiveFailures).toBe(0);
expect(registry.health()[0]?.lastFailureCode).toBeNull();
});
it("reports a provider that throws as temporarily failed, and recovers when it stops", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
announcements.failNext(2);
const first = await registry.resolveNext("fixture.announcements", request());
const second = await registry.resolveNext("fixture.announcements", request());
expect(first).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
expect(second).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
expect(registry.health()[0]).toMatchObject({
consecutiveFailures: 2,
lastFailureCode: "SIGNAGE_PROVIDER_UNAVAILABLE",
});
const recovered = await registry.resolveNext("fixture.announcements", request());
expect(recovered.kind).toBe("resolved");
// Restoring a compatible provider allows a safe refresh without restarting the module.
expect(registry.health()[0]).toMatchObject({ consecutiveFailures: 0, lastFailureCode: null });
});
it("bounds a provider that never answers, so one provider cannot freeze a channel group", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
announcements.hang(true);
const outcome = await registry.resolveNext("fixture.announcements", request());
expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
});
it("distinguishes an explicit withdrawal from an item that is merely unavailable", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
const missing = await registry.refresh(
"fixture.announcements",
request({ itemId: "not-a-real-item" }),
);
announcements.withdraw("welcome");
const gone = await registry.refresh("fixture.announcements", request({ itemId: "welcome" }));
expect(missing).toEqual({ kind: "resolved", resolution: null });
expect(gone.kind).toBe("resolved");
if (gone.kind !== "resolved") {
throw new Error("Expected a resolution.");
}
expect(gone.resolution?.withdrawn).toBe(true);
});
it("reports an unregistered provider as unregistered, never as failed", async () => {
const outcome = await registry.resolveNext("fixture.nothing", request());
// A temporarily missing registration is not an error, and must not erase anything Signage has
// already persisted about what that provider resolved.
expect(outcome).toEqual({ kind: "unregistered" });
});
it("refuses a resolution this contract cannot store or render, without echoing it", async () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const malformed = [
{ itemId: "a", itemRevision: "1", variants: [] },
{ itemId: "a", itemRevision: "1", variants: [{ audienceKey: "online", payload: {} }] },
{ itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: cyclic }] },
{
durationMs: Number.POSITIVE_INFINITY,
itemId: "a",
itemRevision: "1",
variants: [{ audienceKey: null, payload: {} }],
},
{
itemId: "a",
itemRevision: "1",
variants: [{ audienceKey: null, payload: { when: new Date() } }],
},
{ itemId: "", itemRevision: "1", variants: [{ audienceKey: null, payload: {} }] },
];
for (const resolution of malformed) {
const provider = createFixtureProvider({
items: [],
policy: "cycle",
providerId: "fixture.malformed",
sourceId: "s",
sourceLabel: "S",
});
const registration = {
...provider.registration,
resolveNext: () => resolution as never,
};
const handle = registry.capability.register(registration);
const outcome = await registry.resolveNext("fixture.malformed", request({ sourceId: "s" }));
expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" });
handle.unregister();
}
});
it("returns nothing at all for discovery when a provider is absent or failing", async () => {
expect(await registry.listSources("fixture.absent", { at: AT, timezone: "UTC" })).toEqual([]);
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
announcements.failNext(1);
expect(
await registry.listSources("fixture.announcements", { at: AT, timezone: "UTC" }),
).toEqual([]);
// The editor says "no sources right now"; provider health says why.
expect(registry.health()[0]?.consecutiveFailures).toBe(1);
});
it("discovers a provider's own sources and items for the timeline editor", async () => {
const announcements = announcementsFixture();
registry.capability.register(announcements.registration);
const sources = await registry.listSources("fixture.announcements", {
at: AT,
timezone: "UTC",
});
const items = await registry.listItems("fixture.announcements", { at: AT, timezone: "UTC" });
expect(sources).toEqual([{ id: "active-slides", label: "Active announcements" }]);
expect(items.map((item) => item.id)).toEqual(["welcome", "greeting", "giving", "kids"]);
});
});
describe("Signage's own image provider", () => {
let store: MemorySignageStore;
async function seedImage(name: string, width = 1920, height = 1080): Promise<string> {
const { ingestNativeImage } = await import("../src/server/images/service.js");
const result = await ingestNativeImage({
bytes: await makeImage({ format: "png", height, width }),
displayName: name,
expiresAt: null,
filename: `${name}.png`,
storage: createFakeStorage(),
store,
});
return result.image.id;
}
beforeEach(() => {
store = createMemorySignageStore();
});
it("registers through the identical capability an external provider uses", async () => {
const registry = createProviderRegistry({ timeoutMs: 200 });
const native = createNativeImageProvider({ defaultDurationMs: 7000, store });
registry.capability.register(native.registration);
expect(registry.health()).toEqual([
expect.objectContaining({
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
defaultDurationMs: 7000,
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
// Not a browser contribution: a display seeing this identity paints an image itself.
renderer: { contributionId: "signage.native-image", contributionVersion: "1.0.0" },
}),
]);
});
it("cycles the library in the order images were added", async () => {
const first = await seedImage("first");
const second = await seedImage("second", 1600, 900);
const registry = createProviderRegistry({ timeoutMs: 500 });
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
);
const served: (string | undefined)[] = [];
for (let index = 0; index < 3; index += 1) {
const outcome = await registry.resolveNext(
SIGNAGE_NATIVE_PROVIDER_ID,
request({ sourceId: "library" }),
);
served.push(outcome.kind === "resolved" ? outcome.resolution?.itemId : undefined);
}
expect(served).toEqual([first, second, first]);
});
it("carries object keys in its payload, never a delivery URL", async () => {
const imageId = await seedImage("welcome");
const registry = createProviderRegistry({ timeoutMs: 500 });
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
);
const outcome = await registry.refresh(
SIGNAGE_NATIVE_PROVIDER_ID,
request({ itemId: imageId }),
);
if (outcome.kind !== "resolved" || !outcome.resolution) {
throw new Error("Expected a resolution.");
}
const payload = outcome.resolution.variants.get("default") as Record<string, unknown>;
expect(payload).toMatchObject({ imageId, kind: "native-image" });
expect(JSON.stringify(payload)).not.toMatch(/https?:|token=/u);
});
it("treats a deleted image as an explicit withdrawal and an expired one as nothing right now", async () => {
const imageId = await seedImage("welcome");
const registry = createProviderRegistry({ timeoutMs: 500 });
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
);
await store.updateImage(imageId, { expiresAt: new Date("2020-01-01T00:00:00.000Z") });
const expired = await registry.refresh(
SIGNAGE_NATIVE_PROVIDER_ID,
request({ itemId: imageId }),
);
await store.deleteImage(imageId);
const deleted = await registry.refresh(
SIGNAGE_NATIVE_PROVIDER_ID,
request({ itemId: imageId }),
);
expect(expired).toEqual({ kind: "resolved", resolution: null });
expect(deleted.kind === "resolved" && deleted.resolution?.withdrawn).toBe(true);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
} from "@ekklesia/module-sdk";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import moduleSchema from "../src/schema/index.js";
const tables = moduleSchema.tables as Readonly<Record<string, PgTable>>;
async function readSql(): Promise<string> {
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
}
describe("Signage schema", () => {
it("declares every table Signage's data model needs, and nothing outside its namespace", () => {
const physicalNames = Object.values(tables)
.map((table) => getTableConfig(table).name)
.sort();
expect(physicalNames).toEqual([
"signage_channel_groups",
"signage_channels",
"signage_image_assets",
"signage_image_renditions",
"signage_playback_states",
"signage_provider_sources",
"signage_selection_variants",
"signage_selections",
"signage_timeline_slots",
"signage_timelines",
"signage_viewers",
]);
for (const table of Object.values(tables)) {
const config = getTableConfig(table);
// The platform-managed default schema, never a custom PostgreSQL schema.
expect(config.schema).toBeUndefined();
expect(config.name.length).toBeLessThanOrEqual(63);
}
expect(moduleSchema.moduleId).toBe("signage");
});
it("never points a foreign key outside this module", () => {
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name));
for (const table of Object.values(tables)) {
for (const foreignKey of getTableConfig(table).foreignKeys) {
const reference = foreignKey.reference();
expect(owned).toContain(getTableConfig(reference.foreignTable).name);
}
}
});
it("stores another module's identities as opaque text rather than as a reference", () => {
// A provider, its sources, and its items belong to another module. Signage stores what it was
// told and can never join to it — which is exactly what keeps the two independently installable.
const opaque = [
["providerSources", "provider_id"],
["providerSources", "source_id"],
["timelineSlots", "provider_id"],
["timelineSlots", "item_id"],
["selections", "provider_id"],
["selections", "item_id"],
["viewers", "device_id"],
] as const;
for (const [tableName, columnName] of opaque) {
const table = tables[tableName];
if (!table) {
throw new Error(`Signage's schema is missing ${tableName}.`);
}
const config = getTableConfig(table);
const column = config.columns.find((candidate) => candidate.name === columnName);
expect(column?.getSQLType()).toBe("text");
const referenced = config.foreignKeys.some((foreignKey) =>
foreignKey.reference().columns.some((candidate) => candidate.name === columnName),
);
expect(referenced).toBe(false);
}
});
it("stores logical object keys and never a signed delivery URL", async () => {
const sql = await readSql();
expect(sql).toContain('"original_object_key" text NOT NULL');
expect(sql).toContain('"object_key" text NOT NULL');
// A read URL is a bearer capability with an expiry in it. Nothing in this schema can hold one.
for (const forbidden of ["url", "signed_url", "presigned", "delivery_url"]) {
expect(sql.toLowerCase()).not.toContain(`"${forbidden}"`);
}
});
it("makes the invariants playback depends on the database's job, not a convention", async () => {
const sql = await readSql();
// Content-addressed identity: the same bytes are the same asset.
expect(sql).toContain("signage_image_assets_content_sha256_key");
// A slot position is unique within its timeline.
expect(sql).toContain("signage_timeline_slots_position_key");
// Exactly one default channel per group.
expect(sql).toContain("signage_channels_group_default_key");
// A slot is a provider source or a pinned item, never both and never neither.
expect(sql).toContain("signage_timeline_slots_reference_check");
// History is ordered and gapless per group.
expect(sql).toContain("signage_selections_group_sequence_key");
// One payload per audience per selection.
expect(sql).toContain("signage_selection_variants_selection_audience_key");
// Deleting a slot must not erase what already happened on screen.
expect(sql).toMatch(/signage_selections_slot_id_.*ON DELETE set null/su);
});
it("packages one reviewed, non-destructive, forward-only migration", async () => {
const [journalInput, policyInput, manifestInput, bytes] = await Promise.all([
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
]);
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
const manifest = parseModuleManifest(JSON.parse(manifestInput) as unknown);
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
expect(policy).toMatchObject({
moduleId: "signage",
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
});
expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
// Pinned deliberately. Core compares the exact bytes against its applied history and refuses a
// changed migration, so an accidental edit here must fail in this repository first.
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"fbbc448f9a951818bf37e89849ccdbfb55f3221260db0f59d1a2b245cc126838",
);
});
});
+130
View File
@@ -0,0 +1,130 @@
import {
type ModuleStorage,
type ModuleStorageBucket,
ModuleStorageError,
type ModuleStorageListing,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStorageReadUrl,
} from "@ekklesia/module-sdk";
/**
* An in-memory `ModuleStorage`, scoped exactly as the real one is.
*
* `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately
* construct one — and doing so demonstrates the portability claim directly: the host supplying this
* object could be Ekklesia over S3-compatible storage, or a future standalone host over something
* else entirely, and Signage cannot tell.
*
* It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally
* pass by reaching a bucket the manifest never declared.
*/
export interface FakeStorage extends ModuleStorage {
readonly objects: Map<string, { bytes: Uint8Array; contentType?: string }>;
/** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */
failPut(key: string): void;
readonly readUrlsIssued: string[];
}
export function createFakeStorage(
options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {},
): FakeStorage {
const declared = new Set(options.declared ?? ["native-images"]);
const now = options.now ?? (() => new Date());
const objects = new Map<string, { bytes: Uint8Array; contentType?: string }>();
const failingPuts = new Set<string>();
const readUrlsIssued: string[] = [];
function makeBucket(id: string): ModuleStorageBucket {
return {
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
readUrlsIssued.push(key);
// Opaque by contract: shape, host, and query are the host's business. A module that parsed
// one would be welded to a deployment.
return Promise.resolve({
expiresAt: new Date(now().getTime() + 15 * 60 * 1000),
url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`,
});
},
delete(key: string): Promise<void> {
objects.delete(`${id}/${key}`);
return Promise.resolve();
},
get(key: string): Promise<ModuleStorageObject> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(stored.bytes);
controller.close();
},
}),
contentLength: stored.bytes.byteLength,
key,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
},
head(key: string): Promise<ModuleStorageObjectMetadata> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({ contentLength: stored.bytes.byteLength, key });
},
id,
list(options_?: { readonly prefix?: string }): Promise<ModuleStorageListing> {
const prefix = `${id}/${options_?.prefix ?? ""}`;
return Promise.resolve({
objects: [...objects.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([key, value]) => ({
contentLength: value.bytes.byteLength,
key: key.slice(id.length + 1),
})),
});
},
put(
key: string,
body: unknown,
putOptions?: { readonly contentType?: string },
): Promise<ModuleStorageObjectMetadata> {
if (failingPuts.has(key)) {
failingPuts.delete(key);
return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key));
}
if (!(body instanceof Uint8Array)) {
return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key));
}
objects.set(`${id}/${key}`, {
bytes: body,
...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }),
});
return Promise.resolve({ contentLength: body.byteLength, key });
},
};
}
return {
bucket(logicalBucketId: string): ModuleStorageBucket {
if (!declared.has(logicalBucketId)) {
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
}
return makeBucket(logicalBucketId);
},
buckets: () => [...declared].sort(),
failPut(key: string): void {
failingPuts.add(key);
},
objects,
readUrlsIssued,
};
}
+294
View File
@@ -0,0 +1,294 @@
import type {
SignageJsonValue,
SignageProviderDiscoveryRequest,
SignageProviderItemDescriptor,
SignageProviderRefreshRequest,
SignageProviderRegistration,
SignageProviderResolution,
SignageProviderResolveRequest,
SignageProviderSourceDescriptor,
} from "../../src/server/providers/contract.js";
/**
* A **test-only** content provider, standing in for the real modules that do not exist yet.
*
* It is not an installed module, it has no manifest, and nothing about it is reachable from a
* running Signage: it lives under `tests/`, and the only way it enters the system is a test calling
* the same `register` a real provider module would call from its own `start` hook.
*
* The point is to prove the seam with something that behaves like the three real providers will:
* an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped
* providers that refuse to repeat until their pool is exhausted. **Signage implements none of those
* policies** — that is the whole demonstration.
*/
export interface FixtureItem {
readonly id: string;
readonly label: string;
/** Payload for the default audience. */
readonly payload: Record<string, SignageJsonValue>;
/** Payload for one specific audience, when this item has one. */
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
readonly availableUntil?: string | null;
readonly durationMs?: number;
}
export type FixtureRepeatPolicy =
/** Announcements: cycle the active set forever, in order, wrapping when it runs out. */
| "cycle"
/** Trivia and Scripture: never repeat until every currently available item has appeared. */
| "no-repeat-until-exhausted";
export interface FixtureProviderOptions {
readonly providerId: string;
readonly sourceId: string;
readonly sourceLabel: string;
readonly items: readonly FixtureItem[];
readonly policy: FixtureRepeatPolicy;
readonly defaultDurationMs?: number;
readonly contractVersion?: string;
readonly rendererVersion?: string;
}
export interface FixtureProvider {
readonly registration: SignageProviderRegistration;
/** Every item id this provider has handed out, in order, across every scope. */
readonly served: string[];
/** Every `resolveNext` request it received, so a test can prove what Signage supplied. */
readonly resolveRequests: SignageProviderResolveRequest[];
readonly refreshRequests: SignageProviderRefreshRequest[];
/** Replace the available set at run time — an announcement expiring, a question pool refilling. */
setItems(items: readonly FixtureItem[]): void;
/** Make the next `n` calls fail the way a briefly unavailable provider does. */
failNext(count: number): void;
/** Make every call hang, so the deadline is what ends it. */
hang(hanging: boolean): void;
/** Declare one item explicitly gone. */
withdraw(itemId: string): void;
/** Change an item's payload without changing which item it is — a cancellation, a countdown. */
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void;
}
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
let items = [...options.items];
const revisions = new Map<string, number>();
const withdrawn = new Set<string>();
/** Per selection scope: which items have already been served in the current pass. This is the
* provider's own state, exactly where the contract says it belongs — Signage stores none of it. */
const seen = new Map<string, Set<string>>();
const cursors = new Map<string, number>();
const served: string[] = [];
const resolveRequests: SignageProviderResolveRequest[] = [];
const refreshRequests: SignageProviderRefreshRequest[] = [];
let failures = 0;
let hanging = false;
function guard(): void {
if (failures > 0) {
failures -= 1;
throw new Error("fixture provider is briefly unavailable");
}
}
function available(at: string): readonly FixtureItem[] {
const instant = Date.parse(at);
return items.filter((item) => {
if (withdrawn.has(item.id)) {
return false;
}
if (typeof item.availableUntil !== "string") {
return true;
}
return instant < Date.parse(item.availableUntil);
});
}
function revisionOf(item: FixtureItem): string {
return String(revisions.get(item.id) ?? 1);
}
function resolutionFor(item: FixtureItem): SignageProviderResolution {
const variants = [
{ audienceKey: null, payload: item.payload },
...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({
audienceKey,
payload,
})),
];
return {
availableUntil: item.availableUntil ?? null,
itemId: item.id,
itemRevision: revisionOf(item),
variants,
...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }),
};
}
function pickNext(scope: string, at: string): FixtureItem | null {
const pool = available(at);
if (pool.length === 0) {
return null;
}
if (options.policy === "cycle") {
const index = cursors.get(scope) ?? 0;
const item = pool[index % pool.length] as FixtureItem;
cursors.set(scope, index + 1);
return item;
}
// No repeat until exhausted: only when every currently available item has appeared does the
// pass reset. An item added mid-pass joins the current pass rather than waiting for the next.
let usedInPass = seen.get(scope);
if (!usedInPass) {
usedInPass = new Set<string>();
seen.set(scope, usedInPass);
}
let remaining = pool.filter((item) => !usedInPass.has(item.id));
if (remaining.length === 0) {
usedInPass.clear();
remaining = [...pool];
}
const item = remaining[0] as FixtureItem;
usedInPass.add(item.id);
return item;
}
const registration: SignageProviderRegistration = {
contractVersion: options.contractVersion ?? "1.0.0",
defaultDurationMs: options.defaultDurationMs ?? 5000,
listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] {
guard();
return available(request.at).map((item) => ({
id: item.id,
label: item.label,
...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }),
}));
},
listSources(): readonly SignageProviderSourceDescriptor[] {
guard();
return [{ id: options.sourceId, label: options.sourceLabel }];
},
providerId: options.providerId,
providerVersion: "1.0.0",
async refresh(
request: SignageProviderRefreshRequest,
): Promise<SignageProviderResolution | null> {
refreshRequests.push(request);
if (hanging) {
await new Promise(() => undefined);
}
guard();
if (withdrawn.has(request.itemId)) {
// Explicitly gone — distinct from "nothing right now", and Signage removes it at the next
// safe boundary rather than mid-slide.
return {
itemId: request.itemId,
itemRevision: revisionOf({ id: request.itemId } as FixtureItem),
variants: [{ audienceKey: null, payload: { withdrawn: true } }],
withdrawn: true,
};
}
const item = available(request.at).find((candidate) => candidate.id === request.itemId);
return item ? resolutionFor(item) : null;
},
renderer: {
contributionId: `${options.providerId}.slide`,
contributionVersion: options.rendererVersion ?? "1.0.0",
},
async resolveNext(
request: SignageProviderResolveRequest,
): Promise<SignageProviderResolution | null> {
resolveRequests.push(request);
if (hanging) {
await new Promise(() => undefined);
}
guard();
if (request.sourceId !== options.sourceId) {
return null;
}
const item = pickNext(request.selectionScopeId, request.at);
if (!item) {
return null;
}
served.push(item.id);
return resolutionFor(item);
},
};
return {
failNext(count: number): void {
failures = count;
},
hang(value: boolean): void {
hanging = value;
},
refreshRequests,
registration,
resolveRequests,
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void {
items = items.map((item) => (item.id === itemId ? { ...item, payload } : item));
revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1);
},
served,
setItems(next: readonly FixtureItem[]): void {
items = [...next];
},
withdraw(itemId: string): void {
withdrawn.add(itemId);
},
};
}
/** The three provider shapes the real modules will have, so a test can name them the way an operator
* would. Nothing about their policies lives in Signage. */
export function announcementsFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "welcome", label: "Welcome", payload: { headline: "Welcome" } },
{
audiencePayloads: { online: { headline: "Watching from home" } },
id: "greeting",
label: "Greeting",
payload: { headline: "Good morning" },
},
{ id: "giving", label: "Giving", payload: { headline: "Giving" } },
{ id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } },
],
policy: "cycle",
providerId: "fixture.announcements",
sourceId: "active-slides",
sourceLabel: "Active announcements",
});
}
export function triviaFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "q1", label: "Question 1", payload: { question: "one" } },
{ id: "q2", label: "Question 2", payload: { question: "two" } },
{ id: "q3", label: "Question 3", payload: { question: "three" } },
],
policy: "no-repeat-until-exhausted",
providerId: "fixture.trivia",
sourceId: "open-questions",
sourceLabel: "Open questions",
});
}
export function scriptureFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } },
{ id: "john-3", label: "John 3", payload: { reference: "John 3" } },
],
policy: "no-repeat-until-exhausted",
providerId: "fixture.scripture",
sourceId: "reading-plan",
sourceLabel: "Reading plan",
});
}
+80
View File
@@ -0,0 +1,80 @@
import sharp from "sharp";
/** Real encoded images, generated rather than checked in, so a fixture can never drift from what the
* pipeline actually accepts. */
export async function makeImage(options: {
readonly width: number;
readonly height: number;
readonly format: "png" | "jpeg" | "webp";
readonly alpha?: boolean;
readonly colour?: { r: number; g: number; b: number };
}): Promise<Uint8Array> {
const colour = options.colour ?? { b: 40, g: 120, r: 200 };
const pipeline = sharp({
create: {
background: { ...colour, alpha: options.alpha === true ? 0.5 : 1 },
channels: options.alpha === true ? 4 : 3,
height: options.height,
width: options.width,
},
});
const encoded =
options.format === "png"
? await pipeline.png().toBuffer()
: options.format === "jpeg"
? await pipeline.jpeg().toBuffer()
: await pipeline.webp().toBuffer();
return new Uint8Array(encoded);
}
/** A genuine two-frame animated WebP. Signage refuses animation: a signage slide is a still frame,
* and an animated one would loop out of step with the authoritative slot deadline. */
export async function makeAnimatedWebp(): Promise<Uint8Array> {
const frame = (red: number) =>
sharp({
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
})
.raw()
.toBuffer();
const frames = Buffer.concat([await frame(255), await frame(0)]);
const encoded = await sharp(frames, {
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
})
.webp({ delay: [100, 100], loop: 0 })
.toBuffer();
return new Uint8Array(encoded);
}
/** A genuine two-frame animated GIF. GIF is refused outright, but animation is detected first, so
* this proves the *animation* rule rather than the format rule. */
export async function makeAnimatedGif(): Promise<Uint8Array> {
const frame = (red: number) =>
sharp({
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
})
.raw()
.toBuffer();
const frames = Buffer.concat([await frame(255), await frame(0)]);
const encoded = await sharp(frames, {
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
})
.gif({ delay: [100, 100], loop: 0 })
.toBuffer();
return new Uint8Array(encoded);
}
export function bytesOf(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
/** A minimal, genuinely well-formed SVG. Signage refuses it: an SVG is a document with script and
* external-reference capability, not a raster image. */
export const SVG_DOCUMENT =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
/** The first bytes of a PDF. Refused for the same reason as everything else that is not a still
* raster image. */
export const PDF_DOCUMENT = "%PDF-1.7\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>";
/** A GIF89a header with one tiny frame. */
export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+249
View File
@@ -0,0 +1,249 @@
import {
type NewStoredSelection,
type PlaybackChanges,
type SelectionChanges,
type SignagePlaybackStore,
SignageStoreConflict,
type StoredChannel,
type StoredChannelGroup,
type StoredChannelGroupDetail,
type StoredPlaybackState,
type StoredSelection,
} from "../../src/server/store/types.js";
/**
* The playback half of the in-memory store.
*
* It enforces the guarantees the database enforces — one default channel that cannot be removed, a
* sequence that is never handed out twice, variants replaced wholesale — so a decision proven here
* holds against PostgreSQL too.
*/
export interface MemoryPlaybackStore extends SignagePlaybackStore {
readonly groups: Map<string, StoredChannelGroup>;
readonly channels: Map<string, StoredChannel>;
readonly playback: Map<string, StoredPlaybackState>;
readonly selections: Map<string, StoredSelection>;
}
export function createMemoryPlaybackStore(
options: { readonly now?: () => Date } = {},
): MemoryPlaybackStore {
const now = options.now ?? (() => new Date());
const groups = new Map<string, StoredChannelGroup>();
const channels = new Map<string, StoredChannel>();
const playback = new Map<string, StoredPlaybackState>();
const selections = new Map<string, StoredSelection>();
function detail(groupId: string): StoredChannelGroupDetail | undefined {
const group = groups.get(groupId);
const state = playback.get(groupId);
if (!group || !state) {
return undefined;
}
return {
channels: [...channels.values()]
.filter((channel) => channel.groupId === groupId)
.sort((left, right) =>
left.isDefault === right.isDefault
? left.key.localeCompare(right.key)
: left.isDefault
? -1
: 1,
),
group,
playback: state,
};
}
return {
addChannel(channel: StoredChannel): Promise<void> {
channels.set(channel.id, channel);
const group = groups.get(channel.groupId);
if (group) {
groups.set(group.id, { ...group, revision: group.revision + 1 });
}
return Promise.resolve();
},
channels,
createChannelGroup(input: {
readonly groupId: string;
readonly name: string;
readonly channelId: string;
readonly channelName: string;
}): Promise<void> {
groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 });
channels.set(input.channelId, {
groupId: input.groupId,
id: input.channelId,
isDefault: true,
key: "default",
name: input.channelName,
});
playback.set(input.groupId, {
activeTimelineId: null,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
groupId: input.groupId,
nextSequence: 1,
paused: false,
pausedRemainingMs: null,
pendingRequestedAt: null,
pendingTimelineId: null,
revision: 1,
scheduledActivationAt: null,
scheduledTimelineId: null,
slotDeadlineAt: null,
slotStartedAt: null,
staticAssetId: null,
updatedAt: now(),
});
return Promise.resolve();
},
deleteChannelGroup(groupId: string): Promise<void> {
groups.delete(groupId);
playback.delete(groupId);
for (const [id, channel] of channels) {
if (channel.groupId === groupId) {
channels.delete(id);
}
}
for (const [id, selection] of selections) {
if (selection.groupId === groupId) {
selections.delete(id);
}
}
return Promise.resolve();
},
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
return Promise.resolve(detail(groupId));
},
findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined> {
const matching = [...selections.values()]
.filter(
(selection) =>
selection.groupId === groupId && selection.selectionScope === selectionScope,
)
.sort((left, right) => right.sequence - left.sequence);
return Promise.resolve(matching[0]);
},
findSelection(selectionId: string): Promise<StoredSelection | undefined> {
return Promise.resolve(selections.get(selectionId));
},
groups,
insertSelection(selection: NewStoredSelection): Promise<void> {
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
return Promise.resolve();
},
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
return Promise.resolve(
[...groups.values()]
.sort((left, right) => left.name.localeCompare(right.name))
.map((group) => detail(group.id))
.filter((value): value is StoredChannelGroupDetail => value !== undefined),
);
},
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
return Promise.resolve(
[...selections.values()]
.filter((selection) => selection.groupId === groupId)
.sort((left, right) => right.sequence - left.sequence)
.slice(0, limit),
);
},
playback,
pruneSelections(groupId: string, limit: number): Promise<void> {
const ordered = [...selections.values()]
.filter((selection) => selection.groupId === groupId)
.sort((left, right) => right.sequence - left.sequence);
for (const selection of ordered.slice(limit)) {
selections.delete(selection.id);
}
return Promise.resolve();
},
removeChannel(channelId: string): Promise<void> {
const channel = channels.get(channelId);
if (!channel) {
return Promise.resolve();
}
if (channel.isDefault) {
return Promise.reject(new SignageStoreConflict("channel.default"));
}
channels.delete(channelId);
const group = groups.get(channel.groupId);
if (group) {
groups.set(group.id, { ...group, revision: group.revision + 1 });
}
return Promise.resolve();
},
selections,
takeSelectionSequence(groupId: string): Promise<number> {
const state = playback.get(groupId);
if (!state) {
return Promise.reject(new SignageStoreConflict("playback.sequence"));
}
playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 });
return Promise.resolve(state.nextSequence);
},
updatePlayback(
groupId: string,
changes: PlaybackChanges,
updateOptions?: { readonly bumpRevision?: boolean },
): Promise<void> {
const state = playback.get(groupId);
if (!state) {
return Promise.resolve();
}
const next: Record<string, unknown> = { ...state };
for (const key of Object.keys(changes)) {
next[key] = (changes as Record<string, unknown>)[key] ?? null;
}
playback.set(groupId, {
...(next as unknown as StoredPlaybackState),
revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1,
updatedAt: now(),
});
return Promise.resolve();
},
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
const selection = selections.get(selectionId);
if (!selection) {
return Promise.resolve();
}
selections.set(selectionId, {
...selection,
...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }),
...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }),
...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }),
...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }),
...(changes.presentation === undefined ? {} : { presentation: changes.presentation }),
...(changes.stale === undefined ? {} : { stale: changes.stale }),
...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }),
...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }),
...(changes.variants === undefined ? {} : { variants: changes.variants }),
revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision,
});
return Promise.resolve();
},
};
}
+120
View File
@@ -0,0 +1,120 @@
import {
type NewStoredImage,
type SignageStore,
SignageStoreConflict,
type StoredImage,
type StoredImageReferences,
} from "../../src/server/store/types.js";
import { createMemoryPlaybackStore, type MemoryPlaybackStore } from "./memory-playback.js";
import { createMemoryTimelineStore, type MemoryTimelineStore } from "./memory-timelines.js";
import { createMemoryViewerStore, type MemoryViewerStore } from "./memory-viewers.js";
/**
* An in-memory implementation of Signage's data port.
*
* This is what makes Signage's *decisions* testable without PostgreSQL: deduplication, no-op
* detection, reference-aware deletion, provider resolution, and playback reconciliation are all
* behaviour, and behaviour proven against a real database for every case would be slow, flaky, and
* therefore rare. The Drizzle implementation is proven separately, against a live disposable
* database, where what is being proven is the row mapping rather than the decision.
*
* It enforces the same uniqueness guarantees the database does, so a test cannot pass here and fail
* there.
*/
export interface MemorySignageStore
extends SignageStore,
MemoryTimelineStore,
MemoryPlaybackStore,
MemoryViewerStore {
/** Test-only: seed a reference so reference-aware deletion can be exercised. */
addImageReference(imageId: string, kind: "slot" | "static-hold"): void;
readonly images: Map<string, StoredImage>;
}
export function createMemorySignageStore(
options: { readonly now?: () => Date } = {},
): MemorySignageStore {
const now = options.now ?? (() => new Date());
const images = new Map<string, StoredImage>();
const references = new Map<string, StoredImageReferences>();
const timelineStore = createMemoryTimelineStore(options);
const playbackStore = createMemoryPlaybackStore(options);
const viewerStore = createMemoryViewerStore(options);
return {
...timelineStore,
...playbackStore,
...viewerStore,
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
references.set(
imageId,
kind === "slot"
? { slots: current.slots + 1, staticHolds: current.staticHolds }
: { slots: current.slots, staticHolds: current.staticHolds + 1 },
);
},
countImageReferences(imageId: string): Promise<StoredImageReferences> {
return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 });
},
deleteImage(imageId: string): Promise<void> {
images.delete(imageId);
references.delete(imageId);
return Promise.resolve();
},
findImage(imageId: string): Promise<StoredImage | undefined> {
return Promise.resolve(images.get(imageId));
},
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
for (const image of images.values()) {
if (image.contentSha256 === contentSha256 && image.status === "ready") {
return Promise.resolve(image);
}
}
return Promise.resolve(undefined);
},
images,
insertImage(image: NewStoredImage): Promise<void> {
for (const existing of images.values()) {
if (existing.contentSha256 === image.contentSha256) {
// Exactly what the unique index does, so a race is exercised here and not only in
// production.
return Promise.reject(new SignageStoreConflict("image.contentSha256"));
}
}
images.set(image.id, { ...image, createdAt: now() });
return Promise.resolve();
},
listImages(): Promise<readonly StoredImage[]> {
return Promise.resolve(
[...images.values()].sort(
(left, right) => right.createdAt.getTime() - left.createdAt.getTime(),
),
);
},
updateImage(
imageId: string,
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
): Promise<void> {
const existing = images.get(imageId);
if (!existing) {
return Promise.resolve();
}
images.set(imageId, {
...existing,
...(changes.displayName === undefined ? {} : { displayName: changes.displayName }),
...(changes.expiresAt === undefined ? {} : { expiresAt: changes.expiresAt }),
});
return Promise.resolve();
},
};
}
+238
View File
@@ -0,0 +1,238 @@
import {
type NewStoredProviderSource,
type NewStoredSlot,
type NewStoredTimeline,
SignageStoreConflict,
type SignageTimelineStore,
type SlotChanges,
type StoredProviderSource,
type StoredSlot,
type StoredTimeline,
type StoredTimelineDetail,
type StoredTimelineUsage,
type TimelineChanges,
} from "../../src/server/store/types.js";
/**
* The timeline half of the in-memory store.
*
* It enforces the same invariants the database does — contiguous positions, an exhaustive reorder,
* a revision that increments once per semantic change — so a test cannot pass here and fail against
* PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does
* not interleave synchronous work; the property being proven is the same.
*/
export interface MemoryTimelineStore extends SignageTimelineStore {
/** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */
setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void;
readonly timelines: Map<string, StoredTimeline>;
readonly slots: Map<string, StoredSlot>;
readonly sources: Map<string, StoredProviderSource>;
}
export function createMemoryTimelineStore(
options: { readonly now?: () => Date } = {},
): MemoryTimelineStore {
const now = options.now ?? (() => new Date());
const timelines = new Map<string, StoredTimeline>();
const slots = new Map<string, StoredSlot>();
const sources = new Map<string, StoredProviderSource>();
const usage = new Map<string, StoredTimelineUsage>();
function slotsOf(timelineId: string): StoredSlot[] {
return [...slots.values()]
.filter((slot) => slot.timelineId === timelineId)
.sort((left, right) => left.position - right.position);
}
function bump(timelineId: string): void {
const timeline = timelines.get(timelineId);
if (timeline) {
timelines.set(timelineId, {
...timeline,
revision: timeline.revision + 1,
updatedAt: now(),
});
}
}
function renumber(timelineId: string, orderedSlotIds: readonly string[]): void {
for (const [index, slotId] of orderedSlotIds.entries()) {
const slot = slots.get(slotId);
if (slot) {
slots.set(slotId, { ...slot, position: index });
}
}
bump(timelineId);
}
return {
addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
const existing = slotsOf(slot.timelineId).map((entry) => entry.id);
const index =
position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length));
slots.set(slot.id, { ...slot, position: -1 });
renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]);
return Promise.resolve();
},
addSource(source: NewStoredProviderSource): Promise<void> {
sources.set(source.id, { ...source, createdAt: now() });
return Promise.resolve();
},
createTimeline(input: {
readonly timeline: NewStoredTimeline;
readonly sources: readonly NewStoredProviderSource[];
readonly slots: readonly NewStoredSlot[];
}): Promise<void> {
const createdAt = now();
timelines.set(input.timeline.id, {
...input.timeline,
createdAt,
revision: 1,
updatedAt: createdAt,
});
for (const source of input.sources) {
sources.set(source.id, { ...source, createdAt });
}
for (const [index, slot] of input.slots.entries()) {
slots.set(slot.id, { ...slot, position: index });
}
return Promise.resolve();
},
deleteTimeline(timelineId: string): Promise<void> {
timelines.delete(timelineId);
for (const [id, slot] of slots) {
if (slot.timelineId === timelineId) {
slots.delete(id);
}
}
for (const [id, source] of sources) {
if (source.timelineId === timelineId) {
sources.delete(id);
}
}
return Promise.resolve();
},
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
const timeline = timelines.get(timelineId);
if (!timeline) {
return Promise.resolve(undefined);
}
return Promise.resolve({
slots: slotsOf(timelineId),
sources: [...sources.values()]
.filter((source) => source.timelineId === timelineId)
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()),
timeline,
});
},
listTimelines(): Promise<readonly StoredTimeline[]> {
return Promise.resolve(
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
removeSlot(slotId: string): Promise<void> {
const slot = slots.get(slotId);
if (!slot) {
return Promise.resolve();
}
slots.delete(slotId);
renumber(
slot.timelineId,
slotsOf(slot.timelineId).map((entry) => entry.id),
);
return Promise.resolve();
},
removeSource(sourceId: string): Promise<void> {
const source = sources.get(sourceId);
if (!source) {
return Promise.resolve();
}
sources.delete(sourceId);
for (const [id, slot] of slots) {
if (slot.sourceId === sourceId) {
slots.delete(id);
}
}
renumber(
source.timelineId,
slotsOf(source.timelineId).map((entry) => entry.id),
);
return Promise.resolve();
},
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
const current = slotsOf(timelineId).map((slot) => slot.id);
const given = new Set(orderedSlotIds);
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
}
for (const slotId of current) {
if (!given.has(slotId)) {
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
}
}
renumber(timelineId, orderedSlotIds);
return Promise.resolve();
},
setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void {
usage.set(timelineId, value);
},
slots,
sources,
timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
return Promise.resolve(
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
);
},
timelines,
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
const slot = slots.get(slotId);
if (!slot) {
return Promise.resolve();
}
slots.set(slotId, {
...slot,
...(changes.enabled === undefined ? {} : { enabled: changes.enabled }),
...(changes.durationOverrideMs === undefined
? {}
: { durationOverrideMs: changes.durationOverrideMs }),
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
...(changes.itemId === undefined ? {} : { itemId: changes.itemId }),
presentation: { ...slot.presentation, ...(changes.presentation ?? {}) },
});
bump(slot.timelineId);
return Promise.resolve();
},
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
const timeline = timelines.get(timelineId);
if (!timeline) {
return Promise.resolve();
}
timelines.set(timelineId, {
...timeline,
...(changes.name === undefined ? {} : { name: changes.name }),
...(changes.description === undefined ? {} : { description: changes.description }),
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
revision: timeline.revision + 1,
updatedAt: now(),
});
return Promise.resolve();
},
};
}
+72
View File
@@ -0,0 +1,72 @@
import type {
NewStoredViewer,
SignageViewerStore,
StoredViewer,
ViewerChanges,
} from "../../src/server/store/types.js";
export interface MemoryViewerStore extends SignageViewerStore {
readonly viewers: Map<string, StoredViewer>;
/** Test-only: how many times a viewer row was actually written, which is what heartbeat
* coalescing exists to keep small. */
readonly viewerWrites: { count: number };
}
export function createMemoryViewerStore(
options: { readonly now?: () => Date } = {},
): MemoryViewerStore {
const now = options.now ?? (() => new Date());
const viewers = new Map<string, StoredViewer>();
const viewerWrites = { count: 0 };
return {
deleteViewer(viewerId: string): Promise<void> {
viewers.delete(viewerId);
return Promise.resolve();
},
findViewer(viewerId: string): Promise<StoredViewer | undefined> {
return Promise.resolve(viewers.get(viewerId));
},
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
for (const viewer of viewers.values()) {
if (viewer.viewerKey === viewerKey) {
return Promise.resolve(viewer);
}
}
return Promise.resolve(undefined);
},
insertViewer(viewer: NewStoredViewer): Promise<void> {
viewerWrites.count += 1;
viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null });
return Promise.resolve();
},
listViewers(): Promise<readonly StoredViewer[]> {
return Promise.resolve(
[...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
const viewer = viewers.get(viewerId);
if (!viewer) {
return Promise.resolve();
}
viewerWrites.count += 1;
const next: Record<string, unknown> = { ...viewer };
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) {
next[key] = value;
}
}
viewers.set(viewerId, next as unknown as StoredViewer);
return Promise.resolve();
},
viewers,
viewerWrites,
};
}
+106
View File
@@ -0,0 +1,106 @@
import {
ANONYMOUS_PRINCIPAL,
type CapabilityId,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleRouteDefinitionInput,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredCapability,
} from "@ekklesia/module-sdk";
import moduleRuntime from "../../src/server/index.js";
/**
* The module-layer test harness: everything a Signage unit test needs to exercise the real runtime
* definition without a platform.
*
* Every object here is a `@ekklesia/contracts` interface constructed by hand, which is exactly what
* a module-layer test may legitimately do — a module may never import Core. It also demonstrates the
* portability claim directly: the host producing these objects could be Ekklesia, or a future
* standalone host reading YAML from disk, and Signage cannot tell the difference.
*/
export interface CapturedRegistration {
readonly channels: { channel: string; permission: string }[];
readonly provided: Map<CapabilityId, unknown>;
readonly routes: ModuleRouteDefinitionInput[];
}
export async function registerModule(
config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION,
): Promise<CapturedRegistration> {
const channels: { channel: string; permission: string }[] = [];
const provided = new Map<CapabilityId, unknown>();
const routes: ModuleRouteDefinitionInput[] = [];
await moduleRuntime.register?.({
capabilities: {
provide(capabilityId, implementation) {
provided.set(capabilityId, implementation);
},
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
return undefined;
},
},
config,
http: {
route(definition) {
routes.push(definition);
},
},
moduleId: "signage",
realtime: {
channel(definition) {
channels.push({ channel: definition.channel, permission: definition.permission });
},
},
});
return { channels, provided, routes };
}
export function findRoute(
registration: CapturedRegistration,
method: string,
path: string,
): ModuleRouteDefinitionInput {
const route = registration.routes.find(
(candidate) => candidate.method === method && candidate.path === path,
);
if (!route) {
throw new Error(`Signage did not register ${method} ${path}.`);
}
return route;
}
export function fakeConfiguration(values: Readonly<Record<string, unknown>>): ModuleConfiguration {
const read = <Value>(key: string, kind: string): Value | undefined => {
const value = values[key];
return typeof value === kind ? (value as Value) : undefined;
};
return Object.freeze({
get: (key: string) => values[key] as never,
getBoolean: (key: string) => read<boolean>(key, "boolean"),
getEnum: (key: string) => read<string>(key, "string"),
getInteger: (key: string) => read<number>(key, "number"),
getSecret: (key: string) => read<string>(key, "string"),
getString: (key: string) => read<string>(key, "string"),
getUrl: (key: string) => read<string>(key, "string"),
has: (key: string) => Object.hasOwn(values, key),
keys: () => Object.freeze(Object.keys(values)),
});
}
export function fakeHandlerContext(
overrides: Partial<ModuleRouteHandlerContext> = {},
): ModuleRouteHandlerContext {
const services = { database: undefined } as unknown as ModuleRuntimeServices;
return Object.freeze({
config: EMPTY_MODULE_CONFIGURATION,
moduleId: "signage",
params: Object.freeze({}),
principal: ANONYMOUS_PRINCIPAL,
realtime: { publish: () => undefined },
services,
storage: EMPTY_MODULE_STORAGE,
...overrides,
});
}
+486
View File
@@ -0,0 +1,486 @@
import { beforeEach, describe, expect, it } from "vitest";
import { SignageError } from "../src/server/errors.js";
import type { StoredTimelineDetail } from "../src/server/store/types.js";
import {
addProviderSource,
addSlot,
createTimeline,
deleteTimeline,
duplicateTimeline,
removeProviderSource,
removeSlot,
reorderSlots,
updateSlot,
updateTimeline,
} from "../src/server/timelines/service.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(SignageError);
const signageError = error as SignageError;
expect(signageError.code).toBe(code);
return signageError;
}
throw new Error(`Expected ${code}, but the operation succeeded.`);
}
describe("timeline management", () => {
let store: MemorySignageStore;
let counter: number;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(() => {
store = createMemorySignageStore();
counter = 0;
});
async function seedTimeline(name = "Sunday morning"): Promise<StoredTimelineDetail> {
return createTimeline({ name, newId, store });
}
/** The pattern from the product definition: three announcement slots, one trivia, two more
* announcements, one scripture — with the five announcement occurrences sharing one source. */
async function seedPattern(): Promise<StoredTimelineDetail> {
let detail = await seedTimeline();
const timelineId = detail.timeline.id;
detail = await addProviderSource({
label: "Active announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId,
});
detail = await addProviderSource({
label: "Open questions",
newId,
providerId: "fixture.trivia",
sourceId: "open-questions",
store,
timelineId,
});
detail = await addProviderSource({
label: "Reading plan",
newId,
providerId: "fixture.scripture",
sourceId: "reading-plan",
store,
timelineId,
});
const [announcements, trivia, scripture] = detail.sources;
if (!announcements || !trivia || !scripture) {
throw new Error("Expected three sources.");
}
for (const source of [
announcements,
announcements,
announcements,
trivia,
announcements,
announcements,
scripture,
]) {
detail = await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId,
});
}
return detail;
}
it("creates a timeline on demand, with no preset pattern of any kind", async () => {
const detail = await seedTimeline("Christmas Eve");
expect(detail.timeline.name).toBe("Christmas Eve");
expect(detail.timeline.revision).toBe(1);
// No slots, no sources, no "Sunday Morning" a church never asked for.
expect(detail.slots).toEqual([]);
expect(detail.sources).toEqual([]);
expect(await store.listTimelines()).toHaveLength(1);
});
it("lets five slots share one source, so they are one rotation rather than five", async () => {
const detail = await seedPattern();
const announcements = detail.sources[0];
expect(detail.slots).toHaveLength(7);
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
// Five occurrences, one selection scope. The provider advances that one scope each time.
const announcementSlots = detail.slots.filter((slot) => slot.sourceId === announcements?.id);
expect(announcementSlots).toHaveLength(5);
expect(new Set(announcementSlots.map((slot) => slot.sourceId)).size).toBe(1);
});
it("keeps positions unique and contiguous through every kind of edit", async () => {
let detail = await seedPattern();
const timelineId = detail.timeline.id;
// Insert in the middle.
const source = detail.sources[1];
if (!source) {
throw new Error("Expected a source.");
}
detail = await addSlot({
newId,
position: 2,
source: { sourceId: source.id },
store,
timelineId,
});
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
expect(detail.slots[2]?.sourceId).toBe(source.id);
// Remove from the middle.
const removed = detail.slots[3];
if (!removed) {
throw new Error("Expected a slot.");
}
detail = await removeSlot({ slotId: removed.id, store, timelineId });
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(detail.slots.some((slot) => slot.id === removed.id)).toBe(false);
// Reverse the whole order.
const reversed = [...detail.slots].reverse().map((slot) => slot.id);
detail = await reorderSlots({ orderedSlotIds: reversed, store, timelineId });
expect(detail.slots.map((slot) => slot.id)).toEqual(reversed);
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it("keeps a slot's identity across a reorder, so history and a display cursor survive it", async () => {
let detail = await seedPattern();
const before = detail.slots.map((slot) => slot.id);
detail = await reorderSlots({
orderedSlotIds: [...before].reverse(),
store,
timelineId: detail.timeline.id,
});
// Every id still exists. Only position changed.
expect(new Set(detail.slots.map((slot) => slot.id))).toEqual(new Set(before));
});
it("refuses a reorder that is not exactly this timeline's slots", async () => {
const detail = await seedPattern();
const ids = detail.slots.map((slot) => slot.id);
const first = ids[0] as string;
for (const bad of [
ids.slice(1), // missing one
[...ids, first], // duplicated
[...ids.slice(1), "11111111-1111-4111-8111-111111111111"], // a stranger
]) {
await expectSignageCode(
reorderSlots({ orderedSlotIds: bad, store, timelineId: detail.timeline.id }),
"SIGNAGE_INVALID_REQUEST",
);
}
// Nothing moved.
const after = await store.findTimeline(detail.timeline.id);
expect(after?.slots.map((slot) => slot.id)).toEqual(ids);
});
it("removes every slot that referenced a deleted source, and closes the gap", async () => {
let detail = await seedPattern();
const announcements = detail.sources[0];
if (!announcements) {
throw new Error("Expected a source.");
}
detail = await removeProviderSource({
sourceId: announcements.id,
store,
timelineId: detail.timeline.id,
});
// The five announcement occurrences went with it; trivia and scripture remain, renumbered.
expect(detail.slots).toHaveLength(2);
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1]);
expect(detail.sources).toHaveLength(2);
});
it("bumps the revision on a semantic change and not on an exact no-op", async () => {
let detail = await seedTimeline("Sunday morning");
const initial = detail.timeline.revision;
detail = await updateTimeline({
changes: { name: "Sunday morning" },
store,
timelineId: detail.timeline.id,
});
expect(detail.timeline.revision).toBe(initial);
detail = await updateTimeline({
changes: { name: "Sunday evening" },
store,
timelineId: detail.timeline.id,
});
expect(detail.timeline.revision).toBe(initial + 1);
// A slot edit is a timeline change too: a client comparing revisions must be woken by it.
const withSource = await addProviderSource({
label: "Active announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId: detail.timeline.id,
});
const source = withSource.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
const withSlot = await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: detail.timeline.id,
});
const slot = withSlot.slots[0];
if (!slot) {
throw new Error("Expected a slot.");
}
const beforeSlotEdit = withSlot.timeline.revision;
const unchanged = await updateSlot({
changes: { enabled: true },
slotId: slot.id,
store,
timelineId: detail.timeline.id,
});
expect(unchanged.timeline.revision).toBe(beforeSlotEdit);
const disabled = await updateSlot({
changes: { enabled: false },
slotId: slot.id,
store,
timelineId: detail.timeline.id,
});
expect(disabled.timeline.revision).toBe(beforeSlotEdit + 1);
expect(disabled.slots[0]?.enabled).toBe(false);
});
it("duplicates a timeline as a genuinely separate program", async () => {
const original = await seedPattern();
const copy = await duplicateTimeline({
name: "Sunday evening",
newId,
store,
timelineId: original.timeline.id,
});
expect(copy.timeline.name).toBe("Sunday evening");
expect(copy.slots).toHaveLength(original.slots.length);
expect(copy.slots.map((slot) => slot.position)).toEqual(original.slots.map((s) => s.position));
// Every identity is new: a copy that shared them would share history and, worse, a rotation.
expect(copy.timeline.id).not.toBe(original.timeline.id);
for (const slot of copy.slots) {
expect(original.slots.some((other) => other.id === slot.id)).toBe(false);
}
const originalScopes = new Set(
[...store.sources.values()]
.filter((source) => source.timelineId === original.timeline.id)
.map((source) => source.selectionScope),
);
const copyScopes = [...store.sources.values()]
.filter((source) => source.timelineId === copy.timeline.id)
.map((source) => source.selectionScope);
expect(copyScopes).toHaveLength(3);
for (const scope of copyScopes) {
// The copy rotates independently: an evening loop does not continue the morning's cursor.
expect(originalScopes.has(scope)).toBe(false);
}
// The copy's slots still share one source the same way the original's did.
const copyAnnouncements = copy.slots.filter((slot) => slot.sourceId === copy.sources[0]?.id);
expect(copyAnnouncements).toHaveLength(5);
});
it("refuses to delete a timeline playback still points at", async () => {
const detail = await seedTimeline();
store.setTimelineUsage(detail.timeline.id, {
activeGroups: 1,
pendingGroups: 0,
scheduledGroups: 1,
});
const error = await expectSignageCode(
deleteTimeline({ store, timelineId: detail.timeline.id }),
"SIGNAGE_CONFLICT",
);
expect(error.detail).toEqual({ activeGroups: 1, pendingGroups: 0, scheduledGroups: 1 });
expect(store.timelines.size).toBe(1);
});
it("deletes an unused timeline and everything inside it", async () => {
const detail = await seedPattern();
await deleteTimeline({ store, timelineId: detail.timeline.id });
expect(store.timelines.size).toBe(0);
expect(store.slots.size).toBe(0);
expect(store.sources.size).toBe(0);
});
it("refuses a slot that is both a source and a pinned item, or neither", async () => {
const detail = await seedTimeline();
const withSource = await addProviderSource({
label: "Active announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId: detail.timeline.id,
});
const source = withSource.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await expectSignageCode(
addSlot({ newId, store, timelineId: detail.timeline.id }),
"SIGNAGE_INVALID_REQUEST",
);
await expectSignageCode(
addSlot({
newId,
pinned: { itemId: "welcome", providerId: "fixture.announcements" },
source: { sourceId: source.id },
store,
timelineId: detail.timeline.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
});
it("refuses a slot referencing a source configured on a different timeline", async () => {
const first = await seedTimeline("First");
const second = await seedTimeline("Second");
const withSource = await addProviderSource({
label: "Active announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId: first.timeline.id,
});
const source = withSource.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await expectSignageCode(
addSlot({ newId, source: { sourceId: source.id }, store, timelineId: second.timeline.id }),
"SIGNAGE_INVALID_REQUEST",
);
});
it("accepts a pinned item as an opaque identity from another module", async () => {
const detail = await seedTimeline();
const withPinned = await addSlot({
newId,
pinned: { itemId: "advent-service", providerId: "fixture.announcements" },
store,
timelineId: detail.timeline.id,
});
expect(withPinned.slots[0]).toMatchObject({
itemId: "advent-service",
kind: "pinned-item",
providerId: "fixture.announcements",
sourceId: null,
});
});
it("refuses an inverted or empty eligibility window rather than silently never showing a slot", async () => {
const detail = await seedTimeline();
const from = new Date("2026-12-24T18:00:00.000Z");
const until = new Date("2026-12-24T17:00:00.000Z");
await expectSignageCode(
createTimeline({ eligibleFrom: from, eligibleUntil: until, name: "Bad", newId, store }),
"SIGNAGE_INVALID_REQUEST",
);
await expectSignageCode(
addSlot({
eligibleFrom: from,
eligibleUntil: from,
newId,
pinned: { itemId: "x", providerId: "p" },
store,
timelineId: detail.timeline.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
});
it("refuses presentation values it cannot render, and stores the ones it can", async () => {
const detail = await seedTimeline();
await expectSignageCode(
addSlot({
newId,
pinned: { itemId: "x", providerId: "p" },
presentation: { backgroundColor: "red; background: url(x)" },
store,
timelineId: detail.timeline.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
const stored = await addSlot({
newId,
pinned: { itemId: "x", providerId: "p" },
presentation: { backgroundColor: "#112233", backgroundMode: "solid", blurPixels: 30 },
store,
timelineId: detail.timeline.id,
});
expect(stored.slots[0]?.presentation).toEqual({
backgroundColor: "#112233",
backgroundDarkened: null,
backgroundMode: "solid",
blurPixels: 30,
foregroundShadow: null,
});
});
it("refuses a duration outside what a slide can usefully be", async () => {
const detail = await seedTimeline();
for (const durationOverrideMs of [0, 999, 3_600_001, 1.5]) {
await expectSignageCode(
addSlot({
durationOverrideMs,
newId,
pinned: { itemId: "x", providerId: "p" },
store,
timelineId: detail.timeline.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
}
});
it("reports an unknown timeline as not found without saying whether it ever existed", async () => {
await expectSignageCode(
updateTimeline({
changes: { name: "x" },
store,
timelineId: "11111111-1111-4111-8111-111111111111",
}),
"SIGNAGE_NOT_FOUND",
);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { createUploadQueue, type UploadFile, type UploadItem } from "../src/browser/uploads.js";
function file(name: string, size: number, type = "image/png"): UploadFile {
return { name, size, type };
}
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("upload queue", () => {
it("sends one request per file, and never more than the concurrency at once", async () => {
let inFlight = 0;
let peak = 0;
const requests: string[] = [];
const release: (() => void)[] = [];
const queue = createUploadQueue({
concurrency: 3,
maxBytes: 25 * 1024 * 1024,
maxFiles: 20,
newId: () => `id-${requests.length}-${release.length}`,
onChange: () => undefined,
upload: (candidate) => {
requests.push(candidate.name);
inFlight += 1;
peak = Math.max(peak, inFlight);
return new Promise<void>((resolve) => {
release.push(() => {
inFlight -= 1;
resolve();
});
});
},
});
queue.enqueue(Array.from({ length: 20 }, (_, index) => file(`slide-${index}.png`, 1024)));
await settle();
// Twenty files are twenty requests — never one aggregate upload, which the platform's per-request
// ceiling could not express and whose failure would waste every byte already sent.
expect(peak).toBe(3);
expect(requests).toHaveLength(3);
while (release.length > 0) {
release.shift()?.();
await settle();
}
await queue.drain();
expect(requests).toHaveLength(20);
expect(new Set(requests).size).toBe(20);
});
it("fails one file alone and finishes the rest of the batch", async () => {
const items: UploadItem[][] = [];
const queue = createUploadQueue({
concurrency: 2,
maxBytes: 25 * 1024 * 1024,
maxFiles: 20,
newId: () => `id-${items.length}-${Math.random()}`,
onChange: (next) => items.push([...next]),
upload: (candidate) =>
candidate.name === "broken.png"
? Promise.reject({ failure: { code: "SIGNAGE_IMAGE_MALFORMED", message: "Bad image." } })
: Promise.resolve(),
});
queue.enqueue([file("a.png", 10), file("broken.png", 10), file("c.png", 10)]);
await queue.drain();
await settle();
const final = items.at(-1) ?? [];
expect(final.filter((item) => item.status === "done")).toHaveLength(2);
const failed = final.find((item) => item.status === "failed");
expect(failed).toMatchObject({ code: "SIGNAGE_IMAGE_MALFORMED", name: "broken.png" });
});
it("refuses an oversized or unsupported file before a byte is sent", async () => {
const sent: string[] = [];
const seen: UploadItem[][] = [];
const queue = createUploadQueue({
concurrency: 3,
maxBytes: 1024,
maxFiles: 20,
newId: () => `id-${seen.length}-${sent.length}-${Math.random()}`,
onChange: (next) => seen.push([...next]),
upload: (candidate) => {
sent.push(candidate.name);
return Promise.resolve();
},
});
queue.enqueue([
file("huge.png", 2048),
file("document.pdf", 10, "application/pdf"),
file("fine.png", 10),
]);
await queue.drain();
await settle();
// Only the acceptable one was ever sent. The server checks both rules again — a browser is not a
// security boundary — but telling the operator now costs them nothing.
expect(sent).toEqual(["fine.png"]);
const final = seen.at(-1) ?? [];
expect(final.find((item) => item.name === "huge.png")).toMatchObject({
code: "SIGNAGE_IMAGE_TOO_LARGE",
status: "rejected",
});
expect(final.find((item) => item.name === "document.pdf")).toMatchObject({
code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE",
status: "rejected",
});
});
it("refuses the overflow of a batch larger than the dashboard offers", async () => {
const sent: string[] = [];
const seen: UploadItem[][] = [];
const queue = createUploadQueue({
concurrency: 5,
maxBytes: 1024,
maxFiles: 2,
newId: () => `id-${sent.length}-${seen.length}-${Math.random()}`,
onChange: (next) => seen.push([...next]),
upload: (candidate) => {
sent.push(candidate.name);
return Promise.resolve();
},
});
queue.enqueue([file("a.png", 10), file("b.png", 10), file("c.png", 10)]);
await queue.drain();
await settle();
expect(sent).toEqual(["a.png", "b.png"]);
expect((seen.at(-1) ?? []).find((item) => item.name === "c.png")).toMatchObject({
code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE",
status: "rejected",
});
});
});
+603
View File
@@ -0,0 +1,603 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildDisplayChannelState } from "../src/server/display/state.js";
import { SignageError } from "../src/server/errors.js";
import { ingestNativeImage } from "../src/server/images/service.js";
import {
createPlaybackService,
type SignagePlaybackService,
} from "../src/server/playback/service.js";
import { createNativeImageProvider } from "../src/server/providers/native-provider.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import type { StoredChannel } from "../src/server/store/types.js";
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
import { createDisplaysCapability } from "../src/server/viewers/capability.js";
import {
describeViewerHealth,
openViewerSession,
recordViewerHeartbeat,
SIGNAGE_VIEWER_OFFLINE_AFTER_MS,
updateViewerAssignment,
} from "../src/server/viewers/service.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
import { announcementsFixture, type FixtureProvider } from "./support/fixture-provider.js";
import { makeImage } from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
const START = Date.parse("2026-08-30T15:00:00.000Z");
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(SignageError);
const signageError = error as SignageError;
expect(signageError.code).toBe(code);
return signageError;
}
throw new Error(`Expected ${code}, but the operation succeeded.`);
}
describe("viewers", () => {
let store: MemorySignageStore;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let announcements: FixtureProvider;
let clock: number;
let counter: number;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(async () => {
clock = START;
counter = 0;
store = createMemorySignageStore({ now: () => new Date(clock) });
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
announcements = announcementsFixture();
registry.capability.register(announcements.registration);
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store })
.registration,
);
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: SIGNAGE_DEFAULT_SETTINGS,
store,
});
await service.ensureDefaultGroup();
});
async function defaultChannel(): Promise<StoredChannel> {
const groups = await store.listChannelGroups();
const channel = groups[0]?.channels[0];
if (!channel) {
throw new Error("Expected a default channel.");
}
return channel;
}
it("registers a display on the default channel, and never lets it pick one", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
const channel = await defaultChannel();
expect(session.channel.id).toBe(channel.id);
expect(session.channel.isDefault).toBe(true);
// There is no input by which a display could have asked for another channel: `openViewerSession`
// takes an identity and nothing else.
expect(session.viewer.name).toBe("New display");
});
it("recognizes a returning display rather than creating a ghost", async () => {
const first = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
await updateViewerAssignment({ name: "Lobby north", store, viewerId: first.viewer.id });
clock += 60_000;
const second = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
// Same screen, same row, same name. A television that reboots does not fill the roster.
expect(second.viewer.id).toBe(first.viewer.id);
expect(second.viewer.name).toBe("Lobby north");
expect(store.viewers.size).toBe(1);
});
it("stores only the safe device identifier a paired principal carries", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: "3f2c1a9e-1111-4222-8333-444455556666",
newId,
store,
viewerKey: "kiosk-lobby-0002",
});
expect(session.viewer.deviceId).toBe("3f2c1a9e-1111-4222-8333-444455556666");
// Nothing about pairing, and no credential of any kind, is anywhere in the record.
const serialized = JSON.stringify(session.viewer);
for (const forbidden of ["token", "secret", "credential", "password", "pairing"]) {
expect(serialized.toLowerCase()).not.toContain(forbidden);
}
});
it("refuses a viewer key that is not one Signage issued the shape of", async () => {
for (const viewerKey of ["", "short", "has spaces in it", "x".repeat(200)]) {
await expectSignageCode(
openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey }),
"SIGNAGE_INVALID_REQUEST",
);
}
});
it("coalesces a steady display's heartbeats into almost no writes", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
const before = store.viewerWrites.count;
// Ten heartbeats over forty seconds, saying exactly the same thing each time.
for (let index = 0; index < 10; index += 1) {
clock += 4000;
await recordViewerHeartbeat({
at: new Date(clock),
heartbeat: { cachedAssetCount: 0, offlineReady: false, reportedSelectionId: null },
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
store,
viewerId: session.viewer.id,
});
}
// Forty seconds at a thirty-second floor is one write, not ten. A wall of televisions is a wall
// of these, and writing every one would say nothing new very expensively.
expect(store.viewerWrites.count - before).toBe(1);
});
it("writes immediately when a display says something an operator would notice", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
const before = store.viewerWrites.count;
clock += 1000;
const changed = await recordViewerHeartbeat({
at: new Date(clock),
heartbeat: { reportedSelectionId: "sel-1" },
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
store,
viewerId: session.viewer.id,
});
clock += 1000;
const failing = await recordViewerHeartbeat({
at: new Date(clock),
heartbeat: {
error: { code: "SLIDE_DECODE_FAILED", detail: "one image could not be decoded" },
reportedSelectionId: "sel-1",
},
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
store,
viewerId: session.viewer.id,
});
expect(changed.written).toBe(true);
expect(failing.written).toBe(true);
expect(store.viewerWrites.count - before).toBe(2);
expect(failing.viewer.lastError).toMatchObject({ code: "SLIDE_DECODE_FAILED" });
});
it("refuses an unbounded or malformed error report from a display", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
await expectSignageCode(
recordViewerHeartbeat({
at: new Date(clock),
heartbeat: { error: { code: "x".repeat(200) } },
minimumWriteIntervalMs: 0,
store,
viewerId: session.viewer.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
await expectSignageCode(
recordViewerHeartbeat({
at: new Date(clock),
heartbeat: { error: { code: "OK", detail: "y".repeat(5000) } },
minimumWriteIntervalMs: 0,
store,
viewerId: session.viewer.id,
}),
"SIGNAGE_INVALID_REQUEST",
);
});
it("reports a display as offline once it stops checking in", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
expect(describeViewerHealth(session.viewer, clock).online).toBe(true);
expect(
describeViewerHealth(session.viewer, clock + SIGNAGE_VIEWER_OFFLINE_AFTER_MS + 1).online,
).toBe(false);
});
it("moves a display between channels, and refuses a channel that does not exist", async () => {
const groups = await store.listChannelGroups();
const group = groups[0];
if (!group) {
throw new Error("Expected a group.");
}
const onlineChannelId = newId();
await store.addChannel({
groupId: group.group.id,
id: onlineChannelId,
isDefault: false,
key: "online",
name: "Online",
});
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
const moved = await updateViewerAssignment({
channelId: onlineChannelId,
store,
viewerId: session.viewer.id,
});
expect(moved.channelId).toBe(onlineChannelId);
await expectSignageCode(
updateViewerAssignment({
channelId: "11111111-1111-4111-8111-111111111111",
store,
viewerId: session.viewer.id,
}),
"SIGNAGE_NOT_FOUND",
);
});
it("exposes a sanitized roster through the displays capability", async () => {
const session = await openViewerSession({
at: new Date(clock),
deviceId: "3f2c1a9e-1111-4222-8333-444455556666",
newId,
store,
viewerKey: "kiosk-lobby-0001",
});
const capability = createDisplaysCapability(
() => store,
() => new Date(clock),
);
const viewers = await capability.listViewers();
const channels = await capability.listChannels();
expect(viewers).toHaveLength(1);
expect(viewers[0]).toMatchObject({ id: session.viewer.id, online: true });
expect(channels[0]).toMatchObject({ isDefault: true, key: "default" });
// A consuming module sees identity and health. It never sees a credential, an object key, a
// delivery URL, or a provider payload.
const serialized = JSON.stringify({ channels, viewers });
for (const forbidden of ["token", "secret", "objectKey", "https://", "payload"]) {
expect(serialized).not.toContain(forbidden);
}
});
});
describe("display channel state", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let announcements: FixtureProvider;
let clock: number;
let counter: number;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(async () => {
clock = START;
counter = 0;
store = createMemorySignageStore({ now: () => new Date(clock) });
storage = createFakeStorage({ now: () => new Date(clock) });
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
announcements = announcementsFixture();
registry.capability.register(announcements.registration);
registry.capability.register(
createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store })
.registration,
);
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: SIGNAGE_DEFAULT_SETTINGS,
store,
});
await service.ensureDefaultGroup();
});
async function channelsOf(): Promise<readonly StoredChannel[]> {
const groups = await store.listChannelGroups();
return groups[0]?.channels ?? [];
}
async function stateFor(channel: StoredChannel) {
return buildDisplayChannelState({
atMs: clock,
channel,
playback: await service.snapshot(channel.groupId),
registry,
settings: SIGNAGE_DEFAULT_SETTINGS,
storage,
store,
});
}
it("carries the frame, the timeline's shape, and recent selections — and no object key", async () => {
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
let timeline = await createTimeline({ name: "Loop", newId, store });
timeline = await addProviderSource({
label: "Announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId: timeline.timeline.id,
});
const source = timeline.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: timeline.timeline.id,
});
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
const [channel] = await channelsOf();
if (!channel) {
throw new Error("Expected a channel.");
}
const state = await stateFor(channel);
expect(state.current?.itemId).toBe("welcome");
expect(state.current?.renderer).toEqual({
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
kind: "contribution",
});
// The shape a disconnected display advances its own cursor over, with the same estimates the
// server skips passes with.
expect(state.timeline?.slots).toHaveLength(2);
expect(state.timeline?.slots[0]?.estimatedDurationMs).toBe(5000);
expect(state.selections.length).toBeGreaterThan(0);
// A display is handed identity, timing, and payloads — never an object key.
expect(JSON.stringify(state)).not.toContain("objectKey");
});
it("gives each channel its own audience's payload, from one logical selection", async () => {
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
await store.addChannel({
groupId,
id: newId(),
isDefault: false,
key: "online",
name: "Online",
});
let timeline = await createTimeline({ name: "Loop", newId, store });
timeline = await addProviderSource({
label: "Announcements",
newId,
providerId: "fixture.announcements",
sourceId: "active-slides",
store,
timelineId: timeline.timeline.id,
});
const source = timeline.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
// Move to the announcement that has an audience-specific variant.
clock += 5000;
await service.reconcile(groupId, "timer");
const channels = await channelsOf();
const base = channels.find((channel) => channel.key === "default") as StoredChannel;
const online = channels.find((channel) => channel.key === "online") as StoredChannel;
const baseState = await stateFor(base);
const onlineState = await stateFor(online);
expect(baseState.current?.payload).toEqual({ headline: "Good morning" });
expect(onlineState.current?.payload).toEqual({ headline: "Watching from home" });
// One selection, one slot, one deadline — two renderings.
expect(onlineState.current?.id).toBe(baseState.current?.id);
expect(onlineState.current?.deadlineAtMs).toBe(baseState.current?.deadlineAtMs);
expect(onlineState.playback.startedAtMs).toBe(baseState.playback.startedAtMs);
});
it("resolves a native image into a scene and fresh delivery URLs", async () => {
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
const ingested = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1080 }),
displayName: "Square",
expiresAt: null,
filename: "square.png",
storage,
store,
});
let timeline = await createTimeline({ name: "Images", newId, store });
await addSlot({
newId,
pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID },
presentation: { backgroundMode: "blurred", blurPixels: 24 },
store,
timelineId: timeline.timeline.id,
});
timeline = (await store.findTimeline(timeline.timeline.id)) as never;
await service.activate(groupId, timeline.timeline.id, "now");
const [channel] = await channelsOf();
if (!channel) {
throw new Error("Expected a channel.");
}
const state = await stateFor(channel);
expect(state.current?.renderer).toEqual({ kind: "native-image" });
// A square image is not 16:9, so it is shown whole on the background the slot asked for.
expect(state.current?.scene).toMatchObject({
backgroundImage: true,
blurPixels: 24,
fit: "contain",
shadow: true,
});
expect(state.current?.media?.display.url).toContain("https://storage.invalid/");
expect(state.current?.media?.display.expiresAtMs).toBeGreaterThan(clock);
// The URL was minted for this response and is nowhere in the database.
expect(JSON.stringify([...store.selections.values()])).not.toContain("https://");
});
it("shapes a static hold exactly like a selection, so a display has one renderer path", async () => {
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
const ingested = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: "Hold",
expiresAt: null,
filename: "hold.png",
storage,
store,
});
await service.setStaticHold(groupId, ingested.image.id);
const [channel] = await channelsOf();
if (!channel) {
throw new Error("Expected a channel.");
}
const state = await stateFor(channel);
expect(state.playback.transport).toBe("static");
expect(state.staticHold?.renderer).toEqual({ kind: "native-image" });
// A true 16:9 image fills the frame on its own: no blurred duplicate, no shadow.
expect(state.staticHold?.scene).toMatchObject({
backgroundImage: false,
fit: "fill",
shadow: false,
});
});
it("keeps a display's state usable when storage is briefly unreachable", async () => {
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
const ingested = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: "Slide",
expiresAt: null,
filename: "slide.png",
storage,
store,
});
const timeline = await createTimeline({ name: "Images", newId, store });
await addSlot({
newId,
pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
const [channel] = await channelsOf();
if (!channel) {
throw new Error("Expected a channel.");
}
const broken = {
...storage,
bucket: () => {
throw new Error("storage is down");
},
};
const state = await buildDisplayChannelState({
atMs: clock,
channel,
playback: await service.snapshot(groupId),
registry,
settings: SIGNAGE_DEFAULT_SETTINGS,
storage: broken as never,
store,
});
// The frame's identity, timing, and scene all survive. Only the URLs are missing, which costs a
// display that already cached the bytes nothing at all.
expect(state.current?.itemId).toBe(ingested.image.id);
expect(state.current?.scene).toBeDefined();
expect(state.current?.media).toBeUndefined();
});
});