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