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