Files
Signage/tests/viewers.test.ts

632 lines
21 KiB
TypeScript

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<unknown>, code: string): Promise<SignageError> {
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<StoredChannel> {
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("moves an assigned display atomically when its channel group is deleted", async () => {
const original = (await store.listChannelGroups())[0];
if (!original) {
throw new Error("Expected the default group.");
}
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0003",
});
expect(original.channels.some((channel) => channel.id === session.channel.id)).toBe(true);
const fallbackGroupId = newId();
const fallbackChannelId = newId();
await store.createChannelGroup({
channelId: fallbackChannelId,
channelName: "Default",
groupId: fallbackGroupId,
name: "Foyer",
});
await store.deleteChannelGroup(original.group.id, fallbackChannelId);
expect(await store.findChannelGroup(original.group.id)).toBeUndefined();
expect((await store.findViewer(session.viewer.id))?.channelId).toBe(fallbackChannelId);
});
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<readonly StoredChannel[]> {
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();
});
});