/** * v0.31.2 chunker timeout regression tests. * * Closes the bug class where a single pathological code file could * wedge the entire sync inside tree-sitter WASM at 99% CPU with no * I/O and no observable progress. The fix bounds parser.parse() with * setTimeoutMicros and falls back to recursive chunks on timeout. * * Test design (per codex C9): the runtime parser is non-deterministic * about how long it takes to parse arbitrary input, so "force timeout * via huge input" is machine-speed dependent and flaky on slow CI. We * stub the ParserLike seam directly to assert the timeout contract, * then verify the env wiring with a 1ms cap that reliably loses. */ import { describe, test, expect } from 'bun:test'; import { parseWithTimeout, ChunkerTimeoutError, chunkCodeTextFull, } from '../src/core/chunkers/code.ts'; import { withEnv } from './helpers/with-env.ts'; const REAL_TS = `export function add(a: number, b: number): number { return a + b; } export class Counter { private n = 0; increment(): void { this.n++; } get value(): number { return this.n; } } `; describe('parseWithTimeout — pure-function seam', () => { test('1. throws ChunkerTimeoutError when parser.parse returns null', () => { const stub = { _timeoutCalls: 0, _parseCalls: 0, setTimeoutMicros(_t: number) { this._timeoutCalls++; }, parse(_s: string) { this._parseCalls++; return null; }, }; expect(() => parseWithTimeout(stub, 'x', 50, 'foo.ts')).toThrow(ChunkerTimeoutError); expect(stub._timeoutCalls).toBe(1); expect(stub._parseCalls).toBe(1); }); test('2. throws clear error if setTimeoutMicros API is missing', () => { // A future web-tree-sitter that drops the API must NOT silently // regress to no-timeout behavior. const stub = { parse(_s: string) { return { rootNode: null, delete: () => {} }; }, } as any; expect(() => parseWithTimeout(stub, 'x', 50, 'foo.ts')) .toThrow(/setTimeoutMicros/); }); test('3. returns the tree on success; calls setTimeoutMicros once', () => { const stub = { _timeout: 0, setTimeoutMicros(t: number) { this._timeout = t; }, parse(_s: string) { return { rootNode: { type: 'program' }, delete: () => {} }; }, }; const tree = parseWithTimeout(stub, 'x', 50, 'foo.ts') as { rootNode: { type: string } }; expect(tree.rootNode.type).toBe('program'); expect(stub._timeout).toBe(50_000); // microseconds = ms × 1000 }); test('4. ChunkerTimeoutError carries filePath + timeoutMs for actionable logs', () => { const stub = { setTimeoutMicros() {}, parse() { return null; } }; try { parseWithTimeout(stub, 'x', 30_000, 'src/big.ts'); throw new Error('should have thrown'); } catch (e: unknown) { expect(e).toBeInstanceOf(ChunkerTimeoutError); expect((e as ChunkerTimeoutError).filePath).toBe('src/big.ts'); expect((e as ChunkerTimeoutError).timeoutMs).toBe(30_000); } }); }); describe('chunkCodeTextFull — integration with real parser', () => { test('5. default-timeout path on real code completes well under cap', async () => { const result = await chunkCodeTextFull(REAL_TS, 'sample.ts'); expect(result.chunks.length).toBeGreaterThan(0); }); test('6. GBRAIN_CHUNKER_TIMEOUT_MS=1 reliably forces fallback', async () => { // 1ms is below any plausible real-parser wall-clock. The parser // returns null; chunkCodeTextFull catches the ChunkerTimeoutError // and falls back to fallbackChunks (recursive text chunker). await withEnv({ GBRAIN_CHUNKER_TIMEOUT_MS: '1' }, async () => { const result = await chunkCodeTextFull(REAL_TS, 'sample.ts'); // Recursive fallback still produces chunks; assertion is that the // call returned cleanly instead of hanging. expect(Array.isArray(result.chunks)).toBe(true); // v0.34 W2: extractAllEdges can emit imports/references from a partial // parse (top-level statements survive grammar timeout). The original // assertion was edges=[] under the calls-only extractor; with W2's // imports/references emit, top-level imports show up even on the // partial-parse fallback path. The contract that matters is `result` // returned cleanly without hanging — edges array shape (empty or not) // is engine-side noise. expect(Array.isArray(result.edges)).toBe(true); }); }); }); describe('cleanup contract under exception', () => { test('7. parser.delete() still fires when timeout throws (codex C4)', async () => { // chunkCodeTextFull's finally must reap parser+tree even when // parseWithTimeout throws ChunkerTimeoutError. We can't directly // observe parser.delete() in a real WASM run, but we can test the // shape: forcing the env-timeout path through chunkCodeTextFull a // few times must not leak (smoke test — Bun GC won't catch a real // WASM leak but if cleanup were missing on the throw path, repeated // calls would visibly degrade). await withEnv({ GBRAIN_CHUNKER_TIMEOUT_MS: '1' }, async () => { for (let i = 0; i < 5; i++) { const result = await chunkCodeTextFull(REAL_TS, `sample-${i}.ts`); expect(Array.isArray(result.chunks)).toBe(true); } }); }); });