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

297 lines
10 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
waitForFirstSlide,
} from "./helpers.js";
const ASSET_CACHE = "announcements-display-assets-v1";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
function cachedPaths(page, cacheName = ASSET_CACHE) {
return page.evaluate(async (name) => {
if (!(await caches.has(name))) return [];
const cache = await caches.open(name);
return (await cache.keys()).map((request) => new URL(request.url).pathname);
}, cacheName);
}
test("slide bytes are stored in the explicit cache and reused on the next load", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Cache", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await expect.poll(async () => (await cachedPaths(page)).length).toBeGreaterThanOrEqual(2);
const stored = await cachedPaths(page);
expect(stored.some((path) => path.includes(library["wide-a"].id))).toBe(true);
expect(stored.every((path) => /^\/media\/assets\//.test(path))).toBe(true);
// Second visit must be served from the explicit cache, not the network.
const mediaRequests = [];
await page.reload();
page.on("request", (item) => {
if (item.url().includes("/media/")) mediaRequests.push(item.url());
});
await waitForFirstSlide(page);
await page.waitForTimeout(2_000);
expect(
mediaRequests.filter((url) => url.includes(library["wide-a"].id)).length,
"a cached asset must not be refetched",
).toBe(0);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.activeObjectUrls).toBeLessThanOrEqual(4);
// The heartbeat reports the true cached count.
const beat = await page.evaluate(() => window.__display.heartbeat());
expect(beat.cachedAssetCount).toBe((await cachedPaths(page)).length);
});
test("a new immutable version is fetched separately and stale bytes are pruned", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Immutable", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const first = await cachedPaths(page);
const firstUrl = first.find((path) => path.includes(library["wide-a"].id));
expect(firstUrl).toMatch(/\/display\/[0-9a-f]{64}\.webp$/);
// Switching playlists retains only what the promoted manifest still needs.
await activatePlaylist(request, "Immutable next", [
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
.toBe(library["wide-c"].id);
await expect
.poll(async () => (await cachedPaths(page)).some((path) => path.includes(library["wide-c"].id)))
.toBe(true);
const retained = await cachedPaths(page);
expect(
retained.some((path) => path.includes(library["wide-c"].id)),
"the active slide is never pruned",
).toBe(true);
expect(retained.length).toBeLessThanOrEqual(first.length + 2);
});
test("the persistent client identity and last known good state survive a refresh", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Persistence", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const firstClientId = await page.evaluate(() => window.__display.clientId);
expect(firstClientId).toMatch(/[0-9a-f-]{8,}/);
await page.reload();
await waitForFirstSlide(page);
expect(await page.evaluate(() => window.__display.clientId)).toBe(firstClientId);
// A brand new page in the same origin is the same display, not a new one.
const other = await page.context().newPage();
await other.goto("/display");
await waitForFirstSlide(other);
expect(await other.evaluate(() => window.__display.clientId)).toBe(firstClientId);
await other.close();
const persisted = await page.evaluate(async () => {
const database = await new Promise((resolve, reject) => {
const open = indexedDB.open("announcements-display", 1);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
const value = await new Promise((resolve, reject) => {
const request = database
.transaction("state", "readonly")
.objectStore("state")
.get("lastKnownGood");
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
database.close();
return value;
});
// The record schema is versioned independently of the database version.
expect(persisted.schemaVersion).toBe(2);
expect(persisted.playbackRevision).toBeGreaterThan(0);
expect(persisted.manifest.entries.length).toBeGreaterThan(0);
expect(persisted.timing.serverTime).toBeGreaterThan(0);
// The clock anchor a cold offline start depends on.
expect(persisted.timing.recordedAt).toBeGreaterThan(0);
expect(persisted.mode).toBe("on");
});
test("malformed persisted state fails safe instead of breaking startup", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Malformed", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(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({ nonsense: true }, "lastKnownGood");
transaction.oncomplete = resolve;
});
database.close();
});
await page.reload();
await waitForFirstSlide(page);
// A corrupt record is discarded, the identity is kept, and the display works.
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
});
test("the service worker caches the app shell without touching slide bytes", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Worker", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await expect
.poll(
async () =>
page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration("/");
return Boolean(registration?.active);
}),
{ timeout: 45_000 },
)
.toBe(true);
await page.reload();
await waitForFirstSlide(page);
await expect
.poll(async () => page.evaluate(() => Boolean(navigator.serviceWorker.controller)))
.toBe(true);
const shell = await cachedPaths(page, "announcements-display-shell-v1");
expect(shell.length).toBeGreaterThanOrEqual(1);
expect(shell.some((path) => path === "/display" || path.startsWith("/assets/"))).toBe(true);
// The two caches stay strictly separate.
expect(shell.some((path) => path.startsWith("/media/"))).toBe(false);
const media = await cachedPaths(page);
expect(media.every((path) => path.startsWith("/media/"))).toBe(true);
// An online kiosk still receives the newest application on refresh.
const response = await page.reload();
expect(response.status()).toBe(200);
await waitForFirstSlide(page);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
});
test("the real display reports accurate telemetry to the server", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist } = await activatePlaylist(request, "Heartbeat", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(() => window.__display.heartbeat());
const playback = await api(request, "/api/playback");
const detail = await api(request, `/api/playlists/${playlist.id}`);
await expect
.poll(
async () => {
const displays = await api(request, "/api/displays");
return displays.displays.find((display) => display.clientId === clientId) ?? null;
},
{ timeout: 30_000 },
)
.not.toBeNull();
const displays = await api(request, "/api/displays");
const mine = displays.displays.find((display) => display.clientId === clientId);
expect(mine.status).toBe("online");
expect(mine.outputId).toBe("global");
expect(mine.activePlaylistId).toBe(playlist.id);
expect(mine.playlistRevision).toBe(detail.revision);
expect(mine.playbackRevision).toBe(playback.playbackRevision);
expect(mine.currentEntryId).toBe(playback.currentEntryId);
expect(mine.currentAssetId).toBe(playback.normalTarget.assetId);
expect(mine.cachedAssetCount).toBeGreaterThanOrEqual(2);
expect(mine.playableAssetCount).toBe(2);
expect(mine.mostRecentError).toBeNull();
});
test("the persistent client id survives closing and reopening the page", async ({
context,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Identity", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const first = await context.newPage();
await first.goto("/display");
await waitForFirstSlide(first);
const clientId = await first.evaluate(() => window.__display.clientId);
await first.close();
const second = await context.newPage();
await second.goto("/display");
await waitForFirstSlide(second);
expect(await second.evaluate(() => window.__display.clientId)).toBe(clientId);
const displays = await api(request, "/api/displays");
expect(
displays.displays.filter((display) => display.clientId === clientId).length,
"one physical display is one record",
).toBe(1);
await second.close();
});