231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
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<string, Response>();
|
|
|
|
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
|
return this.responses.get(this.key(request))?.clone();
|
|
}
|
|
|
|
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
|
this.responses.set(this.key(request), response.clone());
|
|
}
|
|
|
|
async delete(request: RequestInfo | URL): Promise<boolean> {
|
|
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<string, MemoryCache>();
|
|
|
|
async open(name: string): Promise<Cache> {
|
|
const cache = this.stores.get(name) ?? new MemoryCache();
|
|
this.stores.set(name, cache);
|
|
return cache as unknown as Cache;
|
|
}
|
|
|
|
async delete(name: string): Promise<boolean> {
|
|
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);
|
|
});
|
|
});
|