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,224 @@
|
||||
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$/);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
});
|
||||
|
||||
// Primary acceptance test. It fails if anyone restores the v1 architecture that
|
||||
// faded the outgoing frame to transparent over a black root.
|
||||
test("the outgoing slide stays fully opaque until the incoming slide can be drawn", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await activatePlaylist(request, "No black", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
|
||||
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||
]);
|
||||
|
||||
const slowUrl = `**/media/assets/${library["wide-b"].id}/**`;
|
||||
let released = false;
|
||||
await page.route(slowUrl, async (route) => {
|
||||
// Hold the incoming slide's bytes well past its nominal transition instant.
|
||||
while (!released) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/display");
|
||||
await waitForFirstSlide(page);
|
||||
const firstScene = await visibleScene(page);
|
||||
expect(firstScene.sceneKey).toMatch(/^normal:/);
|
||||
|
||||
const before = await displayDiagnostics(page);
|
||||
expect(before.renderedAssetId).toBe(library["wide-a"].id);
|
||||
|
||||
// Sample continuously across the boundary while B is deliberately stuck.
|
||||
const samples = [];
|
||||
const deadline = Date.now() + 9_000;
|
||||
while (Date.now() < deadline) {
|
||||
samples.push(
|
||||
await page.evaluate(() => ({
|
||||
at: Date.now(),
|
||||
gate: document.querySelector(".output-gate").dataset.active,
|
||||
layers: [...document.querySelectorAll(".scene")].map((layer) => ({
|
||||
key: layer.dataset.sceneKey || "",
|
||||
opacity: Number(getComputedStyle(layer).opacity),
|
||||
})),
|
||||
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||
})),
|
||||
);
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
|
||||
for (const sample of samples) {
|
||||
const opaque = sample.layers.filter((layer) => layer.opacity > 0.99);
|
||||
expect(
|
||||
opaque.length,
|
||||
`black root was exposed at ${sample.at}: ${JSON.stringify(sample.layers)}`,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(sample.gate).toBe("false");
|
||||
// The outgoing scene must never begin fading away.
|
||||
expect(sample.rendered).toBe(library["wide-a"].id);
|
||||
}
|
||||
|
||||
released = true;
|
||||
await expect
|
||||
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||
.toBe(library["wide-b"].id);
|
||||
|
||||
const afterScene = await visibleScene(page);
|
||||
expect(afterScene.opacity).toBe(1);
|
||||
// Exactly one layer is composited afterwards; the other is free for reuse.
|
||||
const layerCount = await page.evaluate(
|
||||
() => document.querySelectorAll(".scene").length,
|
||||
);
|
||||
expect(layerCount, "scene layers are persistent, never recreated").toBe(2);
|
||||
});
|
||||
|
||||
test("a late asset load never replaces a newer authoritative target", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await activatePlaylist(request, "Stale", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
|
||||
]);
|
||||
|
||||
let releaseB = false;
|
||||
await page.route(`**/media/assets/${library["wide-b"].id}/**`, async (route) => {
|
||||
while (!releaseB) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/display");
|
||||
await waitForFirstSlide(page);
|
||||
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
|
||||
|
||||
// Advance to B, whose bytes are stuck, then advance again to C.
|
||||
await api(request, "/api/playback/next", { method: "POST" });
|
||||
await page.waitForTimeout(1_500);
|
||||
await api(request, "/api/playback/next", { method: "POST" });
|
||||
await expect
|
||||
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||
.toBe(library["wide-c"].id);
|
||||
|
||||
// B finally arrives long after it stopped being authoritative.
|
||||
releaseB = true;
|
||||
await page.waitForTimeout(4_000);
|
||||
const diagnostics = await displayDiagnostics(page);
|
||||
expect(
|
||||
diagnostics.renderedAssetId,
|
||||
"a stale load must never overwrite the newer target",
|
||||
).toBe(library["wide-c"].id);
|
||||
});
|
||||
|
||||
test("one unusable asset never blanks the screen or blocks the playlist", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await activatePlaylist(request, "Bad asset", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
|
||||
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
|
||||
]);
|
||||
|
||||
let attempts = 0;
|
||||
await page.route(`**/media/assets/${library["wide-d"].id}/**`, async (route) => {
|
||||
attempts += 1;
|
||||
await route.fulfill({ status: 500, body: "broken" });
|
||||
});
|
||||
|
||||
await page.goto("/display");
|
||||
await waitForFirstSlide(page);
|
||||
|
||||
// Rotation continues across the broken slide without ever losing a frame.
|
||||
const seen = new Set();
|
||||
const deadline = Date.now() + 16_000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await page.evaluate(() => ({
|
||||
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||
opaque: [...document.querySelectorAll(".scene")].filter(
|
||||
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||
).length,
|
||||
}));
|
||||
if (state.rendered) seen.add(state.rendered);
|
||||
expect(state.opaque, "a good frame is always on screen").toBeGreaterThanOrEqual(1);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
expect(seen.has(library["wide-a"].id)).toBe(true);
|
||||
expect(seen.has(library["wide-c"].id)).toBe(true);
|
||||
expect(seen.has(library["wide-d"].id), "the broken slide never displays").toBe(false);
|
||||
expect(attempts, "a bad asset must not be retried in a tight loop").toBeLessThan(25);
|
||||
|
||||
const diagnostics = await displayDiagnostics(page);
|
||||
expect(diagnostics.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
|
||||
expect(diagnostics.mostRecentError).not.toBeNull();
|
||||
expect(["asset_fetch_failed", "slide_decode_failed"]).toContain(
|
||||
diagnostics.mostRecentError.kind,
|
||||
);
|
||||
expect(diagnostics.mostRecentError.assetId).toBe(library["wide-d"].id);
|
||||
|
||||
// The structured error reaches the server through the real heartbeat.
|
||||
const clientId = await page.evaluate(() => window.__display.clientId);
|
||||
await page.evaluate(() => window.__display.heartbeat());
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const displays = await api(request, "/api/displays");
|
||||
const mine = displays.displays.find((display) => display.clientId === clientId);
|
||||
return mine?.mostRecentError?.kind ?? null;
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(diagnostics.mostRecentError.kind);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import zlib from "node:zlib";
|
||||
|
||||
function crc32(buffer) {
|
||||
let table = [];
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(typed));
|
||||
return Buffer.concat([length, typed, crc]);
|
||||
}
|
||||
|
||||
// Mostly flat images so the fixtures compress small; the tests identify slides
|
||||
// by asset ID rather than by pixels.
|
||||
export function makePng(width, height, [r, g, b]) {
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 2;
|
||||
const raw = Buffer.alloc(height * (1 + width * 3));
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const offset = y * (1 + width * 3);
|
||||
raw[offset] = 0;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const p = offset + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = x < 64 && y < 64 ? 255 : b;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export const FIXTURES = [
|
||||
{ name: "wide-a", width: 1920, height: 1080, rgb: [200, 30, 30] },
|
||||
{ name: "wide-b", width: 1920, height: 1080, rgb: [30, 200, 30] },
|
||||
{ name: "wide-c", width: 1280, height: 720, rgb: [30, 30, 200] },
|
||||
{ name: "wide-d", width: 1920, height: 1080, rgb: [200, 200, 30] },
|
||||
{ name: "portrait", width: 1080, height: 1920, rgb: [180, 60, 200] },
|
||||
{ name: "square", width: 1200, height: 1200, rgb: [60, 180, 200] },
|
||||
{ name: "standalone", width: 1920, height: 1080, rgb: [240, 140, 20] },
|
||||
];
|
||||
|
||||
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||
|
||||
// Uploads are content-addressed, so re-running the suite reuses the same assets
|
||||
// instead of piling up duplicates.
|
||||
export async function ensureFixtures(request) {
|
||||
const existing = await (await request.fetch(`${API}/api/assets`)).json();
|
||||
const byName = new Map(existing.assets.map((asset) => [asset.displayName, asset]));
|
||||
for (const fixture of FIXTURES) {
|
||||
if (byName.has(fixture.name)) continue;
|
||||
const response = await request.fetch(`${API}/api/assets`, {
|
||||
method: "POST",
|
||||
multipart: {
|
||||
files: {
|
||||
name: `${fixture.name}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: makePng(fixture.width, fixture.height, fixture.rgb),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`fixture upload failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
const refreshed = await (await request.fetch(`${API}/api/assets`)).json();
|
||||
return Object.fromEntries(refreshed.assets.map((asset) => [asset.displayName, asset]));
|
||||
}
|
||||
@@ -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