mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* 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.
296 lines
12 KiB
TypeScript
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);
|
|
});
|