GitNexus/gitnexus/test/integration/lbug-core-adapter.test.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

231 lines
10 KiB
TypeScript

/**
* P0 Integration Tests: Core LadybugDB Adapter
*
* Tests: loadGraphToLbug CSV round-trip, createFTSIndex, getLbugStats.
*
* IMPORTANT: All core adapter tests share ONE coreHandle and ONE coreInitLbug
* call because the core adapter is a module-level singleton. Calling
* coreInitLbug with a different path closes the previous native DB handle
* and opens a new one — sharing a single handle avoids unnecessary churn.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
/**
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
* does not release the underlying file lock until the process exits, so any
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
* Win32 Error 33. Production paths are unaffected (single open per process).
*
* Tracking: kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on Windows).
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
/**
* The FTS extension is optional and defaults to a `load-only` install policy
* (PR #1161 — offline-first), so on a machine where it was never pre-installed
* it cannot load. The tests below exercise the FTS *primitives* directly and
* have nothing to assert without the extension — skip them rather than fail.
* Graceful degradation when FTS is unavailable is covered at the analyze /
* query layer (see run-analyze.ts and the BM25 fallback tests).
*/
const FTS_UNAVAILABLE_NOTE =
'FTS extension unavailable (load-only policy; not pre-installed on this machine)';
/**
* Dynamically skip an FTS-primitive test when the extension cannot load.
* `ctx.skip()` aborts the test, so callers should `await` this first thing.
*/
const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void }): Promise<void> => {
const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js');
if (!(await loadFTSExtension())) ctx.skip(FTS_UNAVAILABLE_NOTE);
};
// ─── Core LadybugDB Adapter ─────────────────────────────────────────────
withTestLbugDB(
'core-adapter',
(handle) => {
describe('core adapter', () => {
it('loadGraphToLbug: loads a minimal graph and node counts match', async () => {
const { executeQuery: coreExecuteQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
// createMinimalTestGraph has 2 File, 2 Function, 1 Class, 1 Folder = 6 nodes
const fileRows = await coreExecuteQuery('MATCH (n:File) RETURN n.id AS id');
expect(fileRows).toHaveLength(2);
const funcRows = await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id');
expect(funcRows).toHaveLength(2);
const classRows = await coreExecuteQuery('MATCH (n:Class) RETURN n.id AS id');
expect(classRows).toHaveLength(1);
const folderRows = await coreExecuteQuery('MATCH (n:Folder) RETURN n.id AS id');
expect(folderRows).toHaveLength(1);
});
it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
await expect(
createFTSIndex('Function', 'function_fts', ['name', 'content']),
).resolves.toBeUndefined();
});
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const lbug = (await import('@ladybugdb/core')).default;
const { loadFTSExtension, getDatabase } =
await import('../../src/core/lbug/lbug-adapter.js');
const db = getDatabase();
expect(db).not.toBeNull();
// Fresh Connection on the same Database — simulates the pool adapter's
// path where loadFTSExtension is called with an explicit connection
// rather than the module-level singleton.
const freshConn = new lbug.Connection(db!);
try {
const loaded = await loadFTSExtension(freshConn);
expect(loaded).toBe(true);
// Idempotent on the same connection — calling again still returns true
// (exercises the "already loaded" catch branch in the fallback path).
const loadedAgain = await loadFTSExtension(freshConn);
expect(loadedAgain).toBe(true);
} finally {
await freshConn.close().catch(() => {});
}
});
it('getLbugStats: returns correct node and edge counts for seeded data', async () => {
const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');
const stats = await getLbugStats();
// createMinimalTestGraph: 6 nodes (2 File, 2 Function, 1 Class, 1 Folder)
expect(stats.nodes).toBe(6);
// 4 relationships (2 CALLS, 2 CONTAINS)
expect(stats.edges).toBe(4);
});
describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// Deliberately broken syntax: MATCH without a pattern clause
await expect(executeQuery('MATCH RETURN 1')).rejects.toThrow();
});
it('returns empty results for query matching no nodes', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// Valid Cypher, but the id will never exist in the seeded graph
const rows = await executeQuery(
"MATCH (n:Function) WHERE n.id = '__nonexistent_id__' RETURN n.id AS id",
);
expect(rows).toHaveLength(0);
});
it('handles query with non-existent table/node label', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// LadybugDB throws when the node table does not exist in the schema
await expect(executeQuery('MATCH (n:GhostTable) RETURN n')).rejects.toThrow();
});
});
describe('error handling', () => {
it('createFTSIndex handles already-existing index gracefully', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index (may already exist from earlier test)
await createFTSIndex('Function', 'function_fts_dup', ['name', 'content']);
// Second call with same params should NOT throw — createFTSIndex catches "already exists"
await expect(
createFTSIndex('Function', 'function_fts_dup', ['name', 'content']),
).resolves.toBeUndefined();
});
it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index. Second call must short-circuit on the
// in-process cache — guarantees the read-only guard added in #1224
// still respects the success path.
await expect(
ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
).resolves.toBeUndefined();
await expect(
ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
).resolves.toBeUndefined();
});
it('getLbugStats returns valid counts', async () => {
const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');
// getLbugStats NEVER throws — it has silent catch blocks per table
const stats = await getLbugStats();
expect(typeof stats.nodes).toBe('number');
expect(typeof stats.edges).toBe('number');
expect(stats.nodes).toBeGreaterThanOrEqual(0);
expect(stats.edges).toBeGreaterThanOrEqual(0);
});
it('executeQuery with empty string rejects', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
// LadybugDB throws on empty query string
await expect(executeQuery('')).rejects.toThrow();
});
it('deleteNodesForFile with non-existent path returns zero deleted', async () => {
const { deleteNodesForFile } = await import('../../src/core/lbug/lbug-adapter.js');
// deleteNodesForFile has per-query try/catch, returns {deletedNodes: 0} for missing paths
const result = await deleteNodesForFile('/absolutely/nonexistent/path/file.ts');
expect(result).toEqual({ deletedNodes: 0 });
});
});
itLbugReopen(
'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes',
async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const indexName = 'function_fts_init_probe';
await adapter.createFTSIndex('Function', indexName, ['name', 'content']);
await adapter.closeLbug();
await adapter.initLbug(handle.dbPath);
await expect(adapter.queryFTS('Function', indexName, 'main', 5)).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ filePath: 'src/index.ts' })]),
);
},
);
});
},
{
afterSetup: async (handle) => {
// Load a minimal graph via CSV round-trip (core adapter is already initialized by wrapper)
const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js');
const { createMinimalTestGraph } = await import('../helpers/test-graph.js');
const graph = createMinimalTestGraph();
const storagePath = path.join(handle.tmpHandle.dbPath, 'storage');
await fs.mkdir(storagePath, { recursive: true });
await loadGraphToLbug(graph, '/test/repo', storagePath);
},
},
);