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]>
This commit is contained in:
@@ -3,7 +3,9 @@ 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");
|
||||
@@ -422,3 +424,163 @@ test("a structured display error round-trips without loosening validation", (t)
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user