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 <[email protected]>
639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
|
|
import {
|
|
createDisplayController,
|
|
type DisplayChannelStateView,
|
|
type DisplayController,
|
|
} from "../../src/display/controller.js";
|
|
import type { ViewerSessionResponse } from "../../src/shared/display-protocol.js";
|
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
|
|
|
/**
|
|
* The display, driven end to end against a host: register, fetch state, render, report.
|
|
*
|
|
* Every collaborator is injected, so what these prove is the display's own decisions — which frame
|
|
* it keeps when something fails, what it caches and under what key, what it tells the server, and
|
|
* what it never puts on a wall.
|
|
*/
|
|
|
|
const NATIVE_SCENE = {
|
|
backgroundColor: "#000000",
|
|
backgroundImage: false,
|
|
backgroundMode: "none" as const,
|
|
blurPixels: 0,
|
|
darkenOpacity: 0,
|
|
fit: "fill" as const,
|
|
shadow: false,
|
|
};
|
|
|
|
function nativeSelection(id: string, itemRevision = "rev-1") {
|
|
return {
|
|
availableUntilMs: null as number | null,
|
|
deadlineAtMs: 5000,
|
|
durationMs: 5000,
|
|
id,
|
|
itemId: `image-${id}`,
|
|
itemRevision,
|
|
media: {
|
|
display: {
|
|
expiresAtMs: 900_000,
|
|
height: 1080,
|
|
url: `https://cdn.invalid/${id}?sig=a`,
|
|
width: 1920,
|
|
},
|
|
thumbnail: {
|
|
expiresAtMs: 900_000,
|
|
height: 270,
|
|
url: `https://cdn.invalid/${id}-t?sig=a`,
|
|
width: 480,
|
|
},
|
|
},
|
|
payload: { kind: "native-image" },
|
|
presentation: null,
|
|
providerId: "signage.native-images",
|
|
renderer: { kind: "native-image" as const },
|
|
revision: 1,
|
|
scene: NATIVE_SCENE,
|
|
slotId: "slot-1",
|
|
stale: false,
|
|
startedAtMs: 0,
|
|
withdrawn: false,
|
|
};
|
|
}
|
|
|
|
function contributionSelection(id: string, revision = 1) {
|
|
return {
|
|
availableUntilMs: null,
|
|
deadlineAtMs: 5000,
|
|
durationMs: 5000,
|
|
id,
|
|
itemId: "advent",
|
|
itemRevision: String(revision),
|
|
payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" },
|
|
presentation: null,
|
|
providerId: "fixture.announcements",
|
|
renderer: {
|
|
contributionId: "fixture.announcements.slide",
|
|
contributionVersion: "1.0.0",
|
|
kind: "contribution" as const,
|
|
},
|
|
revision,
|
|
slotId: "slot-1",
|
|
stale: false,
|
|
startedAtMs: 0,
|
|
withdrawn: false,
|
|
};
|
|
}
|
|
|
|
function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannelStateView {
|
|
// `??` would fall through on an explicit `null`, which is exactly the case these tests care about.
|
|
const current = "current" in overrides ? overrides.current : nativeSelection("sel-1");
|
|
return {
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
current,
|
|
playback: {
|
|
activeTimelineId: "timeline-1" as string | null,
|
|
blackout: false,
|
|
currentSelectionId: current?.id ?? null,
|
|
currentSlotId: current?.slotId ?? null,
|
|
deadlineAtMs: 5000 as number | null,
|
|
pendingTimelineId: null as string | null,
|
|
remainingMs: 5000 as number | null,
|
|
revision: 3,
|
|
selectionRevision: current?.revision ?? null,
|
|
startedAtMs: 0 as number | null,
|
|
transport: "playing",
|
|
...overrides.playback,
|
|
},
|
|
selections: overrides.selections ?? (current ? [current] : []),
|
|
serverNowMs: 1000,
|
|
staticHold: overrides.staticHold ?? null,
|
|
timeline: overrides.timeline ?? null,
|
|
...(overrides.channel ? { channel: overrides.channel } : {}),
|
|
};
|
|
}
|
|
|
|
/** Typed as the shared protocol message, so a stub cannot drift from what the server sends. */
|
|
const SESSION: ViewerSessionResponse = {
|
|
channelId: "chan-1",
|
|
channelKey: "default",
|
|
groupId: "group-1",
|
|
heartbeatIntervalMs: 12_000,
|
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
|
viewerName: "Foyer",
|
|
};
|
|
|
|
describe("display controller", () => {
|
|
let root: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
root = document.createElement("div");
|
|
document.body.append(root);
|
|
});
|
|
|
|
async function settle(): Promise<void> {
|
|
for (let index = 0; index < 10; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
function build(options: {
|
|
readonly host: ReturnType<typeof createFakeHost>;
|
|
readonly media?: ReturnType<typeof createMemoryMediaCache>;
|
|
readonly store?: ReturnType<typeof createMemoryStore>;
|
|
readonly telemetry?: boolean;
|
|
}): DisplayController {
|
|
return createDisplayController({
|
|
context: options.host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media: options.media ?? createMemoryMediaCache(),
|
|
newViewerKey: () => "viewer-fixed-key-0001",
|
|
root,
|
|
// No real timers: the refresh schedule is exercised by calling `apply` directly.
|
|
setTimer: () => undefined,
|
|
store: options.store ?? createMemoryStore(),
|
|
...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }),
|
|
});
|
|
}
|
|
|
|
it("registers with a persistent identity and keeps it across restarts", async () => {
|
|
const store = createMemoryStore();
|
|
const keys: unknown[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{
|
|
method: "POST",
|
|
path: "viewers/session",
|
|
respond: ({ init }) => {
|
|
keys.push(JSON.parse(String(init?.body)));
|
|
return jsonResponse(SESSION);
|
|
},
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
|
|
const first = build({ host, store });
|
|
await first.start();
|
|
await settle();
|
|
await first.stop();
|
|
|
|
const second = build({ host, store });
|
|
await second.start();
|
|
await settle();
|
|
await second.stop();
|
|
|
|
// The same key both times: a television that reboots is the same screen, not a new one.
|
|
expect(keys).toEqual([
|
|
{ viewerKey: "viewer-fixed-key-0001" },
|
|
{ viewerKey: "viewer-fixed-key-0001" },
|
|
]);
|
|
});
|
|
|
|
it("renders the server's current selection and reports what it is showing", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
|
|
await controller.start();
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
expect(diagnostics.renderedSelectionId).toBe("sel-1");
|
|
expect(diagnostics.slate).toBe(false);
|
|
expect(root.querySelectorAll(".signage-layer")).toHaveLength(2);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => {
|
|
const media = createMemoryMediaCache();
|
|
const stored: string[] = [];
|
|
const wrapped = {
|
|
...media,
|
|
store: (key: string, url: string) => {
|
|
stored.push(key);
|
|
return media.store(key, url);
|
|
},
|
|
};
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, media: wrapped, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
// The same bytes, a new signature — exactly what the next refresh carries.
|
|
const resigned = nativeSelection("sel-1");
|
|
Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" });
|
|
await controller.apply(state({ current: resigned }));
|
|
await settle();
|
|
|
|
// One store, because the key is built from provider, item, and item revision — never from the
|
|
// URL, which changes every time.
|
|
expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("updates a mounted renderer in place when only the payload changed", async () => {
|
|
const mounts: unknown[] = [];
|
|
const updates: unknown[] = [];
|
|
const host = createFakeHost({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
id: "fixture.announcements.slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
mount: (options) => {
|
|
mounts.push(options.contributionId);
|
|
return {
|
|
contributionId: options.contributionId,
|
|
mounted: true,
|
|
unmount: () => Promise.resolve(),
|
|
update: (input) => {
|
|
updates.push(input);
|
|
return Promise.resolve();
|
|
},
|
|
version: "1.0.0",
|
|
};
|
|
},
|
|
permissions: [],
|
|
routes: [],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: contributionSelection("sel-1", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-1", 2) }));
|
|
await settle();
|
|
|
|
// One mount, one update: a cancelled event changes the slide without restarting its slot.
|
|
expect(mounts).toEqual(["fixture.announcements.slide"]);
|
|
expect(updates).toHaveLength(1);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("keeps the last good frame and records a code when a renderer is not published", async () => {
|
|
const host = createFakeHost({ contributions: [], permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
const good = controller.diagnostics().renderedSelectionId;
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
// Nothing changed on screen, and the reason is a bounded code — never a message on a wall.
|
|
expect(diagnostics.renderedSelectionId).toBe(good);
|
|
expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE");
|
|
expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("does not retry a selection it already knows it cannot render", async () => {
|
|
let mountAttempts = 0;
|
|
const host = createFakeHost({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
id: "fixture.announcements.slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
mount: () => {
|
|
mountAttempts += 1;
|
|
return Promise.reject(new Error("renderer exploded"));
|
|
},
|
|
permissions: [],
|
|
routes: [],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await settle();
|
|
|
|
// One attempt. A broken item never becomes a tight retry loop on an unattended screen.
|
|
expect(mountAttempts).toBe(1);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("shows the slate only when it has nothing at all, and never over a good frame", async () => {
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: null, selections: [] }));
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("true");
|
|
expect(controller.diagnostics().slate).toBe(true);
|
|
|
|
await controller.apply(state());
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("false");
|
|
|
|
// Content disappears again — but a good frame is up, so the wall keeps it rather than flashing
|
|
// the slate at a congregation.
|
|
await controller.apply(state({ current: null, selections: [] }));
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("false");
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("covers the output for a blackout while the frame underneath stays prepared", async () => {
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
await controller.apply(state({ playback: { blackout: true } as never }));
|
|
await settle();
|
|
|
|
expect(root.dataset.blackout).toBe("true");
|
|
// The hidden timeline is still what it was: ending the blackout shows this frame, not a slate.
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
|
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({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: /^viewers\/.*\/heartbeat$/u,
|
|
respond: ({ init }) => {
|
|
beats.push(JSON.parse(String(init?.body)));
|
|
return jsonResponse({ acknowledged: true, recorded: true });
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
|
|
await controller.start();
|
|
await settle();
|
|
|
|
expect(beats.length).toBeGreaterThan(0);
|
|
const beat = beats[0] as Record<string, unknown>;
|
|
expect(Object.keys(beat).sort()).toEqual([
|
|
"cachedAssetCount",
|
|
"error",
|
|
"offlineReady",
|
|
"reportedContributionId",
|
|
"reportedPlaybackRevision",
|
|
"reportedProviderId",
|
|
"reportedSelectionId",
|
|
"reportedTimelineId",
|
|
]);
|
|
// Identity, counts, and a code. No URL, no payload, no exception, no credential.
|
|
const serialized = JSON.stringify(beat);
|
|
expect(serialized).not.toContain("https://");
|
|
expect(serialized).not.toContain("payload");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("keeps the frame when the server cannot be reached, and says so quietly", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
await controller.start();
|
|
await settle();
|
|
const before = controller.diagnostics().renderedSelectionId;
|
|
|
|
// Every request now fails, exactly as a dropped uplink does.
|
|
Object.assign(host.context.api, {
|
|
request: () => Promise.reject(new Error("network is gone")),
|
|
});
|
|
await controller.apply(state());
|
|
await settle();
|
|
|
|
expect(controller.diagnostics().renderedSelectionId).toBe(before);
|
|
// Nothing about the failure is on screen: the slate is for having no content, not no network.
|
|
expect(root.dataset.slate).toBe("false");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("registers no viewer and sends no heartbeat when telemetry is off", async () => {
|
|
const host = createFakeHost({
|
|
permissions: [],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{ method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
|
|
await controller.start();
|
|
await controller.apply(state());
|
|
await settle();
|
|
|
|
// The management preview renders exactly what a wall renders and appears in no roster.
|
|
expect(host.requests).toEqual([]);
|
|
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();
|
|
});
|
|
});
|