mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
Port of #1731 (diazMelgarejo) onto current master. gbrain import wrote ~/.gbrain/import-checkpoint.json with the caller's raw dir argument, so a checkpoint left behind by an interrupted run (e.g. SIGTERM) could carry "." or a symlinked spelling — an identity that resolves to whatever CWD the next consumer happens to run from. Downstream tooling that treated the checkpoint dir as an owned staging boundary could then act on the wrong directory. - runImport captures the import target ONCE via resolveImportTargetDir (resolve + realpathSync) and threads that canonical value through collection, checkpoint load/save, and resume filtering - checkpoints are self-describing (schema_version: 1, owner: "gbrain", kind: "import"); loadCheckpoint tolerates absent metadata (legacy path-based files) but rejects present-and-wrong metadata and any relative dir - checkpoint contract documented in docs/guides/live-sync.md (llms bundle regenerated) - test/import-resume.test.ts fixture now realpaths its tmpdir so planted checkpoints match the canonicalized dir (macOS /var -> /private/var) Fixes #1728 Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Lawrence Melgarejo <Lawrence@cyre.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
222 lines
8.7 KiB
TypeScript
222 lines
8.7 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 } 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('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);
|
|
});
|