Files
Announcements/client/e2e/display-transport.spec.js
T
MediaandClaude Opus 5 147e375702 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]>
2026-08-17 19:38:50 -05:00

224 lines
8.3 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
test("mode off gates the output to black while the renderer keeps tracking", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Mode", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 3_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const first = await displayDiagnostics(page);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
expect(
await page.evaluate(
() => Number(getComputedStyle(document.querySelector(".output-gate")).opacity),
),
).toBe(1);
// The hidden renderer keeps advancing underneath the gate.
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.not.toBe(first.renderedAssetId);
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
const hidden = await displayDiagnostics(page);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
const revealed = await displayDiagnostics(page);
const playback = await api(request, "/api/playback");
expect(revealed.renderedEntryId).toBe(playback.currentEntryId);
expect(
[hidden.renderedAssetId, revealed.renderedAssetId].includes(playback.normalTarget.assetId),
).toBe(true);
// Revealing must not restart the playlist from the first slide.
expect(await visibleScene(page)).not.toBeNull();
});
test("a static hold renders a library-only asset and resume returns to rotation", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
// Long durations so normal rotation cannot advance underneath the
// assertions; this test is about the static hold, not about timing.
const { playlist } = await activatePlaylist(request, "Static", [
{ assetId: library["wide-a"].id, durationOverrideMs: 120_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 120_000 },
]);
const detail = await api(request, `/api/playlists/${playlist.id}`);
const inPlaylist = new Set(detail.entries.map((entry) => entry.assetId));
expect(inPlaylist.has(library.standalone.id)).toBe(false);
await page.goto("/display");
await waitForFirstSlide(page);
await api(request, "/api/playback/static", {
method: "POST",
data: { assetId: library.standalone.id },
});
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library.standalone.id);
// The static asset was cached explicitly even though it is not in the manifest.
const cached = await page.evaluate(async () => {
const cache = await caches.open("announcements-display-assets-v1");
return (await cache.keys()).map((request) => new URL(request.url).pathname);
});
expect(cached.some((url) => url.includes(library.standalone.id))).toBe(true);
// Static holds indefinitely: the normal timeline does not advance locally.
await page.waitForTimeout(9_000);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library.standalone.id);
expect((await visibleScene(page)).sceneKey).toContain("static:");
await api(request, "/api/playback/resume", { method: "POST" });
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.not.toBe(library.standalone.id);
// The display converges on whatever normal entry the server is authoritative
// about after the hold is released.
await expect
.poll(
async () => {
const [diagnostics, playback] = await Promise.all([
displayDiagnostics(page),
api(request, "/api/playback"),
]);
return diagnostics.renderedEntryId === playback.currentEntryId;
},
{ timeout: 30_000 },
)
.toBe(true);
expect((await visibleScene(page)).sceneKey).toMatch(/^normal:/);
});
test("a manifest or settings change is staged without reloading or blanking", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist, entries } = await activatePlaylist(request, "Manifest", [
{ assetId: library.portrait.id, durationOverrideMs: 60_000, backgroundModeOverride: "blurred" },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await page.evaluate(() => {
window.__loadCount = (window.__loadCount || 0) + 1;
});
const before = await displayDiagnostics(page);
const watcher = page.evaluate(() => {
// Fail loudly if the root black is ever exposed while edits are applied.
window.__blackFrames = 0;
window.__watch = setInterval(() => {
const opaque = [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
);
if (opaque.length === 0) window.__blackFrames += 1;
}, 60);
});
await watcher;
await api(request, `/api/playlists/${playlist.id}/entries/order`, {
method: "PUT",
data: { entryIds: [entries[2].id, entries[0].id, entries[1].id] },
});
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { durationOverrideMs: 30_000, backgroundModeOverride: "solid", backgroundColorOverride: "#204060" },
});
await api(request, "/api/settings", { method: "PATCH", data: { defaultBlurPx: 24 } });
await expect
.poll(async () => (await visibleScene(page))?.backgroundMode, { timeout: 30_000 })
.toBe("solid");
const after = await page.evaluate(() => ({
blackFrames: window.__blackFrames,
loadCount: window.__loadCount,
}));
expect(after.blackFrames, "presentation edits must not expose black").toBe(0);
expect(after.loadCount, "the page must not reload for a manifest change").toBe(1);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.renderedEntryId).toBe(before.renderedEntryId);
expect(diagnostics.renderedAssetId).toBe(before.renderedAssetId);
await page.evaluate(() => clearInterval(window.__watch));
});
test("a socket interruption keeps the last good scene and resynchronizes on reconnect", async ({
page,
context,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Reconnect", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await page.evaluate(() => {
window.__loadCount = (window.__loadCount || 0) + 1;
});
const before = await displayDiagnostics(page);
await context.setOffline(true);
await page.waitForTimeout(4_000);
// A disconnect must never black the display or reload the page.
const during = await page.evaluate(() => ({
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
loadCount: window.__loadCount,
}));
expect(during.opaque).toBeGreaterThanOrEqual(1);
expect(during.rendered).toBe(before.renderedAssetId);
expect(during.loadCount).toBe(1);
// The server moves on while the display cannot hear it.
await api(request, "/api/playback/next", { method: "POST" });
await context.setOffline(false);
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
.toBe(library["wide-b"].id);
const playback = await api(request, "/api/playback");
const after = await displayDiagnostics(page);
expect(after.renderedEntryId).toBe(playback.currentEntryId);
expect(after.acceptedRevision).toBe(playback.playbackRevision);
expect(await page.evaluate(() => window.__loadCount)).toBe(1);
});