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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5e418bf941
commit
c41b3d02f1
@@ -0,0 +1,306 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user