0.1.2 fixes what the first production Signage deployment found: - #4: the display and server share one message schema; every heartbeat from an image slide used to be refused with a 400; - #5: every display request retries with backoff and never gives up, so a display that failed one request no longer stays offline until reloaded; - #6: one object URL per cached slide, released when done; the leak exhausted the browser within minutes; - CI (#7, #8): every change runs inside a pinned Ekklesia, end to end in a real browser, and cleans up even when killed. compatibility.platform stays ^0.1.0: every fix is module-side, and CI tested it against platform v0.1.1 plus test-only scripts. The README no longer says Signage is unreleased or undeployed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
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.2");
|
|
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.2" });
|
|
});
|
|
|
|
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.2",
|
|
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");
|
|
});
|
|
});
|