feat: initial Signage 0.1.0
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user