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