fix(display): keep one object URL per cached slide, and give them back #6
@@ -0,0 +1,119 @@
|
|||||||
|
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 as SignageMediaCache & { release(): void }).release();
|
||||||
|
expect(live.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user