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]>
224 lines
8.3 KiB
JavaScript
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);
|
|
});
|