Build the real /display Chromium signage client as a React route on the existing single-page build. The server remains authoritative; the display only consumes the verified Phase 3B snapshot and reports telemetry. The renderer owns two permanent scene layers that are never recreated. A transition prepares the hidden layer, decodes the incoming image on the element that will actually be composited, then raises that layer above the current one at opacity 0 and fades it in while the outgoing layer stays fully opaque underneath. This removes the v1 defect where two simultaneous fades let the black root through the composite. Every asynchronous load and transition carries a generation token, so work finishing after authoritative state moved on is discarded rather than shown. A true 16:9 slide fills the frame with no backdrop or shadow; anything else is contained, centred, and uncropped over a blurred, solid, or black background with a subtle shadow. Blur uses the global setting and is overscanned; boolean darkening applies to the background only. Static targets use the same renderer with the snapshot's global presentation defaults, and mode off is a topmost gate while the renderer keeps tracking underneath. Slide bytes live in an explicit Cache Storage cache with reference counted object URLs and conservative pruning, never in ordinary HTTP caching. A new manifest is staged before promotion, so one unfetchable asset marks only itself unavailable instead of rejecting a playlist. A small IndexedDB store with explicit version handling holds the persistent client ID, last known good manifest, timing anchor, and last structured error, and discards malformed records on read. A narrow service worker caches only the /display app shell, network-first for navigations, and explicitly bypasses media, API, and socket traffic. The display sends a real display:heartbeat every 12 seconds with its actual state, and reports structured errors as bounded JSON that the server's existing string validation already accepts; /api/displays parses them back into an object, so no migration was required. Verified against an isolated stack through Nginx with 20 Playwright Chromium tests plus 9 client unit tests, and 187/187 backend tests. Co-Authored-By: Claude Opus 5 <[email protected]>
425 lines
15 KiB
JavaScript
425 lines
15 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { openDatabase } = require("../src/database");
|
|
const { runMigrations } = require("../src/database/migrate");
|
|
const {
|
|
createDisplayService,
|
|
parseDisplayError,
|
|
} = require("../src/displays/service");
|
|
const { createNotificationService } = require("../src/notifications");
|
|
|
|
const BASE_TIME = 2_000_000_000_000;
|
|
const THRESHOLD = 45_000;
|
|
|
|
function createClock(initial = BASE_TIME) {
|
|
let current = initial;
|
|
let sequence = 0;
|
|
const timers = new Set();
|
|
return {
|
|
now: () => current,
|
|
set(value) {
|
|
current = value;
|
|
},
|
|
setTimeout(callback, delay) {
|
|
const timer = { at: current + delay, callback, cleared: false, id: ++sequence };
|
|
timers.add(timer);
|
|
return timer;
|
|
},
|
|
clearTimeout(timer) {
|
|
if (timer) timer.cleared = true;
|
|
},
|
|
runTo(value) {
|
|
current = value;
|
|
for (let guard = 0; guard < 200; guard += 1) {
|
|
const timer = [...timers]
|
|
.filter((candidate) => !candidate.cleared && !candidate.fired && candidate.at <= current)
|
|
.sort((left, right) => left.at - right.at || left.id - right.id)[0];
|
|
if (!timer) return;
|
|
timer.fired = true;
|
|
timer.callback();
|
|
}
|
|
throw new Error("timer loop did not settle");
|
|
},
|
|
};
|
|
}
|
|
|
|
function heartbeatPayload(overrides = {}) {
|
|
return {
|
|
protocolVersion: 1,
|
|
clientId: "lobby-display",
|
|
outputId: "global",
|
|
activePlaylistId: "playlist-a",
|
|
playlistRevision: 12,
|
|
playbackRevision: 42,
|
|
currentEntryId: "entry-a",
|
|
currentAssetId: "asset-a",
|
|
cachedAssetCount: 10,
|
|
playableAssetCount: 10,
|
|
mostRecentError: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createHarness({ start = true } = {}) {
|
|
const database = openDatabase({ path: ":memory:" });
|
|
runMigrations(database);
|
|
const clock = createClock();
|
|
const events = [];
|
|
let idCounter = 0;
|
|
const notifications = createNotificationService({
|
|
database,
|
|
webhookUrl: "http://127.0.0.1:9/hook",
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeout,
|
|
clearTimeoutFn: clock.clearTimeout,
|
|
createId: () => `note-${++idCounter}`,
|
|
fetchFn: () => Promise.resolve({ status: 200 }),
|
|
logger: { warn() {} },
|
|
});
|
|
|
|
function build() {
|
|
const service = createDisplayService({
|
|
database,
|
|
notifications,
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeout,
|
|
clearTimeoutFn: clock.clearTimeout,
|
|
offlineThresholdMs: THRESHOLD,
|
|
onStatusChanged: (event) => events.push(event),
|
|
logger: { warn() {} },
|
|
});
|
|
if (start) service.start();
|
|
return service;
|
|
}
|
|
|
|
return {
|
|
build,
|
|
clock,
|
|
database,
|
|
events,
|
|
notifications,
|
|
service: build(),
|
|
outbox: () =>
|
|
database.prepare("SELECT * FROM notification_outbox ORDER BY created_at, id").all(),
|
|
incidents: () =>
|
|
database.prepare("SELECT * FROM notification_incidents ORDER BY opened_at").all(),
|
|
rows: () => database.prepare("SELECT * FROM display_clients").all(),
|
|
};
|
|
}
|
|
|
|
test("a first heartbeat creates the display and later heartbeats update the same record", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
const first = harness.service.heartbeat(heartbeatPayload());
|
|
assert.equal(first.status, "online");
|
|
assert.equal(first.clientId, "lobby-display");
|
|
assert.equal(harness.rows().length, 1);
|
|
assert.equal(harness.events.at(-1).reason, "first_seen");
|
|
|
|
harness.clock.set(BASE_TIME + 12_000);
|
|
const second = harness.service.heartbeat(
|
|
heartbeatPayload({ playbackRevision: 43, cachedAssetCount: 11 }),
|
|
);
|
|
assert.equal(harness.rows().length, 1, "the persistent client ID is the display identity");
|
|
assert.equal(second.playbackRevision, 43);
|
|
assert.equal(second.cachedAssetCount, 11);
|
|
assert.equal(
|
|
new Date(second.firstSeenAt).getTime(),
|
|
BASE_TIME,
|
|
"first_seen_at is preserved across heartbeats",
|
|
);
|
|
assert.equal(new Date(second.lastSeenAt).getTime(), BASE_TIME + 12_000);
|
|
assert.equal(
|
|
harness.events.filter((event) => event.reason === "first_seen").length,
|
|
1,
|
|
"a steady heartbeat creates no realtime churn",
|
|
);
|
|
});
|
|
|
|
test("the same display keeps its identity across a new Socket.IO connection", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
// A browser reconnect produces a new socket but the same persisted client ID.
|
|
harness.clock.set(BASE_TIME + 20_000);
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
|
|
assert.equal(harness.rows().length, 1);
|
|
assert.equal(harness.service.list().length, 1);
|
|
assert.equal(harness.service.list()[0].status, "online");
|
|
});
|
|
|
|
test("malformed telemetry is rejected safely and never creates a display", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
const invalid = [
|
|
null,
|
|
"string",
|
|
[],
|
|
{},
|
|
heartbeatPayload({ protocolVersion: 2 }),
|
|
heartbeatPayload({ protocolVersion: undefined }),
|
|
heartbeatPayload({ clientId: "" }),
|
|
heartbeatPayload({ clientId: " " }),
|
|
heartbeatPayload({ clientId: 42 }),
|
|
heartbeatPayload({ clientId: "x".repeat(300) }),
|
|
heartbeatPayload({ cachedAssetCount: -1 }),
|
|
heartbeatPayload({ cachedAssetCount: 1.5 }),
|
|
heartbeatPayload({ playbackRevision: -5 }),
|
|
heartbeatPayload({ mostRecentError: { nested: true } }),
|
|
];
|
|
for (const payload of invalid) {
|
|
assert.throws(() => harness.service.heartbeat(payload), TypeError);
|
|
}
|
|
assert.equal(harness.rows().length, 0);
|
|
});
|
|
|
|
test("an oversized error string is bounded before it is stored", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
const result = harness.service.heartbeat(
|
|
heartbeatPayload({ mostRecentError: "E".repeat(5_000_000) }),
|
|
);
|
|
|
|
assert.ok(result.mostRecentError.length <= 500, "a client cannot store megabytes");
|
|
assert.ok(harness.rows()[0].last_error.length <= 500);
|
|
});
|
|
|
|
test("heartbeats never change playback or playlist revisions", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const playbackBefore = harness.database
|
|
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
|
|
.get();
|
|
|
|
for (let beat = 0; beat < 5; beat += 1) {
|
|
harness.clock.set(BASE_TIME + beat * 10_000);
|
|
harness.service.heartbeat(heartbeatPayload({ playbackRevision: 99 }));
|
|
}
|
|
|
|
assert.deepEqual(
|
|
harness.database
|
|
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
|
|
.get(),
|
|
playbackBefore,
|
|
"telemetry is read-only reporting",
|
|
);
|
|
});
|
|
|
|
test("one missed heartbeat does not mark a display offline before the threshold", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
|
|
// Two heartbeat intervals have passed but the 45s threshold has not.
|
|
harness.clock.runTo(BASE_TIME + 30_000);
|
|
assert.equal(harness.service.list()[0].status, "online");
|
|
assert.equal(harness.incidents().length, 0);
|
|
|
|
harness.clock.runTo(BASE_TIME + THRESHOLD - 1);
|
|
assert.equal(harness.service.list()[0].status, "online");
|
|
assert.equal(harness.incidents().length, 0);
|
|
});
|
|
|
|
test("crossing the threshold opens exactly one offline incident that never repeats", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
|
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
|
assert.equal(harness.service.list()[0].status, "offline");
|
|
const incidents = harness.incidents();
|
|
assert.equal(incidents.length, 1);
|
|
assert.equal(incidents[0].event, "display.offline");
|
|
assert.equal(incidents[0].condition_key, "display:lobby-display");
|
|
assert.equal(JSON.parse(incidents[0].context_json).clientId, "lobby-display");
|
|
assert.equal(harness.outbox().length, 1);
|
|
assert.equal(harness.events.at(-1).reason, "offline");
|
|
|
|
// Staying offline for a long time must not produce more notifications.
|
|
harness.clock.runTo(BASE_TIME + 30 * 60_000);
|
|
assert.equal(harness.incidents().length, 1);
|
|
assert.equal(harness.outbox().length, 1);
|
|
assert.equal(
|
|
harness.events.filter((event) => event.reason === "offline").length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("a returning heartbeat recovers the display exactly once", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
|
assert.equal(harness.outbox().length, 1);
|
|
|
|
harness.clock.set(BASE_TIME + 60_000);
|
|
const recovered = harness.service.heartbeat(heartbeatPayload());
|
|
assert.equal(recovered.status, "online");
|
|
assert.equal(harness.incidents()[0].state, "recovered");
|
|
assert.equal(harness.outbox().length, 2);
|
|
assert.equal(harness.outbox()[1].transition, "recovery");
|
|
assert.equal(harness.events.at(-1).reason, "recovered");
|
|
|
|
// Continued healthy heartbeats add nothing further.
|
|
harness.clock.set(BASE_TIME + 70_000);
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
assert.equal(harness.outbox().length, 2);
|
|
assert.equal(
|
|
harness.events.filter((event) => event.reason === "recovered").length,
|
|
1,
|
|
);
|
|
|
|
// A later disappearance is a new episode with its own single notification.
|
|
harness.clock.runTo(BASE_TIME + 70_000 + THRESHOLD);
|
|
assert.equal(harness.incidents().length, 2);
|
|
assert.equal(harness.outbox().length, 3);
|
|
});
|
|
|
|
test("a restart preserves known displays and cannot manufacture offline alerts", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
harness.service.stop();
|
|
|
|
// The API was down for well over the threshold. A healthy display simply
|
|
// has not had the chance to report yet, so startup stays silent until a
|
|
// full threshold has elapsed since the restart.
|
|
harness.clock.set(BASE_TIME + 10 * 60_000);
|
|
const restarted = harness.build();
|
|
assert.equal(harness.rows().length, 1, "display history survives a restart");
|
|
assert.equal(restarted.list()[0].status, "offline", "status still reflects staleness");
|
|
assert.equal(harness.incidents().length, 0, "no notification storm after a restart");
|
|
|
|
harness.clock.runTo(BASE_TIME + 10 * 60_000 + 20_000);
|
|
assert.equal(harness.incidents().length, 0, "still inside the startup grace period");
|
|
|
|
restarted.heartbeat(heartbeatPayload());
|
|
assert.equal(restarted.list()[0].status, "online");
|
|
assert.equal(harness.incidents().length, 0, "a display that comes back was never offline");
|
|
});
|
|
|
|
test("a display still missing after the startup grace period is reported once", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
harness.service.stop();
|
|
|
|
harness.clock.set(BASE_TIME + 10 * 60_000);
|
|
harness.build();
|
|
harness.clock.runTo(BASE_TIME + 10 * 60_000 + THRESHOLD);
|
|
|
|
assert.equal(harness.incidents().length, 1);
|
|
assert.equal(harness.incidents()[0].event, "display.offline");
|
|
assert.equal(harness.outbox().length, 1);
|
|
});
|
|
|
|
test("an already-open offline incident is not duplicated by a restart", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
|
assert.equal(harness.incidents().length, 1);
|
|
harness.service.stop();
|
|
|
|
harness.clock.set(BASE_TIME + 20 * 60_000);
|
|
harness.build();
|
|
harness.clock.runTo(BASE_TIME + 20 * 60_000 + THRESHOLD + 1_000);
|
|
|
|
assert.equal(harness.incidents().length, 1, "the same episode stays open");
|
|
assert.equal(harness.outbox().length, 1);
|
|
});
|
|
|
|
test("a changed error is surfaced as a meaningful status update", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
harness.service.heartbeat(heartbeatPayload());
|
|
harness.events.length = 0;
|
|
|
|
harness.clock.set(BASE_TIME + 10_000);
|
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
|
|
assert.equal(harness.events.length, 1);
|
|
assert.equal(harness.events[0].reason, "error_changed");
|
|
assert.equal(harness.events[0].mostRecentError, "decode failed for asset-a");
|
|
|
|
// The same error repeating is not a new event.
|
|
harness.clock.set(BASE_TIME + 20_000);
|
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
|
|
assert.equal(harness.events.length, 1);
|
|
|
|
// An arbitrary browser string is telemetry only, never a notification.
|
|
assert.equal(harness.incidents().length, 0);
|
|
assert.equal(harness.outbox().length, 0);
|
|
});
|
|
|
|
test("display status derives from heartbeat freshness for every known client", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
harness.service.heartbeat(heartbeatPayload({ clientId: "lobby" }));
|
|
harness.clock.set(BASE_TIME + 40_000);
|
|
harness.service.heartbeat(heartbeatPayload({ clientId: "narthex" }));
|
|
harness.clock.set(BASE_TIME + 50_000);
|
|
|
|
const displays = Object.fromEntries(
|
|
harness.service.list().map((display) => [display.clientId, display]),
|
|
);
|
|
assert.equal(displays.lobby.status, "offline");
|
|
assert.equal(displays.narthex.status, "online");
|
|
assert.equal(harness.service.status().online, 1);
|
|
assert.equal(harness.service.status().offline, 1);
|
|
assert.equal(harness.service.status().known, 2);
|
|
|
|
const serialized = JSON.stringify(harness.service.list());
|
|
assert.doesNotMatch(serialized, /socket|sid|transport/i);
|
|
});
|
|
|
|
test("a structured display error round-trips without loosening validation", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
// The real display serializes its structured error to bounded JSON, which the
|
|
// existing string validation already accepts.
|
|
const structured = JSON.stringify({
|
|
kind: "slide_decode_failed",
|
|
assetId: "asset-a",
|
|
entryId: "entry-a",
|
|
message: "Could not decode /media/assets/asset-a/display/abc.webp",
|
|
at: BASE_TIME,
|
|
});
|
|
const result = harness.service.heartbeat(
|
|
heartbeatPayload({ mostRecentError: structured }),
|
|
);
|
|
|
|
assert.equal(result.mostRecentError.kind, "slide_decode_failed");
|
|
assert.equal(result.mostRecentError.assetId, "asset-a");
|
|
assert.equal(result.mostRecentError.entryId, "entry-a");
|
|
assert.equal(
|
|
harness.rows()[0].last_error,
|
|
structured,
|
|
"the column still stores bounded text; no migration is required",
|
|
);
|
|
|
|
// Non-JSON telemetry stays a plain string.
|
|
harness.clock.set(BASE_TIME + 5_000);
|
|
assert.equal(
|
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "plain failure" }))
|
|
.mostRecentError,
|
|
"plain failure",
|
|
);
|
|
|
|
// Input validation is unchanged: objects are still rejected outright.
|
|
assert.throws(
|
|
() => harness.service.heartbeat(heartbeatPayload({ mostRecentError: { kind: "x" } })),
|
|
TypeError,
|
|
);
|
|
|
|
assert.equal(parseDisplayError(null), null);
|
|
assert.equal(parseDisplayError("{not json"), "{not json");
|
|
assert.equal(parseDisplayError('{"no":"kind"}'), '{"no":"kind"}');
|
|
});
|