/** * P1 Integration Tests: CLI End-to-End * * Tests CLI commands via child process spawn: * - statusCommand: verify stdout for unindexed repo * - analyzeCommand: verify pipeline runs and creates .gitnexus/ output * * Uses process.execPath (never 'node' string), no shell: true. * Accepts status === null (timeout) as valid on slow CI runners. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { fileURLToPath } from 'url'; import { cleanupTempDirSync } from '../helpers/test-db.js'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // `MINI_REPO` is a *per-run temp copy* of the fixture, not the shared // source. Writing into the shared source races with other suites that // ingest it read-only (pipeline-graph-golden, pipeline.test) — those // suites copy the source to their own tmp dir but the copy happens at // `beforeAll`, so if this suite's analyze has already created AGENTS.md // / CLAUDE.md / .claude/ in the source when the other suite's cpSync // runs, the pollution is captured before the isolation kicks in. // // The deterministic fix: this suite never touches the shared source. // `beforeAll` copies the fixture to a fresh mkdtemp'd directory whose // basename is `mini-repo` (so `--repo mini-repo` lookup by basename // still works), `afterAll` rms the parent tmpdir. let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; beforeAll(() => { // Copy the fixture into an isolated tmpdir named `mini-repo` so that the // `--repo mini-repo` CLI arg (which matches by basename) still works. tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-')); suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-')); MINI_REPO = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); // Initialize mini-repo as a git repo so the CLI analyze command // can run the full pipeline (it requires a .git directory). spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' }); spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: MINI_REPO, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); // Index MINI_REPO ONCE into the isolated suite registry so the read-only // tests (query/cypher/impact, eval-server) have a registered repo regardless // of execution order. Previously they relied on an earlier analyze test // having run, and that test silently tolerates a subprocess timeout under // load — so on a busy runner the repo went unregistered and every dependent // test failed confusingly with "no indexed repositories" / exit 1. // // Retried a few times because a tiny fixture analyzes in seconds: a failure // here is almost always transient load, not a defect. Re-running analyze on // an already-indexed repo is a cheap no-op (alreadyUpToDate fast path), so // retrying is safe. A genuine analyze/registration regression is still caught // loudly by the dedicated analyze tests below (which use isolated homes). for (let attempt = 0; attempt < 3; attempt++) { if (runCli('analyze', MINI_REPO, 90_000).status === 0) break; } }, 300_000); afterAll(() => { // Entire tmp copy goes away — no selective cleanup needed. The shared // `test/fixtures/mini-repo/` source was never touched. if (tmpParent) { cleanupTempDirSync(tmpParent); } if (suiteGitnexusHome) { cleanupTempDirSync(suiteGitnexusHome); } }); function cliEnv(extraEnv: Record = {}) { return { ...process.env, GITNEXUS_HOME: suiteGitnexusHome, // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it // and skips the re-exec. The re-exec drops the tsx loader (--import tsx // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), ...extraEnv, }; } function runCli(command: string, cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, command], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(), }); } /** * Like runCli but accepts an arbitrary extra-args array so unhappy-path tests * can pass flags (e.g. --help) or omit a command entirely. */ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(), }); } /** * Like runCliRaw but accepts extra env vars. Used by tests that need to * isolate the global registry via GITNEXUS_HOME so they don't touch the * developer / CI agent's real ~/.gitnexus/registry.json (#829). */ function runCliWithEnv( extraArgs: string[], cwd: string, extraEnv: Record, timeoutMs = 15000, ) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(extraEnv), }); } /** * Create a fresh git-initialised throwaway repo at `/` * and return its path. Used for tests that need multiple repos whose * basenames intentionally collide (#829 reproduction). */ function makeMiniRepoCopy(basename: string, prefix: string): string { const parent = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const repo = path.join(parent, basename); fs.cpSync(FIXTURE_SRC, repo, { recursive: true }); spawnSync('git', ['init'], { cwd: repo, stdio: 'pipe' }); spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: repo, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); return repo; } /** * Detects libuv-emitted bind-restriction errors (EACCES / EPERM / * EADDRNOTAVAIL on `listen` or `bind`) so the host-flag tests can * tolerate CI/sandbox environments that forbid loopback binding. * * Match policy: every alternative MUST carry a `listen ` or `bind ` * prefix. Bare "permission denied" / "operation not permitted" * substrings in stderr (e.g. from a Node fs EACCES during module * loading) MUST NOT match — those represent real test failures that * should not be silently swallowed. */ function isEvalServerBindRestriction(stderr: string): boolean { return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test( stderr, ); } // Subprocess timeout for eval-server READY signal. Must be < the // outer vitest test budget (35s) so the reject branch fires before // vitest gives up on the test. const EVAL_SERVER_READY_TIMEOUT_MS = 30000; /** * Drives the spawn → settle → timer → stderr → close lifecycle shared by * the `eval-server --host` integration tests. Each test passes test-specific * spawn args, a timeout message, and an `onStdout` callback that owns the * READY-signal parsing and any post-READY probing. * * The helper owns: spawn wiring, child.once('error'), the setTimeout * timer, stderr accumulation + 'unknown option' fast-reject, and the close * handler's priority chain (unknown-option → bind-restriction → unexpected-exit * reject). Tests that need to short-circuit before `await` may inspect * `isSettled()`; `settle()` itself is idempotent so calling it after the * promise has already resolved is a safe no-op. */ function runEvalServerHostFlagTest( spawnArgs: string[], opts: { timeoutMsg: string; extraEnv?: Record; onStdout: (params: { stdoutBuffer: string; stderrBuffer: string; isSettled: () => boolean; settle: (fn: () => void) => void; resolve: () => void; reject: (err: Error) => void; }) => void | Promise; }, ): Promise { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [...CLI_SPAWN_PREFIX, 'eval-server', ...spawnArgs], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], env: cliEnv(opts.extraEnv), }); let stdoutBuffer = ''; let stderrBuffer = ''; let settled = false; const settle = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); child.kill('SIGTERM'); fn(); }; child.once('error', (err) => { settle(() => reject(new Error(`Failed to spawn eval-server: ${err.message}`))); }); const timer = setTimeout(() => { settle(() => reject(new Error(opts.timeoutMsg))); }, EVAL_SERVER_READY_TIMEOUT_MS); // The `settled` flag is set synchronously by settle(); onStdout callbacks // that await can rely on isSettled() reflecting any close-handler // settlement that occurred during the await. Do not make settle() async. child.stdout.on('data', async (chunk: Buffer) => { stdoutBuffer += chunk.toString(); if (settled) return; try { await opts.onStdout({ stdoutBuffer, stderrBuffer, isSettled: () => settled, settle, resolve, reject, }); } catch (err) { settle(() => reject(err instanceof Error ? err : new Error(String(err)))); } }); child.stderr.on('data', (chunk: Buffer) => { stderrBuffer += chunk.toString(); if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) { settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`))); } }); child.once('close', (code) => { if (settled) return; if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) { settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`))); return; } if (isEvalServerBindRestriction(stderrBuffer)) { settle(() => { console.warn( `[test tolerated] eval-server could not bind in this environment; --host wiring not verified.\nstderr: ${stderrBuffer.trim()}`, ); resolve(); }); return; } settle(() => reject( new Error( `eval-server exited unexpectedly (code=${code}) before READY signal.\nstderr: ${stderrBuffer.trim() || ''}`, ), ), ); }); }); } describe('CLI end-to-end', () => { it('status command exits cleanly', () => { const result = runCli('status', MINI_REPO); // Accept timeout as valid on slow CI if (result.status === null) return; expect(result.status).toBe(0); const combined = result.stdout + result.stderr; // mini-repo may or may not be indexed depending on prior test runs expect(combined).toMatch(/Repository|not indexed/i); }); // The vitest test-level timeout (60 s) must exceed the subprocess // timeout (30 s) so the "Accept timeout as valid on slow CI" // branch can actually fire on slow runners (Windows CI routinely // comes in at ~2x macOS wall-clock). Without a larger test-level // timeout, the default 30 s vitest timeout races the 30 s // subprocess timeout and the `if (result.status === null) return;` // tolerance never activates. it('analyze command runs pipeline on mini-repo', () => { const result = runCli('analyze', MINI_REPO, 30000); // Accept timeout as valid on slow CI if (result.status === null) return; expect( result.status, [ `analyze exited with code ${result.status}`, `stdout: ${result.stdout}`, `stderr: ${result.stderr}`, ].join('\n'), ).toBe(0); // Successful analyze should create .gitnexus/ output directory const gitnexusDir = path.join(MINI_REPO, '.gitnexus'); expect(fs.existsSync(gitnexusDir)).toBe(true); expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true); expect(fs.existsSync(path.join(MINI_REPO, '.gitignore'))).toBe(false); expect(fs.readFileSync(path.join(gitnexusDir, '.gitignore'), 'utf-8')).toBe('*\n'); }, 60_000); // Regression guard for issue #1169 — analyze must produce BOTH a // meta.json AND a global-registry entry on success. The previous // failure mode on Windows was banner-only output + exit 0 with // neither artifact persisted; the new finalize invariant // (assertAnalysisFinalized) makes that state a hard failure. // // Uses a fresh per-test repo copy (not the shared MINI_REPO) so // an earlier sibling test's analyze cannot push this one onto the // alreadyUpToDate fast path, which would skip the very wiring this // test is here to protect. it('analyze persists meta.json AND a matching registry entry (#1169)', () => { const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-home-')); const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-repo-'); const repoParent = path.dirname(repo); try { const result = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000); expect( result.status, [ 'analyze timed out before asserting finalization artifacts — this test guards #1169 and must not pass silently', `stdout: ${result.stdout}`, `stderr: ${result.stderr}`, ].join('\n'), ).not.toBeNull(); expect( result.status, [ `analyze exited with code ${result.status}`, `stdout: ${result.stdout}`, `stderr: ${result.stderr}`, ].join('\n'), ).toBe(0); // Both metadata filenames must exist after a successful analyze: // gitnexus.json is the primary (what assertAnalysisFinalized checks — // its absence is the #1169 silent-finalize symptom) and meta.json is // the dual-written legacy mirror older consumers still read. const primaryMetaPath = path.join(repo, '.gitnexus', 'gitnexus.json'); expect( fs.existsSync(primaryMetaPath), `gitnexus.json missing at ${primaryMetaPath} after analyze exited 0 — this is the #1169 silent-finalize symptom`, ).toBe(true); const metaPath = path.join(repo, '.gitnexus', 'meta.json'); expect( fs.existsSync(metaPath), `legacy meta.json mirror missing at ${metaPath} after analyze exited 0 — dual-write regressed`, ).toBe(true); expect(fs.readFileSync(primaryMetaPath, 'utf-8')).toBe(fs.readFileSync(metaPath, 'utf-8')); const registryPath = path.join(gnHome, 'registry.json'); expect( fs.existsSync(registryPath), `registry.json missing at ${registryPath} after analyze exited 0`, ).toBe(true); const entries = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as Array<{ name: string; path: string; }>; expect(entries.length).toBeGreaterThanOrEqual(1); const matchesRepo = entries.some((e) => { const a = fs.realpathSync.native(e.path); const b = fs.realpathSync.native(repo); return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; }); expect( matchesRepo, `registry has no entry for ${repo}; entries: ${JSON.stringify(entries.map((e) => e.path))}`, ).toBe(true); } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(repoParent); } }, 60_000); it('already-up-to-date analyze fails when registry entry is missing (#1169)', () => { const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-fastpath-home-')); const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-fastpath-repo-'); const repoParent = path.dirname(repo); try { const first = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 90_000); expect( first.status, [ `initial analyze exited with code ${first.status}`, `stdout: ${first.stdout}`, `stderr: ${first.stderr}`, ].join('\n'), ).toBe(0); const metaPath = path.join(repo, '.gitnexus', 'meta.json'); expect(fs.existsSync(metaPath)).toBe(true); expect(fs.existsSync(path.join(repo, '.gitnexus', 'gitnexus.json'))).toBe(true); // Simulate the half-finalized state from the review: the metadata // (both filenames) is present and lastCommit matches, but the repo is // not discoverable because the global registry entry is missing. fs.writeFileSync(path.join(gnHome, 'registry.json'), '[]', 'utf-8'); const second = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 90_000); expect( second.status, [ 'second analyze timed out before proving alreadyUpToDate finalization', `stdout: ${second.stdout}`, `stderr: ${second.stderr}`, ].join('\n'), ).not.toBeNull(); expect(`${second.stdout}${second.stderr}`).toMatch(/Analysis did not finalize/i); expect(`${second.stdout}${second.stderr}`).toMatch(/registry entry/i); expect(second.status).toBe(1); } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(repoParent); } }, 180_000); // ─── analyze --name + --allow-duplicate-name (#829) ────── // // End-to-end regression guard for the name-collision feature: // 1. `analyze --name X` persists the alias to ~/.gitnexus/registry.json // 2. A second `analyze --name X` on a DIFFERENT path is rejected with // a collision error (exit code 1, "already used" in output) // 3. `analyze --name X --allow-duplicate-name` bypasses the guard; // both entries coexist in registry.json // 4. Pipeline-re-index flags (e.g. --skills) WITHOUT // --allow-duplicate-name must STILL hit the collision guard — // the bypass must stay gated on its dedicated flag so it isn't // silently triggered by unrelated pipeline signals // (review round 2/3 design decision). // // This test invokes the real CLI → runFullAnalysis → registerRepo // chain, so any wiring regression fails here. describe('analyze --name and --allow-duplicate-name (#829)', () => { // Path-equality assertions across CLI spawn boundaries are fragile // cross-platform: // - macOS: os.tmpdir() returns /var/folders/...; child processes // resolve the symlink to /private/var/folders/... // - Windows: os.tmpdir() on GitHub runners returns 8.3 short-name // form (C:\Users\RUNNER~1\...); the child sees the long form // (C:\Users\runneradmin\...). fs.realpathSync does NOT reliably // expand 8.3 to long form. // Rather than fight the platform-path quagmire, we assert STRUCTURAL // properties: entry count, alias value, path basename, path // distinctness. That covers the behavior this test is here to // protect without depending on exact-string path equality. it('--name alias stores; collision rejects; --allow-duplicate-name bypasses', () => { // Isolate the global registry so this test never touches the // developer's real ~/.gitnexus. const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-')); // Two mini-repo copies whose basenames intentionally collide. const repoA = makeMiniRepoCopy('collide-app', 'gn-collide-a-'); const repoB = makeMiniRepoCopy('collide-app', 'gn-collide-b-'); const parentA = path.dirname(repoA); const parentB = path.dirname(repoB); try { // Step 1: analyze repoA with --name shared → registry entry created. const r1 = runCliWithEnv( ['analyze', '--name', 'shared'], repoA, { GITNEXUS_HOME: gnHome }, 60000, ); if (r1.status === null) return; // CI timeout tolerance expect( r1.status, [`step 1 exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join( '\n', ), ).toBe(0); const registryPath = path.join(gnHome, 'registry.json'); const afterStep1 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(Array.isArray(afterStep1)).toBe(true); expect(afterStep1).toHaveLength(1); expect(afterStep1[0].name).toBe('shared'); expect(path.basename(afterStep1[0].path)).toBe('collide-app'); // Step 2: analyze repoB with the SAME --name → collision error. const r2 = runCliWithEnv( ['analyze', '--name', 'shared'], repoB, { GITNEXUS_HOME: gnHome }, 60000, ); if (r2.status === null) return; expect(r2.status).toBe(1); const r2Output = `${r2.stdout}${r2.stderr}`; expect(r2Output).toMatch(/Registry name collision|already used/i); // Registry still has just the first entry — step 2 must not have // silently added, overwritten, or corrupted anything. const afterStep2 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterStep2).toHaveLength(1); // Registry still has only the step-1 entry — the failed call // must not have silently added, overwritten, or corrupted state. expect(afterStep2[0].path).toBe(afterStep1[0].path); // Step 3: REGRESSION GUARD for the missing collision-bypass wire // (originally a --force passthrough bug; per review round 3 the // bypass moved to its own --allow-duplicate-name flag to avoid // conflating it with pipeline re-index). const r3 = runCliWithEnv( ['analyze', '--name', 'shared', '--allow-duplicate-name'], repoB, { GITNEXUS_HOME: gnHome }, 60000, ); if (r3.status === null) return; expect( r3.status, [ `step 3 (--allow-duplicate-name bypass) exited with ${r3.status}`, `stdout: ${r3.stdout}`, `stderr: ${r3.stderr}`, ].join('\n'), ).toBe(0); const afterStep3 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterStep3).toHaveLength(2); expect(afterStep3.every((e: { name: string }) => e.name === 'shared')).toBe(true); // Both entries point to distinct paths (we registered two different // repos under the same alias) and both have the right basename. const step3Basenames = afterStep3.map((e: { path: string }) => path.basename(e.path)); expect(step3Basenames).toEqual(['collide-app', 'collide-app']); const step3Paths = new Set(afterStep3.map((e: { path: string }) => e.path)); expect(step3Paths.size).toBe(2); // One of the two entries is the original from step 1 — unchanged. expect(afterStep3.map((e: { path: string }) => e.path)).toContain(afterStep1[0].path); // Step 4: REGRESSION GUARD for the design decision in review // round 2/3 — pipeline-re-index flags must NOT bypass the // registry collision guard. `--skills` triggers pipeline // re-run (skills generation needs a fresh pipelineResult) but // must leave the registry guard in force. Bypass requires the // explicit --allow-duplicate-name flag. const repoC = makeMiniRepoCopy('collide-app', 'gn-collide-c-'); const parentC = path.dirname(repoC); try { const r4 = runCliWithEnv( ['analyze', '--name', 'shared', '--skills'], repoC, { GITNEXUS_HOME: gnHome }, 60000, ); if (r4.status === null) return; expect(r4.status).toBe(1); const r4Output = `${r4.stdout}${r4.stderr}`; expect(r4Output).toMatch(/Registry name collision|already used/i); // The error hint should point at the new flag. expect(r4Output).toMatch(/--allow-duplicate-name/); // Registry unchanged — still only A + B under "shared". const afterStep4 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterStep4).toHaveLength(2); } finally { cleanupTempDirSync(parentC); } } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(parentA); cleanupTempDirSync(parentB); } }, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup) }); // ─── gitnexus remove (#664) ───────────────────────────── // // End-to-end regression guard for the remove command: // 1. `remove ` without --force is a dry-run (exit 0, preserves state) // 2. `remove --force` deletes the .gitnexus/ directory // AND unregisters from the global registry // 3. `remove ` is idempotent (exit 0 with a warning) // 4. `remove ` (two entries share the alias via // --allow-duplicate-name) exits 1 with a disambiguation hint // and leaves the registry unchanged. // // Every assertion reads the real registry.json on disk, so any // regression in remove.ts → resolveRegistryEntry → unregisterRepo // will surface here. describe('remove (#664)', () => { it('dry-run lists, --force deletes, missing target is a no-op warning', () => { const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-remove-')); const repoA = makeMiniRepoCopy('remove-me', 'gn-rm-a-'); const parentA = path.dirname(repoA); try { // Index the repo under a custom alias so we can target it by // name below. `--name` guarantees a stable alias regardless of // how the host resolves the basename/remote-inferred name. const r1 = runCliWithEnv( ['analyze', '--name', 'alias-a'], repoA, { GITNEXUS_HOME: gnHome }, 60000, ); if (r1.status === null) return; expect( r1.status, [`analyze exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join( '\n', ), ).toBe(0); const registryPath = path.join(gnHome, 'registry.json'); const afterIndex = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterIndex).toHaveLength(1); expect(afterIndex[0].name).toBe('alias-a'); // Storage dir must exist before remove so we can assert its // disappearance below. const storagePath = afterIndex[0].storagePath; expect(fs.existsSync(storagePath)).toBe(true); // Dry-run: must NOT delete. Use parentA as cwd so the test // never runs with the to-be-removed storage dir as its cwd. // // Assert the FULL dry-run output shape, not just the `--force` // hint (#1003 senior-reviewer NIT): `remove.ts` prints the // alias, the resolved path, AND the storage path. Verifying // all three appear catches silent format regressions // (e.g. a future refactor that accidentally drops one of the // three `console.log` lines, or swaps `entry.path` for // `entry.name` in the output). const r2 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000); if (r2.status === null) return; expect(r2.status).toBe(0); const r2Output = `${r2.stdout}${r2.stderr}`; expect(r2Output).toMatch(/Run with --force/i); expect(r2Output, 'dry-run must surface the alias').toContain('alias-a'); expect(r2Output, 'dry-run must surface the repo path').toContain(afterIndex[0].path); expect(r2Output, 'dry-run must surface the storage path').toContain(storagePath); expect(fs.existsSync(storagePath)).toBe(true); // Registry still has the entry. expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(1); // --force: must delete storage AND unregister. const r3 = runCliWithEnv( ['remove', 'alias-a', '--force'], parentA, { GITNEXUS_HOME: gnHome }, 15000, ); if (r3.status === null) return; expect( r3.status, [ `remove --force exited with ${r3.status}`, `stdout: ${r3.stdout}`, `stderr: ${r3.stderr}`, ].join('\n'), ).toBe(0); // Success-case output shape: `Removed: ` header plus the // same path-and-storagePath lines the dry-run prints (same NIT // rationale — the success branch mirrors the dry-run's three // console.log calls, so it has the same silent-regression risk). const r3Output = `${r3.stdout}${r3.stderr}`; expect(r3Output).toMatch(/Removed/i); expect(r3Output, 'success output must surface the alias').toContain('alias-a'); expect(r3Output, 'success output must surface the repo path').toContain(afterIndex[0].path); expect(r3Output, 'success output must surface the storage path').toContain(storagePath); expect(fs.existsSync(storagePath)).toBe(false); expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(0); // Idempotent: removing the same alias AGAIN must exit 0 with a // warning (so `remove X && analyze Y` keeps working in scripts). const r4 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000); if (r4.status === null) return; expect(r4.status).toBe(0); expect(`${r4.stdout}${r4.stderr}`).toMatch(/Nothing to remove/i); } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(parentA); } }, 180000); // 3-min outer budget (1 × ~60s analyze + 3 × fast remove calls) it('ambiguous target (two entries share alias via --allow-duplicate-name) errors without mutating registry', () => { const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-rm-amb-')); const repoA = makeMiniRepoCopy('dup', 'gn-dup-a-'); const repoB = makeMiniRepoCopy('dup', 'gn-dup-b-'); const parentA = path.dirname(repoA); const parentB = path.dirname(repoB); try { // Two repos registered under the same alias — only possible via // --allow-duplicate-name (#829). const r1 = runCliWithEnv( ['analyze', '--name', 'shared'], repoA, { GITNEXUS_HOME: gnHome }, 60000, ); if (r1.status === null) return; expect(r1.status).toBe(0); const r2 = runCliWithEnv( ['analyze', '--name', 'shared', '--allow-duplicate-name'], repoB, { GITNEXUS_HOME: gnHome }, 60000, ); if (r2.status === null) return; expect(r2.status).toBe(0); const registryPath = path.join(gnHome, 'registry.json'); const before = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(before).toHaveLength(2); // `remove shared` must refuse to guess — exit 1, disambiguation hint. const r3 = runCliWithEnv( ['remove', 'shared', '--force'], parentA, { GITNEXUS_HOME: gnHome }, 15000, ); if (r3.status === null) return; expect(r3.status).toBe(1); const r3Output = `${r3.stdout}${r3.stderr}`; expect(r3Output).toMatch(/Multiple registered repos match/i); // Both paths must be surfaced in the hint so the user knows // which ones to disambiguate between. expect(r3Output).toMatch(/dup/); // Registry unchanged — the failed resolution must NOT have // mutated state. const after = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(after).toHaveLength(2); // And path-based remove still works: pass the absolute path of // repoA and it resolves unambiguously. // // We pull the path from the registry snapshot rather than // passing the outer `repoA` variable directly. This is the // belt-and-suspenders for cross-platform path normalisation // (#1003 review): the path the registry recorded has already // gone through the analyze-side canonicalisation (which on // macOS expands /var → /private/var and on Windows expands 8.3 // → long-name). Passing that exact string back to `remove` // guarantees the comparison succeeds even on runners where the // outer `repoA` is the symlink/short-name form. The code-side // fix in `canonicalizePath` makes this redundant in practice, // but the test shouldn't depend on the code fix being perfect // on every platform — it should prove correctness against the // registry contract. const repoAEntry = before.find( (e: { path: string }) => path.basename(e.path) === 'dup' && e.path.includes(path.basename(parentA)), ); expect( repoAEntry, 'repoA entry must exist in registry before path-remove step', ).toBeDefined(); const r4 = runCliWithEnv( ['remove', repoAEntry.path, '--force'], parentA, { GITNEXUS_HOME: gnHome }, 15000, ); if (r4.status === null) return; expect( r4.status, [ `remove-by-path exited with ${r4.status}`, `stdout: ${r4.stdout}`, `stderr: ${r4.stderr}`, ].join('\n'), ).toBe(0); const finalEntries = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(finalEntries).toHaveLength(1); // The survivor is repoB (its path stays in the registry). expect(path.basename(finalEntries[0].path)).toBe('dup'); // And it's NOT the one we just removed. expect(finalEntries[0].path).not.toBe(repoAEntry.path); } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(parentA); cleanupTempDirSync(parentB); } }, 240000); // 4-min outer budget (2 × ~60s analyze + 2 × fast remove) it('refuses to proceed when a registry entry points storagePath outside /.gitnexus (#1003)', () => { // Regression guard for the safety gap flagged by @magyargergo on // PR #1003: `~/.gitnexus/registry.json` is a user-writable JSON // file, so a corrupted or hand-edited entry could point // storagePath at the repo root (catastrophic: rm the working // tree) or at any other arbitrary path. `remove --force` must // refuse to call fs.rm when storagePath isn't the canonical // `/.gitnexus`. We verify: // 1. Exit code 1 with the actionable "registry entry corrupted" // hint. // 2. The .gitnexus/ storage dir is UNTOUCHED. // 3. The repo itself (entry.path) is UNTOUCHED. // 4. The registry entry is NOT removed (no partial mutation). const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-poison-')); const repo = makeMiniRepoCopy('poisoned', 'gn-poison-'); const parent = path.dirname(repo); try { // Index the repo normally first so the registry has a valid // entry we can then poison. const r1 = runCliWithEnv( ['analyze', '--name', 'poisoned-alias'], repo, { GITNEXUS_HOME: gnHome }, 60000, ); if (r1.status === null) return; expect(r1.status).toBe(0); const registryPath = path.join(gnHome, 'registry.json'); const original = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(original).toHaveLength(1); // Poison the entry: set storagePath to the REPO ROOT itself. // If the guard isn't in place, `remove --force` would call // `fs.rm(repo, {recursive: true, force: true})` and wipe the // entire working tree. const poisoned = [{ ...original[0], storagePath: repo }]; fs.writeFileSync(registryPath, JSON.stringify(poisoned, null, 2)); // Sanity: storage dir and working tree both still exist. expect(fs.existsSync(path.join(repo, '.gitnexus'))).toBe(true); expect(fs.existsSync(repo)).toBe(true); expect(fs.existsSync(path.join(repo, '.git'))).toBe(true); // Attempt the remove — must FAIL without deleting anything. const r2 = runCliWithEnv( ['remove', 'poisoned-alias', '--force'], parent, { GITNEXUS_HOME: gnHome }, 15000, ); if (r2.status === null) return; expect( r2.status, [`remove should have exited 1`, `stdout: ${r2.stdout}`, `stderr: ${r2.stderr}`].join( '\n', ), ).toBe(1); const r2Output = `${r2.stdout}${r2.stderr}`; // Must surface the actionable "registry corrupted" hint, not // just a raw fs.rm error. expect(r2Output).toMatch(/Refusing to remove/i); expect(r2Output).toMatch(/registry\.json/i); // Repo + .gitnexus dir + .git dir must all still exist — the // guard aborts BEFORE fs.rm. This is the whole point of the // test: the working tree is not allowed to disappear. expect(fs.existsSync(repo), 'repo working tree must survive').toBe(true); expect(fs.existsSync(path.join(repo, '.gitnexus')), 'storage dir must survive').toBe(true); expect(fs.existsSync(path.join(repo, '.git')), '.git must survive').toBe(true); // Registry unchanged — no partial mutation. const afterRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].storagePath).toBe(repo); // still poisoned (we did that) } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(parent); } }, 120000); // 2-min budget (1 × ~60s analyze + 1 × fast remove-refused) }); // ─── clean --all: same safety guard applies (#1003 review) ─────── // // The `clean --all` path iterates over the registry and calls // `fs.rm(entry.storagePath)` — identical trust-the-registry pattern // as `remove` had before the guard. A poisoned entry must be SKIPPED // (not aborted), so clean --all preserves its existing per-repo // error-tolerance semantics: one bad entry does not halt cleanup of // the rest. We verify: // 1. The poisoned entry is NOT deleted (working tree + .gitnexus // survive), and the CLI prints a "Refusing to clean" message. // 2. The poisoned entry is left in the registry (nothing was // mutated for it). // 3. A co-existing well-formed entry IS still cleaned (both its // .gitnexus dir AND its registry entry are gone). describe('clean --all with a poisoned registry entry (#1003)', () => { it('skips poisoned entries, cleans valid ones, never deletes the working tree', () => { const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-clean-poison-')); const repoBad = makeMiniRepoCopy('bad-repo', 'gn-clean-bad-'); const repoGood = makeMiniRepoCopy('good-repo', 'gn-clean-good-'); const parentBad = path.dirname(repoBad); const parentGood = path.dirname(repoGood); try { // Analyze both so the registry has two well-formed entries. for (const [repo, alias] of [ [repoBad, 'bad-alias'], [repoGood, 'good-alias'], ] as const) { const r = runCliWithEnv( ['analyze', '--name', alias], repo, { GITNEXUS_HOME: gnHome }, 60000, ); if (r.status === null) return; expect(r.status, `analyze ${alias} exited ${r.status}: ${r.stdout}${r.stderr}`).toBe(0); } const registryPath = path.join(gnHome, 'registry.json'); const original = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(original).toHaveLength(2); // Poison the 'bad-alias' entry by pointing its storagePath at // the repo root itself. If the guard isn't wired into the // clean --all loop, `clean --all --force` would fs.rm the // working tree. const poisoned = original.map((e: { name: string; storagePath: string; path: string }) => e.name === 'bad-alias' ? { ...e, storagePath: repoBad } : e, ); fs.writeFileSync(registryPath, JSON.stringify(poisoned, null, 2)); // Sanity: both working trees and .gitnexus dirs still exist. expect(fs.existsSync(repoBad)).toBe(true); expect(fs.existsSync(path.join(repoBad, '.gitnexus'))).toBe(true); expect(fs.existsSync(path.join(repoBad, '.git'))).toBe(true); expect(fs.existsSync(path.join(repoGood, '.gitnexus'))).toBe(true); // clean --all --force from a neutral cwd (parentBad), so the // command isn't "inside" either repo. const r = runCliWithEnv( ['clean', '--all', '--force'], parentBad, { GITNEXUS_HOME: gnHome }, 30000, ); if (r.status === null) return; // clean --all's per-entry error handling always exits 0 at // the end (it only logs per-repo failures). The important // assertions are on side effects, not the exit code. const output = `${r.stdout}${r.stderr}`; expect(output).toMatch(/Refusing to clean/i); expect(output).toMatch(/bad-alias/); // Poisoned repo: working tree + .gitnexus + .git all SURVIVE. expect(fs.existsSync(repoBad), 'poisoned repo working tree must survive').toBe(true); expect( fs.existsSync(path.join(repoBad, '.gitnexus')), 'poisoned repo .gitnexus must survive (guard refused to rm repo root)', ).toBe(true); expect(fs.existsSync(path.join(repoBad, '.git')), '.git must survive').toBe(true); // Good repo: its .gitnexus IS gone (cleanup succeeded despite // the poisoned sibling entry — per-entry error tolerance is // preserved). expect( fs.existsSync(path.join(repoGood, '.gitnexus')), 'good repo .gitnexus should be cleaned', ).toBe(false); // But the good repo's working tree stays (clean never touches // anything outside .gitnexus). expect(fs.existsSync(repoGood), 'good repo working tree must survive').toBe(true); // Registry post-state: poisoned entry still present (skipped, // not mutated); good entry unregistered. const afterRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].name).toBe('bad-alias'); } finally { cleanupTempDirSync(gnHome); cleanupTempDirSync(parentBad); cleanupTempDirSync(parentGood); } }, 240000); // 4-min budget (2 × ~60s analyze + 1 × fast clean --all) }); describe('unhappy path', () => { it('exits with error when no command is given', () => { const result = runCliRaw([], MINI_REPO); // Accept timeout as valid on slow CI if (result.status === null) return; // Commander exits with code 1 when no subcommand is given and // prints a usage/error message to stderr. expect(result.status).toBe(1); const combined = result.stdout + result.stderr; expect(combined.length).toBeGreaterThan(0); }); it('shows help with --help flag', () => { const result = runCliRaw(['--help'], MINI_REPO); // Accept timeout as valid on slow CI if (result.status === null) return; expect(result.status).toBe(0); // Commander writes --help output to stdout. expect(result.stdout).toMatch(/Usage:/i); // The program name and at least one known subcommand should appear. expect(result.stdout).toMatch(/gitnexus/i); expect(result.stdout).toMatch(/analyze|status|serve/i); }); it('fails with unknown command', () => { const result = runCliRaw(['nonexistent'], MINI_REPO); // Accept timeout as valid on slow CI if (result.status === null) return; // Commander exits with code 1 and prints an error to stderr for unknown commands. expect(result.status).toBe(1); expect(result.stderr).toMatch(/unknown command/i); }); }); describe('CLI error handling', () => { /** * Helper to spawn CLI from a cwd outside the project tree via * CLI_SPAWN_PREFIX (built dist in CI, tsx-on-source locally). On the tsx * path the loader is an absolute file:// URL so the --import hook resolves * even when cwd has no node_modules. */ function runCliOutsideProject(args: string[], cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(), }); } it('status on non-indexed repo reports not indexed', () => { // Even though MINI_REPO is now in an isolated tmpdir, previous tests // in this suite may have created MINI_REPO/.gitnexus via analyze, // and findRepo() walks up so any `.gitnexus` along the path still // counts. This test needs a GUARANTEED pristine repo to assert the // "not indexed" output, so it mints its own throwaway tmp git repo. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-noindex-')); try { spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); const result = runCliOutsideProject(['status'], tmpDir); if (result.status === null) return; expect(result.status).toBe(0); expect(result.stdout).toMatch(/Repository not indexed/); } finally { cleanupTempDirSync(tmpDir); } }); it('status on non-git directory reports not a git repo', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-')); try { const result = runCliOutsideProject(['status'], tmpDir); if (result.status === null) return; // status.ts doesn't set process.exitCode — just prints and returns expect(result.status).toBe(0); expect(result.stdout).toMatch(/Not a git repository/); } finally { cleanupTempDirSync(tmpDir); } }); it('analyze on non-git directory fails with exit code 1', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-')); try { // Pass the non-git path as a separate argument via runCliRaw // (runCli passes the whole string as one arg which breaks path parsing) const result = runCliRaw(['analyze', tmpDir], repoRoot); if (result.status === null) return; // analyze.ts sets process.exitCode = 1 for non-git paths expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { cleanupTempDirSync(tmpDir); } }); }); // ─── wiki command flags ───────────────────────────────────────────── describe('wiki command flags', () => { it('wiki --help shows --provider, --review, --verbose flags', () => { const result = runCliRaw(['wiki', '--help'], repoRoot); if (result.status === null) return; expect(result.status).toBe(0); expect(result.stdout).toContain('--provider '); expect(result.stdout).toContain('claude'); expect(result.stdout).toContain('codex'); expect(result.stdout).toContain('--review'); expect(result.stdout).toContain('-v, --verbose'); expect(result.stdout).toContain('--model '); expect(result.stdout).toContain('--gist'); expect(result.stdout).toContain('--concurrency '); }); it('wiki on non-git directory fails with exit code 1', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-nogit-')); try { const result = runCliRaw(['wiki', tmpDir], repoRoot); if (result.status === null) return; expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { cleanupTempDirSync(tmpDir); } }); it('wiki on non-indexed repo fails with "No GitNexus index"', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-noindex-')); try { spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); // Must spawn outside project tree so it doesn't find parent .gitnexus const result = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'wiki', tmpDir], { cwd: tmpDir, encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(), }); if (result.status === null) return; expect(result.status).toBe(1); expect(result.stdout).toMatch(/No GitNexus index found/); } finally { cleanupTempDirSync(tmpDir); } }); it('wiki --provider cursor without API key does not prompt for key in non-TTY', () => { // In non-TTY (piped stdin), --provider cursor should skip the API key prompt // and proceed (or fail gracefully with Cursor CLI not found) const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'cursor'], repoRoot, 15000); if (result.status === null) return; const combined = result.stdout + result.stderr; // Should NOT ask for API key — cursor provider doesn't need one expect(combined).not.toMatch(/API key:/); }); it('wiki --provider claude without API key does not prompt for key in non-TTY', () => { const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'claude'], repoRoot, 15000); if (result.status === null) return; const combined = result.stdout + result.stderr; expect(combined).not.toMatch(/API key:/); }); it('wiki --provider codex without API key does not prompt for key in non-TTY', () => { const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'codex'], repoRoot, 15000); if (result.status === null) return; const combined = result.stdout + result.stderr; expect(combined).not.toMatch(/API key:/); }); it('wiki --help includes --verbose flag description', () => { const result = runCliRaw(['wiki', '--help'], repoRoot); if (result.status === null) return; expect(result.status).toBe(0); expect(result.stdout).toMatch(/verbose/i); }); }); // ─── stdout fd 1 tests (#324) ─────────────────────────────────────── // These tests verify that tool output goes to stdout (fd 1), not stderr. // Requires analyze to have run first (the analyze test above populates .gitnexus/). // All tool commands pass --repo to disambiguate when the global registry // has multiple indexed repos (e.g. the parent project is also indexed). // retry: these spawn the CLI against the suite-indexed mini-repo; a retry // absorbs a transient subprocess hiccup under parallel load (#324 hardening). describe('tool output goes to stdout via fd 1 (#324)', { retry: 2 }, () => { it('cypher: JSON appears on stdout, not stderr', () => { const result = runCliRaw( ['cypher', 'MATCH (n) RETURN n.name LIMIT 3', '--repo', 'mini-repo'], MINI_REPO, ); if (result.status === null) return; // CI timeout tolerance expect(result.status).toBe(0); // stdout must contain valid JSON (array or object) expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); // stderr must NOT contain JSON — only human-readable diagnostics allowed const stderrTrimmed = result.stderr.trim(); if (stderrTrimmed.length > 0) { expect(() => JSON.parse(stderrTrimmed)).toThrow(); } }); it('query: JSON appears on stdout, not stderr', () => { // "handler" is a generic term likely to match something in mini-repo const result = runCliRaw(['query', 'handler', '--repo', 'mini-repo'], MINI_REPO); if (result.status === null) return; expect(result.status).toBe(0); expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); }); it('impact: JSON appears on stdout, not stderr', () => { const result = runCliRaw( ['impact', 'handleRequest', '--direction', 'upstream', '--repo', 'mini-repo'], MINI_REPO, ); if (result.status === null) return; expect(result.status).toBe(0); // impact may return an error object (symbol not found) or a real result — // either way it must be valid JSON on stdout expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); }); it('stdout is pipeable: cypher output parses as valid JSON', () => { const result = runCliRaw( ['cypher', 'MATCH (n:Function) RETURN n.name LIMIT 5', '--repo', 'mini-repo'], MINI_REPO, ); if (result.status === null) return; expect(result.status).toBe(0); // Simulate what jq does: parse stdout as JSON const parsed = JSON.parse(result.stdout.trim()); expect(Array.isArray(parsed) || typeof parsed === 'object').toBe(true); }); }); // ─── EPIPE clean exit test (#324) ─────────────────────────────────── describe('EPIPE handling (#324)', { retry: 2 }, () => { it('cypher: EPIPE exits with code 0, not stderr dump', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, [...CLI_SPAWN_PREFIX, 'cypher', 'MATCH (n) RETURN n LIMIT 500', '--repo', 'mini-repo'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], env: cliEnv(), }, ); let stderrOutput = ''; child.stderr.on('data', (chunk: Buffer) => { stderrOutput += chunk.toString(); }); // Destroy stdout immediately — simulates `| head -0` (consumer closes early) child.stdout.once('data', () => { child.stdout.destroy(); // triggers EPIPE on next write }); const timer = setTimeout(() => { child.kill('SIGTERM'); // Timeout is acceptable on CI — not a failure resolve(); }, 20000); child.on('close', (code) => { clearTimeout(timer); try { // Clean EPIPE exit: code 0 expect(code).toBe(0); // No JSON payload should appear on stderr const trimmed = stderrOutput.trim(); if (trimmed.length > 0) { expect(() => JSON.parse(trimmed)).toThrow(); } resolve(); } catch (err) { reject(err); } }); }); }, 25000); }); // ─── eval-server READY signal test (#324) ─────────────────────────── describe('eval-server READY signal (#324)', { retry: 2 }, () => { it('READY signal appears on stdout, not stderr', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, [...CLI_SPAWN_PREFIX, 'eval-server', '--port', '0', '--idle-timeout', '3'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], env: cliEnv(), }, ); let stdoutBuffer = ''; let foundOnStdout = false; let foundOnStderr = false; child.stdout.on('data', (chunk: Buffer) => { stdoutBuffer += chunk.toString(); if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) { foundOnStdout = true; child.kill('SIGTERM'); } }); child.stderr.on('data', (chunk: Buffer) => { const text = chunk.toString(); if (text.includes('GITNEXUS_EVAL_SERVER_READY:')) { foundOnStderr = true; child.kill('SIGTERM'); } }); const timer = setTimeout(() => { child.kill('SIGTERM'); // Timeout is acceptable on CI — not a failure resolve(); }, 30000); child.on('close', () => { clearTimeout(timer); try { if (foundOnStderr) { reject(new Error('READY signal appeared on stderr instead of stdout')); } else if (foundOnStdout) { resolve(); } else { // eval-server may not start on all CI environments — don't fail resolve(); } } catch (err) { reject(err); } }); }); }, 35000); }); // ─── eval-server --host flag tests ─────────────────────────────────── // Verifies --host is wired to the actual bind address, not just accepted. // Original flag registration test by Val Vladescu (PR #1602). describe('eval-server --host flag', { retry: 2 }, () => { it('refuses an unauthenticated non-loopback bind before emitting READY', () => { const result = runCliWithEnv( ['eval-server', '--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'], MINI_REPO, { GITNEXUS_AUTH_TOKEN: '' }, 30000, ); const output = `${result.stdout}\n${result.stderr}`; expect(result.status).toBe(1); expect(output).toMatch(/non-loopback.*GITNEXUS_AUTH_TOKEN/is); expect(output).not.toContain('GITNEXUS_EVAL_SERVER_READY:'); }, 35000); it('emits READY signal containing the bound host 127.0.0.1', () => { return runEvalServerHostFlagTest( ['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'], { extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, timeoutMsg: 'eval-server did not emit READY signal within 30s', onStdout({ stdoutBuffer, settle, resolve, reject }) { if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) return; if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) { settle(resolve); } else { settle(() => reject( new Error( `READY signal did not contain expected host 127.0.0.1:\n${stdoutBuffer}`, ), ), ); } }, }, ); }, 35000); it('binds to ::1 without a token when IPv6 loopback is available', () => { return runEvalServerHostFlagTest(['--port', '0', '--host', '::1', '--idle-timeout', '3'], { extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, timeoutMsg: 'eval-server --host ::1 did not emit READY signal within 30s', onStdout({ stdoutBuffer, settle, resolve }) { if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:[::1]:')) { settle(resolve); } }, }); }, 35000); it('requires the configured bearer token on a 0.0.0.0 bind', () => { const authToken = 'integration-secret-token'; return runEvalServerHostFlagTest( ['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'], { extraEnv: { GITNEXUS_AUTH_TOKEN: authToken }, timeoutMsg: 'eval-server --host 0.0.0.0 did not emit READY signal within 30s', async onStdout({ stdoutBuffer, stderrBuffer, isSettled, settle, resolve, reject }) { const readyLine = stdoutBuffer .split('\n') .find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:')); if (!readyLine || isSettled()) return; // Parse the actual OS-assigned port from the READY signal const boundPort = readyLine.split(':').pop()?.trim(); if (!boundPort || isNaN(Number(boundPort))) { settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)), ); return; } try { const url = `http://127.0.0.1:${boundPort}/health`; const missing = await fetch(url); const wrong = await fetch(url, { headers: { Authorization: 'Bearer wrong-token' }, }); const correct = await fetch(url, { headers: { Authorization: `Bearer ${authToken}` }, }); const responseText = `${await missing.text()}${await wrong.text()}${await correct.text()}`; if ( missing.status === 401 && wrong.status === 401 && correct.status === 200 && missing.headers.get('www-authenticate') === 'Bearer' && wrong.headers.get('www-authenticate') === 'Bearer' && !responseText.includes(authToken) && !stdoutBuffer.includes(authToken) && !stderrBuffer.includes(authToken) ) { settle(resolve); } else { settle(() => reject( new Error( `/health auth statuses were ${missing.status}/${wrong.status}/${correct.status}; expected 401/401/200`, ), ), ); } } catch (err) { settle(() => reject( new Error( `authenticated eval-server health probe failed on 127.0.0.1:${boundPort}: ${err}`, ), ), ); } }, }, ); }, 35000); it('emits READY signal with bound IP (not literal "localhost") when --host localhost is used', () => { return runEvalServerHostFlagTest( ['--port', '0', '--host', 'localhost', '--idle-timeout', '3'], { extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, timeoutMsg: 'eval-server --host localhost did not emit READY signal within 30s', async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) { const readyLine = stdoutBuffer .split('\n') .find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:')); if (!readyLine || isSettled()) return; // The signal must contain a real bound IP, not the literal input string if (readyLine.includes(':localhost:')) { settle(() => reject( new Error( `READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`, ), ), ); return; } // Parse host and port: everything after the prefix up to the last colon const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length); const lastColon = withoutPrefix.lastIndexOf(':'); const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]" const boundPort = withoutPrefix.slice(lastColon + 1).trim(); if (!boundPort || isNaN(Number(boundPort))) { settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)), ); return; } // Probe /health at the bound address to confirm the server is reachable try { const res = await fetch(`http://${signalHost}:${boundPort}/health`); if (res.status === 200) { settle(resolve); } else { settle(() => reject(new Error(`/health returned ${res.status}, expected 200`))); } } catch (err) { settle(() => reject( new Error( `eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`, ), ), ); } }, }, ); }, 35000); }); }); // ─── impact disambiguation flags reach the backend at runtime (#1907 U2) ── // The mocked unit test proves the CLI option → callTool param mapping; this // proves the flags survive the real Commander → lazy-action → impactCommand → // callTool chain by spawning the actual CLI. The F2 gap is *flag-forwarding*, // so a uniquely-named fixture symbol is enough — no ambiguous fixture needed. // Tests self-skip when the environment cannot index the fixture (e.g. a // worktree without the built parse-worker); CI validates the real path. describe('impact disambiguation flags reach the backend (e2e, #1907)', () => { const SYMBOL = 'formatResponse'; // uniquely named, in mini-repo/src/formatter.ts let uid: string | undefined; let symbolFile: string | undefined; beforeAll(() => { // Idempotent: the earlier analyze test may already have indexed mini-repo. runCli('analyze', MINI_REPO, 60000); // Derive the real uid + filePath from context so the test is robust to the // exact uid format rather than hard-coding `Function::`. const ctx = runCliRaw(['context', SYMBOL, '--repo', 'mini-repo'], MINI_REPO, 30000); if (ctx.status === 0) { try { const parsed = JSON.parse(ctx.stdout.trim()); uid = parsed?.symbol?.uid; symbolFile = parsed?.symbol?.filePath; } catch { /* leave undefined → tests self-skip below */ } } }); it('forwards --uid alone with no positional target (U1 + --uid end-to-end)', () => { if (!uid) return; // environment could not index — validated in CI const res = runCliRaw(['impact', '--uid', uid, '--repo', 'mini-repo'], MINI_REPO, 30000); if (res.status === null) return; expect(res.status).toBe(0); const out = JSON.parse(res.stdout.trim()); expect(out).not.toHaveProperty('error'); expect(out.target?.id).toBe(uid); }); it('forwards --file: the correct file resolves, a wrong file does not (negative control)', () => { if (!uid || !symbolFile) return; const ok = runCliRaw( ['impact', SYMBOL, '--file', symbolFile, '--repo', 'mini-repo'], MINI_REPO, 30000, ); if (ok.status === null) return; expect(ok.status).toBe(0); const okOut = JSON.parse(ok.stdout.trim()); expect(okOut.status).not.toBe('ambiguous'); expect(okOut.target?.filePath).toBe(symbolFile); // Wrong --file hint → CONTAINS matches nothing → must NOT resolve to the // formatter.ts symbol. Proves the --file value reached the resolver. const wrong = runCliRaw( ['impact', SYMBOL, '--file', 'does/not/exist/nowhere.ts', '--repo', 'mini-repo'], MINI_REPO, 30000, ); if (wrong.status === null) return; const wrongOut = JSON.parse(wrong.stdout.trim()); expect(wrongOut.error !== undefined || wrongOut.target?.filePath !== symbolFile).toBe(true); }); it('forwards --kind: exit 0 with the kind hint applied', () => { if (!uid) return; const res = runCliRaw( ['impact', SYMBOL, '--kind', 'Function', '--repo', 'mini-repo'], MINI_REPO, 30000, ); if (res.status === null) return; expect(res.status).toBe(0); const out = JSON.parse(res.stdout.trim()); expect(out).not.toHaveProperty('error'); }); });