Files
gbrain/test/e2e/sync.test.ts
T
e9f3c9c24d docs: live sync setup + verification runbook + API key loading (#24)
* docs: add SKILLPACK Section 18 — Live Sync (MUST ADD)

Contract-first guide for keeping the vector DB in sync with the brain
repo. Documents the pooler prerequisite (Session mode required for
transactions), sync + embed primitives, four example approaches (cron,
--watch, webhook, git hook), isSyncable exclusions, silent skip warning,
and OpenClaw/Hermes cron registration examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add GBRAIN_VERIFY.md installation verification runbook

Six-check runbook: schema (doctor), skillpack loaded, auto-update,
live sync (coverage check + embed check + end-to-end push-and-search
test), embedding coverage, brain-first lookup protocol. Emphasizes
"sync ran" != "sync worked" — the real test is searching for corrected
text after a push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add setup Phases H (Live Sync) and I (Verification)

Phase H: MUST ADD live sync setup — pooler prerequisite check, automatic
sync configuration (agent picks approach), sync+embed chaining, coverage
verification. Phase I: run GBRAIN_VERIFY.md end-to-end before declaring
setup complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add install steps 8-9 (live sync + verification)

Step 8: set up automatic sync with SKILLPACK Section 18 reference.
Step 9: run GBRAIN_VERIFY.md runbook. Add GBRAIN_VERIFY.md to docs
section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add API key loading instructions to CLAUDE.md

Source ~/.zshrc before running Tier 2 tests so OPENAI_API_KEY and
ANTHROPIC_API_KEY are available. Without this, embedding and skills
tests skip silently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to v0.5.0

Live sync, verification runbook, API key loading instructions.
Version markers updated in SKILLPACK and RECOMMENDED_SCHEMA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add anti-hand-roll rule to skill routing in CLAUDE.md

Explicitly prohibit manually running git commit + push + gh pr create
when /ship is available. /ship handles VERSION, CHANGELOG,
document-release, reviews, and coverage audit. Hand-rolling skips
all of these. Added "commit and ship" / "push and ship" variants
to the ship routing rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: changelog voice rule + rewrite 0.5.0 changelog to sell the upgrade

CLAUDE.md: add changelog voice guidance — lead with benefits, not
implementation details. Make users want to upgrade.

CHANGELOG: rewrite 0.5.0 entries from dry feature descriptions to
capability-focused bullets ("your brain never falls behind" not
"SKILLPACK Section 18 added").

SKILLPACK Section 17: update the auto-update message template to
instruct agents to sell the upgrade, not just summarize the diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add v0.5.0 migration directive for live sync + verification

Agents upgrading from v0.4.x will automatically: check their pooler
connection string, set up automatic sync, and run the verification
runbook. Without this migration file, upgrading agents would learn
about live sync (by re-reading Section 18) but wouldn't set it up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: sharpen migration file guidance in CLAUDE.md

Replace vague "requires agent action" with concrete trigger list:
new setup steps existing users don't have, MUST ADD skillpack sections,
schema changes, deprecated commands, new verification steps, new crons.
Add the key test: "if an existing user upgrades and does nothing else,
will their brain work worse?"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: make Section 17 upgrade flow work for direct user requests

Section 17 was structured as a cron-initiated flow only. An agent
handling "upgrade gbrain" might just run the command and stop, missing
the post-upgrade steps where the value is (re-read skills, run
migrations, schema sync). Added explicit entry point for direct
upgrade requests. Made Steps 2-4 more concrete about where to find
files and why migrations can't be skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add E2E sync tests — git-to-DB pipeline (11 tests)

Tests the full sync lifecycle against real Postgres+pgvector:
- First sync imports all pages from a git repo
- Second sync with no changes returns up_to_date
- Incremental sync picks up new files (add → commit → sync → verify)
- Incremental sync picks up modifications — THE CRITICAL TEST:
  corrected text appears in DB and keyword search after sync
- Incremental sync handles deletes
- Non-syncable files are excluded (README, .raw/, ops/)
- Sync state (last_commit, last_run) persisted to config
- Sync logged to ingest_log
- --full reimports everything
- --dry-run shows changes without applying

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: strengthen CLAUDE.md to always run ALL test tiers

Replace passive "source zshrc" suggestion with ALWAYS directive.
Explicitly state that "run all tests" means ALL tiers including
Tier 2 with API keys. Do not skip Tier 2 just because keys need
loading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Tier 2 E2E tests — correct openclaw CLI invocation

The tests used `openclaw -p` which doesn't exist. The correct command
is `openclaw agent --local --agent <id> --message <prompt>`. Also fixed
JSON output parsing (structured JSON goes to stderr, not stdout — use
non-JSON mode instead). Fixed ingest test to assert on agent response
text rather than test DB state (the agent writes to its own configured
DB, not the ephemeral test DB).

82 tests pass, 0 fail, 0 skip across all 5 E2E files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:23:59 -10:00

335 lines
10 KiB
TypeScript

/**
* E2E Sync Tests — Tier 1 (no API keys required)
*
* Tests the full git-to-DB sync pipeline: create a git repo, commit
* markdown files, run gbrain sync, verify pages appear in the database.
* Covers first sync, incremental add/modify/delete, and the critical
* "edit → sync → search returns corrected text" flow.
*
* Run: DATABASE_URL=... bun test test/e2e/sync.test.ts
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import {
hasDatabase, setupDB, teardownDB, getEngine,
} from './helpers.ts';
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
if (skip) {
console.log('Skipping E2E sync tests (DATABASE_URL not set)');
}
/** Create a temp git repo with initial markdown files */
function createTestRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-sync-e2e-'));
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
// Create initial structure
mkdirSync(join(dir, 'people'), { recursive: true });
mkdirSync(join(dir, 'concepts'), { recursive: true });
writeFileSync(join(dir, 'people/alice.md'), [
'---',
'type: person',
'title: Alice Smith',
'tags: [engineer, frontend]',
'---',
'',
'Alice is a frontend engineer at Acme Corp.',
'',
'---',
'',
'- 2026-01-15: Joined Acme Corp',
].join('\n'));
writeFileSync(join(dir, 'concepts/testing.md'), [
'---',
'type: concept',
'title: Testing Philosophy',
'tags: [engineering]',
'---',
'',
'Every untested path is a path where bugs hide.',
].join('\n'));
// Initial commit
execSync('git add -A && git commit -m "initial commit"', { cwd: dir, stdio: 'pipe' });
return dir;
}
function gitCommit(repoPath: string, message: string) {
execSync(`git add -A && git commit -m "${message}"`, { cwd: repoPath, stdio: 'pipe' });
}
describeE2E('E2E: Git-to-DB Sync Pipeline', () => {
let repoPath: string;
beforeAll(async () => {
await setupDB();
repoPath = createTestRepo();
});
afterAll(async () => {
await teardownDB();
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
test('first sync imports all pages from git repo', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('first_sync');
// performFullSync delegates to runImport which doesn't populate pagesAffected
// Verify pages exist in DB directly instead
const alice = await engine.getPage('people/alice');
expect(alice).not.toBeNull();
expect(alice!.title).toBe('Alice Smith');
const testing = await engine.getPage('concepts/testing');
expect(testing).not.toBeNull();
expect(testing!.title).toBe('Testing Philosophy');
});
test('second sync with no changes returns up_to_date', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('up_to_date');
expect(result.added).toBe(0);
expect(result.modified).toBe(0);
expect(result.deleted).toBe(0);
});
test('incremental sync picks up new files', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Add a new file
writeFileSync(join(repoPath, 'people/bob.md'), [
'---',
'type: person',
'title: Bob Jones',
'tags: [designer]',
'---',
'',
'Bob is a product designer who loves typography.',
].join('\n'));
gitCommit(repoPath, 'add bob');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('synced');
expect(result.added).toBe(1);
expect(result.pagesAffected).toContain('people/bob');
const bob = await engine.getPage('people/bob');
expect(bob).not.toBeNull();
expect(bob!.title).toBe('Bob Jones');
expect(bob!.compiled_truth).toContain('typography');
});
test('incremental sync picks up modifications — corrected text appears', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Modify alice's page — this is the critical "correction" test
writeFileSync(join(repoPath, 'people/alice.md'), [
'---',
'type: person',
'title: Alice Smith',
'tags: [engineer, frontend]',
'---',
'',
'Alice is a staff frontend engineer at Acme Corp, leading the design system team.',
'',
'---',
'',
'- 2026-04-01: Promoted to staff engineer',
'- 2026-01-15: Joined Acme Corp',
].join('\n'));
gitCommit(repoPath, 'update alice - promotion');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('synced');
expect(result.modified).toBe(1);
expect(result.pagesAffected).toContain('people/alice');
// THE CRITICAL CHECK: corrected text appears in the DB
const alice = await engine.getPage('people/alice');
expect(alice!.compiled_truth).toContain('staff frontend engineer');
expect(alice!.compiled_truth).toContain('design system team');
// Old text should be replaced, not appended
expect(alice!.compiled_truth).not.toBe('Alice is a frontend engineer at Acme Corp.');
});
test('keyword search finds corrected text after sync', async () => {
const engine = getEngine();
// Search for the new text
const results = await engine.searchKeyword('design system team');
expect(results.length).toBeGreaterThanOrEqual(1);
const aliceResult = results.find((r: any) => r.slug === 'people/alice');
expect(aliceResult).toBeDefined();
});
test('incremental sync handles deletes', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Delete bob's page
unlinkSync(join(repoPath, 'people/bob.md'));
gitCommit(repoPath, 'remove bob');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('synced');
expect(result.deleted).toBe(1);
const bob = await engine.getPage('people/bob');
expect(bob).toBeNull();
});
test('sync skips non-syncable files (README, hidden, .raw)', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Add files that should be excluded
writeFileSync(join(repoPath, 'README.md'), '# Brain Repo\nThis is the readme.');
mkdirSync(join(repoPath, '.raw'), { recursive: true });
writeFileSync(join(repoPath, '.raw/data.md'), '---\ntitle: Raw\n---\nRaw data.');
mkdirSync(join(repoPath, 'ops'), { recursive: true });
writeFileSync(join(repoPath, 'ops/deploy.md'), '---\ntitle: Deploy\n---\nOps stuff.');
gitCommit(repoPath, 'add non-syncable files');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
// These should not create pages
const readme = await engine.getPage('README');
expect(readme).toBeNull();
const raw = await engine.getPage('.raw/data');
expect(raw).toBeNull();
const ops = await engine.getPage('ops/deploy');
expect(ops).toBeNull();
});
test('sync stores last_commit and last_run in config', async () => {
const engine = getEngine();
const lastCommit = await engine.getConfig('sync.last_commit');
const lastRun = await engine.getConfig('sync.last_run');
const repoPathConfig = await engine.getConfig('sync.repo_path');
expect(lastCommit).toBeTruthy();
expect(lastCommit!.length).toBe(40); // full SHA
expect(lastRun).toBeTruthy();
expect(repoPathConfig).toBe(repoPath);
});
test('sync logs to ingest_log', async () => {
const engine = getEngine();
const logs = await engine.getIngestLog();
const syncLogs = logs.filter((l: any) => l.source_type === 'git_sync');
expect(syncLogs.length).toBeGreaterThanOrEqual(1);
expect(syncLogs[0].source_ref).toContain(repoPath);
});
test('--full reimports everything regardless of last_commit', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
full: true,
});
expect(result.status).toBe('first_sync');
// performFullSync delegates to runImport — verify pages exist instead
const alice = await engine.getPage('people/alice');
expect(alice).not.toBeNull();
const testing = await engine.getPage('concepts/testing');
expect(testing).not.toBeNull();
});
test('dry-run shows changes without applying them', async () => {
const { performSync } = await import('../../src/commands/sync.ts');
const engine = getEngine();
// Add a new file
writeFileSync(join(repoPath, 'concepts/dry-run-test.md'), [
'---',
'type: concept',
'title: Dry Run Test',
'---',
'',
'This should not be imported.',
].join('\n'));
gitCommit(repoPath, 'add dry run test');
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
dryRun: true,
});
expect(result.status).toBe('dry_run');
expect(result.added).toBe(1);
// Page should NOT exist in DB
const page = await engine.getPage('concepts/dry-run-test');
expect(page).toBeNull();
// Clean up: do a real sync so the commit is consumed
await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
});
});