Files
Signage/tests/browser/display-contract.test.ts
T
ekklesiaandClaude Opus 5.5 67d89f1020 fix(display): keep blackout across reloads and follow the display's current assignment
A kiosk that reloaded during a blackout restored its last frame from local
state without the blackout, so the hidden slide flashed on the wall until the
server answered.

A display resolved its channel once, at registration. Moving it to another
group or audience, or deleting the channel it was on, never reached a running
screen: it kept showing the old channel, or fell into offline continuation
against a 404 forever. The display now fetches its state by viewer, so the
server resolves the current assignment on every fetch, and the heartbeat reply
names the assigned channel so a reassignment lands within one heartbeat. A
display removed from the roster registers again once, on backoff; only
Signage's own not-found means removal, so a foreign 404 can never cause a
registration loop. The channel route stays for the preview and for display
code a kiosk cached earlier. A replaced realtime subscription can no longer
schedule a stray resubscription when it reports closing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:04 -05:00

269 lines
11 KiB
TypeScript

// @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<Response> {
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<void> {
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");
// By viewer, so the server — not the display's memory of its registration — decides the channel.
expect(sent).toContain("GET /viewers/:viewerId/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();
});
it("a display moved to another channel group is served that group's state on its next fetch", async () => {
const display = controller();
await display.start();
await settle();
const viewerId = display.diagnostics().viewerId;
const firstChannel = display.diagnostics().channelId;
expect(viewerId).toBeDefined();
// An administrator creates a second group and moves this screen to it while it is running.
const groupId = newId();
const channelId = newId();
await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" });
await store.updateViewer(viewerId as string, { channelId });
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
const state = (await response.json()) as { channel: { id: string; groupId: string } };
expect(state.channel).toMatchObject({ groupId, id: channelId });
expect(channelId).not.toBe(firstChannel);
// And the heartbeat names the new assignment, which is how a display with a long slot hears.
const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, {
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId);
await display.stop();
});
it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => {
const display = controller();
await display.start();
await settle();
const viewerId = display.diagnostics().viewerId as string;
await store.updateViewer(viewerId, { channelId: null });
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
expect(response.status).toBe(200);
const [group] = await store.listChannelGroups();
const fallback = group?.channels.find((channel) => channel.isDefault);
expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id);
await display.stop();
});
});