From 67d89f102026db251c0835d1e156ef7a8460d193 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:32:04 -0500 Subject: [PATCH] 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 --- src/display/controller.ts | 174 ++++++++++++++++++++++--- src/server/routes/viewers.ts | 41 +++++- src/server/viewers/service.ts | 13 +- src/shared/display-protocol.ts | 21 ++- tests/browser/display-contract.test.ts | 47 ++++++- tests/browser/display-recovery.test.ts | 2 +- tests/browser/display.test.ts | 172 +++++++++++++++++++++++- tests/browser/offline.test.ts | 2 +- tests/manifest.test.ts | 1 + 9 files changed, 441 insertions(+), 32 deletions(-) diff --git a/src/display/controller.ts b/src/display/controller.ts index 2cdf6d3..a091fb8 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -20,6 +20,7 @@ import { signageMediaKey, } from "../browser/storage.js"; import { + assignedChannelOf, type DisplayChannelState, type DisplayHeartbeat, type DisplayRealtimeGroup, @@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp let stage: SceneStage | undefined; let session: DisplaySessionView | undefined; let subscription: { close(): void } | undefined; + /** Identifies the one subscription whose callbacks may still act. */ + let activeSubscription: symbol | undefined; let heartbeatHandle: unknown; let refreshHandle: unknown; let stopped = false; @@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp * each resets once its request works again. */ let sessionHandle: unknown; let sessionAttempt = 0; + /** One registration in flight at a time. A refresh and a heartbeat can both discover that this + * viewer was removed from the roster; registering twice would open two realtime subscriptions and + * two heartbeat timers for one screen. */ + let registering = false; + /** Consecutive times this screen found itself removed from the roster without a working fetch in + * between. Resets once a state fetch succeeds. */ + let rosterAttempt = 0; let refreshAttempt = 0; let realtimeHandle: unknown; let realtimeAttempt = 0; @@ -347,6 +357,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp // The crossfade is part of the persisted state too, so a screen that resumes offline fades the // way its operator configured rather than the way this file happened to default. stage.setCrossfade(snapshot.crossfadeMs); + // A deliberate blackout is persisted state like any other. Restoring it first means a kiosk that + // reloads mid-blackout stays black while it reconnects, instead of flashing the hidden frame. + stage.setBlackout(snapshot.playback.blackout); const atMs = serverNowFrom(snapshot, now()); const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable }); const resolution = evaluation.resolution; @@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp if (stopped || !session) { return; } + const viewerId = session.viewerId; try { - const response = await options.context.api.request( - `channels/${session.channelId}/display-state`, - ); + // Asked by viewer, not by channel: the server resolves where this screen is assigned *now*, + // so a reassignment or a deleted channel reaches a display that is already running. + const response = await options.context.api.request(`viewers/${viewerId}/display-state`); + if (await removedFromRoster(response)) { + // This screen was removed from the roster. It registers again with the identity it keeps, + // and keeps its frame meanwhile. + reregister(viewerId); + return; + } if (!response.ok) { await refreshFailed(); return; } const state = (await response.json()) as DisplayChannelStateView; refreshAttempt = 0; + rosterAttempt = 0; + adoptAssignment(state.channel); await apply(state); } catch { await refreshFailed(); } } + /** The server's answer about where this screen belongs wins, exactly as its playback state does. */ + function adoptAssignment(channel: DisplayChannelStateView["channel"]): void { + if (!session || (session.channelId === channel.id && session.groupId === channel.groupId)) { + return; + } + session = { + ...session, + channelId: channel.id, + channelKey: channel.key, + groupId: channel.groupId, + }; + } + + /** + * Only Signage's own "no such viewer" means the roster entry is gone. Any other 404 — a proxy, a + * route this server does not have — is an ordinary failure, retried with backoff like the rest; + * treating it as removal would re-register in a loop. + */ + async function removedFromRoster(response: Response): Promise { + if (response.status !== 404) { + return false; + } + const body = (await response + .clone() + .json() + .catch(() => null)) as { code?: unknown } | null; + return body?.code === "SIGNAGE_NOT_FOUND"; + } + + /** Drops a session the server no longer recognizes and registers again, once, on backoff. */ + function reregister(viewerId: string): void { + if (stopped || session?.viewerId !== viewerId) { + return; + } + endSession(); + // Backed off as well: a server that keeps forgetting this screen the moment it registers must + // cost a request a minute, not a tight loop. + const delay = rosterAttempt === 0 ? 0 : displayRetryDelayMs(rosterAttempt); + rosterAttempt += 1; + if (delay === 0) { + void register(); + return; + } + sessionHandle = setTimer(() => { + sessionHandle = undefined; + void register(); + }, delay); + } + + /** Releases everything a registration started: realtime, heartbeats, and pending retries. */ + function endSession(): void { + session = undefined; + activeSubscription = undefined; + subscription?.close(); + subscription = undefined; + if (heartbeatHandle !== undefined) { + clearInterval(heartbeatHandle as never); + heartbeatHandle = undefined; + } + if (realtimeHandle !== undefined) { + clearTimer(realtimeHandle); + realtimeHandle = undefined; + } + } + /** * The server is unreachable, or answered with an error. The frame stays and local continuation * takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried @@ -545,26 +632,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp * state — exactly as if it had worked the first time. */ async function register(): Promise { - if (stopped || session) { + if (stopped || session || registering) { return; } + registering = true; let registered: DisplaySessionView | undefined; try { - const response = await options.context.api.request("viewers/session", { - body: JSON.stringify({ viewerKey: await viewerKey() }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - if (response.ok) { - // Read, not cast: a registration the display cannot understand is no registration, and is - // tried again like any other failure. - const parsed = parseViewerSessionResponse(await response.json()); - registered = parsed.ok ? parsed.value : undefined; - } - } catch { - registered = undefined; + registered = await requestSession(); + } finally { + registering = false; } - if (stopped) { + if (stopped || session) { return; } if (!registered) { @@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp report(); } + async function requestSession(): Promise { + let registered: DisplaySessionView | undefined; + try { + const response = await options.context.api.request("viewers/session", { + body: JSON.stringify({ viewerKey: await viewerKey() }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (response.ok) { + // Read, not cast: a registration the display cannot understand is no registration, and is + // tried again like any other failure. + const parsed = parseViewerSessionResponse(await response.json()); + registered = parsed.ok ? parsed.value : undefined; + } + } catch { + registered = undefined; + } + return registered; + } + /** * Subscribes to the display's realtime channel, and again whenever the host reports that a * subscription has ended for good. The host retries ordinary disconnects itself; a stream that @@ -600,10 +698,21 @@ export function createDisplayController(options: DisplayControllerOptions): Disp if (stopped || !session) { return; } + // Only the newest subscription may act. Closing the old one can report "closed" synchronously, + // and that report must not schedule a second resubscription on top of this one. + const token = Symbol("subscription"); + activeSubscription = token; subscription?.close(); subscription = options.context.realtime.subscribe("display-state", { - onSnapshot: (state) => onRealtime(state), + onSnapshot: (state) => { + if (activeSubscription === token) { + onRealtime(state); + } + }, onStatus: (status) => { + if (activeSubscription !== token) { + return; + } online = status === "connected"; if (status === "connected") { realtimeAttempt = 0; @@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp } report(); }, - onUpdate: (state) => onRealtime(state), + onUpdate: (state) => { + if (activeSubscription === token) { + onRealtime(state); + } + }, }); } @@ -639,11 +752,29 @@ export function createDisplayController(options: DisplayControllerOptions): Disp reportedSelectionId: renderedSelectionId ?? null, reportedTimelineId: lastState?.playback.activeTimelineId ?? null, }; - await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, { + const viewerId = session.viewerId; + const response = await options.context.api.request(`viewers/${viewerId}/heartbeat`, { body: JSON.stringify(report), headers: { "content-type": "application/json" }, method: "POST", }); + if (await removedFromRoster(response)) { + reregister(viewerId); + return; + } + if (!response.ok) { + return; + } + // The heartbeat is also how a screen learns it was reassigned while its slot is long, paused, + // or held — times when no state fetch is otherwise due. + const assigned = assignedChannelOf(await response.json().catch(() => undefined)); + if ( + assigned !== undefined && + session?.viewerId === viewerId && + assigned !== session.channelId + ) { + void refresh(); + } } catch { // Telemetry is best effort and never affects what is on screen. } @@ -695,6 +826,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp async stop(): Promise { stopped = true; + activeSubscription = undefined; subscription?.close(); subscription = undefined; if (heartbeatHandle !== undefined) { diff --git a/src/server/routes/viewers.ts b/src/server/routes/viewers.ts index 0c079c7..aec2bea 100644 --- a/src/server/routes/viewers.ts +++ b/src/server/routes/viewers.ts @@ -18,6 +18,7 @@ import { describeViewerHealth, openViewerSession, recordViewerHeartbeat, + resolveViewerSession, SIGNAGE_VIEWER_NAME_MAX, updateViewerAssignment, } from "../viewers/service.js"; @@ -123,12 +124,50 @@ export function registerViewerRoutes( }); // The response says what a display needs to keep itself honest, and nothing about other // displays or the installation. - const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; + const assigned = await resolveViewerSession(store(context), result.viewer); + const response: DisplayHeartbeatResponse = { + acknowledged: true, + channelId: assigned.channel.id, + recorded: result.written, + }; return Response.json(response); }); }, }); + http.route({ + method: "GET", + path: "/viewers/:viewerId/display-state", + permission: "signage.display.view", + maxBodyBytes: 0, + handler(_request, context) { + return signageBoundary(async () => { + const viewerId = requireUuidParam(context, "viewerId"); + const signageStore = store(context); + const viewer = await signageStore.findViewer(viewerId); + if (!viewer) { + // Removed from the roster. The display registers again with the identity it keeps. + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + // Resolved on every fetch, never remembered from registration: an administrator who moves + // a screen, or deletes the channel it was on, changes what it shows without a reboot. + const { channel } = await resolveViewerSession(signageStore, viewer); + const snapshot = await playback().snapshot(channel.groupId); + return Response.json( + await buildDisplayChannelState({ + atMs: Date.now(), + channel, + playback: snapshot, + registry: registry(), + settings: readSignageSettings(context.config), + storage: context.storage, + store: signageStore, + }), + ); + }); + }, + }); + http.route({ method: "GET", path: "/channels/:channelId/display-state", diff --git a/src/server/viewers/service.ts b/src/server/viewers/service.ts index 0f0a94d..0d611fb 100644 --- a/src/server/viewers/service.ts +++ b/src/server/viewers/service.ts @@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise { +/** + * Where a known viewer is assigned **now**: its own channel, or the default channel of the default + * group when that channel no longer exists. A display asks this on every state fetch, which is what + * makes an administrator's reassignment — or deleting the channel a screen was on — reach a display + * that is already running, rather than only the next time it boots. + */ +export async function resolveViewerSession( + store: SignageStore, + viewer: StoredViewer, +): Promise { const groups = await store.listChannelGroups(); for (const group of groups) { const channel = group.channels.find((candidate) => candidate.id === viewer.channelId); diff --git a/src/shared/display-protocol.ts b/src/shared/display-protocol.ts index e165d9c..0ce6413 100644 --- a/src/shared/display-protocol.ts +++ b/src/shared/display-protocol.ts @@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult 0 && channelId.length <= 64 + ? channelId + : undefined; +} + +// --- Channel state: GET viewers/:viewerId/display-state (and channels/:channelId/display-state) ----- +// +// A registered display asks by its own viewer id, so the server resolves which channel it is assigned +// to at that moment. The channel route remains for the management preview and for display code a +// kiosk cached before the viewer route existed. /** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */ export interface DisplayMedia { diff --git a/tests/browser/display-contract.test.ts b/tests/browser/display-contract.test.ts index 09e3c2a..0f5b5c7 100644 --- a/tests/browser/display-contract.test.ts +++ b/tests/browser/display-contract.test.ts @@ -200,7 +200,8 @@ describe("display ↔ server contract", () => { const sent = calls.map((call) => `${call.method} ${call.path}`); expect(sent).toContain("POST /viewers/session"); - expect(sent).toContain("GET /channels/:channelId/display-state"); + // 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([]); @@ -220,4 +221,48 @@ describe("display ↔ server contract", () => { 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(); + }); }); diff --git a/tests/browser/display-recovery.test.ts b/tests/browser/display-recovery.test.ts index c138491..68908eb 100644 --- a/tests/browser/display-recovery.test.ts +++ b/tests/browser/display-recovery.test.ts @@ -145,7 +145,7 @@ describe("a display recovers from every failed request without a reload", () => }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => { server.stateRequests += 1; if (server.stateFailing) { diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts index 6b819f0..2ec7560 100644 --- a/tests/browser/display.test.ts +++ b/tests/browser/display.test.ts @@ -174,7 +174,7 @@ describe("display controller", () => { }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -205,7 +205,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -376,6 +376,28 @@ describe("display controller", () => { await controller.stop(); }); + it("restores a blackout before the server answers, so a reload never flashes content", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + const online = createFakeHost({ permissions: [], routes: [] }); + const before = build({ host: online, media, store, telemetry: false }); + await before.start(); + await before.apply(state({ playback: { blackout: true } as never })); + await settle(); + await before.stop(); + + // A kiosk reloads mid-blackout and its first request has not been answered yet. + root.dataset.blackout = "false"; + const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] }); + Object.assign(pending.context.api, { request: () => new Promise(() => undefined) }); + const after = build({ host: pending, media, store }); + void after.start(); + await settle(); + + expect(root.dataset.blackout).toBe("true"); + await after.stop(); + }); + it("reports a bounded, sanitized heartbeat and nothing else", async () => { const beats: unknown[] = []; const host = createFakeHost({ @@ -384,7 +406,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { @@ -428,7 +450,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -471,4 +493,146 @@ describe("display controller", () => { expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); + + it("follows the server when an administrator moves it to another channel", async () => { + const moved = { groupId: "group-2", id: "chan-2", key: "online" }; + let stateFetches = 0; + let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + stateFetches += 1; + // Registered on chan-1; moved by an administrator right after its first fetch. + return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved })); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }), + }, + ], + }); + Object.assign(host.context.realtime, { + subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { + realtimeHandlers = handlers; + return { close: () => undefined }; + }, + }); + const controller = build({ host }); + await controller.start(); + await settle(); + + // The heartbeat named a different channel, so the display refetched and adopted it. + expect(stateFetches).toBe(2); + expect(controller.diagnostics().channelId).toBe("chan-2"); + + // A change in the old group no longer concerns it; a change in the new one does. + realtimeHandlers?.onUpdate?.({ + groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }], + }); + await settle(); + expect(stateFetches).toBe(2); + realtimeHandlers?.onUpdate?.({ + groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }], + }); + await settle(); + expect(stateFetches).toBe(3); + await controller.stop(); + }); + + it("registers again, exactly once, when it was removed from the roster", async () => { + let sessions = 0; + let subscriptions = 0; + let closes = 0; + let removed = false; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + sessions += 1; + removed = false; + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => + removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()), + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => + removed + ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) + : jsonResponse({ acknowledged: true }), + }, + ], + }); + let onUpdate: ((state: unknown) => void) | undefined; + Object.assign(host.context.realtime, { + subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { + subscriptions += 1; + onUpdate = handlers.onUpdate; + return { close: () => (closes += 1) }; + }, + }); + const controller = build({ host }); + await controller.start(); + await settle(); + expect(sessions).toBe(1); + + // Removed from the roster; a realtime nudge and a heartbeat both discover it at once. + removed = true; + onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] }); + onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] }); + await settle(); + + expect(sessions).toBe(2); + // One live subscription: the old one closed, exactly one new one opened. + expect(subscriptions - closes).toBe(1); + expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); + await controller.stop(); + }); + + it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => { + let sessions = 0; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + sessions += 1; + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + // A proxy, or a server without this route — not "this viewer is gone". + respond: () => jsonResponse({ code: "NOT_FOUND" }, 404), + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + const controller = build({ host }); + await controller.start(); + await settle(); + await settle(); + + expect(sessions).toBe(1); + await controller.stop(); + }); }); diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index 65b0702..c24703e 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -391,7 +391,7 @@ describe("a display that loses its network", () => { }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => { if (!reachable) { throw new Error("network is gone"); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 07c71a4..5a8e8ab 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -177,6 +177,7 @@ describe("Signage server runtime", () => { "GET /timelines", "GET /timelines/:timelineId", "GET /viewers", + "GET /viewers/:viewerId/display-state", "PATCH /images/:imageId", "PATCH /timelines/:timelineId", "PATCH /timelines/:timelineId/slots/:slotId",