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); });