Files
Signage/tests/browser/media-cache.test.ts
ekklesiaandClaude Opus 5.5 5f52334664
CI / lint (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 25s
CI / build (pull_request) Successful in 32s
CI / test (pull_request) Successful in 33s
CI / package (pull_request) Successful in 24s
CI / integration (pull_request) Successful in 8m43s
fix(display): keep cached image bytes when the browser has no Cache Storage
A display opened over plain http:// on a LAN address is not a secure
context, so the browser gives it no Cache Storage. The media cache then
fell back to the in-memory test double, which remembered only each
slide's signed delivery URL and still reported it as cached. Online that
looked identical. Offline, the next slide's URL could not be loaded, the
slide was marked unusable, and local continuation kept landing on the
image already on screen. The wall froze until the network returned.

Without Cache Storage, the browser media cache now fetches and holds the
bytes in page memory as object URLs (one per key, revoked on prune,
replace and stop), so a running display keeps cycling through what it
already has. Nothing persists across a reload; on such an origin the
platform cannot cache the display page either.

Regression tests: the media cache on an origin without Cache Storage,
and the whole controller on an ordered fake clock, cycling for six slide
intervals with the network gone and then handing back to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:42:00 -05:00

207 lines
7.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createBrowserMediaCache, type SignageMediaCache } from "../../src/browser/storage.js";
/**
* The Cache Storage media cache hands out object URLs, and every live one pins its blob in the
* browser until it is revoked.
*
* Every state refresh looks up every cached slide, so a cache that minted a fresh URL per lookup
* leaked one blob per slide per refresh. In a real browser that exhausted blob storage within
* minutes: the display's own state requests then failed with `net::ERR_INSUFFICIENT_RESOURCES` and
* it fell back to its offline banner on a perfectly good network.
*/
/** Just enough Cache Storage: one named cache, keyed by request URL. */
function fakeCacheStorage() {
const entries = new Map<string, { body: ArrayBuffer; type: string }>();
const cache = {
async delete(request: Request | string): Promise<boolean> {
return entries.delete(typeof request === "string" ? request : request.url);
},
async keys(): Promise<Request[]> {
return [...entries.keys()].map((url) => new Request(url));
},
async match(request: Request | string): Promise<Response | undefined> {
const entry = entries.get(typeof request === "string" ? request : request.url);
return entry
? new Response(entry.body, { headers: { "content-type": entry.type } })
: undefined;
},
async put(request: Request | string, response: Response): Promise<void> {
entries.set(typeof request === "string" ? request : request.url, {
body: await response.arrayBuffer(),
type: response.headers.get("content-type") ?? "application/octet-stream",
});
},
};
return { open: async () => cache } as unknown as CacheStorage;
}
describe("Signage's media cache and object URLs", () => {
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
let live: Set<string>;
let media: SignageMediaCache;
beforeEach(() => {
live = new Set();
let next = 0;
URL.createObjectURL = () => {
next += 1;
const url = `blob:test/${next}`;
live.add(url);
return url;
};
URL.revokeObjectURL = (url: string) => {
live.delete(url);
};
media = createBrowserMediaCache({
caches: fakeCacheStorage(),
fetch: async () =>
new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }),
location: { origin: "https://api.example.test" },
} as unknown as typeof globalThis);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
const keys = ["slide/a", "slide/b", "slide/c"];
it("keeps one live object URL per cached slide, however often it is looked up", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
}
// A state refresh looks every slide up again; a long-running display does this for months.
for (let refresh = 0; refresh < 100; refresh += 1) {
for (const key of keys) {
await media.find(key);
}
}
expect(live.size).toBe(keys.length);
});
it("hands out the same URL for the same bytes", async () => {
await media.store("slide/a", "https://files.example.test/a?sig=1");
const first = await media.find("slide/a");
const second = await media.find("slide/a");
expect(first).toBeDefined();
expect(second).toBe(first);
});
it("revokes a pruned slide's URL", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
await media.find(key);
}
await media.prune(["slide/a"]);
expect(live.size).toBe(1);
expect(await media.find("slide/b")).toBeUndefined();
});
it("revokes the URL it replaces when a slide is stored again", async () => {
await media.store("slide/a", "https://files.example.test/a?sig=1");
await media.store("slide/a", "https://files.example.test/a?sig=2");
await media.find("slide/a");
expect(live.size).toBe(1);
});
it("releases every URL it handed out when the display stops", async () => {
for (const key of keys) {
await media.store(key, `https://files.example.test/${key}?sig=1`);
await media.find(key);
}
media.release();
expect(live.size).toBe(0);
});
});
/**
* A display opened over plain `http://` on a LAN address is not a secure context, and a browser
* gives such a page no Cache Storage. Stage 0's real kiosk ran exactly that way.
*
* The fallback used to remember only the signed delivery URL and report it as cached. Online that
* looked identical; offline, the next slide's URL could not be fetched, the slide was skipped as
* unusable, and the wall sat on one image until the network came back. The bytes are what a display
* needs offline, so the bytes are what this keeps — in the page's memory, for as long as it runs.
*/
describe("Signage's media cache on an origin without Cache Storage", () => {
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
let live: Set<string>;
let reachable: boolean;
let media: SignageMediaCache;
beforeEach(() => {
live = new Set();
reachable = true;
let next = 0;
URL.createObjectURL = () => {
next += 1;
const url = `blob:test/${next}`;
live.add(url);
return url;
};
URL.revokeObjectURL = (url: string) => {
live.delete(url);
};
media = createBrowserMediaCache({
// No `caches`: what an insecure origin gets.
fetch: async (url: string) => {
if (!reachable) {
throw new TypeError("Failed to fetch");
}
return url.includes("missing")
? new Response("not found", { status: 404 })
: new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } });
},
location: { origin: "http://192.168.1.20:3000" },
} as unknown as typeof globalThis);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
it("keeps the bytes, so a slide it cached can still be painted with no network", async () => {
const stored = await media.store("slide/a", "http://files.example.test/a?sig=1");
// A local URL for bytes this page holds — never the delivery URL, which needs the network.
expect(stored).toMatch(/^blob:/u);
expect(stored).not.toContain("files.example.test");
reachable = false;
expect(await media.find("slide/a")).toBe(stored);
expect(await media.count()).toBe(1);
});
it("reports nothing as cached that it does not actually hold", async () => {
expect(await media.store("slide/a", "http://files.example.test/missing?sig=1")).toBeUndefined();
reachable = false;
expect(await media.store("slide/b", "http://files.example.test/b?sig=1")).toBeUndefined();
expect(await media.find("slide/a")).toBeUndefined();
expect(await media.find("slide/b")).toBeUndefined();
expect(await media.count()).toBe(0);
});
it("gives back the URLs it replaces, prunes, and holds when the display stops", async () => {
for (const key of ["slide/a", "slide/b", "slide/c"]) {
await media.store(key, `http://files.example.test/${key}?sig=1`);
}
await media.store("slide/a", "http://files.example.test/slide/a?sig=2");
expect(live.size).toBe(3);
await media.prune(["slide/a", "slide/b"]);
expect(live.size).toBe(2);
expect(await media.find("slide/c")).toBeUndefined();
expect(await media.count()).toBe(2);
media.release();
expect(live.size).toBe(0);
});
});