Files
Signage/tests/manifest.test.ts
ekklesiaandClaude Opus 5.5 dd656eb18a
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 11s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 7m17s
chore: release signage 0.1.3
0.1.3 ships the external package integration validated in #13 (Pass 2):
the canonical linux-x64-glibc Module Package installed and exercised
end to end by CI, with bounded external runtime image processing.

Only the version moves: the manifest, package.json, the server runtime,
both browser artifacts, the settings payload's moduleVersion, the test
fixtures, and the README status line. compatibility stays
platform ^0.2.0 and moduleSdk ^0.2.0; Core 0.2.4 already satisfies it,
so the range is not tightened. pnpm-lock.yaml carries no root version
and is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 12:20:06 -05:00

300 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(8);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.3");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
});
it("declares the Module Database ABI v1, so the platform hands it an executor and no ORM object", async () => {
const manifest = await readManifest();
// Platform ADR 0050: with `database.abi: 1`, `services.database` is the ABI v1 executor. Without
// it, a version 8 manifest would get the legacy Drizzle facade Signage no longer compiles against.
expect(manifest.database).toEqual({ abi: 1 });
});
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 () => {
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.3" });
});
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.3",
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");
});
});