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