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