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:
@@ -0,0 +1,106 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||
|
||||
export async function api(request, path, { method = "GET", data } = {}) {
|
||||
const response = await request.fetch(`${API}${path}`, {
|
||||
method,
|
||||
...(data === undefined
|
||||
? {}
|
||||
: { headers: { "content-type": "application/json" }, data }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${method} ${path} -> ${response.status()} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function assets(request) {
|
||||
const list = await api(request, "/api/assets");
|
||||
return Object.fromEntries(list.assets.map((asset) => [asset.displayName, asset]));
|
||||
}
|
||||
|
||||
// Every test gets its own playlist so fixtures never bleed between cases.
|
||||
export async function activatePlaylist(request, name, entries) {
|
||||
const playlist = await api(request, "/api/playlists", {
|
||||
method: "POST",
|
||||
data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 8)}` },
|
||||
});
|
||||
const created = [];
|
||||
for (const entry of entries) {
|
||||
created.push(
|
||||
await api(request, `/api/playlists/${playlist.id}/entries`, {
|
||||
method: "POST",
|
||||
data: entry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
// Activation legitimately queues behind a slide that is still mid-duration.
|
||||
// A manual next is the documented way to take that boundary immediately, so
|
||||
// fixtures do not have to wait out the previous test's slide.
|
||||
if (activation.activation === "pending") {
|
||||
await api(request, "/api/playback/next", { method: "POST" });
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await api(request, "/api/playback")).activePlaylistId, {
|
||||
timeout: 90_000,
|
||||
})
|
||||
.toBe(playlist.id);
|
||||
return { playlist, entries: created };
|
||||
}
|
||||
|
||||
// Global presentation settings are shared state that individual tests
|
||||
// legitimately change. Restoring the documented defaults before each test keeps
|
||||
// every case deterministic no matter what ran before it.
|
||||
export async function resetPlayback(request) {
|
||||
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
|
||||
const state = await api(request, "/api/playback");
|
||||
if (state.transportState === "static" || state.transportState === "paused") {
|
||||
await api(request, "/api/playback/resume", { method: "POST" });
|
||||
}
|
||||
await api(request, "/api/settings", {
|
||||
method: "PATCH",
|
||||
data: {
|
||||
defaultBlurPx: 20,
|
||||
defaultBackgroundMode: "blurred",
|
||||
defaultSolidBackgroundColor: "#000000",
|
||||
defaultBackgroundDarkened: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function displayDiagnostics(page) {
|
||||
return page.evaluate(() => window.__display?.diagnostics() ?? null);
|
||||
}
|
||||
|
||||
export async function waitForFirstSlide(page) {
|
||||
await expect
|
||||
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId ?? null, {
|
||||
timeout: 45_000,
|
||||
})
|
||||
.not.toBeNull();
|
||||
}
|
||||
|
||||
export function visibleScene(page) {
|
||||
// The scene layer that is actually composited: opacity 1 and highest z-index.
|
||||
return page.evaluate(() => {
|
||||
const layers = [...document.querySelectorAll(".scene")];
|
||||
const painted = layers
|
||||
.map((layer) => ({
|
||||
layer: layer.dataset.layer,
|
||||
sceneKey: layer.dataset.sceneKey || "",
|
||||
fit: layer.dataset.fit || "",
|
||||
backgroundMode: layer.dataset.backgroundMode || "",
|
||||
darkened: layer.dataset.darkened || "",
|
||||
shadow: layer.dataset.shadow || "",
|
||||
opacity: Number(getComputedStyle(layer).opacity),
|
||||
zIndex: Number(getComputedStyle(layer).zIndex || 0),
|
||||
}))
|
||||
.filter((entry) => entry.opacity > 0.99);
|
||||
return painted.sort((a, b) => b.zIndex - a.zIndex)[0] || null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user