/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; // NOTE: We deliberately avoid importing from `./acquireFileBuffer` directly, // because that module pulls in `./ifcConfig`, which references // `import.meta.env.*` (Vite-only). Instead, we shadow the threshold dependency // by re-implementing the public surface against a thin re-export. The // production `acquireFileBuffer()` simply calls `__acquireFileBufferWithThreshold` // with `STREAM_SAB_THRESHOLD`; tests bypass `STREAM_SAB_THRESHOLD` with a // kilobyte-sized injected threshold so the streaming branch is exercised // without multi-hundred-MB allocations. // // Importing the inner function directly would still trigger the ifcConfig // side-effect, so we use Node's loader hook indirection: import via a tiny // module-relative path that is re-exported from acquireFileBuffer.ts itself. import { __acquireFileBufferWithThreshold } from './acquireFileBuffer'; function bytes(n: number, fill: (i: number) => number = (i) => i & 0xff): Uint8Array { // Allocate via ArrayBuffer explicitly so the resulting Uint8Array satisfies // `Uint8Array`. Default `new Uint8Array(n)` infers // `ArrayBufferLike`, which TS 5.7+'s tightened DOM lib rejects as a BlobPart. const ab = new ArrayBuffer(n); const u = new Uint8Array(ab); for (let i = 0; i < n; i++) u[i] = fill(i); return u; } function viewsEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) return false; for (let i = 0; i < a.byteLength; i++) { if (a[i] !== b[i]) return false; } return true; } const TEST_THRESHOLD = 4 * 1024; // 4 KB — small enough to exercise streaming cheaply. describe('acquireFileBuffer', () => { it('returns ArrayBuffer for small files (below the streaming threshold)', async () => { const data = bytes(1024); const file = new File([data], 'small.bin'); const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD); assert.equal(acquired.isShared, false); assert.equal(acquired.buffer.byteLength, data.byteLength); assert.equal(acquired.view.byteLength, data.byteLength); assert.ok(viewsEqual(acquired.view, data), 'bytes round-trip'); assert.ok(acquired.buffer instanceof ArrayBuffer, 'small files keep ArrayBuffer path'); }); it('returns empty buffer for zero-size file', async () => { const file = new File([], 'empty.bin'); const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD); assert.equal(acquired.buffer.byteLength, 0); assert.equal(acquired.view.byteLength, 0); assert.equal(acquired.isShared, false); }); it('streams large files (≥ threshold) into SharedArrayBuffer with byte-identical contents', async () => { // Compose a Blob whose total size sits above the test threshold using a // handful of small chunks so the read loop iterates more than once. The // pattern is `(offset & 0xff)` so we can verify byte-identity without // keeping a parallel copy. const target = TEST_THRESHOLD + 4096; const chunkSize = 1024; const chunks: Uint8Array[] = []; let written = 0; while (written < target) { const remaining = target - written; const size = Math.min(chunkSize, remaining); const chunk = new Uint8Array(new ArrayBuffer(size)); for (let i = 0; i < size; i++) chunk[i] = (written + i) & 0xff; chunks.push(chunk); written += size; } const file = new File(chunks, 'large.bin'); assert.equal(file.size, target); const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD); assert.equal(acquired.buffer.byteLength, target); assert.equal(acquired.view.byteLength, target); // Spot-check at start, chunk boundaries, middle, and end. Full scan adds // no coverage if any byte is correct (the streaming copy either works // for all bytes or fails immediately on misalignment). for (const offset of [0, 1, chunkSize - 1, chunkSize, chunkSize + 1, Math.floor(target / 2), target - 2, target - 1]) { assert.equal(acquired.view[offset], offset & 0xff, `byte at offset ${offset}`); } // SAB iff the runtime supports it. Node 22 gives us SAB and an undefined // `crossOriginIsolated`, so we expect the streaming path to engage. if (typeof SharedArrayBuffer !== 'undefined') { assert.equal(acquired.isShared, true); assert.ok( acquired.buffer instanceof SharedArrayBuffer, 'large files use SharedArrayBuffer when supported', ); } }); it('rejects when the underlying stream errors', async () => { const fakeFile = { name: 'broken.bin', size: TEST_THRESHOLD + 1, stream(): ReadableStream { return new ReadableStream({ start(controller) { controller.error(new Error('synthetic stream failure')); }, }); }, arrayBuffer(): Promise { return Promise.reject(new Error('arrayBuffer not used in this test')); }, } as unknown as File; await assert.rejects( __acquireFileBufferWithThreshold(fakeFile, TEST_THRESHOLD), /synthetic stream failure/, ); }); it('falls back to arrayBuffer() when SharedArrayBuffer is unavailable', async () => { const originalSAB = (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer; try { (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = undefined; const data = bytes(1024); const file = new File([data], 'no-sab.bin'); // Force the size check to think this is a large file while keeping the // actual buffer small — verifies the fallback branch fires before any // SAB allocation is attempted. Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 }); const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD); assert.equal(acquired.isShared, false); assert.ok(acquired.buffer instanceof ArrayBuffer, 'fallback returns ArrayBuffer'); } finally { (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = originalSAB; } }); it('IFCX federation call sites do NOT use SAB streaming (memory regression guard for #647)', () => { // IFCX is JSON. The federation parser path is: // parseFederatedIfcx → safeUtf8Decode(new Uint8Array(buffer)) → JSON.parse // safeUtf8Decode must copy SAB-backed views into a scratch buffer in // Chromium/Firefox (cross-thread JS string decoding cannot read SAB // directly) and retains that scratch. Net peak with SAB streaming: // SAB (file.size) + scratch copy (file.size) + JSON string (~file.size) // + retained scratch — strictly worse than the plain ArrayBuffer path. // // This is a source-level guard: it ensures the two IFCX entry points in // useIfcFederation.ts (loadFederatedIfcx + addIfcxOverlays) stay on // file.arrayBuffer() and don't accidentally regress back to // acquireFileBuffer(). The IFC/STEP path (addModel) keeps SAB streaming. const here = dirname(fileURLToPath(import.meta.url)); const sourcePath = join(here, '..', 'hooks', 'useIfcFederation.ts'); const source = readFileSync(sourcePath, 'utf8'); // Find the loadFederatedIfcx and addIfcxOverlays function bodies and // assert each one reads files via file.arrayBuffer(), not acquireFileBuffer. const ifcxFnNames = ['loadFederatedIfcx', 'addIfcxOverlays']; for (const fnName of ifcxFnNames) { // Match the const declaration through the closing `}, [` of useCallback. const startMarker = `const ${fnName} = useCallback`; const startIdx = source.indexOf(startMarker); assert.ok(startIdx >= 0, `expected ${fnName} declaration in useIfcFederation.ts`); // End at the next useCallback dependency-array opener that closes this fn. // We look for the first `}, [` after `startIdx`. const endIdx = source.indexOf('}, [', startIdx); assert.ok(endIdx > startIdx, `expected end of ${fnName} useCallback`); const body = source.slice(startIdx, endIdx); assert.ok( body.includes('file.arrayBuffer()'), `${fnName} must read files via file.arrayBuffer() (IFCX JSON path)`, ); assert.ok( !body.includes('acquireFileBuffer'), `${fnName} must NOT use acquireFileBuffer() — SAB streaming worsens peak memory for IFCX/JSON (see PR #647 regression).`, ); } // Sanity check: the IFC/STEP path still SAB-streams. addModel now delegates // to the canonical loadFile (one load path), so the acquireFileBuffer SAB // streaming lives there — assert addModel routes through loadFile, and that // loadFile keeps acquireFileBuffer for the STEP/IFC binary path. (IFCX is // still guarded above: its federation entry points stay on file.arrayBuffer.) const addModelStart = source.indexOf('const addModel = useCallback'); assert.ok(addModelStart >= 0, 'expected addModel declaration'); const addModelEnd = source.indexOf('}, [', addModelStart); const addModelBody = source.slice(addModelStart, addModelEnd); assert.ok( addModelBody.includes('loadFile('), 'addModel must delegate to the canonical loadFile (one load path)', ); const loaderSource = readFileSync(join(here, '..', 'hooks', 'useIfcLoader.ts'), 'utf8'); assert.ok( loaderSource.includes('acquireFileBuffer'), 'loadFile (IFC/STEP path) must keep using acquireFileBuffer() for SAB streaming', ); }); it('falls back to arrayBuffer() when crossOriginIsolated is explicitly false', async () => { const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crossOriginIsolated'); try { Object.defineProperty(globalThis, 'crossOriginIsolated', { configurable: true, get: () => false, }); const data = bytes(64); const file = new File([data], 'no-coi.bin'); Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 }); const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD); assert.equal(acquired.isShared, false); assert.ok(acquired.buffer instanceof ArrayBuffer); } finally { if (originalDescriptor) { Object.defineProperty(globalThis, 'crossOriginIsolated', originalDescriptor); } else { delete (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated; } } }); });