/** * 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 } 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 }); } }); }); });