mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
/**
|
|
* 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<void>((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<void>((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);
|
|
});
|
|
});
|