Files
gbrain/test/e2e/cycle.test.ts
T
Garry TanandClaude Opus 4.7 27762abb7a Merge origin/master into garrytan/gbrain-evals
Master landed significant work since this branch was cut (v0.15.x → v0.16.x →
v0.17.0 gbrain dream + runCycle → v0.18.0 multi-source brains → v0.18.1 RLS
hardening). Bumped this branch's version from the claimed 0.18.0 to 0.19.0
because master already owns 0.18.x.

Conflicts resolved:
- VERSION: 0.19.0 (was 0.18.0 on HEAD vs 0.18.1 on master)
- package.json: 0.19.0, kept all 11 eval-facing exports, merged master's
  typescript devDep + postinstall script + test script (typecheck added)
- src/core/types.ts: union of both PageType additions. Master had added
  `meeting | note`; this branch added `email | slack | calendar-event`
  for inbox/chat/calendar ingest. Final enum carries all five.
- CHANGELOG.md: renumbered the BrainBench-extraction entry to 0.19.0 and
  placed it above master's 0.18.1 RLS entry. Tweaked copy ("In v0.17 it
  lived inside this repo" → "Previously it lived inside this repo") to
  stop implying a specific version that never shipped.
- CLAUDE.md: adjusted "BrainBench in a sibling repo" heading from
  (v0.18+) → (v0.19+).
- docs/benchmarks/2026-04-18-minions-vs-openclaw-production.md:
  resolved modify-vs-delete conflict in favor of delete (the extraction).
- scripts/llms-config.ts: dropped the docs/benchmarks/ entry (directory
  no longer exists here; lives in gbrain-evals).
- llms.txt / llms-full.txt: regenerated after the config change.
- bun.lock: accepted master's (master already dropped pdf-parse as a
  drive-by; aligned with our removal).

Tests: 2094 pass, 236 skip, 18 fail. Spot-checked failures — build-llms,
dream, orphans tests all pass in isolation. Failures reproduce only under
full-suite parallel load and are pre-existing master flakiness (matches the
graph-quality flake noted in the earlier summary). Not merge-introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:45:02 -07:00

222 lines
7.8 KiB
TypeScript

