import { createHash } from "node:crypto"; import { gzipSync, gunzipSync } from "node:zlib"; import { Capacitor } from "@capacitor/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BROWSER_CONTENT_CACHE_NAME, browserContentObjectPath, checkForContentUpdates, contentPackHasUpdate, contentPackIsBundled, contentPackIsInstalled, contentPackIsUsable, dungeonContentIsUsable, getContentManagerSnapshot, installContentPack, removeContentPack, } from "./contentManager"; import type { ContentManifest } from "./contentManifest"; class MemoryCache { readonly responses = new Map(); async match(request: RequestInfo | URL): Promise { return this.responses.get(this.key(request))?.clone(); } async put(request: RequestInfo | URL, response: Response): Promise { this.responses.set(this.key(request), response.clone()); } async delete(request: RequestInfo | URL): Promise { return this.responses.delete(this.key(request)); } private key(request: RequestInfo | URL): string { if (request instanceof Request) return request.url; return new URL(String(request), "http://localhost").toString(); } } class MemoryCacheStorage { readonly stores = new Map(); async open(name: string): Promise { const cache = this.stores.get(name) ?? new MemoryCache(); this.stores.set(name, cache); return cache as unknown as Cache; } async delete(name: string): Promise { return this.stores.delete(name); } } function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } function contentManifest(version: string, objectName: string, bytes: Uint8Array): ContentManifest { return { schemaVersion: 1, version, createdAt: "2026-09-04T00:00:00.000Z", minimumAppVersion: "0.1.0", packs: [{ id: "characters:test", label: "Test characters", bytes: bytes.byteLength, fileCount: 1, bundledOnAndroid: false, dependencies: [], }], files: { "/assets/characters/test.glb": { packId: "characters:test", url: `/content/objects/${objectName}`, sha256: sha256(bytes), bytes: bytes.byteLength, }, }, }; } afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("browser content packs", () => { it("downloads, updates, and removes verified optional content", async () => { const firstBytes = new TextEncoder().encode("first-model"); const updatedBytes = new TextEncoder().encode("updated-model"); let manifest = contentManifest("content-one", "first.glb", firstBytes); let corruptUpdate = false; const storage = new MemoryCacheStorage(); vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false); vi.stubGlobal("caches", storage as unknown as CacheStorage); vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/content/manifest.json")) return Response.json(manifest); if (url.endsWith("/first.glb")) return new Response(firstBytes); if (url.endsWith("/updated.glb")) { return new Response(corruptUpdate ? new Uint8Array(updatedBytes.byteLength).fill(7) : updatedBytes); } return new Response(null, { status: 404 }); })); await checkForContentUpdates(); expect(contentPackIsInstalled("characters:test")).toBe(false); expect(contentPackIsUsable("characters:test")).toBe(false); expect(contentPackHasUpdate("characters:test")).toBe(false); await installContentPack("characters:test"); expect(contentPackIsInstalled("characters:test")).toBe(true); expect(contentPackIsUsable("characters:test")).toBe(true); expect(contentPackHasUpdate("characters:test")).toBe(false); expect(getContentManagerSnapshot()).toMatchObject({ installingPackId: null, downloadedBytes: firstBytes.byteLength, totalBytes: firstBytes.byteLength, error: "", }); const entriesSpy = vi.spyOn(Object, "entries"); for (let index = 0; index < 100; index += 1) { expect(contentPackIsUsable("characters:test")).toBe(true); } expect(entriesSpy).not.toHaveBeenCalled(); entriesSpy.mockRestore(); const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME); expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model"); const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]); manifest = contentManifest("content-two", "updated.glb", updatedBytes); await checkForContentUpdates(); expect(contentPackIsUsable("characters:test")).toBe(false); expect(contentPackHasUpdate("characters:test")).toBe(true); corruptUpdate = true; await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i); expect(await (await active?.match(originalObject))?.text()).toBe("first-model"); corruptUpdate = false; await installContentPack("characters:test"); expect(contentPackHasUpdate("characters:test")).toBe(false); expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("updated-model"); expect(await active?.match(originalObject)).toBeUndefined(); await removeContentPack("characters:test"); expect(contentPackIsInstalled("characters:test")).toBe(false); expect(contentPackIsUsable("characters:test")).toBe(false); expect(await active?.match("http://localhost/assets/characters/test.glb")).toBeUndefined(); }); it("keeps default browser content installed and non-removable", async () => { vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false); expect(contentPackIsBundled("core:ui")).toBe(true); expect(contentPackIsUsable("core:ui")).toBe(true); await expect(removeContentPack("core:ui")).resolves.toBeUndefined(); expect(contentPackIsUsable("core:ui")).toBe(true); }); it("stores a compressed duplicate once and retains it until its last pack is removed", async () => { const original = new TextEncoder().encode("original shared geometry ".repeat(200)); const compressed = gzipSync(original); const firstPath = "/assets/characters/shared-first.glb"; const secondPath = "/assets/characters/shared-second.glb"; const thirdPath = "/assets/characters/shared-third.glb"; const base = contentManifest("compressed", "original.glb", original); const entry = { ...base.files["/assets/characters/test.glb"], download: { url: "/content/objects/shared.glb.gz", encoding: "gzip" as const, sha256: sha256(compressed), bytes: compressed.length, } }; const manifest: ContentManifest = { ...base, packs: [ { ...base.packs[0], id: "characters:shared-first", fileCount: 2 }, { ...base.packs[0], id: "characters:shared-second" }, ], files: { [firstPath]: { ...entry, packId: "characters:shared-first" }, [secondPath]: { ...entry, packId: "characters:shared-first" }, [thirdPath]: { ...entry, packId: "characters:shared-second" }, } }; const storage = new MemoryCacheStorage(); vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false); vi.stubGlobal("caches", storage as unknown as CacheStorage); const fetcher = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed)); vi.stubGlobal("fetch", fetcher); await checkForContentUpdates(); await installContentPack("characters:shared-first"); const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME)!; expect(active.responses.size).toBe(3); const objectPath = browserContentObjectPath(entry); expect(gunzipSync(Buffer.from(await (await active.match(objectPath))!.arrayBuffer()))).toEqual(Buffer.from(original)); expect(getContentManagerSnapshot().totalBytes).toBe(compressed.length); await installContentPack("characters:shared-second"); expect(getContentManagerSnapshot().totalBytes).toBe(0); expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith(".gz"))).toHaveLength(1); await removeContentPack("characters:shared-first"); expect(await active.match(objectPath)).toBeDefined(); expect(contentPackIsUsable("characters:shared-second")).toBe(true); await removeContentPack("characters:shared-second"); expect(active.responses.size).toBe(0); }); it("rejects a valid gzip payload that expands to the wrong original model", async () => { const original = new TextEncoder().encode("expected model"); const compressed = gzipSync("different model"); const base = contentManifest("bad-compressed", "model.glb", original); const manifest = { ...base, files: { "/assets/characters/test.glb": { ...base.files["/assets/characters/test.glb"], download: { url: "/content/objects/model.glb.gz", encoding: "gzip", sha256: sha256(compressed), bytes: compressed.length }, } } }; const storage = new MemoryCacheStorage(); vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false); vi.stubGlobal("caches", storage as unknown as CacheStorage); vi.stubGlobal("fetch", vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed))); await checkForContentUpdates(); await expect(installContentPack("characters:test")).rejects.toThrow(/expanded content failed verification/i); expect(storage.stores.get(BROWSER_CONTENT_CACHE_NAME)?.responses.size).toBe(0); expect(contentPackIsUsable("characters:test")).toBe(false); }); it("uses source-served dungeons in development and the content gate in shipped builds", () => { vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false); vi.stubEnv("DEV", true); expect(dungeonContentIsUsable("deadmines")).toBe(true); vi.stubEnv("DEV", false); expect(dungeonContentIsUsable("deadmines")).toBe(false); expect(dungeonContentIsUsable("wailing-caverns")).toBe(true); }); });