122 lines
5.6 KiB
TypeScript
122 lines
5.6 KiB
TypeScript
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<string, Response>();
|
|
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("<!doctype html>"))
|
|
.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);
|
|
});
|
|
});
|