Files
Signage/tests/browser/display-recovery.test.ts
T
ekklesiaandClaude Opus 5.5 518fd101a2 test(display): a display recovers from every failed request without a reload
Pins what a kiosk needs from each network path, on a virtual clock: a
registration that fails once is retried with backoff until it works, a
state refresh that fails is retried without a realtime nudge, and a
realtime stream the host reports closed is subscribed again. Nothing is
scheduled after stop.

Fails today on all three paths: each is tried once. The first matches
the production screen exactly: mounted, showing "Offline — continuing
from the last update", with no heartbeat and no retry until someone
reloads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:31:49 -05:00

307 lines
8.9 KiB
TypeScript

// @vitest-environment happy-dom
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { beforeEach, describe, expect, it } from "vitest";
import {
createMemoryMediaCache,
createMemoryStore,
type SignageLocalStore,
} from "../../src/browser/storage.js";
import {
createDisplayController,
type DisplayChannelStateView,
} from "../../src/display/controller.js";
import { createFakeHost, type FakeHost, jsonResponse } from "./support/fake-host.js";
/**
* A display never reaches a dead end.
*
* Every network path a display depends on — registering, fetching state, realtime — can fail once,
* as a kiosk that boots before its network settles or an API that restarts will make it. Each must
* be tried again, with backoff, until it works, and the display must then be exactly as live as if
* nothing had happened. None of it may need a person to reload a television.
*
* Time is virtual: every timer the controller schedules goes through the queue below.
*/
const SESSION = {
channelId: "chan-1",
channelKey: "default",
groupId: "group-1",
heartbeatIntervalMs: 12_000,
viewerId: "11111111-1111-4111-8111-111111111111",
viewerName: "Foyer",
};
function selection(id: string) {
return {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id,
itemId: `image-${id}`,
itemRevision: "rev-1",
media: {
display: {
expiresAtMs: 900_000,
height: 1080,
url: `https://cdn.invalid/${id}`,
width: 1920,
},
thumbnail: {
expiresAtMs: 900_000,
height: 270,
url: `https://cdn.invalid/${id}-t`,
width: 480,
},
},
payload: { kind: "native-image" },
presentation: null,
providerId: "signage.native-images",
renderer: { kind: "native-image" as const },
revision: 1,
scene: {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
},
slotId: "slot-1",
stale: false,
startedAtMs: 0,
withdrawn: false,
};
}
function channelState(revision = 3): DisplayChannelStateView {
const current = selection("sel-1");
return {
channel: { groupId: "group-1", id: "chan-1", key: "default" },
crossfadeMs: 0,
current,
playback: {
activeTimelineId: "timeline-1",
blackout: false,
currentSelectionId: current.id,
currentSlotId: current.slotId,
deadlineAtMs: 5000,
pendingTimelineId: null,
remainingMs: 5000,
revision,
selectionRevision: 1,
startedAtMs: 0,
transport: "playing",
},
selections: [current],
serverNowMs: 0,
staticHold: null,
timeline: null,
};
}
/** Server behaviour that a test can change mid-run. */
interface Server {
sessionFailures: number;
stateFailing: boolean;
sessionRequests: number;
stateRequests: number;
}
describe("a display recovers from every failed request without a reload", () => {
let clock: number;
let timers: Map<number, { at: number; run: () => void }>;
let nextTimer: number;
let root: HTMLElement;
let local: SignageLocalStore;
let server: Server;
let host: FakeHost;
let subscriptions: { onStatus?: (status: string) => void }[];
beforeEach(() => {
clock = 0;
timers = new Map();
nextTimer = 1;
root = document.createElement("div");
document.body.append(root);
local = createMemoryStore();
server = { sessionFailures: 0, sessionRequests: 0, stateFailing: false, stateRequests: 0 };
subscriptions = [];
host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
server.sessionRequests += 1;
if (server.sessionFailures > 0) {
server.sessionFailures -= 1;
throw new TypeError("Failed to fetch");
}
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: "channels/chan-1/display-state",
respond: () => {
server.stateRequests += 1;
if (server.stateFailing) {
throw new TypeError("Failed to fetch");
}
return jsonResponse(channelState());
},
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
Object.assign(host.context, {
realtime: {
subscribe: (_channel: string, handlers: { onStatus?: (status: string) => void }) => {
subscriptions.push(handlers);
return { close: () => undefined };
},
} satisfies Pick<ModuleUiHostContext["realtime"], "subscribe"> as never,
});
});
async function settle(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Moves virtual time forward, firing every timer that falls due, in order. */
async function advance(ms: number): Promise<void> {
const end = clock + ms;
for (;;) {
const due = [...timers]
.filter(([, timer]) => timer.at <= end)
.sort((left, right) => left[1].at - right[1].at)[0];
if (!due) {
break;
}
timers.delete(due[0]);
clock = due[1].at;
due[1].run();
await settle();
}
clock = end;
}
function build() {
return createDisplayController({
clearTimer: (handle) => timers.delete(handle as number),
context: host.context,
decodeImage: () => Promise.resolve(),
media: createMemoryMediaCache(),
newViewerKey: () => "viewer-fixed-key-0001",
now: () => clock,
root,
setTimer: (run, delayMs) => {
const id = nextTimer;
nextTimer += 1;
timers.set(id, { at: clock + delayMs, run });
return id;
},
store: local,
});
}
/** One ordinary live run, so the display has a persisted snapshot to continue from, exactly as a
* screen that has been on the wall for a while does. */
async function runOnceLive(): Promise<void> {
const display = build();
await display.start();
await settle();
expect(root.dataset.offline).toBe("false");
await display.stop();
timers.clear();
}
it("registers once the server answers, after its first registration failed", async () => {
await runOnceLive();
server.sessionFailures = 1;
const sessionsBefore = server.sessionRequests;
const display = build();
await display.start();
await settle();
// Continuing from its own state while it cannot register: the honest banner.
expect(root.dataset.offline).toBe("true");
await advance(60_000);
expect(server.sessionRequests - sessionsBefore).toBeGreaterThanOrEqual(2);
expect(root.dataset.offline).toBe("false");
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
await display.stop();
});
it("keeps retrying registration with backoff, and never gives up", async () => {
await runOnceLive();
server.sessionFailures = 8;
const sessionsBefore = server.sessionRequests;
const display = build();
await display.start();
await settle();
// An hour of virtual time: enough for every backoff step, and the cap, to have been reached.
await advance(60 * 60_000);
expect(server.sessionRequests - sessionsBefore).toBe(9);
expect(root.dataset.offline).toBe("false");
await display.stop();
});
it("retries a failed state refresh until the server answers, with no realtime nudge", async () => {
const display = build();
await display.start();
await settle();
expect(root.dataset.offline).toBe("false");
// The slot's deadline refresh fails, as it would mid-restart. Realtime says nothing.
server.stateFailing = true;
await advance(6_000);
expect(root.dataset.offline).toBe("true");
const failedRequests = server.stateRequests;
server.stateFailing = false;
await advance(120_000);
expect(server.stateRequests).toBeGreaterThan(failedRequests);
expect(root.dataset.offline).toBe("false");
await display.stop();
});
it("subscribes again when the host reports its realtime stream closed for good", async () => {
const display = build();
await display.start();
await settle();
expect(subscriptions).toHaveLength(1);
subscriptions[0]?.onStatus?.("closed");
await settle();
await advance(60_000);
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
await display.stop();
});
it("schedules nothing after it is stopped", async () => {
await runOnceLive();
server.sessionFailures = 3;
const display = build();
await display.start();
await settle();
await display.stop();
const sessionsAtStop = server.sessionRequests;
await advance(60 * 60_000);
expect(server.sessionRequests).toBe(sessionsAtStop);
});
});