fix: Stage 0 Signage production readiness #18
+153
-21
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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;
|
||||
registered = await requestSession();
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
} catch {
|
||||
registered = undefined;
|
||||
}
|
||||
if (stopped) {
|
||||
if (stopped || session) {
|
||||
return;
|
||||
}
|
||||
if (!registered) {
|
||||
@@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
report();
|
||||
}
|
||||
|
||||
async function requestSession(): Promise<DisplaySessionView | undefined> {
|
||||
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<void> {
|
||||
stopped = true;
|
||||
activeSubscription = undefined;
|
||||
subscription?.close();
|
||||
subscription = undefined;
|
||||
if (heartbeatHandle !== undefined) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise<View
|
||||
if (!refreshed) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return resolveSession(input.store, refreshed);
|
||||
return resolveViewerSession(input.store, refreshed);
|
||||
}
|
||||
|
||||
const channel = defaultChannelOf(group.channels);
|
||||
@@ -121,7 +121,16 @@ function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
|
||||
/**
|
||||
* 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<ViewerSession> {
|
||||
const groups = await store.listChannelGroups();
|
||||
for (const group of groups) {
|
||||
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
||||
|
||||
@@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeart
|
||||
export interface DisplayHeartbeatResponse {
|
||||
readonly acknowledged: true;
|
||||
readonly recorded: boolean;
|
||||
/** The channel an administrator has this viewer assigned to now. A display whose own idea of its
|
||||
* channel differs refetches its state, so a reassignment reaches a running screen within one
|
||||
* heartbeat. Optional: a display ignores a response without it. */
|
||||
readonly channelId?: string;
|
||||
}
|
||||
|
||||
// --- Channel state: GET channels/:channelId/display-state -----------------------------------------
|
||||
/** The assigned channel from a heartbeat response, if it carried a usable one. */
|
||||
export function assignedChannelOf(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const channelId = (raw as { readonly channelId?: unknown }).channelId;
|
||||
return typeof channelId === "string" && channelId.length > 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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Response>(() => 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user