Implement Phase 5A resilient display renderer

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]>
This commit is contained in:
Media
2026-08-17 19:38:50 -05:00
co-authored by Claude Opus 5
parent ae70bf1dd8
commit 147e375702
32 changed files with 3107 additions and 155 deletions
+293
View File
@@ -0,0 +1,293 @@
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;
});
expect(persisted.schemaVersion).toBe(1);
expect(persisted.playbackRevision).toBeGreaterThan(0);
expect(persisted.manifest.entries.length).toBeGreaterThan(0);
expect(persisted.timing.serverTime).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();
});