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 { 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 { 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; devDependencies?: Record }; 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 ABI executor, with Signage's own Drizzle // adapter compiling the queries; there is no client of Signage's own. expect(names).toContain("drizzle-orm"); expect(names).toContain("@ekklesia/module-database"); }); });