Files
Announcements/client/e2e/display-offline-degraded.spec.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

292 lines
10 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import { activatePlaylist, resetPlayback } from "./helpers.js";
import {
BASE_URL,
cachedMedia,
diagnostics,
launchKiosk,
opaqueLayerCount,
waitForCachedAssets,
waitForSlide,
} from "./offline-helpers.js";
import { CONTAINERS, start, stop, waitUntilHealthy, waitUntilUnreachable } from "./stack.js";
test.describe.configure({ mode: "serial" });
test.use({ baseURL: BASE_URL });
async function restoreStack() {
await start(CONTAINERS.storage, CONTAINERS.api, CONTAINERS.web);
await waitUntilHealthy(BASE_URL);
}
test.afterAll(async () => {
await restoreStack();
});
test("the app shell is served from the worker when the origin returns a gateway error", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Gateway", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await expect
.poll(async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
)
.toBe(true);
await context.close();
// A remote kiosk reaches the origin through a reverse proxy, so losing the
// application behind it produces 5xx rather than a refused connection.
await stop(CONTAINERS.web);
await waitUntilUnreachable(BASE_URL);
const restarted = await launchKiosk();
try {
const response = await restarted.page.goto(`${BASE_URL}/display`, {
waitUntil: "domcontentloaded",
});
expect(
response?.status(),
"a proxied 5xx outage must still yield the cached shell",
).toBe(200);
await waitForSlide(restarted.page);
expect((await diagnostics(restarted.page)).offlineActive).toBe(true);
} finally {
await restarted.context.close();
}
} finally {
await restoreStack();
}
});
test("a damaged cached slide is skipped offline without stalling rotation", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Damaged", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await waitForCachedAssets(page, [
library["wide-a"].id,
library["wide-d"].id,
library["wide-c"].id,
]);
await stop(CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
// Replace one cached slide's bytes with something undecodable, then reload
// so the damage is discovered while completely offline.
await page.evaluate(async (assetId) => {
const cache = await caches.open("announcements-display-assets-v1");
for (const request of await cache.keys()) {
if (!request.url.includes(assetId)) continue;
await cache.put(
request,
new Response(new Blob([new Uint8Array([0, 1, 2, 3, 4])], { type: "image/webp" }), {
status: 200,
}),
);
}
}, library["wide-d"].id);
await page.reload({ waitUntil: "domcontentloaded" });
await waitForSlide(page);
const seen = new Set();
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
}));
expect(state.opaque, "the last good frame is never dropped").toBeGreaterThanOrEqual(1);
if (state.rendered) seen.add(state.rendered);
await page.waitForTimeout(200);
}
expect(seen.has(library["wide-d"].id), "the damaged slide never displays").toBe(false);
expect(seen.size, "the other cached slides keep rotating").toBeGreaterThanOrEqual(2);
const state = await diagnostics(page);
expect(state.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
expect(state.mostRecentError).not.toBeNull();
} finally {
await context.close();
await restoreStack();
}
});
test("a missing media cache offline fails safe instead of fabricating state", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Empty cache", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await stop(CONTAINERS.api, CONTAINERS.storage, CONTAINERS.web);
await waitUntilUnreachable(BASE_URL);
// Shell and IndexedDB survive; only the slide bytes are gone.
await page.evaluate(() => caches.delete("announcements-display-assets-v1"));
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(6_000);
// No crash, no invented content, and the identity is intact.
expect(await page.evaluate(() => Boolean(window.__display))).toBe(true);
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
const state = await diagnostics(page);
expect(state.renderedAssetId, "nothing is fabricated").toBeNull();
expect(state.mostRecentError, "the condition is recorded for later reporting").not.toBeNull();
// Once the infrastructure returns, the cache is rebuilt with no intervention.
await restoreStack();
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.not.toBeNull();
await expect.poll(async () => (await cachedMedia(page)).length).toBeGreaterThanOrEqual(1);
} finally {
await context.close();
await restoreStack();
}
});
test("corrupt persisted state offline fails safe and recovers when the server returns", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Corrupt", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(async () => {
const database = await new Promise((resolve) => {
const open = indexedDB.open("announcements-display", 1);
open.onsuccess = () => resolve(open.result);
});
await new Promise((resolve) => {
const transaction = database.transaction("state", "readwrite");
transaction
.objectStore("state")
.put({ schemaVersion: 99, manifest: "not-an-object" }, "lastKnownGood");
transaction.oncomplete = resolve;
});
database.close();
});
await stop(CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(5_000);
// An unreadable record is discarded rather than trusted; the display stays
// alive and keeps its identity.
expect(await page.evaluate(() => Boolean(window.__display))).toBe(true);
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
await restoreStack();
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.toBe(library["wide-a"].id);
} finally {
await context.close();
await restoreStack();
}
});
test("a worker update keeps the separate slide media cache intact", async ({ request }) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Worker update", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await expect
.poll(async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
)
.toBe(true);
const mediaBefore = await cachedMedia(page);
expect(mediaBefore.length).toBeGreaterThanOrEqual(1);
// Simulate a stale shell generation from an older application version.
await page.evaluate(async () => {
const stale = await caches.open("announcements-display-shell-v0");
await stale.put("/display", new Response("<html>old</html>", { status: 200 }));
});
// Cleanup runs when a worker activates, so force a real install/activate
// cycle rather than calling update() on an unchanged worker file.
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration("/");
await registration.unregister();
});
await page.reload({ waitUntil: "domcontentloaded" });
await waitForSlide(page);
await expect
.poll(
async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
{ timeout: 45_000 },
)
.toBe(true);
const cacheNames = await page.evaluate(() => caches.keys());
expect(
cacheNames.includes("announcements-display-shell-v0"),
"superseded shell generations are cleaned up",
).toBe(false);
expect(cacheNames).toContain("announcements-display-assets-v1");
const mediaAfter = await cachedMedia(page);
expect(
mediaAfter,
"a worker update must never delete the explicit slide media cache",
).toEqual(expect.arrayContaining(mediaBefore));
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
} finally {
await context.close();
await restoreStack();
}
});