import { expect, test } from "@playwright/test"; import { ensureFixtures } from "./fixtures.js"; import { activatePlaylist, resetPlayback } from "./helpers.js"; import { BASE_URL, cachedMedia, diagnostics, launchKiosk, opaqueLayerCount, waitForCachedAssets, waitForSlide, } from "./offline-helpers.js"; import { CONTAINERS, start, stop, waitUntilHealthy, waitUntilUnreachable } from "./stack.js"; test.describe.configure({ mode: "serial" }); test.use({ baseURL: BASE_URL }); async function restoreStack() { await start(CONTAINERS.storage, CONTAINERS.api, CONTAINERS.web); await waitUntilHealthy(BASE_URL); } test.afterAll(async () => { await restoreStack(); }); test("the app shell is served from the worker when the origin returns a gateway error", async ({ request, }) => { const library = await ensureFixtures(request); await resetPlayback(request); await activatePlaylist(request, "Gateway", [ { assetId: library["wide-a"].id, durationOverrideMs: 5_000 }, ]); const { context, page } = await launchKiosk(); try { await page.goto(`${BASE_URL}/display`); await waitForSlide(page); await expect .poll(async () => page.evaluate(async () => Boolean((await navigator.serviceWorker.getRegistration("/"))?.active), ), ) .toBe(true); await context.close(); // A remote kiosk reaches the origin through a reverse proxy, so losing the // application behind it produces 5xx rather than a refused connection. await stop(CONTAINERS.web); await waitUntilUnreachable(BASE_URL); const restarted = await launchKiosk(); try { const response = await restarted.page.goto(`${BASE_URL}/display`, { waitUntil: "domcontentloaded", }); expect( response?.status(), "a proxied 5xx outage must still yield the cached shell", ).toBe(200); await waitForSlide(restarted.page); expect((await diagnostics(restarted.page)).offlineActive).toBe(true); } finally { await restarted.context.close(); } } finally { await restoreStack(); } }); test("a damaged cached slide is skipped offline without stalling rotation", async ({ request, }) => { const library = await ensureFixtures(request); await resetPlayback(request); await activatePlaylist(request, "Damaged", [ { assetId: library["wide-a"].id, durationOverrideMs: 3_000 }, { assetId: library["wide-d"].id, durationOverrideMs: 3_000 }, { assetId: library["wide-c"].id, durationOverrideMs: 3_000 }, ]); const { context, page } = await launchKiosk(); try { await page.goto(`${BASE_URL}/display`); await waitForSlide(page); await waitForCachedAssets(page, [ library["wide-a"].id, library["wide-d"].id, library["wide-c"].id, ]); await stop(CONTAINERS.api, CONTAINERS.storage); await waitUntilUnreachable(BASE_URL); // Replace one cached slide's bytes with something undecodable, then reload // so the damage is discovered while completely offline. await page.evaluate(async (assetId) => { const cache = await caches.open("announcements-display-assets-v1"); for (const request of await cache.keys()) { if (!request.url.includes(assetId)) continue; await cache.put( request, new Response(new Blob([new Uint8Array([0, 1, 2, 3, 4])], { type: "image/webp" }), { status: 200, }), ); } }, library["wide-d"].id); await page.reload({ waitUntil: "domcontentloaded" }); await waitForSlide(page); const seen = new Set(); const deadline = Date.now() + 20_000; while (Date.now() < deadline) { const state = await page.evaluate(() => ({ rendered: window.__display?.diagnostics()?.renderedAssetId ?? null, opaque: [...document.querySelectorAll(".scene")].filter( (layer) => Number(getComputedStyle(layer).opacity) > 0.99, ).length, })); expect(state.opaque, "the last good frame is never dropped").toBeGreaterThanOrEqual(1); if (state.rendered) seen.add(state.rendered); await page.waitForTimeout(200); } expect(seen.has(library["wide-d"].id), "the damaged slide never displays").toBe(false); expect(seen.size, "the other cached slides keep rotating").toBeGreaterThanOrEqual(2); const state = await diagnostics(page); expect(state.unavailableEntryIds.length).toBeGreaterThanOrEqual(1); expect(state.mostRecentError).not.toBeNull(); } finally { await context.close(); await restoreStack(); } }); test("a missing media cache offline fails safe instead of fabricating state", async ({ request, }) => { const library = await ensureFixtures(request); await resetPlayback(request); await activatePlaylist(request, "Empty cache", [ { assetId: library["wide-a"].id, durationOverrideMs: 5_000 }, ]); const { context, page } = await launchKiosk(); try { await page.goto(`${BASE_URL}/display`); await waitForSlide(page); const clientId = await page.evaluate(() => window.__display.clientId); await stop(CONTAINERS.api, CONTAINERS.storage, CONTAINERS.web); await waitUntilUnreachable(BASE_URL); // Shell and IndexedDB survive; only the slide bytes are gone. await page.evaluate(() => caches.delete("announcements-display-assets-v1")); await page.reload({ waitUntil: "domcontentloaded" }); await page.waitForTimeout(6_000); // No crash, no invented content, and the identity is intact. expect(await page.evaluate(() => Boolean(window.__display))).toBe(true); expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId); const state = await diagnostics(page); expect(state.renderedAssetId, "nothing is fabricated").toBeNull(); expect(state.mostRecentError, "the condition is recorded for later reporting").not.toBeNull(); // Once the infrastructure returns, the cache is rebuilt with no intervention. await restoreStack(); await expect .poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 }) .not.toBeNull(); await expect.poll(async () => (await cachedMedia(page)).length).toBeGreaterThanOrEqual(1); } finally { await context.close(); await restoreStack(); } }); test("corrupt persisted state offline fails safe and recovers when the server returns", async ({ request, }) => { const library = await ensureFixtures(request); await resetPlayback(request); await activatePlaylist(request, "Corrupt", [ { assetId: library["wide-a"].id, durationOverrideMs: 5_000 }, ]); const { context, page } = await launchKiosk(); try { await page.goto(`${BASE_URL}/display`); await waitForSlide(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({ schemaVersion: 99, manifest: "not-an-object" }, "lastKnownGood"); transaction.oncomplete = resolve; }); database.close(); }); await stop(CONTAINERS.api, CONTAINERS.storage); await waitUntilUnreachable(BASE_URL); await page.reload({ waitUntil: "domcontentloaded" }); await page.waitForTimeout(5_000); // An unreadable record is discarded rather than trusted; the display stays // alive and keeps its identity. expect(await page.evaluate(() => Boolean(window.__display))).toBe(true); expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId); await restoreStack(); await expect .poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 }) .toBe(library["wide-a"].id); } finally { await context.close(); await restoreStack(); } }); test("a worker update keeps the separate slide media cache intact", async ({ request }) => { const library = await ensureFixtures(request); await resetPlayback(request); await activatePlaylist(request, "Worker update", [ { assetId: library["wide-a"].id, durationOverrideMs: 60_000 }, ]); const { context, page } = await launchKiosk(); try { await page.goto(`${BASE_URL}/display`); await waitForSlide(page); await expect .poll(async () => page.evaluate(async () => Boolean((await navigator.serviceWorker.getRegistration("/"))?.active), ), ) .toBe(true); const mediaBefore = await cachedMedia(page); expect(mediaBefore.length).toBeGreaterThanOrEqual(1); // Simulate a stale shell generation from an older application version. await page.evaluate(async () => { const stale = await caches.open("announcements-display-shell-v0"); await stale.put("/display", new Response("old", { status: 200 })); }); // Cleanup runs when a worker activates, so force a real install/activate // cycle rather than calling update() on an unchanged worker file. await page.evaluate(async () => { const registration = await navigator.serviceWorker.getRegistration("/"); await registration.unregister(); }); await page.reload({ waitUntil: "domcontentloaded" }); await waitForSlide(page); await expect .poll( async () => page.evaluate(async () => Boolean((await navigator.serviceWorker.getRegistration("/"))?.active), ), { timeout: 45_000 }, ) .toBe(true); const cacheNames = await page.evaluate(() => caches.keys()); expect( cacheNames.includes("announcements-display-shell-v0"), "superseded shell generations are cleaned up", ).toBe(false); expect(cacheNames).toContain("announcements-display-assets-v1"); const mediaAfter = await cachedMedia(page); expect( mediaAfter, "a worker update must never delete the explicit slide media cache", ).toEqual(expect.arrayContaining(mediaBefore)); expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1); } finally { await context.close(); await restoreStack(); } });