feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it } from "vitest";
import { ingestNativeImage } from "../src/server/images/service.js";
import {
createPlaybackService,
type SignagePlaybackService,
} from "../src/server/playback/service.js";
import {
createNativeImageProvider,
SIGNAGE_NATIVE_LIBRARY_SOURCE,
} from "../src/server/providers/native-provider.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
import { makeImage } from "./support/images.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
/**
* The scenarios a church would check before trusting this on a wall.
*
* Most of them are proven in the suite that owns the behaviour — provider policy in
* `providers.test.ts`, transport in `playback.test.ts`, transitions in `browser/scene.test.ts`. What
* lives here is the handful that cross those seams, plus the two sweeps that are only meaningful
* across the whole module.
*/
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourceDirectory = path.join(path.dirname(moduleDirectory), "src");
/**
* Strips comments before scanning.
*
* These sweeps are about what the code *does*, and this module's comments deliberately name the
* things it refuses to use — "never reads `process.env`", "no MQTT" — which is exactly the prose
* that makes the refusal legible. Scanning it would punish saying so.
*/
function withoutComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//gu, " ").replace(/(^|[^:])\/\/.*$/gmu, "$1");
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await sourceFiles(full)));
} else if (/\.(?:ts|tsx|css)$/u.test(entry.name)) {
files.push(full);
}
}
return files;
}
describe("Signage on its own, with no content module installed", () => {
let store: MemorySignageStore;
let storage: FakeStorage;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let clock: number;
let counter: number;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(async () => {
clock = Date.parse("2026-08-30T15:00:00.000Z");
counter = 0;
store = createMemorySignageStore({ now: () => new Date(clock) });
storage = createFakeStorage({ now: () => new Date(clock) });
// Nothing but Signage's own image provider. No Announcements, no Trivia, no Scripture.
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
registry.capability.register(
createNativeImageProvider({
defaultDurationMs: SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs,
now: () => new Date(clock),
store,
}).registration,
);
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: SIGNAGE_DEFAULT_SETTINGS,
store,
});
await service.ensureDefaultGroup();
});
async function upload(name: string): Promise<string> {
const result = await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
displayName: name,
expiresAt: null,
filename: `${name}.png`,
storage,
store,
});
return result.image.id;
}
it("plays a loop of native images, with nothing else installed", async () => {
const first = await upload("welcome");
// A second image must have different bytes, or content addressing would deduplicate it.
const second = (
await ingestNativeImage({
bytes: await makeImage({ format: "png", height: 900, width: 1600 }),
displayName: "giving",
expiresAt: null,
filename: "giving.png",
storage,
store,
})
).image.id;
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
let timeline = await createTimeline({ name: "Lobby loop", newId, store });
timeline = await addProviderSource({
label: "All Signage images",
newId,
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
sourceId: SIGNAGE_NATIVE_LIBRARY_SOURCE,
store,
timelineId: timeline.timeline.id,
});
const source = timeline.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
const played: (string | undefined)[] = [];
for (let index = 0; index < 3; index += 1) {
const snapshot = await service.snapshot(groupId);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
played.push(selection?.itemId);
clock += SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs;
await service.reconcile(groupId, "timer");
}
// Signage works out of the box: its own provider, its own images, its own rotation.
expect(played).toEqual([first, second, first]);
});
it("gives two displays that connected at different moments the same slot and deadline", async () => {
await upload("welcome");
const groups = await store.listChannelGroups();
const groupId = groups[0]?.group.id as string;
const timeline = await createTimeline({ name: "Lobby loop", newId, store });
await addSlot({
newId,
pinned: { itemId: await upload("second"), providerId: SIGNAGE_NATIVE_PROVIDER_ID },
store,
timelineId: timeline.timeline.id,
});
await service.activate(groupId, timeline.timeline.id, "now");
// One display asks now; another asks two seconds later, mid-slot.
const early = await service.snapshot(groupId);
clock += 2000;
const late = await service.snapshot(groupId);
// Both read the same persisted absolute instants. There is no countdown to drift, so two screens
// that joined at different moments transition together to the millisecond.
expect(late.currentSelectionId).toBe(early.currentSelectionId);
expect(late.startedAtMs).toBe(early.startedAtMs);
expect(late.deadlineAtMs).toBe(early.deadlineAtMs);
// And what each of them has left is computed from that one deadline, not from when it arrived.
expect((early.remainingMs ?? 0) - (late.remainingMs ?? 0)).toBe(2000);
});
});
describe("what this release deliberately does not contain", () => {
it("names no excluded technology anywhere in its source", async () => {
const files = await sourceFiles(sourceDirectory);
const contents = await Promise.all(
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
);
// Every one of these is explicitly out of scope for this release. A single mention would mean
// something crept in that an operator would then have to support.
const forbidden = [
"mqtt",
"home-assistant",
"homeassistant",
"socket.io",
"socketio",
"sqlite",
"better-auth",
"@aws-sdk",
"s3client",
"minio",
"seaweedfs",
"nginx",
"docker-compose",
];
for (const { file, text } of contents) {
const lowered = text.toLowerCase();
for (const term of forbidden) {
expect(`${path.relative(sourceDirectory, file)}:${term}`).toBe(
lowered.includes(term)
? `IMPOSSIBLE:${term}`
: `${path.relative(sourceDirectory, file)}:${term}`,
);
}
}
});
it("hardcodes no platform URL, origin, or environment variable", async () => {
const files = await sourceFiles(sourceDirectory);
const contents = await Promise.all(
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
);
for (const { file, text } of contents) {
const relative = path.relative(sourceDirectory, file);
// Ekklesia's module API prefix is its private deployment detail; a module that wrote it would
// be unhostable anywhere else.
expect(`${relative}:${text.includes("/api/v1/")}`).toBe(`${relative}:false`);
// Configuration reaches Signage through one contract, and it is not the environment.
expect(`${relative}:${/process\.env/u.test(text)}`).toBe(`${relative}:false`);
// Only comments and documentation may mention a real host; nothing may fetch one.
const fetchesHost = /(?:fetch|request)\(\s*["'`]https?:\/\//u.test(text);
expect(`${relative}:${fetchesHost}`).toBe(`${relative}:false`);
}
});
it("declares no dependency this release excludes", async () => {
const manifest = JSON.parse(
await readFile(path.join(path.dirname(moduleDirectory), "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
const names = [
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.devDependencies ?? {}),
];
for (const forbidden of [
"mqtt",
"socket.io",
"socket.io-client",
"better-auth",
"better-sqlite3",
"@aws-sdk/client-s3",
"minio",
"pg",
]) {
expect(names).not.toContain(forbidden);
}
// The database is reached only through the platform's facade; there is no client of Signage's own.
expect(names).toContain("drizzle-orm");
});
});