/** * 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, pathToFileURL } from 'url'; import { createRequire } from 'module'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); const MINI_REPO = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // Absolute file:// URL to tsx loader — needed when spawning CLI with cwd // outside the project tree (bare 'tsx' specifier won't resolve there). // Cannot use require.resolve('tsx/dist/loader.mjs') because the subpath is // not in tsx's package.json exports; resolve the package root then join. const _require = createRequire(import.meta.url); const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; beforeAll(() => { // Initialize mini-repo as a git repo so the CLI analyze command // can run the full pipeline (it requires a .git directory). const gitDir = path.join(MINI_REPO, '.git'); if (!fs.existsSync(gitDir)) { 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' }, }); } }); afterAll(() => { // Clean up .git/ and .gitnexus/ directories created during the test for (const dir of ['.git', '.gitnexus']) { const fullPath = path.join(MINI_REPO, dir); if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { recursive: true, force: true }); } } }); function runCli(command: string, cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, // 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(), }, }); } /** * 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, ['--import', 'tsx', cliEntry, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.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); }); 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); }); 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. * Uses the absolute file:// URL to tsx loader so the --import hook * resolves even when cwd has no node_modules. */ function runCliOutsideProject(args: string[], cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), }, }); } it('status on non-indexed repo reports not indexed', () => { // MINI_REPO is inside the project tree so findRepo() walks up and // finds the parent project's .gitnexus. Use an isolated temp git // repo to guarantee no .gitnexus exists anywhere in the path. 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 { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); 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 { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); 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 { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); }); // ─── 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). describe('tool output goes to stdout via fd 1 (#324)', () => { 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)', () => { it('cypher: EPIPE exits with code 0, not stderr dump', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, ['--import', 'tsx', cliEntry, 'cypher', 'MATCH (n) RETURN n LIMIT 500', '--repo', 'mini-repo'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), }, }, ); 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)', () => { it('READY signal appears on stdout, not stderr', () => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, ['--import', 'tsx', cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), }, }, ); let stdoutBuffer = ''; let foundOnStdout = false; let foundOnStderr = false; child.stdout.on('data', (chunk: Buffer) => { stdoutBuffer += chunk.toString(); if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) { 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); }); });