import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToolcraftModelFormatAdapter } from "./model-import-types"; import { createToolcraftModelFormatAdapterRegistry } from "./formats/model-format-adapter"; import { TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS } from "./model-import-limits"; import type { CreateToolcraftModelSourceBundleOptions } from "./model-source-bundle"; import { createToolcraftModelSourceBundle } from "./model-source-bundle"; import { inspectGltfSourceBuffers } from "./model-source-gltf"; import { preflightAndParseGltfJson } from "./model-source-json-preflight"; import { snapshotModelSourceFiles, TOOLCRAFT_MODEL_RAW_BATCH_FILE_CEILING, } from "./model-source-path"; const encoder = new TextEncoder(); function adapter(): ToolcraftModelFormatAdapter { return { adapterVersion: "gltf-preflight-1", async decode() { throw new Error("decode is not used by preflight tests"); }, format: "gltf", rootExtensions: [".gltf"], workerCapable: true, }; } const registry = createToolcraftModelFormatAdapterRegistry([adapter()]); function binaryFile( name: string, bytes: BlobPart | number[], type = "application/octet-stream", ): File { return new File( [Array.isArray(bytes) ? new Uint8Array(bytes) : bytes], name, { type }, ); } function readBackedFile( name: string, size: number, read: () => Promise, type: string, ): File { return { arrayBuffer: read, name, size, type, webkitRelativePath: "", } as unknown as File; } function gltfFile(json: string): File { return binaryFile("scene.gltf", json, "model/gltf+json"); } function createBundle( files: readonly File[], limits?: CreateToolcraftModelSourceBundleOptions["limits"], ) { return createToolcraftModelSourceBundle(files, { limits, registry }); } function parserSpy() { return vi.fn((text: string) => JSON.parse(text) as unknown); } function lookupObserver() { return { onCanonicalResolution: vi.fn(), onIndexedFile: vi.fn(), }; } function inspectGltfFiles( files: readonly File[], json: string, observer: ReturnType, ) { const snapshots = snapshotModelSourceFiles(files); return inspectGltfSourceBuffers( snapshots[0]!, encoder.encode(json), snapshots, TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS, observer, ); } function expectPreflightRejection( json: string, maxEstimatedWorkerBytes: number, code: string, ): void { const parse = parserSpy(); expect(() => preflightAndParseGltfJson( encoder.encode(json), maxEstimatedWorkerBytes, parse, ), ).toThrowError(expect.objectContaining({ code })); expect(parse).not.toHaveBeenCalled(); } export function registerModelSourceBundlePreflightTests(): void { describe("model source bundle JSON and logical-limit preflight", () => { afterEach(() => vi.restoreAllMocks()); it("rejects many tiny objects before JSON.parse exceeds worker memory", () => { const json = `{"asset":{"version":"2.0"},"extras":[${Array.from( { length: 20_000 }, () => "{}", ).join(",")}]}`; const bytes = encoder.encode(json); expectPreflightRejection( json, bytes.byteLength * 3 + 4_096, "estimated-worker-memory-limit-exceeded", ); }); it("rejects many tiny array elements before JSON.parse", () => { const json = `{"asset":{"version":"2.0"},"extras":[${Array.from( { length: 50_000 }, () => "0", ).join(",")}]}`; const bytes = encoder.encode(json); expectPreflightRejection( json, bytes.byteLength * 3 + 4_096, "estimated-worker-memory-limit-exceeded", ); }); it("rejects extreme nesting before JSON.parse", () => { const json = `${"[".repeat(129)}0${"]".repeat(129)}`; expectPreflightRejection( json, 1024 * 1024, "gltf-json-depth-limit-exceeded", ); }); it("rejects a huge irrelevant string before JSON.parse", () => { const json = JSON.stringify({ asset: { version: "2.0" }, extras: "x".repeat(100_000), }); const bytes = encoder.encode(json); expectPreflightRejection( json, bytes.byteLength * 3 + 512, "estimated-worker-memory-limit-exceeded", ); }); it("accepts valid JSON at its exact estimated worker budget", () => { const bytes = encoder.encode( JSON.stringify({ asset: { version: "2.0" }, buffers: [] }), ); const initial = preflightAndParseGltfJson(bytes, 1024 * 1024); const parse = parserSpy(); const exact = preflightAndParseGltfJson( bytes, initial.preflight.estimatedWorkerBytes, parse, ); expect(exact.value).toEqual({ asset: { version: "2.0" }, buffers: [] }); expect(parse).toHaveBeenCalledTimes(1); }); it.each([ ['{"asset":"\\uZZZZ"}', "malformed Unicode escape"], ['{"asset":01}', "malformed number token"], ['{"asset":"unterminated}', "unterminated string"], ['{"asset":truth}', "malformed literal"], ])("rejects %s before JSON.parse (%s)", (json) => { expectPreflightRejection(json, 1024 * 1024, "malformed-gltf-json"); }); it("applies the narrow worker budget through bundle creation", async () => { const json = JSON.stringify({ asset: { version: "2.0" }, extras: Array.from({ length: 5_000 }, () => ({})), }); const file = gltfFile(json); await expect( createBundle([file], { maxEstimatedWorkerBytes: file.size * 3 + 2_048, }), ).rejects.toMatchObject({ category: "resource-limit", code: "estimated-worker-memory-limit-exceeded", }); await expect( createBundle([gltfFile('{"asset":{"version":"2.0"}}')], { maxEstimatedWorkerBytes: TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS.maxEstimatedWorkerBytes + 1, }), ).rejects.toMatchObject({ code: "invalid-model-limit" }); }); it("accepts more than 32 bounded embedded buffers without charging file count", async () => { const buffers = Array.from({ length: 40 }, () => ({ byteLength: 1, uri: "data:application/octet-stream;base64,AQ==", })); const root = gltfFile( JSON.stringify({ asset: { version: "2.0" }, buffers }), ); const result = await createBundle([root], { maxBundleFiles: 1, maxDecodedBytes: root.size + buffers.length, maxSourceBytes: root.size, }); expect(result.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", ]); }); it("accepts more than 32 records sharing one external dependency", async () => { const root = gltfFile( JSON.stringify({ asset: { version: "2.0" }, buffers: Array.from({ length: 40 }, () => ({ byteLength: 1, uri: "mesh.bin", })), }), ); const dependency = binaryFile("mesh.bin", [1]); const dependencyRead = vi.spyOn(dependency, "arrayBuffer"); const result = await createBundle([root, dependency], { maxBundleFiles: 2, }); expect(result.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", "mesh.bin", ]); expect(dependencyRead).toHaveBeenCalledTimes(1); }); it("rejects more unique external dependencies than maxBundleFiles", async () => { const dependencyNames = Array.from( { length: 4 }, (_, index) => `mesh-${index}.bin`, ); const root = gltfFile( JSON.stringify({ asset: { version: "2.0" }, buffers: dependencyNames.map((uri) => ({ byteLength: 1, uri })), }), ); await expect( createBundle( [root, ...dependencyNames.map((name) => binaryFile(name, [1]))], { maxBundleFiles: 3 }, ), ).rejects.toMatchObject({ code: "bundle-file-limit-exceeded" }); }); it("rejects pathological buffer records through worker-memory preflight", async () => { const json = JSON.stringify({ asset: { version: "2.0" }, buffers: Array.from({ length: 5_000 }, () => ({ byteLength: 1, uri: "data:application/octet-stream;base64,AQ==", })), }); const root = gltfFile(json); await expect( createBundle([root], { maxBundleFiles: 1, maxEstimatedWorkerBytes: root.size * 3 + 2_048, }), ).rejects.toMatchObject({ code: "estimated-worker-memory-limit-exceeded", }); }); it("indexes a near-ceiling raw batch once and resolves one repeated URI once", () => { const json = JSON.stringify({ asset: { version: "2.0" }, buffers: Array.from({ length: 512 }, () => ({ byteLength: 1, uri: "mesh.bin", })), }); const ignoredRead = vi.fn(async () => new ArrayBuffer(1)); const ignored = readBackedFile( "texture.png", 1, ignoredRead, "image/png", ); const files = [ gltfFile(json), binaryFile("mesh.bin", [1]), ...Array.from( { length: TOOLCRAFT_MODEL_RAW_BATCH_FILE_CEILING - 2 }, () => ignored, ), ]; const observer = lookupObserver(); const inspection = inspectGltfFiles(files, json, observer); expect(inspection.dependencies.map(({ path }) => path)).toEqual([ "mesh.bin", ]); expect(observer.onIndexedFile).toHaveBeenCalledTimes(files.length); expect(observer.onCanonicalResolution).toHaveBeenCalledTimes(1); expect(ignoredRead).not.toHaveBeenCalled(); }); it("resolves two canonical URIs once across repeated encoded aliases", () => { const json = JSON.stringify({ asset: { version: "2.0" }, buffers: [ { byteLength: 1, uri: "m%C3%A9sh-a.bin" }, { byteLength: 1, uri: "me\u0301sh-a.bin" }, { byteLength: 1, uri: "mesh-b.bin" }, { byteLength: 1, uri: "mesh-b.bin" }, ], }); const files = [ gltfFile(json), binaryFile("m\u00e9sh-a.bin", [1]), binaryFile("mesh-b.bin", [2]), ]; const observer = lookupObserver(); const inspection = inspectGltfFiles(files, json, observer); expect(inspection.dependencies.map(({ path }) => path)).toEqual([ "m\u00e9sh-a.bin", "mesh-b.bin", ]); expect(observer.onIndexedFile).toHaveBeenCalledTimes(3); expect(observer.onCanonicalResolution).toHaveBeenCalledTimes(2); }); it("retains indexed ambiguity for a required dependency", () => { const json = JSON.stringify({ asset: { version: "2.0" }, buffers: [{ byteLength: 1, uri: "mesh.bin" }], }); const files = [ gltfFile(json), binaryFile("mesh.bin", [1]), binaryFile("mesh.bin", [2]), ]; const observer = lookupObserver(); expect(() => inspectGltfFiles(files, json, observer)).toThrowError( expect.objectContaining({ code: "duplicate-source-path" }), ); expect(observer.onIndexedFile).toHaveBeenCalledTimes(3); expect(observer.onCanonicalResolution).toHaveBeenCalledTimes(1); }); it("charges preserved appearance files to package limits before reading them", async () => { const rootJson = JSON.stringify({ asset: { version: "2.0" }, buffers: [{ byteLength: 3, uri: "mesh.bin" }], images: [{ uri: "texture-0.png" }], }); const root = gltfFile(rootJson); const ignoredRead = vi.fn(async () => { throw new Error("ignored appearance must not be read"); }); const ignored = Array.from({ length: 40 }, (_, index) => readBackedFile( `texture-${index}.png`, index === 0 ? TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS.maxSourceBytes * 4 : 1, ignoredRead, "image/png", ), ); await expect(createBundle( [root, binaryFile("mesh.bin", [1, 2, 3]), ...ignored], { maxBundleFiles: 2, maxSourceBytes: root.size + 3, }, )).rejects.toMatchObject({ code: "bundle-file-limit-exceeded" }); expect(ignoredRead).not.toHaveBeenCalled(); }); it("bounds raw batch admission by metadata count without reading files", async () => { const ignoredRead = vi.fn(async () => new ArrayBuffer(1)); const ignored = readBackedFile( "texture.png", 1, ignoredRead, "image/png", ); const files = [ gltfFile('{"asset":{"version":"2.0"}}'), ...Array.from( { length: TOOLCRAFT_MODEL_RAW_BATCH_FILE_CEILING }, () => ignored, ), ]; const slice = vi.spyOn(files, "slice"); await expect(createBundle(files)).rejects.toMatchObject({ code: "raw-batch-file-limit-exceeded", }); expect(slice).not.toHaveBeenCalled(); expect(ignoredRead).not.toHaveBeenCalled(); }); }); }