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(); const cache = { async delete(request: Request | string): Promise { return entries.delete(typeof request === "string" ? request : request.url); }, async keys(): Promise { return [...entries.keys()].map((url) => new Request(url)); }, async match(request: Request | string): Promise { 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 { 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; 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; 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); }); });