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 as SignageMediaCache & { release(): void }).release(); expect(live.size).toBe(0); }); });