Signage moves from the legacy module contract to Ekklesia 0.2.0's, with no behaviour or schema
change:
- manifest version 8, `database: { abi: 1 }`, compatibility ^0.2.0 for platform and module SDK;
the runtime declares the same ABI, as Core requires;
- every store reaches PostgreSQL through the platform's Module Database ABI v1 executor, with
Signage's own Drizzle tables and the module-side adapter (`moduleDrizzle`) from
@ekklesia/module-database. createSignageStore takes the executor, so no call site changes;
- the schema entrypoint exports only plain ABI v1 metadata (table names and foreign-key targets),
derived from tables.ts with moduleDrizzleSchemaMetadata; no Drizzle object reaches Core;
- duplicate-image detection reads SQLSTATE 23505 from the stable Ekklesia database error, found
with findEkklesiaDatabaseError through Drizzle's DrizzleQueryError wrapping. The old
`error.code === "23505"` check never matched a wrapped error, so a duplicate-upload race
surfaced as a 500 instead of the content-hash conflict.
tables.ts declarations, the migration, and its pinned checksum are unchanged.
Tests: an ABI v1 executor double that enforces Core's scope, parameter, and abort rules drives all
13 store transactions, their rollbacks, raw int8/timestamptz/jsonb decoding, and the 23505
regression, including generic and driver-shaped errors that must not count as duplicates.
CI, temporarily: the platform pin moves to v0.2.0 (1788d17) with a regenerated ci/pnpm-lock.yaml.
The integration job is disabled: from 0.2.0 the platform image is generic (ADR 0048) and installs
modules only as Module Packages, which Signage cannot be until its sharp dependency goes through
the native package flow. The job and ci/integration.sh stay, and the job returns in that change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
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 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");
|
|
});
|
|
});
|