/**
* E2E cycle tests — Tier 1 (no API keys required).
*
* Exercises runCycle against REAL Postgres (via the E2E helpers' setupDB /
* teardownDB lifecycle) with a real git repo and a mocked embedBatch.
* Covers what the unit tests can't: the gbrain_cycle_locks table's
* INSERT...ON CONFLICT...WHERE semantics under a real postgres-js client,
* the v0.17 schema migration applying cleanly to a fresh Postgres, and the
* dry-run regression guard asserting zero writes when flag is set.
*
* Run: DATABASE_URL=... bun test test/e2e/cycle.test.ts
*/
import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
// Mock embedBatch BEFORE importing runCycle so no real OpenAI calls happen
// even when the full cycle's embed phase runs.
mock.module('../../src/core/embedding.ts', () => ({
embedBatch: async (texts: string[]) => {
// Deterministic fake vector for each chunk.
return texts.map(() => new Float32Array(1536));
},
}));
const { runCycle } = await import('../../src/core/cycle.ts');
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
if (skip) {
console.log('Skipping E2E cycle tests (DATABASE_URL not set)');
}
function makeGitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-cycle-'));
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email test@test.co', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name test', { cwd: dir, stdio: 'pipe' });
mkdirSync(join(dir, 'people'), { recursive: true });
writeFileSync(
join(dir, 'people/alice.md'),
'---\ntype: person\ntitle: Alice\n---\n\nAlice collaborates with Bob.\n',
);
writeFileSync(
join(dir, 'people/bob.md'),
'---\ntype: person\ntitle: Bob\n---\n\nBob is a person.\n',
);
execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
return dir;
}
describeE2E('E2E: runCycle against real Postgres', () => {
let repo: string;
beforeAll(async () => {
await setupDB();
repo = makeGitRepo();
});
afterAll(async () => {
await teardownDB();
if (repo) rmSync(repo, { recursive: true, force: true });
});
test('v0.17 migration v16 created gbrain_cycle_locks table', async () => {
const conn = getConn();
const rows = await conn.unsafe(
`SELECT tablename FROM pg_tables WHERE tablename = 'gbrain_cycle_locks'`,
);
expect(rows.length).toBe(1);
// idx_cycle_locks_ttl index also exists.
const idx = await conn.unsafe(
`SELECT indexname FROM pg_indexes WHERE indexname = 'idx_cycle_locks_ttl'`,
);
expect(idx.length).toBe(1);
});
test('dry-run full cycle: zero DB writes + zero filesystem changes', async () => {
const conn = getConn();
// Baseline: track initial state.
const beforePages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
const beforeSync = await conn.unsafe(
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
);
const report = await runCycle(getEngine(), {
brainDir: repo,
dryRun: true,
pull: false,
});
expect(report.schema_version).toBe('1');
// Cycle ran all 6 phases (or skipped the ones that don't support dry-run).
expect(report.phases.length).toBe(6);
// Nothing got written.
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
expect(afterPages[0].n).toBe(beforePages[0].n);
// sync.last_commit unchanged (wasn't set before, isn't set now).
const afterSync = await conn.unsafe(
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
);
expect(afterSync.length).toBe(beforeSync.length);
// Cycle lock was acquired + released; table should be empty after.
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
expect(locks[0].n).toBe(0);
});
test('live cycle: pages get synced + chunks created + cycle lock cleaned up', async () => {
const conn = getConn();
const report = await runCycle(getEngine(), {
brainDir: repo,
dryRun: false,
pull: false,
});
expect(report.schema_version).toBe('1');
// The sync phase should have run and imported real pages.
const syncPhase = report.phases.find(p => p.phase === 'sync');
expect(syncPhase).toBeDefined();
expect(syncPhase?.status).not.toBe('fail');
// Pages exist in the DB.
const pages = await conn.unsafe(`SELECT slug FROM pages ORDER BY slug`);
const slugs = (pages as unknown as Array<{ slug: string }>).map(p => p.slug);
expect(slugs).toContain('people/alice');
expect(slugs).toContain('people/bob');
// sync.last_commit bookmark is now set.
const sync = await conn.unsafe(
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
);
expect(sync.length).toBe(1);
expect((sync[0] as any).value.length).toBeGreaterThanOrEqual(7);
// Cycle lock is released.
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
expect(locks[0].n).toBe(0);
}, 60_000);
test('concurrent cycle is blocked by the lock (status:skipped)', async () => {
const conn = getConn();
// Seed a fresh-TTL lock held by a different (fake) PID.
await conn.unsafe(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
);
try {
const report = await runCycle(getEngine(), {
brainDir: repo,
dryRun: true,
pull: false,
});
expect(report.status).toBe('skipped');
expect(report.reason).toBe('cycle_already_running');
expect(report.phases.length).toBe(0);
} finally {
// Clean up the seeded lock.
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
}
});
test('TTL-expired lock is auto-claimed (crashed holder recovery)', async () => {
const conn = getConn();
// Seed a stale lock (TTL in the past).
await conn.unsafe(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`,
);
const report = await runCycle(getEngine(), {
brainDir: repo,
dryRun: true,
pull: false,
});
// Crashed holder's stale TTL lets the new run acquire the lock.
expect(report.status).not.toBe('skipped');
// Lock released after the run.
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
expect(locks[0].n).toBe(0);
});
test('--phase orphans skips the lock entirely (read-only optimization)', async () => {
const conn = getConn();
// Seed a fresh-TTL lock held by someone else. A read-only phase
// selection should succeed anyway (orphans never acquires the lock).
await conn.unsafe(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
);
try {
const report = await runCycle(getEngine(), {
brainDir: repo,
phases: ['orphans'],
pull: false,
});
// Status is NOT skipped — orphans ran despite the held lock.
expect(report.status).not.toBe('skipped');
const orphansPhase = report.phases.find(p => p.phase === 'orphans');
expect(orphansPhase).toBeDefined();
} finally {
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
}
});
});