import { webcrypto } from "node:crypto"; import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import asset from "./persona-resource-manifest.json"; const bytes = Uint8Array.from(readFileSync(`public${asset.path}`)).buffer; const source = `${asset.path}?v=${asset.sha256}`; const absolute = (path: string) => new URL(path, window.location.href).href; function mockStorage() { const entries = new Map(); const cache = { match: vi.fn(async (key: string) => entries.get(absolute(key))?.clone()), put: vi.fn(async (key: string, response: Response) => { entries.set(absolute(key), response.clone()); }), keys: vi.fn(async () => Array.from(entries.keys(), (key) => new Request(key))), delete: vi.fn(async (request: Request) => entries.delete(request.url)) }; vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue(cache) }); return { entries, cache }; } beforeEach(() => { vi.stubGlobal("crypto", webcrypto); vi.stubGlobal("caches", undefined); }); afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); }); describe("Obsidian resource", () => { it("shares one download across simultaneous and later renderer mounts", async () => { const fetch = vi.fn().mockResolvedValue(new Response(bytes)); vi.stubGlobal("fetch", fetch); const { loadPersonaResource } = await import("./persona-resource"); const [first, second] = await Promise.all([loadPersonaResource(), loadPersonaResource()]); expect(new Uint8Array(first)).toEqual(new Uint8Array(bytes)); expect(second).toBe(first); expect(await loadPersonaResource()).toBe(first); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( source, expect.objectContaining({ signal: expect.any(AbortSignal) }) ); }); it("allows a new mount to retry a failed download", async () => { const fetch = vi .fn() .mockResolvedValueOnce(new Response(null, { status: 503 })) .mockResolvedValueOnce(new Response(bytes)); vi.stubGlobal("fetch", fetch); const { loadPersonaResource } = await import("./persona-resource"); await expect(loadPersonaResource()).rejects.toThrow("503"); expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes)); expect(fetch).toHaveBeenCalledTimes(2); }); it("loads from persistent storage after module memory is gone and the network is unavailable", async () => { const { entries } = mockStorage(); const fetch = vi.fn().mockResolvedValue(new Response(bytes)); vi.stubGlobal("fetch", fetch); await (await import("./persona-resource")).loadPersonaResource(); expect(entries.has(absolute(source))).toBe(true); vi.resetModules(); fetch.mockRejectedValue(new TypeError("offline")); const restored = await (await import("./persona-resource")).loadPersonaResource(); expect(new Uint8Array(restored)).toEqual(new Uint8Array(bytes)); expect(fetch).toHaveBeenCalledTimes(1); }); it("fetches a new content version and removes only old entries for this animation", async () => { const { entries } = mockStorage(); entries.set(absolute(`${asset.path}?v=old`), new Response("old animation")); entries.set(absolute("/unrelated.riv"), new Response("keep")); const fetch = vi.fn().mockResolvedValue(new Response(bytes)); vi.stubGlobal("fetch", fetch); await (await import("./persona-resource")).loadPersonaResource(); expect(fetch).toHaveBeenCalledTimes(1); expect(Array.from(entries.keys()).sort()).toEqual([absolute(source), absolute("/unrelated.riv")].sort()); }); it("replaces corrupted cached bytes even when their size and RIVE header still match", async () => { const { entries } = mockStorage(); const corrupted = new Uint8Array(bytes.slice(0)); corrupted[100] ^= 1; entries.set(absolute(source), new Response(corrupted)); const fetch = vi.fn().mockResolvedValue(new Response(bytes)); vi.stubGlobal("fetch", fetch); await (await import("./persona-resource")).loadPersonaResource(); expect(fetch).toHaveBeenCalledTimes(1); expect(new Uint8Array(await entries.get(absolute(source))!.arrayBuffer())).toEqual(new Uint8Array(bytes)); }); it.each(["unavailable", "denied", "full"])("keeps playing when storage is %s", async (failure) => { if (failure === "denied") { vi.stubGlobal("caches", { open: vi.fn().mockRejectedValue(new Error("Storage denied")) }); } else if (failure === "full") { mockStorage().cache.put.mockRejectedValue(new Error("Quota exceeded")); } vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(bytes))); expect(new Uint8Array(await (await import("./persona-resource")).loadPersonaResource())).toEqual(new Uint8Array(bytes)); }); it("does not persist an HTML fallback or the wrong resource version and can retry", async () => { const { cache } = mockStorage(); const wrongVersion = new Uint8Array(bytes.slice(0)); wrongVersion[100] ^= 1; vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(new Response("")) .mockResolvedValueOnce(new Response(wrongVersion)) .mockResolvedValueOnce(new Response(bytes))); const { loadPersonaResource } = await import("./persona-resource"); await expect(loadPersonaResource()).rejects.toThrow("invalid animation data"); await expect(loadPersonaResource()).rejects.toThrow("version mismatch"); expect(cache.put).not.toHaveBeenCalled(); expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes)); expect(cache.put).toHaveBeenCalledTimes(1); }); });