import { beforeEach, describe, expect, it } from "vitest"; import { buildDisplayChannelState } from "../src/server/display/state.js"; import { SignageError } from "../src/server/errors.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 type { StoredChannel } from "../src/server/store/types.js"; import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js"; import { createDisplaysCapability } from "../src/server/viewers/capability.js"; import { describeViewerHealth, openViewerSession, recordViewerHeartbeat, SIGNAGE_VIEWER_OFFLINE_AFTER_MS, updateViewerAssignment, } from "../src/server/viewers/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 { announcementsFixture, type FixtureProvider } from "./support/fixture-provider.js"; import { makeImage } from "./support/images.js"; import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; const START = Date.parse("2026-08-30T15:00:00.000Z"); async function expectSignageCode(promise: Promise, code: string): Promise { try { await promise; } catch (error) { expect(error).toBeInstanceOf(SignageError); const signageError = error as SignageError; expect(signageError.code).toBe(code); return signageError; } throw new Error(`Expected ${code}, but the operation succeeded.`); } describe("viewers", () => { let store: MemorySignageStore; let registry: SignageProviderRegistry; let service: SignagePlaybackService; let announcements: FixtureProvider; let clock: number; let counter: number; const newId = () => { counter += 1; return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; }; beforeEach(async () => { clock = START; counter = 0; store = createMemorySignageStore({ now: () => new Date(clock) }); registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); announcements = announcementsFixture(); registry.capability.register(announcements.registration); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) .registration, ); service = createPlaybackService({ newId, now: () => clock, registry, setTimer: () => undefined, settings: SIGNAGE_DEFAULT_SETTINGS, store, }); await service.ensureDefaultGroup(); }); async function defaultChannel(): Promise { const groups = await store.listChannelGroups(); const channel = groups[0]?.channels[0]; if (!channel) { throw new Error("Expected a default channel."); } return channel; } it("registers a display on the default channel, and never lets it pick one", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); const channel = await defaultChannel(); expect(session.channel.id).toBe(channel.id); expect(session.channel.isDefault).toBe(true); // There is no input by which a display could have asked for another channel: `openViewerSession` // takes an identity and nothing else. expect(session.viewer.name).toBe("New display"); }); it("recognizes a returning display rather than creating a ghost", async () => { const first = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); await updateViewerAssignment({ name: "Lobby north", store, viewerId: first.viewer.id }); clock += 60_000; const second = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); // Same screen, same row, same name. A television that reboots does not fill the roster. expect(second.viewer.id).toBe(first.viewer.id); expect(second.viewer.name).toBe("Lobby north"); expect(store.viewers.size).toBe(1); }); it("stores only the safe device identifier a paired principal carries", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: "3f2c1a9e-1111-4222-8333-444455556666", newId, store, viewerKey: "kiosk-lobby-0002", }); expect(session.viewer.deviceId).toBe("3f2c1a9e-1111-4222-8333-444455556666"); // Nothing about pairing, and no credential of any kind, is anywhere in the record. const serialized = JSON.stringify(session.viewer); for (const forbidden of ["token", "secret", "credential", "password", "pairing"]) { expect(serialized.toLowerCase()).not.toContain(forbidden); } }); it("refuses a viewer key that is not one Signage issued the shape of", async () => { for (const viewerKey of ["", "short", "has spaces in it", "x".repeat(200)]) { await expectSignageCode( openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey }), "SIGNAGE_INVALID_REQUEST", ); } }); it("coalesces a steady display's heartbeats into almost no writes", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); const before = store.viewerWrites.count; // Ten heartbeats over forty seconds, saying exactly the same thing each time. for (let index = 0; index < 10; index += 1) { clock += 4000; await recordViewerHeartbeat({ at: new Date(clock), heartbeat: { cachedAssetCount: 0, offlineReady: false, reportedSelectionId: null }, minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, store, viewerId: session.viewer.id, }); } // Forty seconds at a thirty-second floor is one write, not ten. A wall of televisions is a wall // of these, and writing every one would say nothing new very expensively. expect(store.viewerWrites.count - before).toBe(1); }); it("writes immediately when a display says something an operator would notice", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); const before = store.viewerWrites.count; clock += 1000; const changed = await recordViewerHeartbeat({ at: new Date(clock), heartbeat: { reportedSelectionId: "sel-1" }, minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, store, viewerId: session.viewer.id, }); clock += 1000; const failing = await recordViewerHeartbeat({ at: new Date(clock), heartbeat: { error: { code: "SLIDE_DECODE_FAILED", detail: "one image could not be decoded" }, reportedSelectionId: "sel-1", }, minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, store, viewerId: session.viewer.id, }); expect(changed.written).toBe(true); expect(failing.written).toBe(true); expect(store.viewerWrites.count - before).toBe(2); expect(failing.viewer.lastError).toMatchObject({ code: "SLIDE_DECODE_FAILED" }); }); it("refuses an unbounded or malformed error report from a display", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); await expectSignageCode( recordViewerHeartbeat({ at: new Date(clock), heartbeat: { error: { code: "x".repeat(200) } }, minimumWriteIntervalMs: 0, store, viewerId: session.viewer.id, }), "SIGNAGE_INVALID_REQUEST", ); await expectSignageCode( recordViewerHeartbeat({ at: new Date(clock), heartbeat: { error: { code: "OK", detail: "y".repeat(5000) } }, minimumWriteIntervalMs: 0, store, viewerId: session.viewer.id, }), "SIGNAGE_INVALID_REQUEST", ); }); it("reports a display as offline once it stops checking in", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); expect(describeViewerHealth(session.viewer, clock).online).toBe(true); expect( describeViewerHealth(session.viewer, clock + SIGNAGE_VIEWER_OFFLINE_AFTER_MS + 1).online, ).toBe(false); }); it("moves a display between channels, and refuses a channel that does not exist", async () => { const groups = await store.listChannelGroups(); const group = groups[0]; if (!group) { throw new Error("Expected a group."); } const onlineChannelId = newId(); await store.addChannel({ groupId: group.group.id, id: onlineChannelId, isDefault: false, key: "online", name: "Online", }); const session = await openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey: "kiosk-lobby-0001", }); const moved = await updateViewerAssignment({ channelId: onlineChannelId, store, viewerId: session.viewer.id, }); expect(moved.channelId).toBe(onlineChannelId); await expectSignageCode( updateViewerAssignment({ channelId: "11111111-1111-4111-8111-111111111111", store, viewerId: session.viewer.id, }), "SIGNAGE_NOT_FOUND", ); }); it("exposes a sanitized roster through the displays capability", async () => { const session = await openViewerSession({ at: new Date(clock), deviceId: "3f2c1a9e-1111-4222-8333-444455556666", newId, store, viewerKey: "kiosk-lobby-0001", }); const capability = createDisplaysCapability( () => store, () => new Date(clock), ); const viewers = await capability.listViewers(); const channels = await capability.listChannels(); expect(viewers).toHaveLength(1); expect(viewers[0]).toMatchObject({ id: session.viewer.id, online: true }); expect(channels[0]).toMatchObject({ isDefault: true, key: "default" }); // A consuming module sees identity and health. It never sees a credential, an object key, a // delivery URL, or a provider payload. const serialized = JSON.stringify({ channels, viewers }); for (const forbidden of ["token", "secret", "objectKey", "https://", "payload"]) { expect(serialized).not.toContain(forbidden); } }); }); describe("display channel state", () => { let store: MemorySignageStore; let storage: FakeStorage; let registry: SignageProviderRegistry; let service: SignagePlaybackService; let announcements: FixtureProvider; let clock: number; let counter: number; const newId = () => { counter += 1; return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; }; beforeEach(async () => { clock = START; counter = 0; store = createMemorySignageStore({ now: () => new Date(clock) }); storage = createFakeStorage({ now: () => new Date(clock) }); registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); announcements = announcementsFixture(); registry.capability.register(announcements.registration); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) .registration, ); service = createPlaybackService({ newId, now: () => clock, registry, setTimer: () => undefined, settings: SIGNAGE_DEFAULT_SETTINGS, store, }); await service.ensureDefaultGroup(); }); async function channelsOf(): Promise { const groups = await store.listChannelGroups(); return groups[0]?.channels ?? []; } async function stateFor(channel: StoredChannel) { return buildDisplayChannelState({ atMs: clock, channel, playback: await service.snapshot(channel.groupId), registry, settings: SIGNAGE_DEFAULT_SETTINGS, storage, store, }); } it("carries the frame, the timeline's shape, and recent selections — and no object key", async () => { const groups = await store.listChannelGroups(); const groupId = groups[0]?.group.id as string; let timeline = await createTimeline({ name: "Loop", newId, store }); timeline = await addProviderSource({ label: "Announcements", newId, providerId: "fixture.announcements", sourceId: "active-slides", 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 addSlot({ newId, source: { sourceId: source.id }, store, timelineId: timeline.timeline.id, }); await service.activate(groupId, timeline.timeline.id, "now"); const [channel] = await channelsOf(); if (!channel) { throw new Error("Expected a channel."); } const state = await stateFor(channel); expect(state.current?.itemId).toBe("welcome"); expect(state.current?.renderer).toEqual({ contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0", kind: "contribution", }); // The shape a disconnected display advances its own cursor over, with the same estimates the // server skips passes with. expect(state.timeline?.slots).toHaveLength(2); expect(state.timeline?.slots[0]?.estimatedDurationMs).toBe(5000); expect(state.selections.length).toBeGreaterThan(0); // A display is handed identity, timing, and payloads — never an object key. expect(JSON.stringify(state)).not.toContain("objectKey"); }); it("gives each channel its own audience's payload, from one logical selection", async () => { const groups = await store.listChannelGroups(); const groupId = groups[0]?.group.id as string; await store.addChannel({ groupId, id: newId(), isDefault: false, key: "online", name: "Online", }); let timeline = await createTimeline({ name: "Loop", newId, store }); timeline = await addProviderSource({ label: "Announcements", newId, providerId: "fixture.announcements", sourceId: "active-slides", 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"); // Move to the announcement that has an audience-specific variant. clock += 5000; await service.reconcile(groupId, "timer"); const channels = await channelsOf(); const base = channels.find((channel) => channel.key === "default") as StoredChannel; const online = channels.find((channel) => channel.key === "online") as StoredChannel; const baseState = await stateFor(base); const onlineState = await stateFor(online); expect(baseState.current?.payload).toEqual({ headline: "Good morning" }); expect(onlineState.current?.payload).toEqual({ headline: "Watching from home" }); // One selection, one slot, one deadline — two renderings. expect(onlineState.current?.id).toBe(baseState.current?.id); expect(onlineState.current?.deadlineAtMs).toBe(baseState.current?.deadlineAtMs); expect(onlineState.playback.startedAtMs).toBe(baseState.playback.startedAtMs); }); it("resolves a native image into a scene and fresh delivery URLs", async () => { const groups = await store.listChannelGroups(); const groupId = groups[0]?.group.id as string; const ingested = await ingestNativeImage({ bytes: await makeImage({ format: "png", height: 1080, width: 1080 }), displayName: "Square", expiresAt: null, filename: "square.png", storage, store, }); let timeline = await createTimeline({ name: "Images", newId, store }); await addSlot({ newId, pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, presentation: { backgroundMode: "blurred", blurPixels: 24 }, store, timelineId: timeline.timeline.id, }); timeline = (await store.findTimeline(timeline.timeline.id)) as never; await service.activate(groupId, timeline.timeline.id, "now"); const [channel] = await channelsOf(); if (!channel) { throw new Error("Expected a channel."); } const state = await stateFor(channel); expect(state.current?.renderer).toEqual({ kind: "native-image" }); // A square image is not 16:9, so it is shown whole on the background the slot asked for. expect(state.current?.scene).toMatchObject({ backgroundImage: true, blurPixels: 24, fit: "contain", shadow: true, }); expect(state.current?.media?.display.url).toContain("https://storage.invalid/"); expect(state.current?.media?.display.expiresAtMs).toBeGreaterThan(clock); // The URL was minted for this response and is nowhere in the database. expect(JSON.stringify([...store.selections.values()])).not.toContain("https://"); }); it("shapes a static hold exactly like a selection, so a display has one renderer path", async () => { const groups = await store.listChannelGroups(); const groupId = groups[0]?.group.id as string; const ingested = await ingestNativeImage({ bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), displayName: "Hold", expiresAt: null, filename: "hold.png", storage, store, }); await service.setStaticHold(groupId, ingested.image.id); const [channel] = await channelsOf(); if (!channel) { throw new Error("Expected a channel."); } const state = await stateFor(channel); expect(state.playback.transport).toBe("static"); expect(state.staticHold?.renderer).toEqual({ kind: "native-image" }); // A true 16:9 image fills the frame on its own: no blurred duplicate, no shadow. expect(state.staticHold?.scene).toMatchObject({ backgroundImage: false, fit: "fill", shadow: false, }); }); it("keeps a display's state usable when storage is briefly unreachable", async () => { const groups = await store.listChannelGroups(); const groupId = groups[0]?.group.id as string; const ingested = await ingestNativeImage({ bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), displayName: "Slide", expiresAt: null, filename: "slide.png", storage, store, }); const timeline = await createTimeline({ name: "Images", newId, store }); await addSlot({ newId, pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, store, timelineId: timeline.timeline.id, }); await service.activate(groupId, timeline.timeline.id, "now"); const [channel] = await channelsOf(); if (!channel) { throw new Error("Expected a channel."); } const broken = { ...storage, bucket: () => { throw new Error("storage is down"); }, }; const state = await buildDisplayChannelState({ atMs: clock, channel, playback: await service.snapshot(groupId), registry, settings: SIGNAGE_DEFAULT_SETTINGS, storage: broken as never, store, }); // The frame's identity, timing, and scene all survive. Only the URLs are missing, which costs a // display that already cached the bytes nothing at all. expect(state.current?.itemId).toBe(ingested.image.id); expect(state.current?.scene).toBeDefined(); expect(state.current?.media).toBeUndefined(); }); });