GitNexus/gitnexus/test/helpers/test-indexed-db.ts
Gergő Magyar 2a5bbbeaae
fix: make extension installs offline-first (#1161)
* feat(review): add PR reviewer swarm agents

Seven read-only subagents coordinated by an orchestration skill for
structured, evidence-grounded production-readiness PR reviews.

Agents: facts-historian, branch-hygiene, risk-architect, test-ci-verifier,
security-boundary, docs-dod, synthesis-critic. All use Read/Grep/Glob/Bash
only — no edit tools.

Skill invoked as /gitnexus-pr-swarm-review <PR>.

* fix: patch vector extension and uncaughtException for review findings

- Add { policy: 'auto' } to both loadVectorExtension() calls in
  embedding-pipeline.ts so analyze --embeddings auto-installs VECTOR
- Add void to uncaughtException shutdown(1) call for Node v20+ safety
- Re-add getExtensionInstallPolicy export + default change + 4 tests

* fix(mcp,lbug): graceful shutdown exit codes + complete offline-first VECTOR policy

Completes the two live issues PR #1161 only partially addressed.

#1132 — MCP shutdown crash: SIGINT/SIGTERM were registered with `shutdown`
directly, so Node passed the signal NAME string into process.exit(), crashing
with ERR_INVALID_ARG_TYPE ('SIGTERM'). Map signals to numeric exit codes
(SIGINT->130, SIGTERM->143) via a testable installSignalShutdown(); add an
unref'd force-exit watchdog so a hung disconnect()/close() cannot wedge
shutdown; and void the stdin/stdout handlers so event payloads never reach
process.exit() as a non-number.

#1153 — offline-first extension loading:
- semanticSearch (a query/read path) no longer forces policy:'auto'; queries
  use load-only and never spawn a network INSTALL (extension.ladybugdb.com).
- the analyze embedding WRITE path resolves the policy from
  GITNEXUS_LBUG_EXTENSION_INSTALL (honoring never/load-only/auto; default auto)
  instead of hard-forcing 'auto', so an offline/locked-down operator's override
  is respected (the regression that re-broke #1153 for the VECTOR path).
- surface the active install policy in `gitnexus doctor` (was claimed but never
  delivered; also gives the previously-dead getExtensionInstallPolicy a caller).
- emit an actionable message when VECTOR is unavailable.

Tests: regression for the signal->numeric mapping (reproduces the signal-string
crash condition) and for embedding install-policy resolution. tsc/prettier clean,
eslint 0 errors, 55 unit tests pass.

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

* fix(analyze): degrade gracefully when FTS extension is unavailable

The load-only default made `gitnexus analyze` throw when the FTS
extension was not pre-installed, breaking CI and offline use. Make the
analyze write path opt into the `auto` install policy (LOAD-first then
bounded INSTALL — symmetric with the VECTOR/embeddings path and the #726
contract) and degrade gracefully when the extension still cannot load:
skip search-index creation, log a warning, and complete with a fully
queryable graph (only full-text/BM25 search is disabled). `--repair-fts`
still fails loudly.

- Surface the degraded state instead of reporting healthy:
  AnalyzeResult.ftsSkipped, a persistent CLI summary warning, and
  meta.json capabilities.fts.status = "unavailable".
- Skip the FTS-primitive integration tests when the extension is
  unavailable (shared skipUnlessFtsAvailable helper).
- Add a unit test for the degradation branch; fix the existing
  full-analyze test mock that omitted loadFTSExtension.

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

* test(lbug): skip FTS-seeding suites when extension is unavailable

The withTestLbugDB helper seeds FTS indexes in beforeAll via createFTSIndex,
which throws when the optional FTS extension cannot load — failing the whole
suite on machines where it is neither pre-installed nor installable (the
macOS platform-sensitive CI runner). Probe the extension once (mirroring the
analyze write path's `auto` policy), bypass FTS seeding when it is
unavailable, and skip the suite's tests via beforeEach with a one-time
warning so the skip is visible rather than a setup crash.

Fixes the macOS failures in search-core, search-pool, local-backend-calltool,
and staleness-and-stability. Suites still run normally where FTS is available.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:04:41 +01:00

214 lines
8.5 KiB
TypeScript

/**
* Test helper: Indexed LadybugDB lifecycle manager
*
* Creates an isolated LadybugDB per suite, reseeds, and initializes adapters.
*
* Cleanup properly closes adapters and releases native resources.
*
* Each test file gets a unique repoId to prevent MCP pool map collisions.
* Seed data is NOT included — each test provides its own via options.seed.
*/
import path from 'path';
import { describe, beforeAll, beforeEach, afterAll } from 'vitest';
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
import { createTempDir, type TestDBHandle } from './test-db.js';
import { NODE_TABLES, EMBEDDING_TABLE_NAME } from '../../src/core/lbug/schema.js';
export interface IndexedDBHandle {
/** Path to the LadybugDB database file */
dbPath: string;
/** Unique repoId for MCP pool adapter — prevents cross-file collisions */
repoId: string;
/** Temp directory handle for filesystem cleanup */
tmpHandle: TestDBHandle;
/** Cleanup: closes adapters and releases native resources */
cleanup: () => Promise<void>;
}
let repoCounter = 0;
/** FTS index definition for withTestLbugDB */
export interface FTSIndexDef {
table: string;
indexName: string;
columns: string[];
}
/**
* Options for withTestLbugDB lifecycle.
*
* Lifecycle: initLbug → loadFTS → dropFTS → clearData → seed
* → createFTS → [closeCoreLbug + poolInitLbug] → afterSetup
*/
export interface WithTestLbugDBOptions {
/** Cypher CREATE queries to insert seed data (runs before core adapter opens). */
seed?: string[];
/** FTS indexes to create after seeding. */
ftsIndexes?: FTSIndexDef[];
/** Close core adapter and open pool adapter (read-only) after FTS setup. */
poolAdapter?: boolean;
/** Run after all lifecycle phases complete (mocks, dynamic imports, etc). */
afterSetup?: (handle: IndexedDBHandle) => Promise<void>;
/** Timeout for beforeAll in ms (default: 120000). */
timeout?: number;
}
/**
* Manages the full LadybugDB test lifecycle:
* database creation, data clearing, reseeding, FTS indexes, adapter init/teardown.
*
* All data operations go through the core adapter's writable connection —
* no raw lbug.Database() connections are opened. This avoids file-lock
* conflicts with orphaned native objects from previous test files.
*
* Each call is wrapped in its own `describe` block to isolate lifecycle
* hooks — safe to call multiple times in the same file.
*/
export function withTestLbugDB(
prefix: string,
fn: (handle: IndexedDBHandle) => void,
options?: WithTestLbugDBOptions,
): void {
const ref: { handle: IndexedDBHandle | undefined } = { handle: undefined };
// Default must match vitest.config hookTimeout (120s). KuzuDB pool-adapter
// init on Windows CI regularly exceeds 30s due to native resource setup.
const timeout = options?.timeout ?? 120_000;
// Suites that seed FTS indexes need the optional FTS extension. It is not
// guaranteed on every machine (e.g. the macOS platform-sensitive CI runner,
// where it is neither pre-installed nor installable). Track availability so
// setup can skip FTS seeding instead of throwing, and so every test in the
// suite is skipped rather than failing against a missing index. (PR #1161.)
const ftsRequired = !!options?.ftsIndexes?.length;
let ftsAvailable = true;
let ftsSkipWarned = false;
const setup = async () => {
const tmpHandle = await createTempDir('gitnexus-lbug-');
const dbPath = path.join(tmpHandle.dbPath, 'lbug');
const repoId = `test-${prefix}-${Date.now()}-${repoCounter++}`;
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// 1. Init core adapter (writable) — reuses existing connection if
// already open for this dbPath (no new native objects created).
await adapter.initLbug(dbPath);
// 1b. Probe the FTS extension for suites that need it, mirroring the
// analyze write path (`auto`: LOAD-first, then one bounded INSTALL).
// When it still cannot load, the suite is skipped (see beforeEach)
// and FTS seeding below is bypassed so setup never throws.
if (ftsRequired) {
ftsAvailable = await adapter.loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
}
// 2. Drop stale FTS indexes from previous test file
if (options?.ftsIndexes?.length) {
for (const idx of options.ftsIndexes) {
try {
await adapter.dropFTSIndex(idx.table, idx.indexName);
} catch {
/* may not exist */
}
}
}
// 3. Clear all data via adapter (DETACH DELETE cascades to relationships)
for (const table of NODE_TABLES) {
await adapter.executeQuery(`MATCH (n:\`${table}\`) DETACH DELETE n`);
}
await adapter.executeQuery(`MATCH (n:${EMBEDDING_TABLE_NAME}) DELETE n`);
// 4. Seed new data via adapter
if (options?.seed?.length) {
for (const q of options.seed) {
await adapter.executeQuery(q);
}
}
// 5. Create FTS indexes on fresh data (only when the extension loaded;
// otherwise the suite is skipped via beforeEach below).
if (options?.ftsIndexes?.length && ftsAvailable) {
for (const idx of options.ftsIndexes) {
await adapter.createFTSIndex(idx.table, idx.indexName, idx.columns);
}
}
// 5b. Flush WAL so seed data + FTS indexes are visible to the pool
// adapter's read path. Without this, Windows CI intermittently
// fails FTS queries because the WAL hasn't been checkpointed
// before the pool adapter starts reading.
await adapter.flushWAL();
// 6. Open pool adapter by injecting the core adapter's writable Database.
// LadybugDB enforces file locks — writable + read-only can't coexist
// on the same path, and db.close() segfaults on macOS due to N-API
// destructor issues. Reusing the writable Database avoids both problems.
// NOTE: This injected DB is writable by design for test setup.
// Read-only enforcement tests must initialize a separate pool entry
// via initLbug(...) so Ladybug native read-only mode is exercised.
if (options?.poolAdapter) {
const coreDb = adapter.getDatabase();
if (!coreDb) throw new Error('withTestLbugDB: core adapter has no open Database');
const { initLbugWithDb } = await import('../../src/core/lbug/pool-adapter.js');
await initLbugWithDb(repoId, coreDb, dbPath);
}
const cleanup = async () => {
if (options?.poolAdapter) {
const poolAdapter = await import('../../src/core/lbug/pool-adapter.js');
await poolAdapter.closeLbug(repoId);
}
await adapter.closeLbug();
await tmpHandle.cleanup();
};
// tmpHandle.dbPath → parent temp dir (not the lbug file) so tests
// that create sibling directories (e.g. 'storage') still work.
ref.handle = { dbPath, repoId, tmpHandle, cleanup };
// 7. User's final setup (mocks, dynamic imports, etc.)
if (options?.afterSetup) {
await options.afterSetup(ref.handle);
}
};
const lazyHandle = new Proxy({} as IndexedDBHandle, {
get(_target, prop) {
if (!ref.handle)
throw new Error('withTestLbugDB: handle not initialized — beforeAll has not run yet');
return (ref.handle as any)[prop];
},
});
// Wrap in describe to scope beforeAll/afterAll — prevents lifecycle
// collisions when multiple withTestLbugDB calls share the same file.
describe(`withTestLbugDB(${prefix})`, () => {
beforeAll(setup, timeout);
// Skip FTS-dependent suites when the extension could not be loaded or
// installed on this machine. Without this, tests would assert against a
// missing index and fail. Warn once so the skip is visible, not silent.
beforeEach((ctx) => {
if (ftsRequired && !ftsAvailable) {
if (!ftsSkipWarned) {
ftsSkipWarned = true;
console.warn(
`[withTestLbugDB(${prefix})] Skipping FTS-dependent tests — the LadybugDB ` +
`FTS extension is unavailable (not pre-installed and could not be installed).`,
);
}
ctx.skip();
}
});
// Explicit timeout: KuzuDB's C++ destructor can hang on Windows during
// native resource cleanup. The vitest hookTimeout (120s) should apply
// automatically, but some vitest versions fall back to testTimeout (30s)
// for afterAll. Pass 120s explicitly to avoid CI flakes on Windows.
afterAll(async () => {
if (ref.handle) await ref.handle.cleanup();
}, 120_000);
fn(lazyHandle);
});
}