/** * P1 Integration Tests: FTS extension lifecycle end-to-end (#2374) * * Everything real, nothing mocked: each test spawns the actual CLI entry as a * child process, LadybugDB loads the actual extension shared library from * disk, and the real out-of-process installer downloads the real extension in * the network-gated cases. * * Isolation: LadybugDB resolves its extension directory from the process HOME * (USERPROFILE on Windows), so every scenario owns a hermetic fake home with * its own `.lbdb/extension///fts/` state — the machine's * real ~/.lbdb is never read or written. GITNEXUS_HOME additionally isolates * the registry (#829), following cli-e2e.test.ts conventions. * * Scenario matrix (the #2374 report, codified): * - happy: valid extension pre-installed, offline (load-only) * - unhappy: extension file present but broken — the reporter's exact state * - unhappy: extension file missing entirely (distinguishable reason) * - heal: FORCE INSTALL replaces a broken file over the network (auto) */ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { getExtensionInstallChildProcessArgs } from '../../src/core/lbug/extension-loader.js'; import { cleanupTempDirSync } from '../helpers/test-db.js'; import { findInstalledFtsExtension } from '../helpers/fts-availability.js'; /** `.lbdb/extension///fts/libfts.lbug_extension`, discovered not hardcoded. */ let extensionRelPath: string; /** Canonical valid extension bytes (path to a known-good file). */ let seedExtensionFile: string | null = null; /** Real reachability of the extension repo — gates the auto-install cases. */ let networkAvailable = false; const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; const tmpDirs: string[] = []; const makeTmpDir = (label: string): string => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-fts-e2e-${label}-`)); tmpDirs.push(dir); return dir; }; /** * Locate a known-good extension file for the running LadybugDB version. * Prefers a copy already installed under the machine's real home (pure file * read, offline); falls back to one real out-of-process install into a probe * home — the production installer script, not a reimplementation. */ const resolveSeedExtension = (): void => { const realExtensionRoot = path.join(os.homedir(), '.lbdb', 'extension'); const installed = findInstalledFtsExtension(realExtensionRoot); if (installed) { extensionRelPath = path.relative(os.homedir(), installed); seedExtensionFile = installed; return; } // No local copy — run the real installer against a hermetic probe home. const probeHome = makeTmpDir('seed-home'); const install = spawnSync(process.execPath, getExtensionInstallChildProcessArgs('fts'), { encoding: 'utf8', timeout: 120_000, env: { ...process.env, HOME: probeHome, USERPROFILE: probeHome }, }); const probeExtensionRoot = path.join(probeHome, '.lbdb', 'extension'); const probeInstalled = findInstalledFtsExtension(probeExtensionRoot); if (install.status === 0 && probeInstalled) { extensionRelPath = path.relative(probeHome, probeInstalled); seedExtensionFile = probeInstalled; networkAvailable = true; return; } }; type ExtensionState = 'valid' | 'broken' | 'missing'; /** Create a hermetic fake home whose `.lbdb` holds the requested extension state. */ const makeHome = (state: ExtensionState): { home: string; extensionFile: string } => { const home = makeTmpDir(`home-${state}`); const extensionFile = path.join(home, extensionRelPath); fs.mkdirSync(path.dirname(extensionFile), { recursive: true }); if (state === 'valid' && seedExtensionFile) fs.copyFileSync(seedExtensionFile, extensionFile); if (state === 'broken') fs.writeFileSync(extensionFile, 'not a shared library'); return { home, extensionFile }; }; /** Fresh git-initialised throwaway repo with a uniquely named symbol to search for. */ const makeFixtureRepo = (label: string): string => { const repo = path.join(makeTmpDir(`repo-${label}`), `fts-e2e-${label}`); fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); fs.writeFileSync( path.join(repo, 'src', 'greeter.ts'), 'export function greetE2eSymbol(name: string): string {\n' + ' return `Hello, ${name}`;\n' + '}\n' + "greetE2eSymbol('world');\n", ); const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }; spawnSync('git', ['init'], { cwd: repo, stdio: 'pipe' }); spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'initial'], { cwd: repo, stdio: 'pipe', env: gitEnv }); return repo; }; interface CliResult { status: number | null; /** stdout + stderr combined — warn lines and progress renderer interleave streams. */ output: string; } const runCli = ( args: string[], cwd: string, home: string, policy: 'load-only' | 'auto', timeoutMs = 180_000, ): CliResult => { const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, HOME: home, USERPROFILE: home, GITNEXUS_HOME: path.join(home, '.gitnexus'), GITNEXUS_LANG: 'en', GITNEXUS_LBUG_EXTENSION_INSTALL: policy, GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS: '60000', // Skip analyzeCommand's ensureHeap re-exec, which would drop the tsx loader. NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), }, }); return { status: result.status, output: `${result.stdout ?? ''}\n${result.stderr ?? ''}` }; }; beforeAll(() => { resolveSeedExtension(); if (!seedExtensionFile && REQUIRE_FTS) { throw new Error( 'GITNEXUS_REQUIRE_FTS=1 but no FTS extension could be located or installed for the E2E suite.', ); } // The self-heal cases need the real extension repo; probe it cheaply when // the seed came from a local copy (the installer fallback already proved it). return (async () => { if (seedExtensionFile && !networkAvailable) { try { const res = await fetch('https://extension.ladybugdb.com/', { method: 'HEAD', signal: AbortSignal.timeout(5000), }); networkAvailable = res.ok; } catch { networkAvailable = false; } } })(); }, 180_000); afterAll(() => { for (const dir of tmpDirs) cleanupTempDirSync(dir); }); // Skip everything (visibly) when no valid extension exists and the machine is // offline — mirrors the dynamic-skip convention in test/helpers/fts-availability.ts. beforeEach((ctx) => { if (!seedExtensionFile) ctx.skip(); }); describe('happy path — extension pre-installed, fully offline (load-only)', () => { let home: string; let repo: string; beforeAll(() => { // The file-level beforeEach skip fires only per-test; this hook runs first, // so guard makeHome() (which needs extensionRelPath) when there is no seed. if (!seedExtensionFile) return; ({ home } = makeHome('valid')); repo = makeFixtureRepo('happy'); }); it('analyze builds the index with FTS and emits no degradation warning', () => { const result = runCli(['analyze'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); expect(result.output).not.toContain('search is disabled'); }, 180_000); it('query finds the symbol via BM25 with no degradation warning', () => { const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('greetE2eSymbol'); expect(result.output).not.toContain('keyword search degraded'); }, 60_000); it('doctor reports a live-probed available FTS and a resolved LadybugDB version', () => { const result = runCli(['doctor'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('Full-text search: available'); // #2374: version used to print as "unknown" on every platform. expect(result.output).toMatch(/LadybugDB:\s*\d+\.\d+\.\d+/); }, 60_000); it('analyze --repair-fts rebuilds the search indexes offline', () => { const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('FTS indexes repaired successfully'); }, 180_000); }); describe('unhappy path — extension file present but broken (the #2374 report)', () => { let home: string; let repo: string; beforeAll(() => { // See the happy-path note: skip setup when no seed extension is available // so the per-test beforeEach skip is reached instead of throwing here. if (!seedExtensionFile) return; ({ home } = makeHome('broken')); repo = makeFixtureRepo('broken'); }); it('analyze degrades gracefully and names the real LOAD failure, not "not pre-installed"', () => { const result = runCli(['analyze'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).toContain('FTS extension unavailable'); // The load-side ground truth must survive to the user… expect(result.output).toContain('LOAD fts failed'); expect(result.output).toContain('Failed to load library'); // …and the old misdiagnosis must not: the file IS pre-installed. expect(result.output).not.toContain('not pre-installed'); }, 180_000); it('analyze --repair-fts fails loudly with the live reason and an honest remedy', () => { const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only'); expect(result.status).not.toBe(0); expect(result.output).toContain('Cannot repair FTS indexes'); expect(result.output).toContain('FTS extension failed to load'); expect(result.output).toContain('LOAD fts failed'); // Old message sent users to doctor "to install it"; doctor never installed. expect(result.output).not.toContain('doctor` to install'); expect(result.output).toContain('gitnexus doctor'); // #2374 (U2): a corrupt file classifies as corrupt_file, so the Windows // missing-dependency remedy must not misfire on the repair path either. expect(result.output).not.toContain('Visual C++'); }, 180_000); it('query warns with the extension-load failure, not the misleading indexes-missing message', () => { const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('FTS extension failed to load'); expect(result.output).toContain('Failed to load library'); expect(result.output).not.toContain('FTS indexes missing'); }, 60_000); it('doctor live-probes FTS as unavailable, prints the real error and an actionable remedy', () => { const result = runCli(['doctor'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('Full-text search: unavailable'); expect(result.output).toContain('Failed to load library'); // #2374 (U2): doctor routes the reason through the classifier and prints a // remedy. A broken file is corrupt_file → re-download guidance; the Windows // missing-dependency remedy (VC++/OpenSSL) must NOT misfire on a corrupt file // — the catch-all guard, verified end-to-end through the real CLI. expect(result.output).toContain('Re-download it with network access'); expect(result.output).not.toContain('Visual C++'); }, 60_000); }); describe('unhappy path — extension missing entirely', () => { it('analyze degrades with a reason that distinguishes missing from broken', () => { const { home } = makeHome('missing'); const repo = makeFixtureRepo('missing'); const result = runCli(['analyze'], repo, home, 'load-only'); expect(result.status).toBe(0); expect(result.output).toContain('FTS extension unavailable'); expect(result.output).toContain('has not been installed'); expect(result.output).not.toContain('Failed to load library'); }, 180_000); }); describe('regression — the extension disappears between analyze runs (#2841)', () => { it('the incremental run completes with a full DB write instead of an opaque Binder exception', (ctx) => { const { home, extensionFile } = makeHome('valid'); const repo = makeFixtureRepo('vanishing-extension'); // 1. First analyze with the extension in place: the index ends up carrying // an FTS index on every searchable table. const first = runCli(['analyze'], repo, home, 'load-only'); expect(first.status).toBe(0); // This case needs run 1 to actually BUILD the indexes — without them there // is nothing for the gate to trip on and the assertions below would be // vacuous. When the seeded extension cannot load on this host (the same // environment gap the other cases in this file hit), skip VISIBLY rather // than report a red that says nothing about the fix. if (first.output.includes('FTS extension unavailable')) { if (REQUIRE_FTS) { throw new Error( 'GITNEXUS_REQUIRE_FTS=1 but the seeded FTS extension did not load — cannot verify the #2841 regression.', ); } ctx.skip(); } // 2. The extension becomes unloadable — the reporter moved // ~/.lbdb/extension away; a HOME change or a wiped cache does the same. fs.rmSync(extensionFile); // 3. A content change makes the next run incremental, so it must rewrite // rows of tables that still carry the indexes from step 1. fs.appendFileSync(path.join(repo, 'src', 'greeter.ts'), '\n// #2841 incremental touch\n'); const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }; spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', '#2841 touch'], { cwd: repo, stdio: 'pipe', env: gitEnv }); const second = runCli(['analyze'], repo, home, 'load-only'); // Pre-fix: exit 1 with "Binder exception: Trying to delete from an index on // table File but its extension is not loaded" and no mention of FTS at all. expect(second.status).toBe(0); expect(second.output).not.toContain('its extension is not loaded'); expect(second.output).toContain('full DB write'); expect(second.output).toContain('FTS'); }, 400_000); }); describe('self-heal over the network — FORCE INSTALL replaces a broken file (auto)', () => { beforeEach((ctx) => { // The platform matrix already exercises offline FTS load/diagnostic paths // against real macOS/Windows binaries. Keep network redownload coverage on // Ubuntu, where the full test job has the most stable extension fetch path. if (process.platform !== 'linux') ctx.skip(); if (!networkAvailable) ctx.skip(); }); it('the reported journey heals: degraded analyze, then repair-fts with auto re-downloads and repairs', () => { const { home, extensionFile } = makeHome('broken'); const repo = makeFixtureRepo('heal'); const degraded = runCli(['analyze'], repo, home, 'load-only'); expect(degraded.status).toBe(0); expect(degraded.output).toContain('FTS extension unavailable'); // The reporter's exact failing command — plain INSTALL used to no-op // over the broken file and this kept failing forever. const repair = runCli(['analyze', '--repair-fts'], repo, home, 'auto'); expect(repair.status).toBe(0); expect(repair.output).toContain('FTS indexes repaired successfully'); expect(fs.statSync(extensionFile).size).toBeGreaterThan(1024 * 1024); const query = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); expect(query.status).toBe(0); expect(query.output).toContain('greetE2eSymbol'); expect(query.output).not.toContain('keyword search degraded'); }, 600_000); it('a fresh machine with no extension installs it during analyze and gets full FTS', () => { const { home, extensionFile } = makeHome('missing'); const repo = makeFixtureRepo('fresh'); const result = runCli(['analyze'], repo, home, 'auto'); expect(result.status).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); expect(fs.existsSync(extensionFile)).toBe(true); }, 600_000); });