Files
gbrain/test/e2e/helpers.ts
T
a86f995883 feat: GBrain v0.3.0 — contract-first architecture + ClawHub plugin (#7)
* feat: contract-first operations.ts with OperationError, dry_run, importFromContent

30 shared operations as single source of truth for CLI and MCP.
- OperationError with typed error codes (page_not_found, invalid_params, etc.)
- dry_run support on all mutating operations
- importFromContent split from importFile with transaction wrapping
- Idempotency hash now includes ALL fields (title, type, frontmatter, tags)
- Config env var fallback: GBRAIN_DATABASE_URL > DATABASE_URL > config file

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

* refactor: rewrite MCP server + CLI + tools-json from operations

server.ts: 233 -> ~80 lines. Tool definitions and dispatch generated from operations[].
cli.ts: shared operations auto-registered, CLI-only commands kept as manual dispatch.
tools-json: generated FROM operations[], eliminating the third contract surface.
Parity test verifies structural contract between operations, CLI, and MCP.

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

* refactor: delete 12 command files migrated to operations.ts

Handler logic for get, put, delete, list, search, query, health, stats,
tags, link, timeline, and version now lives in operations.ts.
Kept: init, upgrade, import, export, files, embed, sync, serve, call, config.

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

* feat: init --non-interactive, upgrade verification, schema migration

- gbrain init --non-interactive --url <url> for plugin mode (no TTY required)
- Post-upgrade version verification in gbrain upgrade
- Drop storage_url from files table (storage_path is the only identifier)

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

* feat: tool-agnostic skills + new setup skill

All 7 skills rewritten with intent-based language instead of CLI commands.
Works with both CLI and MCP plugin contexts.
New setup skill replaces install: auto-provision Supabase via CLI,
AGENTS.md injection, target TTHW < 2 min.

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

* feat: ClawHub bundle plugin, CI workflows, v0.3.0

- openclaw.plugin.json with configSchema, MCP server config, skill listing
- GitHub Actions: test on push/PR, multi-platform release (macOS arm64 + Linux x64)
- Version bump 0.3.0, CHANGELOG, README ClawHub section, CLAUDE.md updated

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

* fix: idempotency hash mismatch + MCP dry_run passthrough

importFromContent now passes its all-fields hash through putPage via
content_hash on PageInput, so the stored hash matches the computed hash.
Previously the skip-if-unchanged check never fired because the hash
formulas differed.

MCP server now passes dry_run from tool params to OperationContext.

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

* chore: bump version and changelog (v0.3.0.0)

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

* fix: schema loader handles PL/pgSQL $$ blocks

Delete the semicolon-based SQL splitter in db.ts which broke on
PL/pgSQL trigger functions containing semicolons inside $$ delimiter
blocks. Use single conn.unsafe(schemaSql) call instead — the postgres
driver handles multi-statement SQL natively. schema.sql already uses
IF NOT EXISTS / CREATE OR REPLACE for idempotency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: E2E test infrastructure + realistic brain fixtures

Add test infrastructure for running E2E tests against real
Postgres+pgvector. Includes:
- test/e2e/helpers.ts: DB lifecycle, fixture import, timing, diagnostics
- 13 fixture files as a miniature realistic brain (people, companies,
  deals, meetings, concepts, projects, sources) following the
  compiled truth + timeline format from GBRAIN_RECOMMENDED_SCHEMA.md
- docker-compose.test.yml: local pgvector convenience (port 5433)
- .env.testing.example: template for test credentials
- package.json: add test:e2e script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: E2E test suites + CI workflow

Tier 1 (mechanical.test.ts): 14 test suites covering all operations
against real Postgres — page CRUD, search with quality scoring, links,
tags, timeline, versions, admin, chunks, resolution, ingest log, raw
data, files, idempotency stress, setup journey (full CLI flow), init
edge cases, schema idempotency, schema diff guard, performance baselines.

Tier 1 (mcp.test.ts): MCP protocol test — spawns server, sends JSON-RPC,
verifies tools/list matches operations count.

Tier 2 (skills.test.ts): OpenClaw skill tests — ingest, query, health.
Skips gracefully when dependencies missing.

CI (.github/workflows/e2e.yml): Tier 1 on every PR (pgvector service),
Tier 2 nightly/manual with API key secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: E2E test fixes + traverseGraph jsonb cast

- Fix traverseGraph query: cast json_agg to jsonb_agg so SELECT DISTINCT works
- Fix put_page tests to use importFromContent with noEmbed (no OpenAI key in Tier 1)
- Fix get_health assertion (page_count not total_pages)
- Fix raw_data test to handle JSONB string/object return
- Simplify MCP test to verify tool generation directly
- Add timeouts to CLI subprocess tests
- Use port 5434 for docker-compose (5433 often in use)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update all project docs for E2E test suite

- CLAUDE.md: updated test count (9 unit + 3 E2E), added E2E test
  instructions, fixed skill count to 8
- CONTRIBUTING.md: updated project structure with test/e2e/, added E2E
  test instructions, rewrote "Adding a new command" to reflect
  contract-first architecture (add to operations.ts, done)
- README.md: fixed table count (10 not 9), added recommended schema doc
  to Docs section, added E2E instructions to Contributing section
- CHANGELOG.md: added E2E test suite, docker-compose, schema loader fix,
  and traverseGraph jsonb fix to v0.3.0 entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

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

200 lines
5.6 KiB
TypeScript

/**
* E2E test helpers: DB lifecycle, fixture import, timing, and diagnostics.
*
* Usage in test files:
* import { setupDB, teardownDB, importFixtures, time } from './helpers.ts';
* beforeAll(async () => { await setupDB(); await importFixtures(); });
* afterAll(async () => { await teardownDB(); });
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join, resolve, relative, dirname, basename, extname } from 'path';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import * as db from '../../src/core/db.ts';
import { importFromContent } from '../../src/core/import-file.ts';
import { parseMarkdown } from '../../src/core/markdown.ts';
// Load .env.testing if present
const envPath = resolve(import.meta.dir, '../../.env.testing');
if (existsSync(envPath)) {
const lines = readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq);
const val = trimmed.slice(eq + 1);
if (!process.env[key]) process.env[key] = val;
}
}
const DATABASE_URL = process.env.DATABASE_URL;
const FIXTURES_DIR = resolve(import.meta.dir, 'fixtures');
let engine: PostgresEngine | null = null;
const ALL_TABLES = [
'content_chunks',
'links',
'tags',
'raw_data',
'timeline_entries',
'page_versions',
'ingest_log',
'files',
'pages', // last because of foreign keys
'config',
];
/**
* Check if a real database is available for E2E tests.
*/
export function hasDatabase(): boolean {
return !!DATABASE_URL;
}
/**
* Connect to DB, run schema init, truncate all tables.
* Call in beforeAll() of each test file.
*/
export async function setupDB(): Promise<PostgresEngine> {
if (!DATABASE_URL) {
throw new Error('DATABASE_URL not set. Copy .env.testing.example to .env.testing and configure it.');
}
// Disconnect any prior connection (clean slate)
await db.disconnect();
// Connect fresh
await db.connect({ database_url: DATABASE_URL });
await db.initSchema();
// Truncate all data tables (preserves schema + extensions)
const conn = db.getConnection();
for (const table of ALL_TABLES) {
await conn.unsafe(`TRUNCATE ${table} CASCADE`);
}
// Re-seed config (initSchema inserts default config rows)
await conn.unsafe(`
INSERT INTO config (key, value) VALUES ('schema_version', '1')
ON CONFLICT (key) DO NOTHING
`);
engine = new PostgresEngine();
await engine.connect({ database_url: DATABASE_URL });
return engine;
}
/**
* Disconnect from DB. Call in afterAll() of each test file.
*/
export async function teardownDB(): Promise<void> {
if (engine) {
await engine.disconnect();
engine = null;
}
await db.disconnect();
}
/**
* Get the current engine instance.
*/
export function getEngine(): PostgresEngine {
if (!engine) throw new Error('setupDB() must be called first');
return engine;
}
/**
* Get a raw DB connection for direct queries.
*/
export function getConn() {
return db.getConnection();
}
/**
* Import all fixture files from test/e2e/fixtures/ into the brain.
* Returns the list of import results.
*/
export async function importFixtures() {
const e = getEngine();
const results: Array<{ slug: string; status: string; chunks: number }> = [];
const files = findMarkdownFiles(FIXTURES_DIR);
for (const filePath of files) {
const relPath = relative(FIXTURES_DIR, filePath);
const content = readFileSync(filePath, 'utf-8');
const parsed = parseMarkdown(content, relPath);
const result = await importFromContent(e, parsed.slug, content, { noEmbed: true });
results.push(result);
}
return results;
}
/**
* Import a single fixture by its relative path within fixtures/.
*/
export async function importFixture(relativePath: string) {
const e = getEngine();
const filePath = join(FIXTURES_DIR, relativePath);
const content = readFileSync(filePath, 'utf-8');
const parsed = parseMarkdown(content, relativePath);
return importFromContent(e, parsed.slug, content, { noEmbed: true });
}
/**
* Recursively find all .md files in a directory.
*/
function findMarkdownFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
results.push(...findMarkdownFiles(full));
} else if (extname(entry) === '.md') {
results.push(full);
}
}
return results.sort();
}
/**
* Time a function and return [result, durationMs].
*/
export async function time<T>(fn: () => Promise<T>): Promise<[T, number]> {
const start = performance.now();
const result = await fn();
const dur = performance.now() - start;
return [result, dur];
}
/**
* Dump DB state for debugging on test failure.
*/
export async function dumpDBState(): Promise<string> {
const conn = db.getConnection();
const pages = await conn.unsafe(`SELECT slug, type, title FROM pages ORDER BY slug`);
const chunkCount = await conn.unsafe(`SELECT count(*) as n FROM content_chunks`);
const linkCount = await conn.unsafe(`SELECT count(*) as n FROM links`);
const tagCount = await conn.unsafe(`SELECT count(*) as n FROM tags`);
const lines = [
`=== DB STATE DUMP ===`,
`Pages (${pages.length}):`,
...pages.map((p: any) => ` ${p.slug} [${p.type}] "${p.title}"`),
`Chunks: ${chunkCount[0]?.n ?? 0}`,
`Links: ${linkCount[0]?.n ?? 0}`,
`Tags: ${tagCount[0]?.n ?? 0}`,
`=== END DUMP ===`,
];
return lines.join('\n');
}
/**
* Get the fixtures directory path.
*/
export const FIXTURES_PATH = FIXTURES_DIR;