Files
Announcements/client/e2e/display-basic.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

225 lines
8.5 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
// The outgoing layer deliberately stays opaque during and after a transition,
// so the layer actually on screen is the opaque one with the highest stacking
// order — never simply the first opaque layer in DOM order.
function readTopLayer(page, extract) {
return page.evaluate((source) => {
const layer = [...document.querySelectorAll(".scene")]
.map((element) => ({ element, style: getComputedStyle(element) }))
.filter((entry) => Number(entry.style.opacity) > 0.99)
.sort(
(left, right) =>
Number(right.style.zIndex || 0) - Number(left.style.zIndex || 0),
)[0]?.element;
if (!layer) return null;
return new Function("layer", `return (${source})(layer);`)(layer);
}, extract.toString());
}
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
test("the display loads, connects, and shows the authoritative slide", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { entries } = await activatePlaylist(request, "Basic", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const playback = await api(request, "/api/playback");
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.renderedEntryId).toBe(playback.currentEntryId);
expect(diagnostics.renderedEntryId).toBe(entries[0].id);
expect(diagnostics.renderedAssetId).toBe(library["wide-a"].id);
expect(diagnostics.acceptedRevision).toBe(playback.playbackRevision);
// Signage output only: no dashboard chrome may appear over the slides.
await expect(page.locator(".placeholder")).toHaveCount(0);
await expect(page.locator("h1")).toHaveCount(0);
await expect(page.getByTestId("display-root")).toBeVisible();
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
expect(await page.evaluate(() => document.body.innerText.trim())).toBe("");
});
test("a true 16:9 slide fills the frame with no blurred duplicate", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Wide", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const scene = await visibleScene(page);
expect(scene.fit).toBe("fill");
expect(scene.backgroundMode).toBe("none");
expect(scene.shadow).toBe("false");
const painted = await readTopLayer(page, (layer) => {
const background = layer.querySelector(".scene-background-image");
const foreground = layer.querySelector(".scene-foreground");
return {
backgroundDisplay: getComputedStyle(background).display,
foregroundFit: getComputedStyle(foreground).objectFit,
foregroundFilter: getComputedStyle(foreground).filter,
foregroundBox: foreground.getBoundingClientRect().toJSON(),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
});
expect(painted.backgroundDisplay).toBe("none");
expect(painted.foregroundFit).toBe("cover");
expect(painted.foregroundFilter).toBe("none");
expect(painted.foregroundBox.width).toBeCloseTo(painted.viewport.width, 0);
expect(painted.foregroundBox.height).toBeCloseTo(painted.viewport.height, 0);
});
for (const fixture of ["portrait", "square"]) {
test(`a ${fixture} slide is contained over a blurred background`, async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, `Blurred ${fixture}`, [
{
assetId: library[fixture].id,
durationOverrideMs: 60_000,
backgroundModeOverride: "blurred",
},
]);
await page.goto("/display");
await waitForFirstSlide(page);
const scene = await visibleScene(page);
expect(scene.fit).toBe("contain");
expect(scene.backgroundMode).toBe("blurred");
expect(scene.shadow).toBe("true");
const painted = await readTopLayer(page, (layer) => {
const background = layer.querySelector(".scene-background-image");
const foreground = layer.querySelector(".scene-foreground");
const backgroundStyle = getComputedStyle(background);
return {
backgroundDisplay: backgroundStyle.display,
backgroundFit: backgroundStyle.objectFit,
backgroundFilter: backgroundStyle.filter,
backgroundTransform: backgroundStyle.transform,
backgroundBox: background.getBoundingClientRect().toJSON(),
foregroundFit: getComputedStyle(foreground).objectFit,
foregroundFilter: getComputedStyle(foreground).filter,
foregroundBox: foreground.getBoundingClientRect().toJSON(),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
});
expect(painted.backgroundDisplay).toBe("block");
expect(painted.backgroundFit).toBe("cover");
expect(painted.backgroundFilter).toBe("blur(20px)");
expect(painted.backgroundTransform).not.toBe("none");
expect(painted.backgroundBox.width).toBeGreaterThanOrEqual(painted.viewport.width);
expect(painted.backgroundBox.height).toBeGreaterThanOrEqual(painted.viewport.height);
expect(painted.foregroundFit).toBe("contain");
expect(painted.foregroundFilter).toContain("drop-shadow");
// Contain must never crop: the whole slide fits inside the viewport.
expect(painted.foregroundBox.width).toBeLessThanOrEqual(painted.viewport.width + 1);
expect(painted.foregroundBox.height).toBeLessThanOrEqual(painted.viewport.height + 1);
});
}
test("solid, black, and darkened backgrounds use the effective server values", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist, entries } = await activatePlaylist(request, "Backgrounds", [
{
assetId: library.portrait.id,
durationOverrideMs: 60_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#123456",
backgroundDarkenedOverride: false,
},
]);
await page.goto("/display");
await waitForFirstSlide(page);
const readBackground = () =>
readTopLayer(page, (layer) => ({
mode: layer.dataset.backgroundMode,
darkened: layer.dataset.darkened,
color: getComputedStyle(layer.querySelector(".scene-background")).backgroundColor,
darkenOpacity: Number(
getComputedStyle(layer.querySelector(".scene-darken")).opacity,
),
foregroundOpacity: Number(
getComputedStyle(layer.querySelector(".scene-foreground")).opacity,
),
}));
await expect.poll(async () => (await readBackground()).mode).toBe("solid");
let background = await readBackground();
expect(background.color).toBe("rgb(18, 52, 86)");
expect(background.darkened).toBe("false");
expect(background.darkenOpacity).toBe(0);
// Darkening is boolean and applies to the background only.
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { backgroundDarkenedOverride: true },
});
await expect.poll(async () => (await readBackground()).darkened).toBe("true");
background = await readBackground();
expect(background.darkenOpacity).toBeCloseTo(0.25, 2);
expect(background.foregroundOpacity, "the readable slide is never darkened").toBe(1);
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { backgroundModeOverride: "black" },
});
await expect.poll(async () => (await readBackground()).mode).toBe("black");
expect((await readBackground()).color).toBe("rgb(0, 0, 0)");
});
test("the browser never receives private storage details", async ({ page, request }) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Privacy", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const urls = [];
page.on("request", (item) => urls.push(item.url()));
await page.goto("/display");
await waitForFirstSlide(page);
const snapshot = await api(request, "/api/playback");
const serialized = JSON.stringify(snapshot) + urls.join(" ");
expect(serialized).not.toMatch(
/seaweed|8333|private\/|object_key|objectKey|bucket|secret|accessKey/i,
);
for (const url of urls.filter((item) => item.includes("/media/"))) {
expect(url).toMatch(/\/media\/assets\/[0-9a-f-]{36}\/display\/[0-9a-f]{64}\.webp$/);
}
});