/** * v0.38.2.0 — partial-scan state tests for scanBrainSources. * * Codex outside-voice C1 caught that AbortSignal.timeout cannot interrupt * the sync walker (event loop blocked by readdirSync / readFileSync). The * load-bearing interruption mechanism is `deadline?: number` checked * inside scanOneSource's visit closure before parsing each file. * * These tests use `deadline: Date.now() - 1` (already-expired) to force * partial state deterministically — NOT AbortSignal, which doesn't fire * in the sync loop and would make this test flake or never trigger. * * They also cover codex C2 (`ok` after abort must be false even on clean * prefix), C4 (`files_scanned` numerator surfaced), and the * `aborted_at_source` field that lets doctor name the partial source. */ import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { scanBrainSources } from '../src/core/brain-writer.ts'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { resetPgliteState } from './helpers/reset-pglite.ts'; let engine: PGLiteEngine; let sourceA: string; let sourceB: string; let sourceC: string; beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); // Three source dirs, each with a few markdown files. sourceA = mkdtempSync(join(tmpdir(), 'partial-scan-a-')); sourceB = mkdtempSync(join(tmpdir(), 'partial-scan-b-')); sourceC = mkdtempSync(join(tmpdir(), 'partial-scan-c-')); for (const dir of [sourceA, sourceB, sourceC]) { mkdirSync(join(dir, 'people'), { recursive: true }); for (let i = 0; i < 5; i++) { writeFileSync( join(dir, 'people', `p${i}.md`), `---\ntitle: Person ${i}\n---\n\nbody\n`, ); } } }); afterAll(async () => { await engine.disconnect(); for (const d of [sourceA, sourceB, sourceC]) { rmSync(d, { recursive: true, force: true }); } }); beforeEach(async () => { await resetPgliteState(engine); // Register all three sources for each test. await engine.executeRaw( `INSERT INTO sources (id, name, local_path) VALUES ('src-a', 'A', $1), ('src-b', 'B', $2), ('src-c', 'C', $3)`, [sourceA, sourceB, sourceC], ); }); describe('scanBrainSources partial-scan state', () => { test('no deadline + no abort: every source scanned, partial=false, ok reflects grandTotal', async () => { const report = await scanBrainSources(engine); expect(report.partial).toBe(false); expect(report.aborted_at_source).toBe(null); expect(report.per_source.length).toBe(3); for (const src of report.per_source) { expect(src.status).toBe('scanned'); expect(src.files_scanned).toBe(5); } expect(report.total).toBe(0); expect(report.ok).toBe(true); }); test('deadline expired before any source starts: all three skipped', async () => { const report = await scanBrainSources(engine, { deadline: Date.now() - 1, // already expired }); expect(report.partial).toBe(true); expect(report.per_source.length).toBe(3); for (const src of report.per_source) { expect(src.status).toBe('skipped'); expect(src.files_scanned).toBe(0); } // ok must be false even though zero errors were found — partial state // means the clean count can't speak for unscanned files (codex C2). expect(report.ok).toBe(false); }); test('after-abort ok field is false even on clean prefix (codex C2 regression guard)', async () => { // Force the abort path: deadline already expired. Even though no // errors found (because no files scanned), `ok` must reflect the // partial-scan reality. const report = await scanBrainSources(engine, { deadline: Date.now() - 1, }); expect(report.total).toBe(0); expect(report.partial).toBe(true); expect(report.ok).toBe(false); }); test('files_scanned numerator populated on completed sources (codex C4 regression guard)', async () => { const report = await scanBrainSources(engine); for (const src of report.per_source) { // Each source has 5 .md files under people/; all syncable. expect(src.files_scanned).toBe(5); } }); test('dbPageCountForSource hook plumbed onto db_page_count; failure degrades to null', async () => { let calls = 0; const report = await scanBrainSources(engine, { dbPageCountForSource: async (sourceId) => { calls++; if (sourceId === 'src-b') throw new Error('synthetic query failure'); return sourceId === 'src-a' ? 42 : 99; }, }); expect(calls).toBe(3); const a = report.per_source.find(r => r.source_id === 'src-a')!; const b = report.per_source.find(r => r.source_id === 'src-b')!; const c = report.per_source.find(r => r.source_id === 'src-c')!; expect(a.db_page_count).toBe(42); // Throw → null, no crash, scan continues. expect(b.db_page_count).toBe(null); expect(c.db_page_count).toBe(99); // files_scanned numerator still populated regardless of denominator outcome. expect(b.files_scanned).toBe(5); }); // Codex adversarial #3 regression: when the outer-loop deadline check fires // BEFORE any source starts, aborted_at_source MUST stamp the first // would-have-been-scanned source so the doctor message can name it. test('aborted_at_source stamped when deadline fires before any source starts', async () => { const report = await scanBrainSources(engine, { deadline: Date.now() - 1, }); expect(report.partial).toBe(true); // First source in deterministic order (sources ORDER BY id) is 'src-a'. expect(report.aborted_at_source).toBe('src-a'); // Every source skipped, no scans started. expect(report.per_source.every(r => r.status === 'skipped')).toBe(true); }); // Codex adversarial #2 regression: a slow dbPageCountForSource that exceeds // the deadline must NOT result in scanOneSource running and reporting // status='partial' with files_scanned=0 (misleading — nothing was scanned). // The post-await deadline re-check should mark the source as 'skipped'. test('slow COUNT that exceeds deadline marks source skipped, not partial', async () => { const start = Date.now(); // CI timing fix (same class as the "hanging COUNT" test below): widened // 50→250ms deadline + 100→500ms simulated query delay. Keeps the 2x // ratio that proves "query exceeds deadline" while giving CI runners // enough headroom to schedule both setTimeout callbacks on-budget. const report = await scanBrainSources(engine, { deadline: start + 250, dbPageCountForSource: async () => { // Simulate a hung query: take 500ms (past the deadline). await new Promise(resolve => setTimeout(resolve, 500)); return 42; }, }); // The first source should be skipped (post-await deadline re-check fires), // NOT marked partial with files_scanned=0. const firstSource = report.per_source.find(r => r.source_id === 'src-a')!; expect(firstSource.status).toBe('skipped'); expect(firstSource.files_scanned).toBe(0); expect(report.partial).toBe(true); expect(report.aborted_at_source).toBe('src-a'); }); // #2946: the deadline race's timeout arm resolves a SENTINEL, not null — // a COUNT that legitimately resolves null (failed/absent count) before the // deadline must NOT be mistaken for a deadline hit: the source still gets // scanned, with db_page_count simply unavailable. test('COUNT resolving null before the deadline is not a deadline verdict — source still scans', async () => { const start = Date.now(); const report = await scanBrainSources(engine, { deadline: start + 5_000, dbPageCountForSource: async () => null, }); const firstSource = report.per_source.find(r => r.source_id === 'src-a')!; expect(firstSource.status).toBe('scanned'); expect(firstSource.db_page_count == null).toBe(true); }); // Codex adversarial #4 regression: even when dbPageCountForSource itself // would hang indefinitely, the Promise.race against the deadline must // resolve null and the scan must abort cleanly. // // Boundary fix (CI flake): brain-writer.ts post-await deadline check uses // `>=` not `>`. The Promise.race setTimeout resolves at exactly // `remainingMs` from now, so post-await Date.now() often equals deadline // within integer-ms precision. Strict `>` missed those landings on CI // runners and let scanOneSource run anyway, marking src-a as 'scanned' // instead of 'skipped'. Test budget is generous enough to absorb runner // timer drift; the real fix is the operator change in brain-writer.ts. test('hanging COUNT does not exceed deadline — Promise.race timeout fires', async () => { const start = Date.now(); const DEADLINE_MS = 500; const BOUND_MS = 2500; const report = await scanBrainSources(engine, { deadline: start + DEADLINE_MS, dbPageCountForSource: () => { // Never resolves — would hang forever without the deadline race. return new Promise(() => {}); }, }); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(BOUND_MS); expect(report.partial).toBe(true); const firstSource = report.per_source.find(r => r.source_id === 'src-a')!; expect(firstSource.status).toBe('skipped'); // Skipped sources never get db_page_count set — they weren't attempted. // (Either null from the race or undefined from never reaching the DB // path; both express "no denominator available" honestly.) expect(firstSource.db_page_count == null).toBe(true); }); });