Files
gbrain/test/e2e/migration-flow.test.ts
T
Aurora CapitalandGitHub 2c96787867 test(e2e): harden suite — kill flakes, no-op assertions, cross-test coupling (#1704)
* test(e2e): drop flaky wall-clock bounds in minions-resilience

The runaway-dead-letter and cascade-kill tests asserted tight real-clock
upper bounds (<2000ms, <3000ms) on top of already-complete terminal-state
checks. Those bounds carry no correctness signal — the dead/cancelled status
and abortedChildren==10 assertions fully prove behavior — and flake on loaded
CI runners where the stall/timeout sweep cadence varies. Removed both bounds,
kept the diagnostic values, de-promised the test titles.

* test(e2e): kill order-dependence + no-op assertions in mechanical

- traverse_graph: self-contained (re-adds its own idempotent link) and asserts
  the linked company is reachable, instead of depending on a prior it() and
  only checking array shape.
- file_list-without-slug: seeds its own >100 rows instead of relying on the
  previous test's 150 surviving in the DB; asserts the cap is exercised.
- precision@5: add a loose floor (every known-item query surfaces >=1 truth doc
  in top-5) so a 0% retrieval regression no longer passes silently.
- get_health: assert value bounds (page_count==16, embed_coverage 0..1) not just
  typeof; get_chunks: assert non-empty text, numeric non-decreasing chunk_index,
  and that the page name appears, instead of toBeTruthy on chunks[0].

* test(e2e): strengthen graph/search quality assertions + close coverage gaps

graph-quality: truncate+reseed 'config' in truncateAll (kills config leak where a
setConfig test throwing before its finally bleeds into later tests); replace
toBeGreaterThan(0) link/timeline floors with fixture-derived minimums; assert exact
attendee slugs are 'attended' instead of a vacuous .every; pin autoLinks.created to
the provable 2 (Alice+Acme); add direction out/both + depth:2 multi-hop and a
cycle-safety (A->B->A terminates) test.
search-quality: fix the vacuous detail=low vector test; assert pedro returns >=2
chunks; assert detail=high includes the timeline chunk; add empty-query and
zero-vector no-throw edge tests.

* test(e2e): self-contain multi-source sync test + assert ledger cascade

Break the sequential dependency where 'performSync no sourceId' relied on a prior
test writing sync.repo_path — it now sets its own config. Add the missing
file_migration_ledger COUNT(*)==0 cascade assertion. Tighten the source_id default
check from toContain('default') to exact "'default'::text".

* test(e2e): make migration-flow HOME/PATH swap throw-safe

The suite repoints process.env.HOME/PATH to a temp dir and only restored them in
afterAll, so a mid-test throw left HOME dead for the rest of the bun process and
silently broke sibling suites. Wrap each test body in try/finally restore + a
defensive restore at the top of beforeEach.

* test(e2e): loud-skip jsonb-roundtrip + doctor-progress

Both skipped silently with no DATABASE_URL, giving zero signal the regression guard
never ran. Add the console.log skip line matching the sibling e2e files.

* test(e2e): robust check-update contract + find_orphans tool coverage

upgrade: the 'no-releases' test hard-asserted update_available===false, which flips
to failing the moment the repo has a real release. Assert the JSON contract shape
(boolean update_available, current_version===VERSION, typed optional fields) instead.
mcp: add find_orphans to the asserted generated tool names.
2026-07-22 18:46:44 -07:00

296 lines
12 KiB
TypeScript

/**
* E2E: v0.11.0 migration-flow against real Postgres + temp $HOME.
*
* Exercises the full orchestrator from Phase A (schema apply via
* `gbrain init --migrate-only`) through Phase G (completed.jsonl append),
* skipping Phase F (autopilot install) to avoid writing a launchd plist
* or crontab entry on the CI host. Worker supervision + autopilot-cycle
* handler are covered by the unit-layer tests (test/handlers.test.ts and
* test/autopilot-resolve-cli.test.ts); this E2E locks the schema → prefs
* → host-rewrite → completed.jsonl chain against a live database.
*
* Gated by DATABASE_URL — skips gracefully when unset per CLAUDE.md
* lifecycle.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
existsSync,
mkdirSync,
statSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { v0_11_0 } from '../../src/commands/migrations/v0_11_0.ts';
import { loadPreferences, loadCompletedMigrations } from '../../src/core/preferences.ts';
import { hasDatabase } from './helpers.ts';
const SKIP = !hasDatabase();
const describeE2E = SKIP ? describe.skip : describe;
const DATABASE_URL = process.env.DATABASE_URL ?? '';
let tmp: string;
let origHome: string | undefined;
let origGbrainHome: string | undefined;
let origPath: string | undefined;
let fakeBinDir: string;
const CLI_PATH = join(import.meta.dir, '..', '..', 'src', 'cli.ts');
// Module-level PATH shim. The orchestrator shells out to `gbrain init
// --migrate-only` and `gbrain jobs smoke`. On source-install test envs
// there's no `gbrain` on $PATH. Install a tiny bash shim that `exec`s
// `bun run src/cli.ts` and prepend it to $PATH before any tests import
// the orchestrator. Running at module-init (not beforeAll) guarantees
// the shim exists before Bun's test runner loads described blocks.
if (!SKIP) {
origPath = process.env.PATH;
fakeBinDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-bin-'));
const shim = join(fakeBinDir, 'gbrain');
writeFileSync(
shim,
`#!/usr/bin/env bash\nexec bun run "${CLI_PATH}" "$@"\n`,
{ mode: 0o755 },
);
process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
console.log('[migration-flow.e2e] shim installed at', shim, 'PATH prepended');
}
function freshTempHome(label: string) {
const dir = mkdtempSync(join(tmpdir(), `gbrain-e2e-migration-${label}-`));
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
// is unset. Test fixtures write to `$dir/.gbrain/...`, so set HOME only
// and clear any inherited GBRAIN_HOME (which would route prefs to $dir
// directly, no .gbrain suffix).
process.env.HOME = dir;
delete process.env.GBRAIN_HOME;
// Seed config so Phase A's `gbrain init --migrate-only` has a target.
mkdirSync(join(dir, '.gbrain'), { recursive: true });
writeFileSync(
join(dir, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'postgres', database_url: DATABASE_URL }, null, 2) + '\n',
{ mode: 0o600 },
);
return dir;
}
// Restore HOME/PATH to the captured originals. Called from each test's
// `finally` so a throw mid-test can never leave HOME/PATH pointed at a temp
// dir for the rest of the bun process (which would silently break unrelated
// suites that read HOME). PATH keeps the shim prepended because the
// module-level shim install is what subsequent tests in this suite rely on;
// afterAll does the final teardown to the pristine origPath.
function restoreHomePath() {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
}
beforeAll(() => {
if (SKIP) {
console.log('[migration-flow.e2e] DATABASE_URL not set — skipping.');
return;
}
origHome = process.env.HOME;
origGbrainHome = process.env.GBRAIN_HOME;
});
afterAll(() => {
if (SKIP) return;
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origGbrainHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origGbrainHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = origPath;
try { if (fakeBinDir) rmSync(fakeBinDir, { recursive: true, force: true }); } catch { /* best-effort */ }
});
beforeEach(() => {
if (SKIP) return;
// Robust restore: if a prior test threw before its own finally ran (or
// before afterAll), HOME/PATH could still point at a dead temp dir. Reset
// them to the captured originals at the start of every test so a throw in
// one test can never leak a temp HOME/PATH into sibling suites that read
// them. freshTempHome() re-points HOME per test immediately after this.
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
try { if (tmp) rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
});
const COMMON_OPTS = {
yes: true,
mode: 'pain_triggered' as const,
dryRun: false,
hostDir: undefined,
noAutopilotInstall: true, // critical: don't install launchd/systemd/crontab on CI
};
describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
test('fresh install flow: schema → smoke → prefs → host-rewrite → completed', async () => {
tmp = freshTempHome('fresh');
try {
const result = await v0_11_0.orchestrator(COMMON_OPTS);
// Orchestrator returns a structured result (status is `complete` when
// no pending-host-work TODOs fired, `partial` otherwise).
expect(result.version).toBe('0.11.0');
expect(['complete', 'partial']).toContain(result.status);
// Phase D: preferences.json exists with 0o600 + mode=pain_triggered.
const prefsPath = join(tmp, '.gbrain', 'preferences.json');
expect(existsSync(prefsPath)).toBe(true);
expect(statSync(prefsPath).mode & 0o777).toBe(0o600);
const prefs = loadPreferences();
expect(prefs.minion_mode).toBe('pain_triggered');
expect(prefs.set_at).toBeTruthy();
expect(prefs.set_in_version).toBeTruthy();
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
// The runner (apply-migrations.ts) persists the result after the
// orchestrator returns. A direct orchestrator call in E2E leaves the
// ledger empty; the runner path is tested separately in
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
const completed = loadCompletedMigrations();
const v0110Entries = completed.filter(e => e.version === '0.11.0');
expect(v0110Entries.length).toBe(0);
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
// installed on this host.
expect(result.autopilot_installed).toBe(false);
} finally {
restoreHomePath();
}
}, 60_000);
test('idempotent rerun: second invocation is a safe no-op', async () => {
tmp = freshTempHome('rerun');
try {
const first = await v0_11_0.orchestrator(COMMON_OPTS);
expect(['complete', 'partial']).toContain(first.status);
const second = await v0_11_0.orchestrator(COMMON_OPTS);
expect(['complete', 'partial']).toContain(second.status);
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
// repeated direct invocations don't accumulate ledger entries. Assert
// the preferences state stays stable (the real idempotency signal for
// this orchestrator is "running again doesn't corrupt preferences").
expect(loadPreferences().minion_mode).toBe('pain_triggered');
const completed = loadCompletedMigrations();
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
} finally {
restoreHomePath();
}
}, 90_000);
test('host rewrite: builtin handlers auto-rewritten, non-builtins queued as JSONL TODOs', async () => {
tmp = freshTempHome('host-rewrite');
try {
// Fixture: AGENTS.md + cron/jobs.json with a mix of gbrain-builtin and
// non-builtin handlers.
const claudeDir = join(tmp, '.claude');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(
join(claudeDir, 'AGENTS.md'),
'# Test AGENTS.md\n\nSome existing content referencing sessions_spawn routing.\n',
);
mkdirSync(join(claudeDir, 'cron'), { recursive: true });
writeFileSync(
join(claudeDir, 'cron', 'jobs.json'),
JSON.stringify({
jobs: [
{ schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' }, // builtin
{ schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' }, // non-builtin
{ schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'embed' }, // builtin
{ schedule: '0 8 * * *', kind: 'agentTurn', skill: 'morning-briefing' }, // non-builtin
],
}, null, 2) + '\n',
);
const result = await v0_11_0.orchestrator(COMMON_OPTS);
// Builtins rewritten in place; non-builtins left alone.
const cronAfter = JSON.parse(readFileSync(join(claudeDir, 'cron', 'jobs.json'), 'utf-8'));
expect(cronAfter.jobs[0].kind).toBe('shell'); // sync (builtin)
expect(cronAfter.jobs[0].cmd).toContain('gbrain jobs submit sync');
expect(cronAfter.jobs[1].kind).toBe('agentTurn'); // ea-inbox-sweep (non-builtin)
expect(cronAfter.jobs[2].kind).toBe('shell'); // embed (builtin)
expect(cronAfter.jobs[3].kind).toBe('agentTurn'); // morning-briefing (non-builtin)
// files_rewritten counts the 2 builtin rewrites.
expect(result.files_rewritten).toBeGreaterThanOrEqual(2);
// pending_host_work counts the 2 non-builtin TODOs.
expect(result.pending_host_work).toBe(2);
// Status is "partial" because non-builtin TODOs remain.
expect(result.status).toBe('partial');
// AGENTS.md got the marker injected.
const agentsMdAfter = readFileSync(join(claudeDir, 'AGENTS.md'), 'utf-8');
expect(agentsMdAfter).toContain('gbrain:subagent-routing v0.11.0');
expect(agentsMdAfter).toContain('skills/conventions/subagent-routing.md');
// JSONL TODO file written under ~/.gbrain/migrations/.
const jsonlPath = join(tmp, '.gbrain', 'migrations', 'pending-host-work.jsonl');
expect(existsSync(jsonlPath)).toBe(true);
const lines = readFileSync(jsonlPath, 'utf-8').split('\n').filter(l => l.trim());
expect(lines.length).toBe(2);
const todos = lines.map(l => JSON.parse(l));
const handlers = todos.map(t => t.handler).sort();
expect(handlers).toEqual(['ea-inbox-sweep', 'morning-briefing']);
for (const todo of todos) {
expect(todo.type).toBe('cron-handler-needs-host-registration');
expect(todo.status).toBe('pending');
expect(todo.manifest_path).toContain('cron/jobs.json');
}
} finally {
restoreHomePath();
}
}, 90_000);
test('resumable: partial run → orchestrator re-run → complete', async () => {
tmp = freshTempHome('resumable');
try {
// Simulate a stopgap-written partial entry BEFORE running the orchestrator.
mkdirSync(join(tmp, '.gbrain', 'migrations'), { recursive: true });
writeFileSync(
join(tmp, '.gbrain', 'migrations', 'completed.jsonl'),
JSON.stringify({
version: '0.11.0',
status: 'partial',
apply_migrations_pending: true,
mode: 'pain_triggered',
source: 'fix-v0.11.0.sh',
ts: new Date().toISOString(),
}) + '\n',
);
// Orchestrator re-running on a partial → should succeed (schema apply
// and smoke are idempotent; prefs are preserved from the partial
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
// the orchestrator itself doesn't append to completed.jsonl — the
// runner does. The stopgap's partial entry stays unchanged here.
const result = await v0_11_0.orchestrator(COMMON_OPTS);
expect(['complete', 'partial']).toContain(result.status);
const completed = loadCompletedMigrations();
const v0110 = completed.filter(e => e.version === '0.11.0');
// Just the stopgap partial — orchestrator doesn't add its own entry.
expect(v0110.length).toBe(1);
expect(v0110[0].status).toBe('partial');
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
} finally {
restoreHomePath();
}
}, 90_000);
});