Files
gbrain/test/import-resume.test.ts

275 lines
12 KiB
TypeScript

/**
* Integration tests for `runImport`'s checkpoint behavior.
*
* Predicate-level tests for `loadCheckpoint`/`saveCheckpoint`/`resumeFilter`
* live in `test/import-checkpoint.test.ts`. This file drives the full
* `runImport` against PGLite to verify the end-to-end resume contract:
*
* - Old positional checkpoints from pre-v0.33.2 brains are discarded
* cleanly + the migration stderr log fires.
* - v0.33.2 path-based checkpoints honor the completedPaths set on resume.
* - Failed files do NOT enter `completedPaths`; the next run retries them
* (the pre-existing P1 codex caught).
* - Clean completion clears the checkpoint.
*
* Test isolation:
* - `GBRAIN_HOME` env override via `withEnv` so we NEVER touch the real
* `~/.gbrain/import-checkpoint.json`. Pre-v0.33.2 this file did exactly
* that — see codex finding P2 in the plan.
* - PGLite via the canonical block (`beforeAll` + `resetPgliteState` +
* `afterAll`) per CLAUDE.md test-isolation rules R3 + R4.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, realpathSync, chmodSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { runImport } from '../src/commands/import.ts';
let engine: PGLiteEngine;
let workspace: string; // GBRAIN_HOME target — `${workspace}/.gbrain/` holds the checkpoint file
let gbrainHomeDir: string; // Resolves to `${workspace}/.gbrain` — the actual checkpoint dir
let cpPath: string; // The checkpoint file path inside gbrainHomeDir
let brainDir: string; // The brain content dir — fixture markdown lives here
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
await engine.disconnect();
}, 60_000);
beforeEach(async () => {
await resetPgliteState(engine);
workspace = mkdtempSync(join(tmpdir(), 'gbrain-import-resume-home-'));
// GBRAIN_HOME is the parent dir; configDir() appends '.gbrain' itself.
// The checkpoint lives at `${workspace}/.gbrain/import-checkpoint.json`.
gbrainHomeDir = join(workspace, '.gbrain');
mkdirSync(gbrainHomeDir, { recursive: true });
cpPath = join(gbrainHomeDir, 'import-checkpoint.json');
// #1728: realpath so planted checkpoints match runImport's canonicalized
// dir (macOS tmpdir is a /var → /private/var symlink).
brainDir = realpathSync(mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-')));
});
afterEach(() => {
if (workspace) rmSync(workspace, { recursive: true, force: true });
if (brainDir) rmSync(brainDir, { recursive: true, force: true });
});
function writeBrainFile(rel: string, body: string) {
const full = join(brainDir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, body);
}
function validMarkdown(slug: string, title = slug) {
return [
'---',
`slug: ${slug}`,
`title: ${title}`,
'---',
'',
`Body for ${slug}.`,
].join('\n');
}
describe('runImport checkpoint resume — v0.33.2 path-based', () => {
test('old positional checkpoint gets discarded with stderr log', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
// Plant a pre-v0.33.2 positional checkpoint.
writeFileSync(cpPath, JSON.stringify({
dir: brainDir,
totalFiles: 10,
processedIndex: 5,
completedFiles: 5,
timestamp: '2026-01-01T00:00:00Z',
}));
// One fixture file so runImport has work to do.
writeBrainFile('concepts/foo.md', validMarkdown('concepts/foo'));
// Capture console.error to verify the migration log fires.
let captured = '';
const origErr = console.error.bind(console);
console.error = (...args: unknown[]) => {
captured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
};
try {
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.imported + result.skipped).toBeGreaterThan(0);
} finally {
console.error = origErr;
}
expect(captured).toContain('Older checkpoint format detected');
});
}, 30_000);
test('v0.33.2 checkpoint with completedPaths skips already-done files', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('a.md', validMarkdown('a'));
writeBrainFile('b.md', validMarkdown('b'));
writeBrainFile('c.md', validMarkdown('c'));
// Plant a v0.33.2 checkpoint that says a.md and b.md are done.
writeFileSync(cpPath, JSON.stringify({
dir: brainDir,
completedPaths: ['a.md', 'b.md'],
timestamp: '2026-05-14T00:00:00Z',
}));
const result = await runImport(engine, [brainDir, '--no-embed']);
// Only c.md should have been imported this run. The other two are
// already in `completed` and got filtered out before processFile.
expect(result.imported).toBe(1);
});
}, 30_000);
test('clean completion clears the checkpoint file', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('only.md', validMarkdown('only'));
// No prior checkpoint.
expect(existsSync(cpPath)).toBe(false);
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.errors).toBe(0);
expect(result.imported).toBe(1);
// After clean completion the checkpoint is cleaned up so the next
// run doesn't think it needs to resume.
expect(existsSync(cpPath)).toBe(false);
});
}, 30_000);
test('interrupted run preserves its tail below the 100-file boundary', async () => {
// The periodic checkpoint save fires on `completed.size % 100 === 0`. With
// fewer than 100 successful files there is no boundary to hit, so before
// the final save every completed file in a run that ends with errors was
// discarded and re-done on the next invocation. On a corpus whose files
// are individually expensive, `completed` can advance ~1 per several
// minutes, putting the next boundary hours away — the run then never
// converges under repeated kills.
await withEnv({ GBRAIN_HOME: workspace }, async () => {
// Three small good files (well under the 100-boundary) plus one that
// exceeds the content-sanity block threshold. That throws, so `errors`
// is non-zero and the checkpoint is PRESERVED rather than cleared —
// note a SLUG_MISMATCH would NOT work here: it is a soft `failures`
// entry that leaves `errors` at 0, so upstream clears the checkpoint.
writeBrainFile('people/alice.md', validMarkdown('people/alice'));
writeBrainFile('people/carol.md', validMarkdown('people/carol'));
writeBrainFile('people/dave.md', validMarkdown('people/dave'));
// A file the reader cannot open raises inside importFile, which is the
// path that increments `errors` (a SLUG_MISMATCH would NOT work: it is
// a soft `failures` entry leaving `errors` at 0, so upstream clears the
// checkpoint rather than preserving it).
writeBrainFile('people/unreadable.md', validMarkdown('people/unreadable'));
chmodSync(join(brainDir, 'people/unreadable.md'), 0o000);
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.errors).toBeGreaterThan(0);
// The checkpoint exists AND carries the successful files, even though
// no 100-boundary was ever crossed.
expect(existsSync(cpPath)).toBe(true);
const cp = JSON.parse(readFileSync(cpPath, 'utf8'));
expect(cp.completedPaths).toContain('people/alice.md');
expect(cp.completedPaths).toContain('people/carol.md');
expect(cp.completedPaths).toContain('people/dave.md');
// The failed file must still be absent so the next run retries it.
expect(cp.completedPaths).not.toContain('people/unreadable.md');
});
}, 30_000);
test('clean completion still leaves no checkpoint (final save must not resurrect it)', async () => {
// Guards the ordering of the final save: it runs BEFORE the
// clear/preserve decision and only on the error path, so a fully clean
// run must still end with no checkpoint file.
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('x.md', validMarkdown('x'));
writeBrainFile('y.md', validMarkdown('y'));
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.errors).toBe(0);
expect(existsSync(cpPath)).toBe(false);
});
}, 30_000);
test('failed file does NOT enter completedPaths — next run retries it', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
// Two healthy files plus one with a path-vs-frontmatter slug mismatch.
// import-file.ts rejects path-derived 'people/bob' vs declared slug
// 'wrong-slug' with a SLUG_MISMATCH failure (test/e2e/sync.test.ts uses
// the same fixture shape).
writeBrainFile('people/alice.md', validMarkdown('people/alice'));
writeBrainFile('people/carol.md', validMarkdown('people/carol'));
writeBrainFile('people/bob.md', [
'---', 'type: person', 'title: Bob', 'slug: wrong-slug', '---', '', 'Body.',
].join('\n'));
// First run: bob fails with SLUG_MISMATCH, others succeed.
const result1 = await runImport(engine, [brainDir, '--no-embed']);
// `failures` includes both thrown-exception (errors++) and
// returned-skipped-with-error paths. SLUG_MISMATCH hits the latter.
expect(result1.failures.length).toBeGreaterThan(0);
expect(result1.failures.some(f => f.path.includes('bob'))).toBe(true);
// Fix the broken file.
writeBrainFile('people/bob.md', validMarkdown('people/bob'));
// Second run: every file should now succeed. Critically, bob.md must
// process — not silently skipped because of a stale checkpoint
// pointer (the pre-v0.33.2 bug class).
const result2 = await runImport(engine, [brainDir, '--no-embed']);
expect(result2.failures.length).toBe(0);
// bob now exists in the DB.
const pages = await engine.executeRaw<{ slug: string }>(
`SELECT slug FROM pages WHERE slug = 'people/bob'`,
);
expect(pages.length).toBe(1);
// Suppress unused warning — cpPath is referenced for clarity above.
void cpPath;
});
}, 60_000);
test('checkpoint with mismatched dir is discarded silently (no migration log)', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('one.md', validMarkdown('one'));
// v0.33.2-shaped checkpoint pointing at a different brain dir.
writeFileSync(cpPath, JSON.stringify({
dir: '/some/other/brain',
completedPaths: ['one.md'],
timestamp: '2026-05-14T00:00:00Z',
}));
let captured = '';
const origErr = console.error.bind(console);
console.error = (...args: unknown[]) => {
captured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
};
try {
const result = await runImport(engine, [brainDir, '--no-embed']);
// Dir mismatch → discard → re-walk → import the file fresh.
expect(result.imported).toBe(1);
} finally {
console.error = origErr;
}
// The "older checkpoint format" log is for the POSITIONAL legacy
// shape, not v0.33.2-dir-mismatch. Silent discard is intentional.
expect(captured).not.toContain('Older checkpoint format');
});
}, 30_000);
});