import { expect, test } from "@playwright/test"; import { ensureFixtures } from "./fixtures.js"; import { activatePlaylist, api, displayDiagnostics, resetPlayback, waitForFirstSlide, } from "./helpers.js"; const ASSET_CACHE = "announcements-display-assets-v1"; test.beforeEach(async ({ request }) => { await ensureFixtures(request); await resetPlayback(request); }); function cachedPaths(page, cacheName = ASSET_CACHE) { return page.evaluate(async (name) => { if (!(await caches.has(name))) return []; const cache = await caches.open(name); return (await cache.keys()).map((request) => new URL(request.url).pathname); }, cacheName); } test("slide bytes are stored in the explicit cache and reused on the next load", async ({ page, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Cache", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, { assetId: library["wide-b"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); await expect.poll(async () => (await cachedPaths(page)).length).toBeGreaterThanOrEqual(2); const stored = await cachedPaths(page); expect(stored.some((path) => path.includes(library["wide-a"].id))).toBe(true); expect(stored.every((path) => /^\/media\/assets\//.test(path))).toBe(true); // Second visit must be served from the explicit cache, not the network. const mediaRequests = []; await page.reload(); page.on("request", (item) => { if (item.url().includes("/media/")) mediaRequests.push(item.url()); }); await waitForFirstSlide(page); await page.waitForTimeout(2_000); expect( mediaRequests.filter((url) => url.includes(library["wide-a"].id)).length, "a cached asset must not be refetched", ).toBe(0); const diagnostics = await displayDiagnostics(page); expect(diagnostics.activeObjectUrls).toBeLessThanOrEqual(4); // The heartbeat reports the true cached count. const beat = await page.evaluate(() => window.__display.heartbeat()); expect(beat.cachedAssetCount).toBe((await cachedPaths(page)).length); }); test("a new immutable version is fetched separately and stale bytes are pruned", async ({ page, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Immutable", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); const first = await cachedPaths(page); const firstUrl = first.find((path) => path.includes(library["wide-a"].id)); expect(firstUrl).toMatch(/\/display\/[0-9a-f]{64}\.webp$/); // Switching playlists retains only what the promoted manifest still needs. await activatePlaylist(request, "Immutable next", [ { assetId: library["wide-c"].id, durationOverrideMs: 60_000 }, ]); await expect .poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 }) .toBe(library["wide-c"].id); await expect .poll(async () => (await cachedPaths(page)).some((path) => path.includes(library["wide-c"].id))) .toBe(true); const retained = await cachedPaths(page); expect( retained.some((path) => path.includes(library["wide-c"].id)), "the active slide is never pruned", ).toBe(true); expect(retained.length).toBeLessThanOrEqual(first.length + 2); }); test("the persistent client identity and last known good state survive a refresh", async ({ page, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Persistence", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); const firstClientId = await page.evaluate(() => window.__display.clientId); expect(firstClientId).toMatch(/[0-9a-f-]{8,}/); await page.reload(); await waitForFirstSlide(page); expect(await page.evaluate(() => window.__display.clientId)).toBe(firstClientId); // A brand new page in the same origin is the same display, not a new one. const other = await page.context().newPage(); await other.goto("/display"); await waitForFirstSlide(other); expect(await other.evaluate(() => window.__display.clientId)).toBe(firstClientId); await other.close(); const persisted = await page.evaluate(async () => { const database = await new Promise((resolve, reject) => { const open = indexedDB.open("announcements-display", 1); open.onsuccess = () => resolve(open.result); open.onerror = () => reject(open.error); }); const value = await new Promise((resolve, reject) => { const request = database .transaction("state", "readonly") .objectStore("state") .get("lastKnownGood"); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); database.close(); return value; }); // The record schema is versioned independently of the database version. expect(persisted.schemaVersion).toBe(2); expect(persisted.playbackRevision).toBeGreaterThan(0); expect(persisted.manifest.entries.length).toBeGreaterThan(0); expect(persisted.timing.serverTime).toBeGreaterThan(0); // The clock anchor a cold offline start depends on. expect(persisted.timing.recordedAt).toBeGreaterThan(0); expect(persisted.mode).toBe("on"); }); test("malformed persisted state fails safe instead of breaking startup", async ({ page, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Malformed", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); const clientId = await page.evaluate(() => window.__display.clientId); await page.evaluate(async () => { const database = await new Promise((resolve) => { const open = indexedDB.open("announcements-display", 1); open.onsuccess = () => resolve(open.result); }); await new Promise((resolve) => { const transaction = database.transaction("state", "readwrite"); transaction.objectStore("state").put({ nonsense: true }, "lastKnownGood"); transaction.oncomplete = resolve; }); database.close(); }); await page.reload(); await waitForFirstSlide(page); // A corrupt record is discarded, the identity is kept, and the display works. expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId); expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id); }); test("the service worker caches the app shell without touching slide bytes", async ({ page, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Worker", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); await expect .poll( async () => page.evaluate(async () => { const registration = await navigator.serviceWorker.getRegistration("/"); return Boolean(registration?.active); }), { timeout: 45_000 }, ) .toBe(true); await page.reload(); await waitForFirstSlide(page); await expect .poll(async () => page.evaluate(() => Boolean(navigator.serviceWorker.controller))) .toBe(true); const shell = await cachedPaths(page, "announcements-display-shell-v1"); expect(shell.length).toBeGreaterThanOrEqual(1); expect(shell.some((path) => path === "/display" || path.startsWith("/assets/"))).toBe(true); // The two caches stay strictly separate. expect(shell.some((path) => path.startsWith("/media/"))).toBe(false); const media = await cachedPaths(page); expect(media.every((path) => path.startsWith("/media/"))).toBe(true); // An online kiosk still receives the newest application on refresh. const response = await page.reload(); expect(response.status()).toBe(200); await waitForFirstSlide(page); expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id); }); test("the real display reports accurate telemetry to the server", async ({ page, request, }) => { const library = await ensureFixtures(request); const { playlist } = await activatePlaylist(request, "Heartbeat", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, { assetId: library["wide-b"].id, durationOverrideMs: 60_000 }, ]); await page.goto("/display"); await waitForFirstSlide(page); const clientId = await page.evaluate(() => window.__display.clientId); await page.evaluate(() => window.__display.heartbeat()); const playback = await api(request, "/api/playback"); const detail = await api(request, `/api/playlists/${playlist.id}`); await expect .poll( async () => { const displays = await api(request, "/api/displays"); return displays.displays.find((display) => display.clientId === clientId) ?? null; }, { timeout: 30_000 }, ) .not.toBeNull(); const displays = await api(request, "/api/displays"); const mine = displays.displays.find((display) => display.clientId === clientId); expect(mine.status).toBe("online"); expect(mine.outputId).toBe("global"); expect(mine.activePlaylistId).toBe(playlist.id); expect(mine.playlistRevision).toBe(detail.revision); expect(mine.playbackRevision).toBe(playback.playbackRevision); expect(mine.currentEntryId).toBe(playback.currentEntryId); expect(mine.currentAssetId).toBe(playback.normalTarget.assetId); expect(mine.cachedAssetCount).toBeGreaterThanOrEqual(2); expect(mine.playableAssetCount).toBe(2); expect(mine.mostRecentError).toBeNull(); }); test("the persistent client id survives closing and reopening the page", async ({ context, request, }) => { const library = await ensureFixtures(request); await activatePlaylist(request, "Identity", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); const first = await context.newPage(); await first.goto("/display"); await waitForFirstSlide(first); const clientId = await first.evaluate(() => window.__display.clientId); await first.close(); const second = await context.newPage(); await second.goto("/display"); await waitForFirstSlide(second); expect(await second.evaluate(() => window.__display.clientId)).toBe(clientId); const displays = await api(request, "/api/displays"); expect( displays.displays.filter((display) => display.clientId === clientId).length, "one physical display is one record", ).toBe(1); await second.close(); });