mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(recipes/openai): add max_batch_tokens to embedding touchpoint OpenAI is the only recipe in the codebase without a max_batch_tokens cap. Every other provider declares one (voyage=120K, azure-openai=8K, dashscope=8K, zhipu=8K, minimax=4K). Without it, gbrain's recursive-halving safety net never engages — batches dispatched purely on the char/4 estimator window will trip OpenAI's 1M-token TPM ceiling on token-dense pages (Discord exports, JSON dumps, code-heavy markdown), then retry storm and block the queue head. Setting cap to 100_000: - gbrain's batcher estimates tokens as chars/4 - Token-dense markdown+JSON tokenizes at ~chars/2.7 - 100K estimated = ~150K real worst-case, safely under OpenAI's 300K per-request hard cap and the 1M/min TPM ceiling - Leaves headroom for recursive-halving on outlier chunks (cherry picked from commit40536aace5) * fix(ai/embed): recognize OpenAI 'maximum request size' error in isTokenLimitError OpenAI's /v1/embeddings endpoint hard-caps a single request at 300k tokens total across all input items. When the cap is exceeded it returns: Invalid 'input': maximum request size is 300000 tokens per request. None of the three existing regexes in isTokenLimitError matched this phrasing, so the recursive-halving safety net in embedSubBatch never engaged for OpenAI. The same fat page (a token-dense markdown export, e.g. a Discord transcript) would re-fail every pass, blocking forward progress on the whole batch indefinitely. Locally reproduced on a 31,129-chunk Postgres brain: 2,125 chunks stuck at 'remaining' across 30+ embed --stale passes with retry loops + sleep delays. Adding the two new patterns lets halving fire; the same backlog cleared in one pass after the regex change (the companion max_batch_tokens recipe fix from PR #924 caps fresh batches, but existing oversize pages still need halving to recover). Adds: - /maximum request size.*tokens/i — OpenAI verbatim - /max.*tokens.*per.*request/i — defensive against minor rewording Tests: - Regression test for the exact OpenAI error string - Coverage for the generic 'max tokens per request' variant - All 25 tests in adaptive-embed-batch.test.ts pass No behavior change for providers whose errors already matched. (cherry picked from commitb834e84c56) * fix(connection-manager): strip .<project-ref> suffix from username when deriving direct URL `deriveDirectUrl()` correctly rewrites the host (`aws-0-us-east-1.pooler.supabase.com` → `db.abcxyz.supabase.co`) but preserves the full pooler-form username (`postgres.abcxyz`). Supabase direct connections expect a bare `postgres` username — Supavisor uses the `.<ref>` suffix for tenant routing, but it's not a real database user. The auto-derived URL therefore fails to authenticate even with the correct password: password authentication failed for user "postgres.abcxyz" Strip the suffix to `postgres` whenever the project-ref was successfully extracted (same condition that triggers the host rewrite). The non-pooler username branch is unaffected — preserved as-is to keep the port-only fallback case working. Hit while exercising v0.30.1's dual-pool routing on a real Supabase brain; the kill switch (`GBRAIN_DISABLE_DIRECT_POOL=1`) papered over it locally but every Supabase user with a stock pooler URL would silently fall through to single-pool until the user-supplied a `GBRAIN_DIRECT_DATABASE_URL` override. With this fix, dual-pool works out of the box for the canonical Supabase shape. Test additions: - 1 case asserting bare `postgres:secret@` in the derived URL when project-ref is parseable from the pooler URL (the new behavior) - extends the existing "falls back to port-only" case with an assertion that non-pooler usernames are preserved (unchanged behavior) `bun run typecheck` clean. `deriveDirectUrl` test block passes 5/5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commitddf2c6a9a0) * fix(init): --help should not mutate config or scan filesystem `gbrain init --help` (and `-h`) currently fall through to the smart-detection branch in runInit(), which scans cwd for .md files and on a directory with 1000+ files prints "Found ~1500 .md files. For a brain this size, Supabase gives faster search..." then defaults to PGLite — calling saveConfig() and overwriting any existing Postgres config with `engine: 'pglite' + database_path: ~/.gbrain/brain.pglite`. Confirmed in the wild: ran `gbrain init --help` from $HOME on a machine where ~/.gbrain/config.json pointed at a Supabase Postgres brain with 10K+ pages. The config was silently flipped to PGLite. The Supabase data was intact, but gbrain stopped pointing at it until the config was manually restored. Root cause: cli.ts:62-69 only routes --help → printOpHelp() for shared-op commands; CLI_ONLY commands (init, embed, etc.) fall through to their handler with --help still in argv. None of them check for it. Fix: add a --help/-h guard at the top of runInit() that prints help text and returns. Help should never mutate state — Postel's robustness principle for CLI tools. Help text covers all flags (engine selection, AI provider options, thin-client mode) so users running `--help` get the canonical list rather than having to read the source. A wider architectural fix — adding --help routing for all CLI_ONLY commands in cli.ts — is plausible follow-up, but each CLI_ONLY command would still need its own help text. This per-command pattern matches how shared ops handle it via printOpHelp(). Init is the highest-stakes case because it's the only CLI_ONLY command that calls saveConfig(). Smoke test: from a directory with 1500 .md files, with GBRAIN_HOME pointed at a fresh tempdir: - Before fix: ~/.gbrain/config.json materialized with engine: 'pglite' - After fix: help text printed, no config dir created `bun run typecheck` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commited11fdd58c) * test(frontmatter-install-hook): isolate hooksPath assertion from developer global config The "installHook writes ... and sets core.hooksPath" test asserted `git config --get core.hooksPath` returns `.githooks`, which falls back to the global scope when local is unset. Developers who set `core.hooksPath` globally (common with dotfiles managers pointing at ~/.config/git/hooks) saw a deterministic FAIL because installHook intentionally respects an existing global value and skips writing the local one — exactly the documented contract. Fix: read via `git config --local --get core.hooksPath` (scope-locked) and branch the assertion on whether a global is already set. Both clean-CI (local should be '.githooks') and developer-with-global (local should be empty; installHook correctly didn't clobber) now pass deterministically. No API change. installHook behavior is unchanged. Verified locally with the affected test passing under `GIT_CONFIG_GLOBAL=~/.gitconfig` carrying `core.hooksPath=...`. (cherry picked from commit0e4da2cb38) * fix: guard against missing 'intent' field in routing-eval fixtures Two defensive fixes: 1. normalizeText(): return empty string on null/undefined input instead of crashing with 'undefined is not an object (evaluating s.toLowerCase)' 2. loadRoutingFixtures(): validate that parsed fixture has 'intent' as a string before adding to fixtures array. Fixtures with wrong field names (e.g. 'input' instead of 'intent') are now reported as malformed with a helpful error message listing the actual keys found. Root cause: a skill's routing-eval.jsonl used {"input": ...} instead of {"intent": ...}. The JSON parsed fine but the cast to RoutingFixture was unchecked, so fixture.intent was undefined. normalizeText(undefined) then crashed. This made 'gbrain doctor' completely unusable. (cherry picked from commitb142bbdb0d) * fix(test): isolate HOME in run-e2e.sh to stop config corruption Replaces #517 (re-ported fresh against current scripts/run-e2e.sh after v0.23.1 rewrote the script — original cherry-pick would not apply). E2E tests call setupDB which writes $HOME/.gbrain/config.json pointing at the docker test container. When the container tears down, the user's real autopilot daemon wedges trying to connect to a vanished postgres. Three operators hit this within 16 days before the original PR filed. Fix: wrapper exports HOME + GBRAIN_HOME to a mktemp tmpdir BEFORE bun starts so config writes land in the tmpdir, with a post-run breach detector that compares md5 of the user's real config against pre-run. Both env vars required: loadConfig/saveConfig resolve via HOME while configPath honors GBRAIN_HOME. HOME set before bun starts because os.homedir() caches at first call. Test seam: test/gbrain-home-isolation.test.ts updated to assert against homedir() === configDir() when GBRAIN_HOME unset (correct under the safety wrapper itself) instead of the prior "not /tmp/" sentinel. Revert path: git revert <this-sha> if test:e2e regresses on master. Co-Authored-By: orendi84 <orendi84@users.noreply.github.com> * test(dream-cycle): add schema-suggest to EXPECTED_PHASES v0.40.7.0 Schema Cathedral v3 added the 'schema-suggest' phase between 'orphans' and 'purge' in ALL_PHASES, but the E2E phase-order test was not updated to match. ALL_PHASES vs EXPECTED_PHASES diverged and the shape-pin test failed every run on master. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(autopilot-fanout): use relative timestamp inside freshness window The 'end-to-end: updateSourceConfig persists timestamp visible to next listAllSources' test pinned last_full_cycle_at to a hardcoded '2026-05-22T15:00:00.000Z'. The 60-minute freshness window passed within ~1 hour of write — every run after the deadline classified the source as stale and dispatched it, breaking the test's .skippedFresh expectation. Switch to Date.now() - 30min relative timestamp (mirrors the prior 'source with last_full_cycle_at < 60min ago is skipped by gate' test). Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(fresh-install-pglite): unset other provider keys in beforeEach init.ts:455 fails loud when multiple embedding providers are env-ready in non-TTY mode. The test sets ZEROENTROPY_API_KEY then runs init, but developer machines commonly have OPENAI_API_KEY + VOYAGE_API_KEY + ZEROENTROPY_API_KEY all set, so init sees 3 providers and exits 1. Save+unset OPENAI_API_KEY + VOYAGE_API_KEY in beforeEach, restore in afterEach. Now only ZE is env-ready, init picks it, schema sized to zembed-1's 1280d as the test expects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(voyage-multimodal): switch fixture from AVIF to PNG Voyage's /multimodalembeddings endpoint rejects AVIF as of 2026-05 with 'Please provide a valid base64-encoded image'. The prior comment ('AVIF is fine for an embed call') held at v0.27.x and regressed silently on the provider side. Add test/fixtures/images/tiny.png (16x16 RGB PNG, 1307 bytes generated via sips from the macOS default wallpaper). PNG is universally accepted by Voyage and other multimodal providers. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cycle/synthesize): prefix bare anthropic model ids before queue.add queue.add's subagent capability validator (classifyCapabilities → resolveRecipe) requires provider:model format and rejects bare ids with 'unknown provider'. resolveModel returns the bare id from TIER_DEFAULTS / DEFAULT_ALIASES (e.g. 'claude-sonnet-4-6'), which the validator then rejects, dropping the synthesize phase to status:fail with SYNTH_PHASE_FAIL. Narrow fix at the call site: if config.model has no colon AND starts with 'claude-', prefix 'anthropic:'. Other providers must already declare a colon. Avoids changing TIER_DEFAULTS / DEFAULT_ALIASES constant shapes, which would ripple across every resolveModel caller. Surfaced by dream-synthesize-chunking E2E during fix-wave: warm-narwhal. Affected tests: 'single-chunk transcript uses legacy idempotency key' and 'multi-chunk transcript spawns N children with chunk-suffixed idempotency keys' — both relied on result.details.children_submitted which only the ok() path sets; the failed() path returns details: {}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(mechanical): pin doctor init embedding model + clean non-default sources Two fixes in the E2E Doctor Command describe block, both surfaced by cross-file state pollution under the full sequential E2E run: 1. Pass --embedding-model openai:text-embedding-3-large to the init subprocess. Without the explicit flag, doctor inherits whatever the resolver picks from env keys (ZE if ZEROENTROPY_API_KEY is set, defaulting to zembed-1 at 1280d). The test's setupDB initialized schema at 1536d, so the dim mismatch fires embedding_width_consistency WARN, exiting doctor 1. 2. DELETE FROM sources WHERE id != 'default' in beforeAll. Prior E2E files leave non-default source rows (e.g. 'delta' from autopilot / sources tests). sync_freshness + cycle_freshness then FAIL on those orphans because they were never synced/cycled, exiting doctor 1. setupDB TRUNCATEs sources but schema.sql re-seeds 'default' via initSchema; this leaves only the canonical single-source brain the test expects. Surfaced during fix-wave: warm-narwhal E2E gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(run-e2e): per-file connection flush + 180s outer timeout Two cross-file isolation hardenings for the sequential E2E runner: 1. Terminate stale Postgres connections before each file. Without this, idle connections from the prior bun process's pool race with the next file's setupDB() TRUNCATE CASCADE, producing 'fixture pages disappear mid-test' failures. The terminate call is idempotent + ~50ms; first iteration is a no-op. 2. Hard outer timeout (180s per file) via gtimeout / timeout. bun's --timeout=60000 is per-test; if a PGLite WASM call hangs in beforeAll/afterAll (e.g. ingestion-roundtrip.test.ts wedging 30+ minutes on macOS), --timeout never fires and the entire suite wedges. Outer SIGKILL lets the suite advance and the file is recorded as failed for triage. Falls through to bare bun if neither gtimeout nor timeout is on PATH. Surfaced during fix-wave: warm-narwhal — 3 of 5 cross-file flakes caught by the connection flush; ingestion-roundtrip 30-min wedge caught by the outer timeout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.3.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: annotate synthesize.ts narrow prefix fix (v0.41.3.0) CLAUDE.md gains the v0.41.3.0 note on src/core/cycle/synthesize.ts (narrow anthropic: prefix at the queue.add boundary so resolveModel's bare ids satisfy the subagent validator). llms-full.txt regenerated to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.41.3.0 → v0.41.5.0 (queue drift; PR #1377 claimed .4.0) Sibling fix-wave PR #1377 (garrytan/community-pr-wave) claimed v0.41.4.0 between my queue check (.3.0 was available) and PR creation. Re-bump to the next available slot per workspace-aware allocator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cycle/synthesize): refuse empty brainDir + resolve relative paths Pre-fix, runPhaseSynthesize accepted any brainDir string and passed it to writeReversePages which does join(brainDir, '<slug>.md'). When brainDir is '' or relative ('.' / './brain' / etc), join() produces a relative path that writeFileSync resolves against cwd. Result: every synthesize reverse-write spills into <cwd>/companies/<slug>.md, <cwd>/people/<slug>.md, etc. instead of the intended brainDir tempdir. Surfaced by the warm-narwhal wave when E2E test cleanup found orphan synthesize pages (companies/novamind.md, people/sarah-chen.md, meetings/2025-04-01-novamind-board-update.md) at the gbrain repo root from a runCycle({brainDir: '.'}) chain that ran during morning E2E execution. Fix at the function entry, single location, all callers protected: 1. Empty/whitespace brainDir → return failed(BRAINDIR_EMPTY) loud instead of silently resolving against cwd 2. Relative brainDir → resolve(opts.brainDir) before any read/write can use it. opts.brainDir mutated so writeReversePages, writeSummaryPage, and every join() downstream see the absolute path Regression test pins all 4 contracts: - empty string → fail(BRAINDIR_EMPTY) - whitespace-only → fail(BRAINDIR_EMPTY) - '.' → mutated to absolute on entry - already-absolute → unchanged Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(dream): resolve brainDir to absolute at CLI surface Defense-in-depth for the synthesize-braindir spillage bug class. The core fix lives in runPhaseSynthesize (commit98222a08); this resolves brainDir one layer earlier so the entire 9-phase runCycle gets the absolute path, not just synthesize. Two paths in resolveBrainDir get path.resolve(): - explicit --dir argument (e.g., `gbrain dream --dir .`) - sync.repo_path config (in case it was ever stored relative) resolveBrainDir already checked existsSync; resolve() just canonicalizes before return. No behavior change for paths already absolute. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Matt Gunnin <mgunnin@esports.one> Co-authored-by: Brandon Lipman <brandon@offdeck.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jeremy Knows <jeremy@veefriends.com> Co-authored-by: root <root@localhost> Co-authored-by: orendi84 <orendigergo@gmail.com> Co-authored-by: orendi84 <orendi84@users.noreply.github.com> Co-authored-by: Garry Tan <garry@ycombinator.com>
1459 lines
62 KiB
TypeScript
1459 lines
62 KiB
TypeScript
/**
|
|
* E2E Mechanical Tests — Tier 1 (no API keys required)
|
|
*
|
|
* Tests all operations against a real Postgres+pgvector database.
|
|
* Requires DATABASE_URL env var or .env.testing file.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/mechanical.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
hasDatabase, setupDB, teardownDB, getEngine, getConn,
|
|
importFixtures, importFixture, time, dumpDBState, FIXTURES_PATH,
|
|
} from './helpers.ts';
|
|
import { operationsByName, operations } from '../../src/core/operations.ts';
|
|
import type { OperationContext } from '../../src/core/operations.ts';
|
|
import { importFromContent } from '../../src/core/import-file.ts';
|
|
|
|
// Skip all E2E tests if no database is configured
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
function makeCtx(opts: { remote?: boolean } = {}): OperationContext {
|
|
return {
|
|
engine: getEngine(),
|
|
config: { engine: 'postgres', database_url: process.env.DATABASE_URL! },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
// Default: trusted local invocation (matches `gbrain call` semantics).
|
|
remote: opts.remote ?? false,
|
|
sourceId: 'default',
|
|
};
|
|
}
|
|
|
|
async function callOp(name: string, params: Record<string, unknown> = {}) {
|
|
const op = operationsByName[name];
|
|
if (!op) throw new Error(`Unknown operation: ${name}`);
|
|
return op.handler(makeCtx(), params);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Page CRUD
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Page CRUD', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('fixture import creates correct page count', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(16);
|
|
}, 30_000);
|
|
|
|
test('get_page returns correct data for person', async () => {
|
|
const page = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(page.title).toBe('Sarah Chen');
|
|
expect(page.type).toBe('person');
|
|
expect(page.compiled_truth).toContain('NovaMind');
|
|
expect(page.tags).toContain('founder');
|
|
expect(page.tags).toContain('yc-w25');
|
|
});
|
|
|
|
test('get_page returns correct data for concept', async () => {
|
|
const page = await callOp('get_page', { slug: 'concepts/retrieval-augmented-generation' }) as any;
|
|
expect(page.title).toBe('Retrieval-Augmented Generation');
|
|
expect(page.type).toBe('concept');
|
|
expect(page.compiled_truth).toContain('検索拡張生成');
|
|
});
|
|
|
|
test('get_page for company includes key details', async () => {
|
|
const page = await callOp('get_page', { slug: 'companies/novamind' }) as any;
|
|
expect(page.type).toBe('company');
|
|
expect(page.compiled_truth).toContain('Sarah Chen');
|
|
});
|
|
|
|
test('list_pages type filter returns correct count', async () => {
|
|
const people = await callOp('list_pages', { type: 'person' }) as any[];
|
|
expect(people.length).toBe(3);
|
|
|
|
const companies = await callOp('list_pages', { type: 'company' }) as any[];
|
|
expect(companies.length).toBe(3); // novamind, threshold-ventures, ohmygreen
|
|
|
|
const concepts = await callOp('list_pages', { type: 'concept' }) as any[];
|
|
expect(concepts.length).toBe(5); // compiled-truth, hybrid-search, RAG, notes-march-2024, big-file
|
|
});
|
|
|
|
test('list_pages tag filter works', async () => {
|
|
const ycPages = await callOp('list_pages', { tag: 'yc-w25' }) as any[];
|
|
expect(ycPages.length).toBeGreaterThanOrEqual(2);
|
|
expect(ycPages.some((p: any) => p.slug === 'people/sarah-chen')).toBe(true);
|
|
});
|
|
|
|
test('put_page updates existing page', async () => {
|
|
const updated = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Stanford CS', 'MIT CS');
|
|
// Use importFromContent directly with noEmbed to avoid OpenAI timeout
|
|
const engine = getEngine();
|
|
const result = await importFromContent(engine, 'people/sarah-chen', updated, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
const page = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(page.compiled_truth).toContain('MIT CS');
|
|
});
|
|
|
|
test('delete_page removes page and others survive', async () => {
|
|
await callOp('delete_page', { slug: 'sources/crustdata-sarah-chen' });
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(15);
|
|
|
|
// Other pages still exist
|
|
const sarah = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(sarah.title).toBe('Sarah Chen');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Search
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Search', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('keyword search for "NovaMind" returns multiple hits', async () => {
|
|
const results = await callOp('search', { query: 'NovaMind' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(3);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('companies/novamind');
|
|
}, 30_000);
|
|
|
|
test('keyword search for "Threshold Ventures" finds investor', async () => {
|
|
const results = await callOp('search', { query: 'Threshold Ventures' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('companies/threshold-ventures');
|
|
});
|
|
|
|
test('keyword search for "Stanford" finds Priya', async () => {
|
|
const results = await callOp('search', { query: 'Stanford' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('people/priya-patel');
|
|
});
|
|
|
|
test('keyword search for nonexistent term returns empty', async () => {
|
|
const results = await callOp('search', { query: 'xyznonexistent123' }) as any[];
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
test('search quality: precision@5 for known queries', async () => {
|
|
const groundTruth: Record<string, string[]> = {
|
|
'NovaMind': ['people/sarah-chen', 'companies/novamind', 'deals/novamind-seed'],
|
|
'hybrid search': ['concepts/hybrid-search', 'concepts/retrieval-augmented-generation'],
|
|
'compiled truth': ['concepts/compiled-truth'],
|
|
};
|
|
|
|
const scores: Record<string, number> = {};
|
|
for (const [query, expected] of Object.entries(groundTruth)) {
|
|
const results = await callOp('search', { query, limit: 5 }) as any[];
|
|
const topSlugs = results.slice(0, 5).map((r: any) => r.slug);
|
|
const hits = expected.filter(e => topSlugs.includes(e));
|
|
scores[query] = hits.length / Math.min(expected.length, 5);
|
|
}
|
|
|
|
console.log('\n Search Quality (precision@5, keyword-only):');
|
|
for (const [query, score] of Object.entries(scores)) {
|
|
console.log(` "${query}": ${(score * 100).toFixed(0)}%`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Links
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Links', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('add_link + get_links + get_backlinks round trip', async () => {
|
|
await callOp('add_link', {
|
|
from: 'people/sarah-chen',
|
|
to: 'companies/novamind',
|
|
link_type: 'founded',
|
|
context: 'CEO and founder since 2024',
|
|
});
|
|
|
|
const links = await callOp('get_links', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(links.some((l: any) => l.to_slug === 'companies/novamind' || l.to_page_slug === 'companies/novamind')).toBe(true);
|
|
|
|
const backlinks = await callOp('get_backlinks', { slug: 'companies/novamind' }) as any[];
|
|
expect(backlinks.some((l: any) => l.from_slug === 'people/sarah-chen' || l.from_page_slug === 'people/sarah-chen')).toBe(true);
|
|
}, 30_000);
|
|
|
|
test('traverse_graph finds connected pages', async () => {
|
|
// Links should already be added from prior test in this describe block
|
|
const graph = await callOp('traverse_graph', { slug: 'people/sarah-chen', depth: 2 }) as any;
|
|
expect(Array.isArray(graph)).toBe(true);
|
|
expect(graph.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('remove_link removes the link', async () => {
|
|
await callOp('add_link', { from: 'people/marcus-reid', to: 'companies/threshold-ventures' });
|
|
await callOp('remove_link', { from: 'people/marcus-reid', to: 'companies/threshold-ventures' });
|
|
|
|
const links = await callOp('get_links', { slug: 'people/marcus-reid' }) as any[];
|
|
const hasLink = links.some((l: any) =>
|
|
(l.to_slug || l.to_page_slug) === 'companies/threshold-ventures'
|
|
);
|
|
expect(hasLink).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Tags
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Tags', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('get_tags returns imported tags', async () => {
|
|
const tags = await callOp('get_tags', { slug: 'people/sarah-chen' }) as string[];
|
|
expect(tags).toContain('founder');
|
|
expect(tags).toContain('yc-w25');
|
|
expect(tags).toContain('ai-agents');
|
|
}, 30_000);
|
|
|
|
test('add_tag + remove_tag round trip', async () => {
|
|
await callOp('add_tag', { slug: 'people/marcus-reid', tag: 'test-tag' });
|
|
let tags = await callOp('get_tags', { slug: 'people/marcus-reid' }) as string[];
|
|
expect(tags).toContain('test-tag');
|
|
|
|
await callOp('remove_tag', { slug: 'people/marcus-reid', tag: 'test-tag' });
|
|
tags = await callOp('get_tags', { slug: 'people/marcus-reid' }) as string[];
|
|
expect(tags).not.toContain('test-tag');
|
|
});
|
|
|
|
test('list_pages with tag filter finds tagged pages', async () => {
|
|
await callOp('add_tag', { slug: 'people/priya-patel', tag: 'test-search-tag' });
|
|
const pages = await callOp('list_pages', { tag: 'test-search-tag' }) as any[];
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].slug).toBe('people/priya-patel');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Timeline
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Timeline', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('add_timeline_entry + get_timeline round trip', async () => {
|
|
await callOp('add_timeline_entry', {
|
|
slug: 'people/sarah-chen',
|
|
date: '2025-04-01',
|
|
summary: 'Test timeline entry',
|
|
detail: 'Added via E2E test',
|
|
source: 'e2e-test',
|
|
});
|
|
|
|
const timeline = await callOp('get_timeline', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(timeline.length).toBeGreaterThanOrEqual(1);
|
|
const entry = timeline.find((e: any) => e.summary === 'Test timeline entry');
|
|
expect(entry).toBeDefined();
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Batch methods (addLinksBatch / addTimelineEntriesBatch)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// Postgres-engine batch methods use postgres-js's sql(rows, 'col1', ...) helper,
|
|
// which is structurally different from PGLite's manual $N placeholder construction
|
|
// (covered in test/pglite-engine.test.ts). These tests verify the postgres-js code
|
|
// path against a real Postgres against the same invariants.
|
|
|
|
describeE2E('E2E: addLinksBatch (postgres-engine)', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('empty batch returns 0 with no DB call', async () => {
|
|
const engine = getEngine();
|
|
expect(await engine.addLinksBatch([])).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('within-batch duplicates dedup via ON CONFLICT (no 21000 cardinality error)', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
// Deterministic cleanup so re-runs aren't perturbed by prior fixture state.
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-dup'`;
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-dup' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-dup' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-dup'`;
|
|
});
|
|
|
|
test('rows with missing slug silently dropped by JOIN', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-missing'`;
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/does-not-exist', to_slug: 'companies/novamind', link_type: 'e2e-batch-missing' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-missing' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-missing'`;
|
|
});
|
|
|
|
test('half-existing batch returns count of new only', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-half'`;
|
|
await engine.addLink('people/sarah-chen', 'companies/novamind', 'pre-existing', 'e2e-batch-half');
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-half' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'people/marcus-reid', link_type: 'e2e-batch-half' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-half'`;
|
|
});
|
|
|
|
test('missing optional fields normalize to empty strings (NOT NULL safety)', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = ''`;
|
|
// No link_type, no context — must default to '' to satisfy NOT NULL.
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
const rows = await conn`
|
|
SELECT link_type, context FROM links
|
|
WHERE from_page_id = (SELECT id FROM pages WHERE slug = 'people/sarah-chen')
|
|
AND to_page_id = (SELECT id FROM pages WHERE slug = 'companies/novamind')
|
|
AND link_type = ''
|
|
`;
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].context).toBe('');
|
|
await conn`DELETE FROM links WHERE link_type = ''`;
|
|
});
|
|
});
|
|
|
|
describeE2E('E2E: addTimelineEntriesBatch (postgres-engine)', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('empty batch returns 0', async () => {
|
|
const engine = getEngine();
|
|
expect(await engine.addTimelineEntriesBatch([])).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('within-batch duplicates dedup via ON CONFLICT', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-dup'`;
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/sarah-chen', date: '2025-05-01', summary: 'e2e-batch-tl-dup' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-01', summary: 'e2e-batch-tl-dup' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-dup'`;
|
|
});
|
|
|
|
test('rows with missing slug silently dropped by JOIN', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-missing'`;
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/no-such-page', date: '2025-05-02', summary: 'e2e-batch-tl-missing' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-02', summary: 'e2e-batch-tl-missing' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-missing'`;
|
|
});
|
|
|
|
test('mix of new + existing returns count of new only', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary IN ('e2e-batch-tl-half-1', 'e2e-batch-tl-half-2')`;
|
|
await engine.addTimelineEntry('people/sarah-chen', { date: '2025-05-03', summary: 'e2e-batch-tl-half-1' });
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/sarah-chen', date: '2025-05-03', summary: 'e2e-batch-tl-half-1' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-04', summary: 'e2e-batch-tl-half-2' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary IN ('e2e-batch-tl-half-1', 'e2e-batch-tl-half-2')`;
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Versions
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Versions', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('put_page creates version, revert restores', async () => {
|
|
const original = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
|
|
// Modify page using importFromContent with noEmbed
|
|
const modified = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Sarah Chen', 'Sarah Chen (Modified)');
|
|
const engine = getEngine();
|
|
await importFromContent(engine, 'people/sarah-chen', modified, { noEmbed: true });
|
|
|
|
// Check versions exist
|
|
const versions = await callOp('get_versions', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(versions.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Revert to first version
|
|
const firstVersion = versions[versions.length - 1];
|
|
await callOp('revert_version', { slug: 'people/sarah-chen', version_id: firstVersion.id });
|
|
|
|
const reverted = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(reverted.compiled_truth).not.toContain('(Modified)');
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Admin
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Admin', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('get_stats returns valid structure', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(16);
|
|
expect(typeof stats.chunk_count).toBe('number');
|
|
}, 30_000);
|
|
|
|
test('get_health returns valid structure', async () => {
|
|
const health = await callOp('get_health') as any;
|
|
expect(health).toBeDefined();
|
|
expect(typeof health.page_count).toBe('number');
|
|
expect(typeof health.embed_coverage).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Chunks & Resolution
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Chunks & Resolution', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('get_chunks returns chunks for imported page', async () => {
|
|
const chunks = await callOp('get_chunks', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(chunks.length).toBeGreaterThan(0);
|
|
expect(chunks[0].chunk_text).toBeTruthy();
|
|
}, 30_000);
|
|
|
|
test('resolve_slugs finds partial match', async () => {
|
|
const matches = await callOp('resolve_slugs', { partial: 'sarah' }) as string[];
|
|
expect(matches).toContain('people/sarah-chen');
|
|
});
|
|
|
|
test('resolve_slugs finds exact match', async () => {
|
|
const matches = await callOp('resolve_slugs', { partial: 'people/sarah-chen' }) as string[];
|
|
expect(matches).toContain('people/sarah-chen');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Ingest Log & Raw Data
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Ingest Log & Raw Data', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('log_ingest + get_ingest_log round trip', async () => {
|
|
await callOp('log_ingest', {
|
|
source_type: 'e2e-test',
|
|
source_ref: 'test-run-1',
|
|
pages_updated: ['people/sarah-chen', 'companies/novamind'],
|
|
summary: 'E2E test ingest',
|
|
});
|
|
|
|
const log = await callOp('get_ingest_log', { limit: 5 }) as any[];
|
|
expect(log.length).toBeGreaterThanOrEqual(1);
|
|
const entry = log.find((e: any) => e.source_ref === 'test-run-1');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.source_type).toBe('e2e-test');
|
|
}, 30_000);
|
|
|
|
test('put_raw_data + get_raw_data round trip', async () => {
|
|
const testData = { education: 'Stanford CS 2020', title: 'CEO' };
|
|
await callOp('put_raw_data', {
|
|
slug: 'people/sarah-chen',
|
|
source: 'crustdata',
|
|
data: testData,
|
|
});
|
|
|
|
const raw = await callOp('get_raw_data', {
|
|
slug: 'people/sarah-chen',
|
|
source: 'crustdata',
|
|
}) as any[];
|
|
expect(raw.length).toBeGreaterThanOrEqual(1);
|
|
// JSONB may come back as string or parsed object
|
|
const data = typeof raw[0].data === 'string' ? JSON.parse(raw[0].data) : raw[0].data;
|
|
expect(data.education).toBe('Stanford CS 2020');
|
|
expect(data.title).toBe('CEO');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Files (stub verification)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Files', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('file_list returns empty initially', async () => {
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('file_upload stores metadata + file_list shows it', async () => {
|
|
// Create a temp file
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-'));
|
|
const tmpFile = join(tmpDir, 'test-doc.pdf');
|
|
writeFileSync(tmpFile, 'fake pdf content');
|
|
|
|
try {
|
|
const result = await callOp('file_upload', {
|
|
path: tmpFile,
|
|
page_slug: 'people/sarah-chen',
|
|
}) as any;
|
|
expect(result.status).toBe('uploaded');
|
|
expect(result.storage_path).toContain('sarah-chen');
|
|
|
|
// Verify file_list
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBe(1);
|
|
|
|
// Verify file_url returns URI format
|
|
const url = await callOp('file_url', { storage_path: result.storage_path }) as any;
|
|
expect(url.url).toContain('gbrain:files/');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// Security-wave-3 regression: MCP/remote callers MUST be confined to cwd
|
|
// (Issue #139). Local CLI callers are unrestricted — different trust model.
|
|
test('file_upload rejects outside-cwd paths for remote (MCP) callers', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-ssrf-'));
|
|
const tmpFile = join(tmpDir, 'stealable.txt');
|
|
writeFileSync(tmpFile, 'sensitive');
|
|
|
|
try {
|
|
const op = operationsByName['file_upload'];
|
|
let threw = false;
|
|
try {
|
|
await op.handler(makeCtx({ remote: true }), {
|
|
path: tmpFile,
|
|
page_slug: 'people/sarah-chen',
|
|
});
|
|
} catch (e: any) {
|
|
threw = true;
|
|
expect(String(e.message || e)).toMatch(/within the working directory/i);
|
|
}
|
|
expect(threw).toBe(true);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Security: Query Bounds
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: file_list LIMIT enforcement', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('file_list with slug filter respects LIMIT 100', async () => {
|
|
const sql = getConn();
|
|
const testSlug = 'test-limit-slug';
|
|
|
|
// Create the parent page first (FK constraint on files.page_slug)
|
|
await sql`
|
|
INSERT INTO pages (slug, title, type, compiled_truth, frontmatter)
|
|
VALUES (${testSlug}, ${'Test Limit Page'}, ${'note'}, ${'body'}, ${'{}'}::jsonb)
|
|
ON CONFLICT (source_id, slug) DO NOTHING
|
|
`;
|
|
|
|
// Insert 150 file rows for the same slug
|
|
for (let i = 0; i < 150; i++) {
|
|
await sql`
|
|
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
|
VALUES (${testSlug}, ${'file-' + String(i).padStart(3, '0') + '.txt'}, ${testSlug + '/file-' + i + '.txt'}, ${'text/plain'}, ${100}, ${'hash-' + i}, ${'{}'}::jsonb)
|
|
ON CONFLICT (storage_path) DO NOTHING
|
|
`;
|
|
}
|
|
|
|
// Verify we inserted 150
|
|
const count = await sql`SELECT count(*) as cnt FROM files WHERE page_slug = ${testSlug}`;
|
|
expect(Number(count[0].cnt)).toBe(150);
|
|
|
|
// Call file_list with slug — should return at most 100
|
|
const files = await callOp('file_list', { slug: testSlug }) as any[];
|
|
expect(files.length).toBeLessThanOrEqual(100);
|
|
expect(files.length).toBe(100);
|
|
}, 30_000);
|
|
|
|
test('file_list without slug also respects LIMIT 100', async () => {
|
|
// The 150 rows from the previous test are still in the DB
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Idempotency Stress
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Idempotency', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('double import produces no duplicates', async () => {
|
|
// First import
|
|
await importFixtures();
|
|
const stats1 = await callOp('get_stats') as any;
|
|
|
|
// Second import (identical content)
|
|
await importFixtures();
|
|
const stats2 = await callOp('get_stats') as any;
|
|
|
|
expect(stats2.page_count).toBe(stats1.page_count);
|
|
expect(stats2.chunk_count).toBe(stats1.chunk_count);
|
|
}, 30_000);
|
|
|
|
test('modify one fixture, reimport, only that page updates', async () => {
|
|
await importFixtures();
|
|
const engine = getEngine();
|
|
|
|
// Modify sarah-chen content
|
|
const modified = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Stanford CS', 'MIT CS');
|
|
|
|
const result = await importFromContent(engine, 'people/sarah-chen', modified, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
|
|
// Other pages should have been skipped if reimported
|
|
const content = readFileSync(join(FIXTURES_PATH, 'people/marcus-reid.md'), 'utf-8');
|
|
const { parseMarkdown } = await import('../../src/core/markdown.ts');
|
|
const parsed = parseMarkdown(content, 'people/marcus-reid.md');
|
|
const result2 = await importFromContent(engine, parsed.slug, content, { noEmbed: true });
|
|
expect(result2.status).toBe('skipped');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Setup Journey (CLI subprocess tests)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Setup Journey', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
test('gbrain init --non-interactive connects and initializes', () => {
|
|
// v0.37.10.0: pass --embedding-model explicitly. Tier-1 CI runs without
|
|
// any embedding-provider env var, and the v0.37 fail-loud-no-key gate
|
|
// (D3) would otherwise exit 1 here. The provider is offline-resolved
|
|
// (preflight validates dim against recipe; no HTTP call), so this works
|
|
// without a real API key. After this init writes config, subsequent
|
|
// inits in the file honor persisted config per D5 (no flag needed).
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!,
|
|
'--embedding-model', 'openai:text-embedding-3-large'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout).toContain('Brain ready');
|
|
}, 30_000);
|
|
|
|
test('gbrain import imports fixtures via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout).toContain('imported');
|
|
}, 60_000);
|
|
|
|
test('gbrain search returns results via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'search', 'NovaMind'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout.length).toBeGreaterThan(0);
|
|
}, 30_000);
|
|
|
|
test('gbrain stats shows page count via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'stats'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('gbrain health runs via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'health'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Init Edge Cases
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Init Edge Cases', () => {
|
|
afterAll(teardownDB);
|
|
|
|
test('init --non-interactive without URL fails gracefully', () => {
|
|
const env = { ...process.env };
|
|
delete env.DATABASE_URL;
|
|
delete env.GBRAIN_DATABASE_URL;
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive'],
|
|
cwd: join(import.meta.dir, '../..'),
|
|
env,
|
|
timeout: 10_000,
|
|
});
|
|
expect(result.exitCode).not.toBe(0);
|
|
}, 30_000);
|
|
|
|
test('double init is idempotent', async () => {
|
|
await setupDB();
|
|
const conn = getConn();
|
|
const before = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
|
|
// Re-init
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const after = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
expect(after[0].n).toBe(before[0].n);
|
|
});
|
|
|
|
test('init then import then re-init preserves pages', async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
const before = await callOp('get_stats') as any;
|
|
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const after = await callOp('get_stats') as any;
|
|
expect(after.page_count).toBe(before.page_count);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Schema Idempotency
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Schema Idempotency', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('initSchema twice produces no errors and same object count', async () => {
|
|
const conn = getConn();
|
|
const tables1 = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
const indexes1 = await conn.unsafe(`SELECT count(*) as n FROM pg_indexes WHERE schemaname = 'public'`);
|
|
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const tables2 = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
const indexes2 = await conn.unsafe(`SELECT count(*) as n FROM pg_indexes WHERE schemaname = 'public'`);
|
|
|
|
expect(tables2[0].n).toBe(tables1[0].n);
|
|
expect(indexes2[0].n).toBe(indexes1[0].n);
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Schema Diff Guard
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Schema Diff Guard', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('all expected tables exist', async () => {
|
|
const conn = getConn();
|
|
const tables = await conn.unsafe(`
|
|
SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
ORDER BY table_name
|
|
`);
|
|
const tableNames = tables.map((t: any) => t.table_name);
|
|
|
|
const expected = [
|
|
'config', 'content_chunks', 'files', 'ingest_log',
|
|
'links', 'page_versions', 'pages', 'raw_data',
|
|
'tags', 'timeline_entries',
|
|
];
|
|
for (const table of expected) {
|
|
expect(tableNames).toContain(table);
|
|
}
|
|
}, 30_000);
|
|
|
|
test('pgvector extension is installed', async () => {
|
|
const conn = getConn();
|
|
const ext = await conn.unsafe(`SELECT extname FROM pg_extension WHERE extname = 'vector'`);
|
|
expect(ext.length).toBe(1);
|
|
});
|
|
|
|
test('pg_trgm extension is installed', async () => {
|
|
const conn = getConn();
|
|
const ext = await conn.unsafe(`SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'`);
|
|
expect(ext.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Slug with Special Characters (Apple Notes fix)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Slug with Special Characters', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('imports files with spaces in filename', async () => {
|
|
const page = await callOp('get_page', { slug: 'apple-notes/2017-05-03-ohmygreen' }) as any;
|
|
expect(page).not.toBeNull();
|
|
expect(page.title).toBe('OhMyGreen');
|
|
expect(page.type).toBe('company');
|
|
}, 30_000);
|
|
|
|
test('imports files with parens in filename', async () => {
|
|
const page = await callOp('get_page', { slug: 'apple-notes/notes-march-2024' }) as any;
|
|
expect(page).not.toBeNull();
|
|
expect(page.title).toBe('March 2024 Notes');
|
|
});
|
|
|
|
test('search finds content from special-char files', async () => {
|
|
const results = await callOp('search', { query: 'OhMyGreen' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('apple-notes/2017-05-03-ohmygreen');
|
|
});
|
|
|
|
test('re-import of special-char files is idempotent', async () => {
|
|
const before = await callOp('get_stats') as any;
|
|
await importFixtures(); // second import
|
|
const after = await callOp('get_stats') as any;
|
|
expect(after.page_count).toBe(before.page_count);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// RLS Verification
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: RLS Verification', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
// Seed a unique suffix per run so concurrent test DBs / crashed prior
|
|
// runs don't collide. All helper tables follow `gbrain_rls_regression_<suffix>`.
|
|
const suffix = `${process.pid}_${Date.now()}`;
|
|
|
|
test('RLS is enabled on every public table (no hardcoded allowlist)', async () => {
|
|
const conn = getConn();
|
|
const tables = await conn.unsafe(`
|
|
SELECT tablename, rowsecurity FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
`);
|
|
const noRls = tables.filter((t: any) => !t.rowsecurity);
|
|
// Some test DBs may not have BYPASSRLS privilege, so RLS might be skipped.
|
|
// If RLS was enabled at all (the common case against Docker postgres), EVERY
|
|
// public table must have it — no hardcoded IN-list exceptions.
|
|
if (tables.some((t: any) => t.rowsecurity)) {
|
|
expect(noRls.map((t: any) => t.tablename)).toEqual([]);
|
|
}
|
|
}, 30_000);
|
|
|
|
test('current user role has BYPASSRLS', async () => {
|
|
const conn = getConn();
|
|
const rows = await conn.unsafe(`SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user`);
|
|
if (rows.length > 0) {
|
|
expect(rows[0].rolbypassrls).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('gbrain doctor fails with exit 1 when a public table is missing RLS', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_regression_${suffix}`;
|
|
try {
|
|
// Init first so all migrations (including v35's auto-RLS event trigger
|
|
// and one-time backfill) are applied. AFTER migrations run, simulate
|
|
// the post-v35 escape route: operator drops the auto-RLS trigger
|
|
// (e.g. while debugging) and creates a public table without RLS.
|
|
// doctor's existing rls check must still flag it. The new
|
|
// rls_event_trigger check warns separately about the missing trigger.
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
|
|
// Drop the trigger so CREATE TABLE doesn't auto-enable RLS, then create
|
|
// the test table without RLS. ALTER TABLE … DISABLE is a belt-and-
|
|
// suspenders no-op in this path but matches what an operator would do
|
|
// if they had toggled RLS off manually after the trigger ran.
|
|
await conn.unsafe(`DROP EVENT TRIGGER IF EXISTS auto_rls_on_create_table`);
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls).toBeDefined();
|
|
expect(rls.status).toBe('fail');
|
|
expect(rls.message).toContain(tbl);
|
|
expect(rls.message).toContain('ALTER TABLE');
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
// Restore the trigger via a no-op v35 replay so subsequent tests in
|
|
// this file (which expect the post-init steady state) don't see drift.
|
|
const { MIGRATIONS } = await import('../../src/core/migrate.ts');
|
|
const v35sql = (MIGRATIONS.find(m => m.version === 35)?.sqlFor as any)?.postgres;
|
|
if (v35sql) await conn.unsafe(v35sql);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('GBRAIN:RLS_EXEMPT comment with valid reason exempts a non-RLS public table', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_exempt_ok_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT reason=e2e test fixture, anon-readable ok'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('ok');
|
|
expect(rls.message).toContain('explicitly exempt');
|
|
expect(rls.message).toContain(tbl);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('GBRAIN:RLS_EXEMPT comment WITHOUT reason= still fails doctor', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_exempt_bad_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
// Missing the `reason=<...>` segment — prefix alone is not enough.
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('fail');
|
|
expect(rls.message).toContain(tbl);
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('Non-exempt unrelated COMMENT on a no-RLS table still fails doctor', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_comment_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'Regular docs comment, not an exemption'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('fail');
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
// Regression test for the v24 self-healing guard. If an operator manually
|
|
// drops budget_ledger and/or budget_reservations (they are migration-only
|
|
// per v12, not in schema.sql, and the data is regenerable from resolver
|
|
// logs — so dropping them is a reasonable cleanup), v24 must NOT fail
|
|
// with 42P01. The information_schema.tables IF EXISTS guards around those
|
|
// two ALTERs let the migration skip them and continue.
|
|
//
|
|
// Without the guard, a brain with dropped budget_* tables would get stuck
|
|
// in an infinite retry loop: v24 fails → transaction rolls back →
|
|
// schema_version stays at prior value → next initSchema re-runs v24 →
|
|
// same failure forever.
|
|
test('v24 self-heals when budget_ledger + budget_reservations are missing', async () => {
|
|
const conn = getConn();
|
|
let priorVersion: string | null = null;
|
|
try {
|
|
// Capture current version so we can restore after the test.
|
|
const verRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
|
priorVersion = (verRows[0] as any)?.value ?? null;
|
|
|
|
// Simulate an operator who dropped the budget_* tables for any reason
|
|
// (cleanup, migration from an older gbrain, etc).
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_ledger CASCADE`);
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_reservations CASCADE`);
|
|
|
|
// Roll the version back to 23 so v24 re-runs on the next initSchema.
|
|
// UPSERT so this works whether the key exists or not.
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('version', '23')
|
|
ON CONFLICT (key) DO UPDATE SET value = '23'
|
|
`);
|
|
|
|
// Re-trigger initSchema via the CLI. With the guard, this should
|
|
// apply v24 cleanly and advance version to 24. Without the guard,
|
|
// this would error out with 42P01 and leave version at 23.
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 30_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const stderr = new TextDecoder().decode(result.stderr);
|
|
|
|
// Must succeed — no 42P01, no transaction rollback.
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stderr + stdout).not.toMatch(/42P01|does not exist.*budget/i);
|
|
|
|
// Version must have advanced PAST 24. Since v0.18.1, v25-v29 (v0.19.0
|
|
// + v0.21.0 Cathedral II) and v30 (OAuth) have shipped. init runs every
|
|
// pending migration, so after rolling back to 23 the version advances
|
|
// to LATEST_VERSION. The test's intent is to prove v24 didn't crash on
|
|
// missing budget_* tables — assert version >= 24.
|
|
const afterRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
|
const finalVersion = parseInt((afterRows[0] as any).value, 10);
|
|
expect(finalVersion).toBeGreaterThanOrEqual(24);
|
|
|
|
// The tables stayed dropped (v12 didn't re-run because current=23 > 12
|
|
// was already true before this test ran). That's intentional — we're
|
|
// proving v24 doesn't require those tables to exist.
|
|
const tblRows = await conn.unsafe(`
|
|
SELECT tablename FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
AND tablename IN ('budget_ledger', 'budget_reservations')
|
|
`);
|
|
expect(tblRows.length).toBe(0);
|
|
} finally {
|
|
// Restore: recreate the budget_* tables (minimal schema — just enough
|
|
// to keep the rest of the test suite happy) and reset version.
|
|
// Mirror migration v12's CREATE TABLE IF NOT EXISTS exactly so any
|
|
// downstream test that touches these tables sees the original shape.
|
|
await conn.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS budget_ledger (
|
|
scope TEXT NOT NULL,
|
|
resolver_id TEXT NOT NULL,
|
|
local_date DATE NOT NULL,
|
|
reserved_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
|
committed_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
|
cap_usd NUMERIC(12,4),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (scope, resolver_id, local_date)
|
|
)
|
|
`);
|
|
await conn.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS budget_reservations (
|
|
reservation_id TEXT PRIMARY KEY,
|
|
scope TEXT NOT NULL,
|
|
resolver_id TEXT NOT NULL,
|
|
local_date DATE NOT NULL,
|
|
estimate_usd NUMERIC(12,4) NOT NULL,
|
|
reserved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'held'
|
|
)
|
|
`);
|
|
// Enable RLS on the recreated tables so the "every public table has
|
|
// RLS" assertion earlier in this block stays green if re-run.
|
|
await conn.unsafe(`ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY`);
|
|
// Restore version so we don't leave the DB at a weird state for
|
|
// subsequent test blocks.
|
|
if (priorVersion !== null) {
|
|
await conn.unsafe(
|
|
`UPDATE config SET value = $1 WHERE key = 'version'`,
|
|
[priorVersion],
|
|
);
|
|
}
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Doctor Command
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Doctor Command', () => {
|
|
// Scope GBRAIN_HOME to a hermetic tmpdir so `gbrain doctor` doesn't read
|
|
// the developer's local ~/.gbrain/migrations/completed.jsonl. Stale partial
|
|
// entries from in-flight workspaces (e.g. v0.31.x santiago) would make the
|
|
// minions_migration check fail and exit 1, masking real DB-health failures.
|
|
let gbrainHome: string;
|
|
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
// Isolate GBRAIN_HOME to a per-block tempdir so the developer's
|
|
// ~/.gbrain/migrations/completed.jsonl ledger doesn't leak in. Without
|
|
// this, doctor reads the dev machine state — partial v0.21/v0.22.4/v0.28.0
|
|
// migration entries from in-flight workspaces — and surfaces them as the
|
|
// 'minions_migration' [FAIL] check, exiting with code 1.
|
|
gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-e2e-'));
|
|
// Cross-file isolation: prior E2E files can leave non-default `sources`
|
|
// rows (e.g. 'delta' from autopilot/sources tests). Doctor's
|
|
// sync_freshness + cycle_freshness checks then FAIL on those orphans,
|
|
// exit 1, breaking 'doctor exits 0 on healthy DB'. setupDB TRUNCATEs
|
|
// sources but schema.sql re-seeds 'default' via initSchema; clean any
|
|
// other rows so the doctor sees a clean single-source brain.
|
|
const conn = getConn();
|
|
await conn`DELETE FROM sources WHERE id != 'default'`;
|
|
}, 30_000);
|
|
afterAll(async () => {
|
|
await teardownDB();
|
|
if (gbrainHome) rmSync(gbrainHome, { recursive: true, force: true });
|
|
});
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({
|
|
...process.env,
|
|
DATABASE_URL: process.env.DATABASE_URL!,
|
|
GBRAIN_DATABASE_URL: process.env.DATABASE_URL!,
|
|
GBRAIN_HOME: gbrainHome,
|
|
});
|
|
|
|
test('gbrain doctor exits 0 on healthy DB', () => {
|
|
// Init first so config exists for CLI. Pin --embedding-model explicitly
|
|
// so the spawned doctor doesn't pick a different default (e.g. ZE-1280d
|
|
// when ZEROENTROPY_API_KEY is in env) that mismatches the 1536d schema
|
|
// setupDB initialized, producing a WARN-status embedding_width_consistency
|
|
// check and exit 1. Mirrors the same pattern in 'Setup Journey'.
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive',
|
|
'--url', process.env.DATABASE_URL!,
|
|
'--embedding-model', 'openai:text-embedding-3-large'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
if (result.exitCode !== 0) {
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const stderr = new TextDecoder().decode(result.stderr);
|
|
console.error('doctor stdout:', stdout.slice(-2000));
|
|
console.error('doctor stderr:', stderr.slice(-1000));
|
|
}
|
|
expect(result.exitCode).toBe(0);
|
|
}, 60_000);
|
|
|
|
test('gbrain doctor --json produces valid JSON', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.status).toBeDefined();
|
|
expect(Array.isArray(parsed.checks)).toBe(true);
|
|
expect(parsed.checks.length).toBeGreaterThan(0);
|
|
for (const check of parsed.checks) {
|
|
expect(['ok', 'warn', 'fail']).toContain(check.status);
|
|
expect(typeof check.name).toBe('string');
|
|
expect(typeof check.message).toBe('string');
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Parallel Import
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Parallel Import', () => {
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
function initCli() {
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
// Store sequential baseline for comparison
|
|
let seqPageCount: number;
|
|
let seqChunkCount: number;
|
|
let seqPageSlugs: string[];
|
|
|
|
test('sequential baseline: import all fixtures', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
seqPageCount = stats.page_count;
|
|
seqChunkCount = stats.chunk_count;
|
|
|
|
const pages = await callOp('list_pages', { limit: 200 }) as any[];
|
|
seqPageSlugs = pages.map((p: any) => p.slug).sort();
|
|
|
|
expect(seqPageCount).toBeGreaterThan(0);
|
|
expect(seqChunkCount).toBeGreaterThan(0);
|
|
}, 60_000);
|
|
|
|
test('parallel import with --workers 2 matches sequential page count', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '2', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
}, 60_000);
|
|
|
|
test('parallel import has same chunk count (no duplicates)', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
});
|
|
|
|
test('parallel import has same page slugs', async () => {
|
|
const pages = await callOp('list_pages', { limit: 200 }) as any[];
|
|
const parSlugs = pages.map((p: any) => p.slug).sort();
|
|
expect(parSlugs).toEqual(seqPageSlugs);
|
|
});
|
|
|
|
test('no duplicate pages from concurrent writes', async () => {
|
|
const conn = getConn();
|
|
const dupes = await conn.unsafe(`
|
|
SELECT slug, count(*) as n FROM pages GROUP BY slug HAVING count(*) > 1
|
|
`);
|
|
expect(dupes.length).toBe(0);
|
|
});
|
|
|
|
test('no duplicate chunks from concurrent writes', async () => {
|
|
const conn = getConn();
|
|
const dupes = await conn.unsafe(`
|
|
SELECT page_id, chunk_index, count(*) as n
|
|
FROM content_chunks
|
|
GROUP BY page_id, chunk_index
|
|
HAVING count(*) > 1
|
|
`);
|
|
expect(dupes.length).toBe(0);
|
|
});
|
|
|
|
test('parallel import with --workers 4 also works', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '4', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
}, 60_000);
|
|
|
|
test('re-import with workers is idempotent', async () => {
|
|
// Import again on top of existing data
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '2', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
}, 60_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Performance Baselines
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Performance Baselines', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
}, 30_000);
|
|
afterAll(teardownDB);
|
|
|
|
test('import + search + link performance', async () => {
|
|
const [_, importMs] = await time(importFixtures);
|
|
|
|
const searchTimes: number[] = [];
|
|
for (const q of ['NovaMind', 'hybrid search', 'Stanford', 'investor', 'compiled truth']) {
|
|
const [__, ms] = await time(() => callOp('search', { query: q }));
|
|
searchTimes.push(ms);
|
|
}
|
|
|
|
const [___, linkMs] = await time(async () => {
|
|
await callOp('add_link', { from: 'people/sarah-chen', to: 'companies/novamind' });
|
|
await callOp('get_backlinks', { slug: 'companies/novamind' });
|
|
});
|
|
|
|
searchTimes.sort((a, b) => a - b);
|
|
const p50 = searchTimes[Math.floor(searchTimes.length * 0.5)];
|
|
const p99 = searchTimes[searchTimes.length - 1];
|
|
|
|
console.log('\n Performance Baselines:');
|
|
console.log(` Import 13 fixtures: ${importMs.toFixed(0)}ms`);
|
|
console.log(` Search p50: ${p50.toFixed(0)}ms`);
|
|
console.log(` Search p99: ${p99.toFixed(0)}ms`);
|
|
console.log(` Link + backlink: ${linkMs.toFixed(0)}ms`);
|
|
}, 30_000);
|
|
});
|