Files
Announcements/server/test/displays.test.js
T
MediaandClaude Opus 5 ba678d08d5 Complete Phase 5B offline display resilience
Make the /display kiosk survive losing its infrastructure, then surrender
authority cleanly when it returns. The Phase 5A renderer is unchanged: this
builds on its two permanent scene layers, explicit Cache Storage, IndexedDB,
Service Worker, timing anchors, and structured errors.

While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state
it persisted, and the moment the server speaks again the local cursor is
abandoned rather than reconciled or reported back.

A small client evaluator resolves offline playback from the persisted
manifest and clock anchor. Catch-up is arithmetic rather than iterative:
whole rotations are skipped between eligibility boundaries, so a month
offline resolves in milliseconds. Mixed durations, wraparound, [start, end)
schedules, and expirations are evaluated locally, and a null expiration
stays permanent. Mode off keeps the hidden cursor advancing, pause holds
its slide and remainder, static holds until the cached metadata says the
asset genuinely expired, and an activation queued for an unknown playlist
holds the last good frame instead of continuing into a playlist that is
being replaced. Contract tests run this evaluator and the real Phase 3B
engine over identical scenarios and assert the same slide, start, and
deadline, so the client tracks the server without refactoring it.

Cold start restores signage from the Service Worker shell, IndexedDB, and
Cache Storage with the entire stack down, keeping the same persistent
display identity.

Validated display error codes now become Home Assistant incidents through
the Phase 4 machinery, keyed per display and condition so a persisting
error reports once and recovers once. Arbitrary browser text stays
telemetry.

Corrects the deployment assumption: the kiosk runs on a separate machine,
so production requires a trusted HTTPS origin and localhost is development
only. Verifying on a real trusted certificate exposed two production bugs
that localhost had hidden — behind a reverse proxy an outage arrives as a
502 rather than a refused connection, and an unbounded shell fetch left a
restarting kiosk blank for the proxy's whole connect timeout. The worker
now treats a gateway error as an outage and bounds that wait.

Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on
a trusted HTTPS origin with no browser security flags weakened.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:36:13 -05:00

587 lines
20 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 {
DISPLAY_ERROR_SEVERITY,
createDisplayService,
errorConditionKey,
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"}');
});
function structuredError(kind, overrides = {}) {
return JSON.stringify({
kind,
assetId: null,
entryId: null,
message: `${kind} detail`,
at: BASE_TIME,
...overrides,
});
}
test("a structured display error opens exactly one incident and never repeats", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
for (let beat = 0; beat < 6; beat += 1) {
harness.clock.set(BASE_TIME + beat * 12_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
}),
);
}
const incidents = harness.incidents();
assert.equal(incidents.length, 1, "a persisting error is reported once, not per heartbeat");
assert.equal(incidents[0].event, "display.slide_decode_failed");
assert.equal(incidents[0].severity, "warning");
assert.equal(
incidents[0].condition_key,
errorConditionKey("lobby-display", { kind: "slide_decode_failed", assetId: "asset-a" }),
);
assert.equal(JSON.parse(incidents[0].context_json).assetId, "asset-a");
assert.equal(harness.outbox().length, 1);
});
test("a resolved display error recovers exactly once", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
}),
);
assert.equal(harness.outbox().length, 1);
for (let beat = 1; beat <= 3; beat += 1) {
harness.clock.set(BASE_TIME + beat * 12_000);
harness.service.heartbeat(heartbeatPayload({ mostRecentError: null }));
}
const incidents = harness.incidents();
assert.equal(incidents.length, 1);
assert.equal(incidents[0].state, "recovered");
assert.equal(harness.outbox().length, 2, "one failure and one recovery");
assert.equal(harness.outbox()[1].transition, "recovery");
});
test("an error moving to another asset recovers the old condition and opens the new one", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
}),
);
harness.clock.set(BASE_TIME + 12_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-b" }),
}),
);
const byKey = Object.fromEntries(
harness.incidents().map((incident) => [incident.condition_key, incident]),
);
const keyA = errorConditionKey("lobby-display", {
kind: "asset_fetch_failed",
assetId: "asset-a",
});
const keyB = errorConditionKey("lobby-display", {
kind: "asset_fetch_failed",
assetId: "asset-b",
});
assert.equal(byKey[keyA].state, "recovered", "the resolved asset condition closes");
assert.equal(byKey[keyB].state, "open", "the new asset condition opens");
assert.equal(harness.outbox().length, 3);
});
test("only validated error codes become incidents", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
// Arbitrary browser text is telemetry, never a Home Assistant notification.
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: "TypeError: undefined is not a function" }),
);
assert.equal(harness.incidents().length, 0);
assert.equal(
harness.service.list()[0].mostRecentError,
"TypeError: undefined is not a function",
"the raw telemetry is still visible to operators",
);
// A structured payload with an unrecognised code is equally inert.
harness.clock.set(BASE_TIME + 12_000);
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: structuredError("something_invented") }),
);
assert.equal(harness.incidents().length, 0);
assert.equal(harness.outbox().length, 0);
assert.equal(harness.service.list()[0].mostRecentError.kind, "something_invented");
});
test("a manifest that cannot render locally is critical", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: structuredError("manifest_invalid") }),
);
assert.equal(harness.incidents()[0].severity, "critical");
assert.equal(DISPLAY_ERROR_SEVERITY.manifest_invalid, "critical");
assert.equal(DISPLAY_ERROR_SEVERITY.offline_capability_unavailable, "warning");
});
test("display error incidents never disturb the display offline condition", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
// The display goes offline, then returns while reporting a slide problem.
harness.clock.runTo(BASE_TIME + THRESHOLD);
assert.equal(harness.incidents().length, 1);
assert.equal(harness.incidents()[0].event, "display.offline");
harness.clock.set(BASE_TIME + 60_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
}),
);
const offline = harness
.incidents()
.find((incident) => incident.condition_key === "display:lobby-display");
assert.equal(offline.state, "recovered", "returning still recovers the offline condition once");
const decode = harness
.incidents()
.find((incident) => incident.event === "display.slide_decode_failed");
assert.equal(decode.state, "open");
assert.equal(
harness.incidents().filter((incident) => incident.event === "display.offline").length,
1,
);
});