Complete Phase 5B offline display resilience
Make the /display kiosk survive losing its infrastructure, then surrender authority cleanly when it returns. The Phase 5A renderer is unchanged: this builds on its two permanent scene layers, explicit Cache Storage, IndexedDB, Service Worker, timing anchors, and structured errors. While connected the server is authoritative. While disconnected the display becomes a deterministic local continuation of the last authoritative state it persisted, and the moment the server speaks again the local cursor is abandoned rather than reconciled or reported back. A small client evaluator resolves offline playback from the persisted manifest and clock anchor. Catch-up is arithmetic rather than iterative: whole rotations are skipped between eligibility boundaries, so a month offline resolves in milliseconds. Mixed durations, wraparound, [start, end) schedules, and expirations are evaluated locally, and a null expiration stays permanent. Mode off keeps the hidden cursor advancing, pause holds its slide and remainder, static holds until the cached metadata says the asset genuinely expired, and an activation queued for an unknown playlist holds the last good frame instead of continuing into a playlist that is being replaced. Contract tests run this evaluator and the real Phase 3B engine over identical scenarios and assert the same slide, start, and deadline, so the client tracks the server without refactoring it. Cold start restores signage from the Service Worker shell, IndexedDB, and Cache Storage with the entire stack down, keeping the same persistent display identity. Validated display error codes now become Home Assistant incidents through the Phase 4 machinery, keyed per display and condition so a persisting error reports once and recovers once. Arbitrary browser text stays telemetry. Corrects the deployment assumption: the kiosk runs on a separate machine, so production requires a trusted HTTPS origin and localhost is development only. Verifying on a real trusted certificate exposed two production bugs that localhost had hidden — behind a reverse proxy an outage arrives as a 502 rather than a refused connection, and an unbounded shell fetch left a restarting kiosk blank for the proxy's whole connect timeout. The worker now treats a gateway error as an outage and bounds that wait. Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on a trusted HTTPS origin with no browser security flags weakened. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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("<html>old</html>", { 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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { ensureFixtures } from "./fixtures.js";
|
||||
import { activatePlaylist, api, 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";
|
||||
|
||||
// These scenarios remove real infrastructure, so they must not interleave.
|
||||
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("cached rotation continues through a complete server outage", async ({ request }) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await resetPlayback(request);
|
||||
await activatePlaylist(request, "Outage", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||
{ assetId: library["wide-b"].id, durationOverrideMs: 9_000 },
|
||||
{ assetId: library["wide-c"].id, durationOverrideMs: 4_000 },
|
||||
]);
|
||||
|
||||
const { context, page } = await launchKiosk();
|
||||
try {
|
||||
await page.goto(`${BASE_URL}/display`);
|
||||
await waitForSlide(page);
|
||||
// Every slide of this playlist must be cached before the outage begins.
|
||||
await waitForCachedAssets(page, [
|
||||
library["wide-a"].id,
|
||||
library["wide-b"].id,
|
||||
library["wide-c"].id,
|
||||
]);
|
||||
|
||||
await stop(CONTAINERS.api, CONTAINERS.storage);
|
||||
await waitUntilUnreachable(BASE_URL);
|
||||
|
||||
// Sample continuously across multiple full cycles with nothing serving.
|
||||
const seen = new Set();
|
||||
const starts = new Map();
|
||||
const deadline = Date.now() + 40_000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await page.evaluate(() => ({
|
||||
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||
offline: window.__display?.diagnostics()?.offlineActive ?? false,
|
||||
opaque: [...document.querySelectorAll(".scene")].filter(
|
||||
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||
).length,
|
||||
gate: document.querySelector(".output-gate").dataset.active,
|
||||
}));
|
||||
// The original black-frame bug must remain impossible offline too.
|
||||
expect(state.opaque, "a good frame is always on screen offline").toBeGreaterThanOrEqual(1);
|
||||
expect(state.gate).toBe("false");
|
||||
if (state.rendered) {
|
||||
seen.add(state.rendered);
|
||||
if (!starts.has(state.rendered)) starts.set(state.rendered, Date.now());
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
expect(await diagnostics(page).then((d) => d.offlineActive)).toBe(true);
|
||||
expect(seen.size, "mixed-duration rotation kept cycling with no server").toBe(3);
|
||||
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
|
||||
|
||||
await restoreStack();
|
||||
// The server is authoritative again the moment it speaks.
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 60_000 })
|
||||
.toBe(false);
|
||||
const playback = await api(request, "/api/playback");
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.acceptedRevision, { timeout: 30_000 })
|
||||
.toBeGreaterThanOrEqual(playback.playbackRevision);
|
||||
} finally {
|
||||
await context.close();
|
||||
await restoreStack();
|
||||
}
|
||||
});
|
||||
|
||||
test("a cold Chromium restart with the whole stack down restores signage", async ({
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await resetPlayback(request);
|
||||
await activatePlaylist(request, "Cold start", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
|
||||
{ assetId: library["wide-d"].id, durationOverrideMs: 6_000 },
|
||||
]);
|
||||
|
||||
// A. Warm the persistent profile while everything is healthy.
|
||||
let clientId;
|
||||
let cachedBefore;
|
||||
{
|
||||
const { context, page } = await launchKiosk();
|
||||
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 expect.poll(async () => (await cachedMedia(page)).length).toBeGreaterThanOrEqual(2);
|
||||
clientId = await page.evaluate(() => window.__display.clientId);
|
||||
cachedBefore = await cachedMedia(page);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// B. Remove the entire origin: no web server, no API, no object storage.
|
||||
await stop(CONTAINERS.web, CONTAINERS.api, CONTAINERS.storage);
|
||||
await waitUntilUnreachable(BASE_URL);
|
||||
|
||||
try {
|
||||
// C. Relaunch the kiosk against the same URL with the same profile.
|
||||
const { context, page } = await launchKiosk();
|
||||
try {
|
||||
const response = await page.goto(`${BASE_URL}/display`, { waitUntil: "domcontentloaded" });
|
||||
expect(
|
||||
response?.status(),
|
||||
"the Service Worker served the app shell with the origin down",
|
||||
).toBe(200);
|
||||
|
||||
await waitForSlide(page);
|
||||
const offline = await diagnostics(page);
|
||||
expect(offline.offlineActive, "the display took local authority").toBe(true);
|
||||
expect(
|
||||
await page.evaluate(() => window.__display.clientId),
|
||||
"the persistent display identity survived the restart",
|
||||
).toBe(clientId);
|
||||
expect(cachedBefore).toContain(
|
||||
(await cachedMedia(page)).find((path) => path.includes(library["wide-a"].id)),
|
||||
);
|
||||
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// D. It must keep advancing, not merely show one restored frame.
|
||||
const first = offline.renderedAssetId;
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||
.not.toBe(first);
|
||||
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// E. Restore the stack and confirm it surrenders authority cleanly.
|
||||
await restoreStack();
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 90_000 })
|
||||
.toBe(false);
|
||||
const playback = await api(request, "/api/playback");
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedEntryId, { timeout: 45_000 })
|
||||
.toBe(playback.currentEntryId);
|
||||
|
||||
const displays = await api(request, "/api/displays");
|
||||
expect(
|
||||
displays.displays.filter((display) => display.clientId === clientId).length,
|
||||
"one physical display stays one record across a cold restart",
|
||||
).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await restoreStack();
|
||||
}
|
||||
});
|
||||
|
||||
test("an object storage outage never disturbs already cached slides", async ({ request }) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await resetPlayback(request);
|
||||
await activatePlaylist(request, "Storage outage", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||
{ assetId: library["wide-b"].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-b"].id]);
|
||||
|
||||
// The API stays up; only the bytes become unreachable.
|
||||
await stop(CONTAINERS.storage);
|
||||
|
||||
const seen = new Set();
|
||||
const deadline = Date.now() + 14_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).toBeGreaterThanOrEqual(1);
|
||||
if (state.rendered) seen.add(state.rendered);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
expect(seen.size, "cached slides keep rotating without object storage").toBe(2);
|
||||
|
||||
// A brand new asset cannot be fetched; the last good frame must survive.
|
||||
const held = await diagnostics(page);
|
||||
await activatePlaylist(request, "Uncached", [
|
||||
{ assetId: library.standalone.id, durationOverrideMs: 60_000 },
|
||||
]);
|
||||
await page.waitForTimeout(6_000);
|
||||
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
|
||||
const during = await diagnostics(page);
|
||||
expect(during.renderedAssetId, "the last good frame is retained").toBe(held.renderedAssetId);
|
||||
// Nothing in the newly active playlist can be rendered locally, which is a
|
||||
// more severe condition than one failed asset and is reported as such.
|
||||
expect(during.mostRecentError?.kind).toBe("manifest_invalid");
|
||||
|
||||
// That condition reaches the server as a critical incident, exactly once.
|
||||
const clientId = await page.evaluate(() => window.__display.clientId);
|
||||
await page.evaluate(() => window.__display.heartbeat());
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const displays = await api(request, "/api/displays");
|
||||
return (
|
||||
displays.displays.find((display) => display.clientId === clientId)?.mostRecentError
|
||||
?.kind ?? null
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("manifest_invalid");
|
||||
|
||||
// Restoring storage recovers without a page reload.
|
||||
await start(CONTAINERS.storage);
|
||||
await waitUntilHealthy(BASE_URL);
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
|
||||
.toBe(library.standalone.id);
|
||||
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
await restoreStack();
|
||||
}
|
||||
});
|
||||
|
||||
test("an API outage keeps local playback running and resynchronizes on return", async ({
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await resetPlayback(request);
|
||||
await activatePlaylist(request, "API outage", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||
{ assetId: library["wide-b"].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-b"].id,
|
||||
library["wide-c"].id,
|
||||
]);
|
||||
|
||||
await stop(CONTAINERS.api);
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 45_000 })
|
||||
.toBe(true);
|
||||
|
||||
const seen = new Set();
|
||||
const deadline = Date.now() + 14_000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await diagnostics(page);
|
||||
if (state?.renderedAssetId) seen.add(state.renderedAssetId);
|
||||
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
expect(seen.size).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// While the display was blind, the operator changed authoritative state.
|
||||
await start(CONTAINERS.api);
|
||||
await waitUntilHealthy(BASE_URL);
|
||||
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
|
||||
|
||||
// Server wins immediately on reconnect, including the visual gate.
|
||||
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect((await diagnostics(page)).offlineActive).toBe(false);
|
||||
|
||||
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
|
||||
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const playback = await api(request, "/api/playback");
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedEntryId, { timeout: 30_000 })
|
||||
.toBe(playback.currentEntryId);
|
||||
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
await restoreStack();
|
||||
}
|
||||
});
|
||||
|
||||
test("a playlist changed during the outage is adopted cleanly on reconnect", async ({
|
||||
request,
|
||||
}) => {
|
||||
const library = await ensureFixtures(request);
|
||||
await resetPlayback(request);
|
||||
await activatePlaylist(request, "Before outage", [
|
||||
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
|
||||
{ assetId: library["wide-b"].id, durationOverrideMs: 5_000 },
|
||||
]);
|
||||
|
||||
const { context, page } = await launchKiosk();
|
||||
try {
|
||||
await page.goto(`${BASE_URL}/display`);
|
||||
await waitForSlide(page);
|
||||
const before = await diagnostics(page);
|
||||
|
||||
await stop(CONTAINERS.api);
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 45_000 })
|
||||
.toBe(true);
|
||||
await page.waitForTimeout(6_000);
|
||||
|
||||
await start(CONTAINERS.api);
|
||||
await waitUntilHealthy(BASE_URL);
|
||||
// A different playlist entirely, activated while the display was offline.
|
||||
await activatePlaylist(request, "After outage", [
|
||||
{ assetId: library.square.id, durationOverrideMs: 60_000 },
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
|
||||
.toBe(library.square.id);
|
||||
const after = await diagnostics(page);
|
||||
expect(after.offlineActive).toBe(false);
|
||||
expect(after.renderedAssetId).not.toBe(before.renderedAssetId);
|
||||
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The superseded playlist's bytes are pruned once the new one is promoted.
|
||||
await expect
|
||||
.poll(async () => (await cachedMedia(page)).some((path) => path.includes(library.square.id)))
|
||||
.toBe(true);
|
||||
const cached = await cachedMedia(page);
|
||||
expect(cached.length, "repeated playlist changes must not grow the cache forever")
|
||||
.toBeLessThanOrEqual(6);
|
||||
} finally {
|
||||
await context.close();
|
||||
await restoreStack();
|
||||
}
|
||||
});
|
||||
@@ -138,10 +138,13 @@ test("the persistent client identity and last known good state survive a refresh
|
||||
database.close();
|
||||
return value;
|
||||
});
|
||||
expect(persisted.schemaVersion).toBe(1);
|
||||
// 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");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
|
||||
export const PROFILE_DIR = process.env.DISPLAY_PROFILE_DIR || "/profiles/kiosk";
|
||||
export const BASE_URL = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||
|
||||
// A real kiosk keeps a persistent Chromium profile: the Service Worker,
|
||||
// Cache Storage, and IndexedDB that make offline startup possible all live
|
||||
// there. Incognito or an ephemeral user-data-dir would defeat the whole phase.
|
||||
export async function launchKiosk() {
|
||||
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
serviceWorkers: "allow",
|
||||
});
|
||||
const page = context.pages()[0] || (await context.newPage());
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
export function diagnostics(page) {
|
||||
return page.evaluate(() => window.__display?.diagnostics() ?? null);
|
||||
}
|
||||
|
||||
export async function waitForSlide(page, timeout = 60_000) {
|
||||
await expect
|
||||
.poll(async () => (await diagnostics(page))?.renderedAssetId ?? null, { timeout })
|
||||
.not.toBeNull();
|
||||
}
|
||||
|
||||
export function opaqueLayerCount(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
[...document.querySelectorAll(".scene")].filter(
|
||||
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||
).length,
|
||||
);
|
||||
}
|
||||
|
||||
// Waiting for "some" cached assets is not enough: a scenario that removes
|
||||
// storage must first know that the specific slides it needs are present.
|
||||
export async function waitForCachedAssets(page, assetIds, timeout = 60_000) {
|
||||
const { expect } = await import("@playwright/test");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cached = await cachedMedia(page);
|
||||
return assetIds.every((id) => cached.some((path) => path.includes(id)));
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function cachedMedia(page) {
|
||||
return page.evaluate(async () => {
|
||||
if (!(await caches.has("announcements-display-assets-v1"))) return [];
|
||||
const cache = await caches.open("announcements-display-assets-v1");
|
||||
return (await cache.keys()).map((request) => new URL(request.url).pathname);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import http from "node:http";
|
||||
|
||||
// The offline suite must genuinely remove infrastructure, not simulate it.
|
||||
// Talking to the Docker Engine socket directly avoids installing a CLI into
|
||||
// the browser image and keeps every scenario inside one Playwright run.
|
||||
const SOCKET = "/var/run/docker.sock";
|
||||
|
||||
function engine(method, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({ socketPath: SOCKET, method, path }, (response) => {
|
||||
let body = "";
|
||||
response.on("data", (chunk) => (body += chunk));
|
||||
response.on("end", () => resolve({ status: response.statusCode, body }));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
const PROJECT = process.env.VERIFY_PROJECT || "announcements-p5b-verify";
|
||||
export const CONTAINERS = {
|
||||
web: `${PROJECT}-web-1`,
|
||||
api: `${PROJECT}-api-1`,
|
||||
storage: `${PROJECT}-seaweedfs-1`,
|
||||
};
|
||||
|
||||
export async function stop(...names) {
|
||||
for (const name of names) await engine("POST", `/containers/${name}/stop?t=2`);
|
||||
}
|
||||
|
||||
export async function start(...names) {
|
||||
for (const name of names) await engine("POST", `/containers/${name}/start`);
|
||||
}
|
||||
|
||||
export async function isRunning(name) {
|
||||
const { status, body } = await engine("GET", `/containers/${name}/json`);
|
||||
if (status !== 200) return false;
|
||||
return Boolean(JSON.parse(body).State?.Running);
|
||||
}
|
||||
|
||||
export async function waitUntilHealthy(baseUrl, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/playback`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (response.ok) return true;
|
||||
} catch {
|
||||
// Still coming up.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error("stack did not become reachable in time");
|
||||
}
|
||||
|
||||
// With the API stopped but Nginx still running, the proxy answers 502. That is
|
||||
// a response, not a reachable API, so a gateway error counts as unreachable
|
||||
// just like a refused connection does.
|
||||
export async function waitUntilUnreachable(baseUrl, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/playback`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (!response.ok) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error("stack stayed reachable");
|
||||
}
|
||||
Reference in New Issue
Block a user