mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-19 00:03:33 +00:00
* Initial plan * feat(ingestion): add variable extraction types, factory, configs, and wire into language providers - Create variable-types.ts with VariableInfo, VariableExtractionConfig, VariableExtractor interfaces - Create variable-extractors/generic.ts with createVariableExtractor() factory - Add variableExtractor field to LanguageProvider interface - Create per-language variable extraction configs for all 16 languages - Wire variableExtractor into all language providers - Add variable metadata enrichment to parse-worker for Const/Static/Variable labels Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(ingestion): add variable extraction tests and fix Python/TS config issues - Create test/unit/variable-extraction.test.ts with 29 tests covering TypeScript, JavaScript, Python, Go, Rust, C, C++, Ruby, and factory behavior - Fix isConst in generic factory to use config.isConst over node-type membership (TS let/const both use lexical_declaration) - Fix Python type extraction for annotated assignments at module scope - Fix Python dunder name visibility (e.g., __name__ is public, not protected) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review feedback — move imports, clarify scope comment, use shared test context Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review comments, fix prettier formatting and lint errors - Fix prettier formatting in 5 files (c-cpp, jvm, swift configs, test file) - Remove unused SyntaxNode imports in php.ts and ruby.ts (lint errors) - Remove unused constNodeSet/variableNodeSet variables in generic.ts (warnings) - Remove semantically wrong `methodProps.isReadonly = varInfo.isConst` (review) - Remove dead `nodeLabel === 'Variable'` guard in parse-worker (review) - Fix test guard: replace `if (declNode)` with `expect(declNode).toBeDefined()` (review) - Add comment about Python expression_statement broadness (review) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/040edbbf-65b5-40e1-80c8-e98f7c4bb54a * feat(ingestion): add block-scoped variable extraction via tree-sitter queries Add @definition.const and @definition.variable tree-sitter query patterns for TypeScript, JavaScript, Python, Go, Java, C, C++, C#, PHP, Ruby, and Dart. Add parse-worker dedup logic to avoid duplicate nodes when variable captures overlap with existing function/property captures. Add 'Variable' label support in getLabelFromCaptures and DEFINITION_CAPTURE_KEYS. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add block-scoped variable extraction tests and query capture tests Add 6 tests for block-scoped variable extraction (TypeScript, Go, Rust, C, Python). Add 14 tests verifying @definition.const/@definition.variable query patterns exist in all language query strings. Import RUBY_QUERIES in test file. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add Python non-assignment expression statement rejection test Addresses code review feedback: verify that the Python variable extractor returns null for expression_statement nodes that contain function calls rather than assignments (e.g. `print("hello")`). Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: Dart query node type, add Variable schema, update schema counts - Change `top_level_variable_declaration` → `declaration` in DART_QUERIES (the former doesn't exist in tree-sitter-dart grammar, causing all Dart integration tests to fail with TSQueryErrorNodeType) - Add VARIABLE_SCHEMA to schema.ts and register in initLbug() so that Variable-labeled nodes are persisted to LadybugDB (not silently dropped) - Add 'Variable' to MULTI_LANG_TYPES in csv-generator.ts - Update Dart variable config to remove invalid node type - Update schema test counts (30→31 node schemas, 32→33 total) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review comment improvements - Clarify processedDefinitionNodes tracks start indices, not nodes - Improve Python variableNodeTypes comment wording Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: add Variable to NODE_TABLES, RELATION_SCHEMA, update golden snapshot - Add 'Variable' to NODE_TABLES in gitnexus-shared so validTables.has('Variable') returns true and Variable graph edges are not silently dropped - Add FROM File TO Variable, FROM Variable TO Community, FROM Variable TO Process to RELATION_SCHEMA so KuzuDB can represent edges connecting Variable nodes - Update schema.test.ts: add Variable to multiLang list, fix count 30→31 - Regenerate pipeline-graph-golden snapshot for mini-repo fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e3aad558-e7bb-40d1-b53f-0a2c0132ca96 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: isolate golden test from cli-e2e fixture pollution The pipeline-graph-golden test was non-deterministic because cli-e2e.test.ts creates AGENTS.md, CLAUDE.md, .claude/skills/, and .gitignore in the shared mini-repo fixture during analyze. These leftover files caused the golden test to find 9 files instead of 7 when tests ran in parallel. Fixes: - Golden test now copies the fixture to a temp dir before running, making it immune to concurrent test pollution - cli-e2e afterAll cleanup now removes ALL generated files (AGENTS.md, CLAUDE.md, .claude/, .gitignore) not just .git/ and .gitnexus/ - Golden snapshot regenerated from clean 7-file fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bd378e73-6f37-49c6-aed6-7fabf4dc6183 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
527 lines
19 KiB
TypeScript
527 lines
19 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 all files/dirs created by analyze (git init, .gitnexus output,
|
|
// AI context files, skill files, .gitignore) so parallel tests like
|
|
// pipeline-graph-golden see a pristine fixture.
|
|
for (const entry of ['.git', '.gitnexus', '.claude', 'AGENTS.md', 'CLAUDE.md', '.gitignore']) {
|
|
const fullPath = path.join(MINI_REPO, entry);
|
|
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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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 <provider>');
|
|
expect(result.stdout).toContain('--review');
|
|
expect(result.stdout).toContain('-v, --verbose');
|
|
expect(result.stdout).toContain('--model <model>');
|
|
expect(result.stdout).toContain('--gist');
|
|
expect(result.stdout).toContain('--concurrency <n>');
|
|
});
|
|
|
|
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 {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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,
|
|
['--import', tsxImportUrl, cliEntry, 'wiki', tmpDir],
|
|
{
|
|
cwd: tmpDir,
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
|
},
|
|
},
|
|
);
|
|
if (result.status === null) return;
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toMatch(/No GitNexus index found/);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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 --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).
|
|
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);
|
|
});
|
|
});
|