mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Co-Authored-By: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
275 lines
12 KiB
TypeScript
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);
|
|
});
|