// @vitest-environment happy-dom import type { ModuleRouteDefinitionInput, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import { beforeEach, describe, expect, it } from "vitest"; import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; import { createDisplayController } from "../../src/display/controller.js"; import { ingestNativeImage } from "../../src/server/images/service.js"; import { createPlaybackService, type SignagePlaybackService, } from "../../src/server/playback/service.js"; import { createNativeImageProvider } from "../../src/server/providers/native-provider.js"; import { createProviderRegistry, type SignageProviderRegistry, } from "../../src/server/providers/registry.js"; import { registerViewerRoutes } from "../../src/server/routes/viewers.js"; import { 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"; import { fakeConfiguration, fakeHandlerContext } from "../support/module-harness.js"; import { createFakeHost, type FakeHost } from "./support/fake-host.js"; /** * The display and the server, speaking to each other for real. * * Every other display test answers the controller with a hand-written stub, and every server test * calls the service with a hand-written object. Each side was therefore only ever tested against its * own idea of the other, and in production every heartbeat a display sent was refused with a 400: * the display sent `null` for an absent value and the route accepted only a missing field. * * Here the **real** display controller's requests are answered by the **real** viewer routes, over * Signage's in-memory store. A request one side builds and the other refuses fails this suite. */ const START = Date.parse("2026-10-05T15:00:00.000Z"); const DEVICE_ID = "6f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e"; interface RouteCall { readonly method: string; readonly path: string; readonly status: number; readonly code: string | undefined; } /** Matches a relative module path against the routes the module registered, and runs the handler * exactly as the platform would: one Request, one handler context. */ function routeTable(routes: readonly ModuleRouteDefinitionInput[]) { return routes.map((route) => { const names: string[] = []; const pattern = new RegExp( `^${route.path.replace(/:(\w+)/gu, (_match, name: string) => { names.push(name); return "([^/]+)"; })}$`, "u", ); return { names, pattern, route }; }); } describe("display ↔ server contract", () => { let clock: number; let counter: number; let store: MemorySignageStore; let storage: FakeStorage; let registry: SignageProviderRegistry; let playback: SignagePlaybackService; let calls: RouteCall[]; let host: FakeHost; let root: HTMLElement; const newId = () => { counter += 1; return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; }; beforeEach(async () => { clock = START; counter = 0; calls = []; store = createMemorySignageStore({ now: () => new Date(clock) }); storage = createFakeStorage({ now: () => new Date(clock) }); registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) .registration, ); playback = createPlaybackService({ newId, now: () => clock, registry, setTimer: () => undefined, settings: SIGNAGE_DEFAULT_SETTINGS, store, }); await playback.ensureDefaultGroup(); // One native image on a pinned slot: the most ordinary thing a wall shows, and exactly the case // whose heartbeat carries no contribution id. const image = await ingestNativeImage({ bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), displayName: "Welcome", expiresAt: null, filename: "welcome.png", storage, store, }); const timeline = await createTimeline({ name: "Lobby", newId, store }); await addSlot({ newId, pinned: { itemId: image.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, store, timelineId: timeline.timeline.id, }); const groups = await store.listChannelGroups(); await playback.activate(groups[0]?.group.id as string, timeline.timeline.id, "now"); const registered: ModuleRouteDefinitionInput[] = []; registerViewerRoutes( { route: (definition) => registered.push(definition) }, () => playback, () => registry, () => store, ); const table = routeTable(registered); host = createFakeHost({ permissions: ["signage.display.view"], principal: { deviceId: DEVICE_ID, kind: "device" } as never, routes: [], }); Object.assign(host.context.api, { async request(path: string, init?: RequestInit): Promise { const method = (init?.method ?? "GET").toUpperCase(); const relative = `/${path.replace(/^\//u, "").split("?")[0]}`; for (const { names, pattern, route } of table) { const match = pattern.exec(relative); if (route.method !== method || !match) { continue; } const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]])); const context: ModuleRouteHandlerContext = fakeHandlerContext({ config: fakeConfiguration({}), params: Object.freeze(params) as never, principal: { deviceId: DEVICE_ID, kind: "device" } as never, storage, }); const request = new Request( `https://api.example.test/api/v1/modules/signage${relative}`, { ...(init?.body === undefined ? {} : { body: init.body }), headers: init?.headers ?? {}, method, }, ); const response = await route.handler(request, context); const body = (await response .clone() .json() .catch(() => null)) as { code?: string } | null; calls.push({ code: body?.code, method, path: route.path, status: response.status }); return response; } throw new Error( `The display asked for ${method} ${relative}, which Signage does not serve.`, ); }, }); root = document.createElement("div"); document.body.append(root); }); async function settle(): Promise { for (let index = 0; index < 20; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } function controller() { return createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media: createMemoryMediaCache(), newViewerKey: () => "kiosk-contract-0001", now: () => clock, root, store: createMemoryStore(), }); } it("the server accepts every request the display sends", async () => { const display = controller(); await display.start(); await settle(); await display.stop(); const sent = calls.map((call) => `${call.method} ${call.path}`); expect(sent).toContain("POST /viewers/session"); expect(sent).toContain("GET /channels/:channelId/display-state"); expect(sent).toContain("POST /viewers/:viewerId/heartbeat"); // Not one refusal, of any kind, for anything the display built itself. expect(calls.filter((call) => call.status >= 400)).toEqual([]); }); it("a heartbeat from a screen showing a native image is recorded", async () => { const display = controller(); await display.start(); await settle(); await display.stop(); const [viewer] = await store.listViewers(); expect(viewer).toBeDefined(); // What the display reported is what the roster now holds — which is only true if the server // took the heartbeat, rather than refusing it and leaving the session's values in place. expect(viewer?.reportedSelectionId).not.toBeNull(); expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID); expect(viewer?.reportedContributionId).toBeNull(); }); });