import { expect, test } from "@playwright/test"; import { ensureFixtures, makePng } from "./fixtures.js"; import { api, resetPlayback } from "./helpers.js"; const BASE = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199"; async function freshPlaylist(request, name, assetIds) { const playlist = await api(request, "/api/playlists", { method: "POST", data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 7)}` }, }); for (const assetId of assetIds) { await api(request, `/api/playlists/${playlist.id}/entries`, { method: "POST", data: { assetId, durationOverrideMs: 60_000 }, }); } return api(request, `/api/playlists/${playlist.id}`); } async function openDashboardOn(page, playlistName) { await page.goto(`${BASE}/`); await page.waitForSelector('[data-testid="playlist-select"]'); await page.selectOption('[data-testid="playlist-select"]', { label: playlistName }); await expect(page.locator("h1")).toHaveText(playlistName); } function tiles(page) { return page.locator('[data-testid="tile"]'); } test.beforeEach(async ({ request }) => { await ensureFixtures(request); await resetPlayback(request); }); test("the dashboard opens on the active playlist and the signage route still works", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Active open", [ library["wide-a"].id, library["wide-b"].id, ]); const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { method: "POST", }); if (activation.activation === "pending") { await api(request, "/api/playback/next", { method: "POST" }); } await page.goto(`${BASE}/`); // No playlist has to be chosen before something is visible. await expect(page.locator("h1")).toHaveText(playlist.name, { timeout: 30_000 }); await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "active"); await expect(tiles(page)).toHaveCount(2); await expect(page.getByTestId("scene-preview")).toBeVisible(); // The signage route is untouched by the dashboard. await page.goto(`${BASE}/display`); await expect(page.getByTestId("display-root")).toBeVisible(); await expect(page.getByTestId("dashboard")).toHaveCount(0); }); test("selecting another playlist never activates it; activation is explicit", async ({ page, request, }) => { const library = await ensureFixtures(request); const active = await freshPlaylist(request, "Stays active", [library["wide-a"].id]); const other = await freshPlaylist(request, "Guest Church", [library["wide-b"].id]); const activation = await api(request, `/api/playlists/${active.id}/activate`, { method: "POST", }); if (activation.activation === "pending") { await api(request, "/api/playback/next", { method: "POST" }); } await openDashboardOn(page, other.name); await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "inactive"); await expect(page.getByTestId("activate-button")).toBeVisible(); // Merely viewing it must not have changed the server's active playlist. expect((await api(request, "/api/playback")).activePlaylistId).toBe(active.id); await page.getByTestId("activate-button").click(); // The server decides immediate versus queued; the UI must not fake either. await expect .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), { timeout: 30_000, }) .toMatch(/^(active|pending)$/); const state = await page.getByTestId("activation-state").getAttribute("data-state"); const playback = await api(request, "/api/playback"); if (state === "pending") { expect(playback.pendingPlaylistId).toBe(other.id); expect(playback.activePlaylistId).toBe(active.id); // Once the boundary passes, authoritative state promotes it with no reload. await api(request, "/api/playback/next", { method: "POST" }); } await expect .poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), { timeout: 45_000, }) .toBe("active"); expect((await api(request, "/api/playback")).activePlaylistId).toBe(other.id); }); test("tiles show artwork, clean names, and accurate status without relying on colour", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Statuses", [ library["wide-a"].id, library["wide-b"].id, library["wide-c"].id, ]); await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[1].id}`, { method: "PATCH", data: { enabled: false }, }); await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[2].id}`, { method: "PATCH", data: { scheduleStartAt: Date.now() + 86_400_000 }, }); await openDashboardOn(page, playlist.name); await expect(tiles(page)).toHaveCount(3); const rings = await tiles(page).evaluateAll((nodes) => nodes.map((node) => ({ ring: node.dataset.ring, entryId: node.dataset.entryId, name: node.querySelector(".tile-name").textContent, status: node.querySelector('[data-testid="tile-status"]').textContent, transform: getComputedStyle(node.querySelector(".tile-name")).textTransform, font: getComputedStyle(node.querySelector(".tile-name")).fontFamily, hasImage: Boolean(node.querySelector("img.tile-image")), label: node.querySelector(".tile-surface").getAttribute("aria-label"), })), ); expect(rings.map((tile) => tile.ring)).toEqual(["playable", "disabled", "scheduled"]); expect(rings[0].status).toBe("Enabled"); expect(rings[1].status).toBe("Disabled"); expect(rings[2].status).toContain("Scheduled"); // Status is described in text as well as colour, and reaches the accessible name. for (const tile of rings) expect(tile.label).toContain(tile.status); for (const tile of rings) { expect(tile.hasImage).toBe(true); expect(tile.name, "file extensions are never shown").not.toMatch(/\.(png|jpe?g|webp)$/i); // Uppercase is presentation only; the stored name keeps its case. expect(tile.transform).toBe("uppercase"); expect(tile.font).toContain("Roboto"); } const stored = await api(request, `/api/playlists/${playlist.id}`); expect(stored.entries[0].asset.displayName).toBe(library["wide-a"].displayName); // Stable PlaylistEntry UUIDs are the identity, never positions. expect(rings.map((tile) => tile.entryId)).toEqual( playlist.entries.map((entry) => entry.id), ); for (const tile of rings) expect(tile.entryId).toMatch(/^[0-9a-f-]{36}$/); }); test("clicking a tile toggles only that playlist entry", async ({ page, request }) => { const library = await ensureFixtures(request); const first = await freshPlaylist(request, "Toggle A", [library["wide-a"].id]); const second = await freshPlaylist(request, "Toggle B", [library["wide-a"].id]); await openDashboardOn(page, first.name); const tile = tiles(page).first(); await expect(tile).toHaveAttribute("data-ring", "playable"); await tile.locator(".tile-surface").click(); await expect(tile).toHaveAttribute("data-ring", "disabled"); // Server-confirmed, and scoped to this playlist entry only. const reloadedFirst = await api(request, `/api/playlists/${first.id}`); expect(reloadedFirst.entries[0].enabled).toBe(false); const reloadedSecond = await api(request, `/api/playlists/${second.id}`); expect(reloadedSecond.entries[0].enabled, "the same asset elsewhere is untouched").toBe(true); expect( (await api(request, `/api/assets/${library["wide-a"].id}`)).status, "the library asset itself is never modified", ).toBe("ready"); await tile.locator(".tile-surface").click(); await expect(tile).toHaveAttribute("data-ring", "playable"); }); test("dragging a tile reorders the playlist and the order survives a refresh", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Reorder", [ library["wide-a"].id, library["wide-b"].id, library["wide-c"].id, library["wide-d"].id, ]); await openDashboardOn(page, playlist.name); await expect(tiles(page)).toHaveCount(4); const originalIds = await tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId), ); // Drag the first tile onto the last position. const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]'); const target = tiles(page).nth(3); const from = await source.boundingBox(); const to = await target.boundingBox(); await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); await page.mouse.down(); await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 25 }); await page.mouse.up(); const expected = [originalIds[1], originalIds[2], originalIds[3], originalIds[0]]; await expect .poll( async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), { timeout: 20_000 }, ) .toEqual(expected); // The backend holds the same order with contiguous zero-based positions. const stored = await api(request, `/api/playlists/${playlist.id}`); expect(stored.entries.map((entry) => entry.id)).toEqual(expected); expect(stored.entries.map((entry) => entry.position)).toEqual([0, 1, 2, 3]); // A reload correctly reopens on the active playlist, so navigate back to the // one being edited and confirm the order persisted. await page.reload(); await openDashboardOn(page, playlist.name); await expect .poll(async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), ) .toEqual(expected); }); test("a rejected reorder restores the authoritative order without losing the grid", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Reorder fail", [ library["wide-a"].id, library["wide-b"].id, library["wide-c"].id, ]); await openDashboardOn(page, playlist.name); const originalIds = await tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId), ); await page.route("**/entries/order", (route) => route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: { code: "PLAYLIST_ORDER_INCOMPLETE", message: "stale" } }), }), ); const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]'); const target = tiles(page).nth(2); const from = await source.boundingBox(); const to = await target.boundingBox(); await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); await page.mouse.down(); await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 20 }); await page.mouse.up(); await expect(page.getByTestId("toast")).toContainText("Could not save the new order"); await expect .poll(async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)), ) .toEqual(originalIds); expect((await api(request, `/api/playlists/${playlist.id}`)).entries.map((e) => e.id)).toEqual( originalIds, ); }); test("uploads append in selection order and report duplicates without blocking", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Uploads", [library["wide-a"].id]); await openDashboardOn(page, playlist.name); // Content identity is the SHA-256 of the bytes, so each run needs genuinely // new pixels; reusing fixture bytes would correctly deduplicate instead. const unique = `${Date.now()}`; const seed = Date.now() % 200; const files = [ { name: `alpha-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [seed, 20, 30]) }, { name: `beta-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [40, seed, 60]) }, { name: `gamma-${unique}.png`, mimeType: "image/png", buffer: makePng(1200, 1200, [70, 80, seed]) }, ]; await page.getByTestId("file-input").setInputFiles(files); // The dashboard stays usable while uploads run. await expect(page.getByTestId("upload-queue")).toBeVisible(); await expect(page.getByTestId("playlist-select")).toBeEnabled(); await expect(tiles(page)).toHaveCount(4, { timeout: 90_000 }); const names = await tiles(page).evaluateAll((nodes) => nodes.map((node) => node.querySelector(".tile-name").textContent), ); // Appended at the end, in the order they were selected. expect(names.slice(1)).toEqual([ `alpha-${unique}`.toLowerCase(), `beta-${unique}`.toLowerCase(), `gamma-${unique}`.toLowerCase(), ]); await expect(page.locator('[data-testid="upload-item"][data-state="complete"]')).toHaveCount(3); // Re-uploading identical bytes is recognised as content already present. await page.getByTestId("file-input").setInputFiles([files[0]]); await expect( page.locator('[data-testid="upload-item"][data-state="already_in_playlist"]'), ).toHaveCount(1, { timeout: 60_000 }); await expect(tiles(page), "no duplicate entry is created").toHaveCount(4); // A PDF is refused before it reaches the upload pipeline. await page.getByTestId("file-input").setInputFiles([ { name: "handout.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") }, ]); await expect(page.locator('[data-testid="upload-item"][data-state="failed"]')).toContainText( "PNG, JPEG, and WebP", ); await expect(tiles(page)).toHaveCount(4); }); test("dropping files onto the dashboard uploads them", async ({ page, request }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Drop", [library["wide-a"].id]); await openDashboardOn(page, playlist.name); const unique = `drop-${Date.now()}`; // Unique bytes so this is a new asset rather than a content duplicate. const buffer = makePng(1920, 1080, [Date.now() % 200, 45, 67]).toString("base64"); await page.evaluate( async ([name, base64]) => { const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); const file = new File([bytes], `${name}.png`, { type: "image/png" }); const transfer = new DataTransfer(); transfer.items.add(file); const root = document.querySelector('[data-testid="dashboard"]'); root.dispatchEvent(new DragEvent("dragover", { dataTransfer: transfer, bubbles: true })); root.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true })); }, [unique, buffer], ); await expect(tiles(page)).toHaveCount(2, { timeout: 90_000 }); await expect(tiles(page).last().locator(".tile-name")).toHaveText(unique); }); test("the library reuses existing assets and refuses duplicates within a playlist", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Library reuse", [library["wide-a"].id]); await openDashboardOn(page, playlist.name); await page.getByTestId("library-button").click(); await expect(page.getByTestId("library-panel")).toBeVisible(); // An asset already in this playlist cannot be added twice. const already = page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-a"].id}"]`); await expect(already).toHaveAttribute("data-in-playlist", "true"); await expect(already.locator("button")).toBeDisabled(); // A permanent asset must never be presented as expired. await expect(already).not.toContainText("Expired"); // Add two different assets at once. await page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-b"].id}"] button`).click(); await page.locator(`[data-testid="library-item"][data-asset-id="${library.square.id}"] button`).click(); await page.getByRole("button", { name: /Add 2 to playlist/ }).click(); await expect(tiles(page)).toHaveCount(3, { timeout: 30_000 }); const stored = await api(request, `/api/playlists/${playlist.id}`); expect(stored.entries.map((entry) => entry.assetId)).toEqual([ library["wide-a"].id, library["wide-b"].id, library.square.id, ]); // The same asset is free to appear in a different playlist. const other = await freshPlaylist(request, "Library reuse other", [library["wide-a"].id]); expect(other.entries[0].assetId).toBe(library["wide-a"].id); }); test("the live preview renders slides with the same presentation as the signage output", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Preview", [library.portrait.id]); await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[0].id}`, { method: "PATCH", data: { backgroundModeOverride: "blurred", backgroundDarkenedOverride: true, }, }); const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { method: "POST", }); if (activation.activation === "pending") { await api(request, "/api/playback/next", { method: "POST" }); } const read = (target) => target.evaluate(() => { const layer = document.querySelector('.scene[data-visible="true"]'); const background = layer.querySelector(".scene-background-image"); const foreground = layer.querySelector(".scene-foreground"); return { fit: layer.dataset.fit, backgroundMode: layer.dataset.backgroundMode, darkened: layer.dataset.darkened, shadow: layer.dataset.shadow, backgroundDisplay: getComputedStyle(background).display, backgroundFit: getComputedStyle(background).objectFit, backgroundFilter: getComputedStyle(background).filter, foregroundFit: getComputedStyle(foreground).objectFit, foregroundFilter: getComputedStyle(foreground).filter, darkenOpacity: Number(getComputedStyle(layer.querySelector(".scene-darken")).opacity), }; }); await page.goto(`${BASE}/`); await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 }); await expect .poll(async () => page.locator('.scene[data-visible="true"]').count(), { timeout: 30_000 }) .toBe(1); const preview = await read(page); const kiosk = await page.context().newPage(); await kiosk.goto(`${BASE}/display`); await expect .poll(async () => kiosk.locator('.scene[data-visible="true"]').count(), { timeout: 45_000 }) .toBeGreaterThanOrEqual(1); await kiosk.waitForTimeout(1_500); const signage = await read(kiosk); await kiosk.close(); // Proving shared semantics rather than a screenshot likeness. expect(preview).toEqual(signage); expect(preview.fit).toBe("contain"); expect(preview.backgroundMode).toBe("blurred"); expect(preview.backgroundFilter).toBe("blur(20px)"); expect(preview.darkenOpacity).toBeCloseTo(0.25, 2); }); test("the preview follows mode off and 16:9 fill exactly like the signage output", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Preview modes", [library["wide-a"].id]); const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { method: "POST", }); if (activation.activation === "pending") { await api(request, "/api/playback/next", { method: "POST" }); } await page.goto(`${BASE}/`); await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 }); await expect .poll(async () => page.locator('.scene[data-visible="true"]').getAttribute("data-fit"), { timeout: 30_000, }) .toBe("fill"); // A true 16:9 slide fills with no blurred duplicate and no card shadow. const wide = await page.evaluate(() => { const layer = document.querySelector('.scene[data-visible="true"]'); return { backgroundMode: layer.dataset.backgroundMode, shadow: layer.dataset.shadow, backgroundDisplay: getComputedStyle(layer.querySelector(".scene-background-image")).display, foregroundFilter: getComputedStyle(layer.querySelector(".scene-foreground")).filter, }; }); expect(wide).toEqual({ backgroundMode: "none", shadow: "false", backgroundDisplay: "none", foregroundFilter: "none", }); await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } }); await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "true", { timeout: 30_000, }); await expect(page.getByTestId("playback-status")).toContainText("Output Off"); await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } }); await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "false", { timeout: 30_000, }); }); test("the dashboard reflects a real display coming online and going offline", async ({ page, request, }) => { const library = await ensureFixtures(request); const playlist = await freshPlaylist(request, "Display status", [library["wide-a"].id]); const activation = await api(request, `/api/playlists/${playlist.id}/activate`, { method: "POST", }); if (activation.activation === "pending") { await api(request, "/api/playback/next", { method: "POST" }); } await page.goto(`${BASE}/`); await expect(page.getByTestId("display-status")).toBeVisible({ timeout: 30_000 }); // A real kiosk page produces genuine heartbeats. const kiosk = await page.context().newPage(); await kiosk.goto(`${BASE}/display`); await expect .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), { timeout: 60_000, }) .toBe("true"); await expect(page.getByTestId("display-status")).toContainText("Display Online"); await kiosk.close(); // Crossing the server's 45s heartbeat threshold, with no page reload. await expect .poll(async () => page.getByTestId("display-status").getAttribute("data-online"), { timeout: 120_000, }) .toBe("false"); await expect(page.getByTestId("display-status")).toContainText("Display Offline"); }); test("an empty playlist explains itself and unplayable slides are distinguished", async ({ page, request, }) => { const library = await ensureFixtures(request); const empty = await freshPlaylist(request, "Empty", []); await openDashboardOn(page, empty.name); await expect(page.getByTestId("empty-playlist")).toBeVisible(); await expect(page.getByTestId("empty-playlist")).toContainText("no slides yet"); // A playlist with slides that are all currently unplayable is a different // problem and says so. const disabled = await freshPlaylist(request, "All disabled", [library["wide-a"].id]); await api(request, `/api/playlists/${disabled.id}/entries/${disabled.entries[0].id}`, { method: "PATCH", data: { enabled: false }, }); await openDashboardOn(page, disabled.name); await expect(page.getByTestId("empty-playlist")).toHaveCount(0); await expect(page.getByTestId("playable-summary")).toContainText("No slides are playable"); }); test("management requires a desktop while the signage route stays available", async ({ browser, }) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); const page = await context.newPage(); await page.goto(`${BASE}/`); await expect(page.getByTestId("mobile-splash")).toBeVisible(); await expect(page.getByTestId("tile-grid")).toHaveCount(0); // The kiosk output must never be gated by the management restriction. await page.goto(`${BASE}/display`); await expect(page.getByTestId("display-root")).toBeVisible(); await expect(page.getByTestId("mobile-splash")).toHaveCount(0); await context.close(); });