mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout The `windows-latest (platform-sensitive)` job was hitting its 15-min internal vitest watchdog in run-cross-platform.ts. It's cumulative slowness, not a hang: the fixed 72-file suite is dominated by ~50 CLI/worker process spawns, and Windows is ~5x slower than macOS at process startup (macOS ran the same set in ~3min of tests). Two complementary changes bring it back under the watchdog with headroom, without touching any test assertion: - Shard the platform-sensitive matrix (windows/macos × shard [1,2]) and forward `--shard=i/2` through run-cross-platform.ts to vitest, which partitions the fixed file list deterministically (sha1, equal file-count) — halving each runner. macOS/Ubuntu were already under budget. - New test/helpers/cli-entry.ts (`CLI_SPAWN_PREFIX`): spawn the built `dist/cli/index.js` when `GITNEXUS_E2E_CLI=dist` (set on the cross-platform job, which already builds) instead of `node --import tsx src/cli/index.ts`, which re-transpiles the whole CLI on every spawn. Defaults to tsx-on-source so local runs always reflect current source; `GITNEXUS_E2E_CLI=dist` on an unbuilt tree throws an actionable "run npm run build" error. dist is opt-in only — never inferred from a generic `CI` env — so an ambient `CI=1` can't silently run a stale build. Converted 8 spawn-based e2e suites; added test/unit/cli-entry.test.ts. The Ubuntu coverage job leaves `GITNEXUS_E2E_CLI` unset, so the tsx-on-source path stays exercised in CI too (both entry points covered). Measured on Linux: cli-limit-e2e 121.5s→91s, cli-e2e 289s→217s (~25%); larger on Windows where the transpile is a bigger share of each spawn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ci): derive platform-sensitive shard count from one source (#2394) The shard total was hardcoded in three coupled, unenforced places (matrix length, job-name suffix, --shard denominator); editing one without the others silently dropped a shard's tests with green CI. Add a checkout-free shard-plan job whose single TOTAL generates both the shard index list (consumed via fromJSON) and the /N denominator (job name + --shard arg), so they cannot drift. Asserts TOTAL>=1 to rule out an empty-matrix silent skip. No behavior change — still 2 shards per OS. Addresses PR #2394 tri-review finding F2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): 3 shards for real Windows headroom + honest sharding comments (#2394) vitest shards by file COUNT, not runtime, so the heaviest spawn suites cluster into one shard: live CI showed Windows shard 1/2 at 12m12s (~81% of the 15-min watchdog) vs shard 2/2 at 3m0s. The old comments claimed "comfortable/generous headroom", which the count-based split doesn't deliver at 2 shards. Bump TOTAL to 3 (one line, single source) so even the busiest Windows shard clears the watchdog, and reword the comments to describe count-based (not time-based) sharding. Addresses PR #2394 tri-review finding F1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ci): extract testable parseShardArg from run-cross-platform (#2394) The --shard parse/forward glue had no unit test. Extract it into a pure scripts/shard-arg.ts (mirroring the computeSpawnPrefix extraction precedent) so the branch logic is lockable without the script's top-level execFileSync, and add test/unit/shard-arg.test.ts (absent -> undefined, valid token -> passed through, found amid other args). Behavior unchanged; U4 adds the malformed fail-loud on top. Addresses PR #2394 tri-review finding F3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): fail loud on a malformed --shard arg (#2394) A shard-shaped-but-malformed arg (--shard=1, --shard, --shard=abc) was silently ignored, dropping the shard flag so both legs ran the full unsharded ~50-spawn suite — re-arming the Windows watchdog timeout with no signal. parseShardArg now throws an actionable error on any --shard/--shard=… arg that fails the strict regex (unrelated flags like --shardx= pass through), and the call site in run-cross-platform.ts catches it into console.error + exit 1, kept outside the execFileSync try so the message isn't swallowed by that catch's watchdog-only branch. Addresses PR #2394 tri-review finding F4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): fail loud on an unknown GITNEXUS_E2E_CLI value (#2394) computeSpawnPrefix silently degraded any unknown GITNEXUS_E2E_CLI value to tsx-on-source, so a typo (e.g. `dsit`) would make CI believe it tests the dist entry point while actually running src. Throw on any value other than 'dist'/'src'/unset (the safe tsx default is preserved for unset/''/'src', so it still never selects dist without an explicit opt-in). Flip the unknown-mode unit test to assert the throw and add the missing {mode:undefined, distExists:true} case. Only ci-tests.yml sets the var (=dist), so no existing suite is affected. Addresses PR #2394 tri-review findings minor-a/b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ci): run cli-entry.test.ts on the cross-platform matrix (#2394) cli-entry.test.ts resolves CLI_SPAWN_PREFIX from a real path, and its last assertion (cli[/\\]index) has a Windows backslash branch that only Ubuntu exercised. Register it in PLATFORM_LOGIC so it runs on the Windows/macOS matrix too. (shard-arg.test.ts stays out — pure string logic, OS-independent.) List grows 73 -> 74; the generated shard matrix keeps coverage complete. Addresses PR #2394 tri-review finding minor-c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(test): share tsxLoaderUrl(), dedup the last tsx-loader boilerplate (#2394) bridge-cache-reopen.test.ts carried its own copy of the tsx-loader-resolution boilerplate (createRequire -> resolve('tsx/package.json') -> pathToFileURL) — the one site the PR's CLI_SPAWN_PREFIX migration didn't cover (it spawns a seed script, not the CLI). Export the existing tsxLoaderUrl() from cli-entry.ts and reuse it here; the resolved loader URL is byte-identical. Addresses PR #2394 tri-review finding minor-d. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): make skipUnlessFtsAvailable install FTS on miss so shards are self-sufficient (#2394) Sharding the platform-sensitive suite into 3 exposed a latent test-isolation bug: load-only FTS primitives (test/integration/lbug-core-adapter.test.ts) only passed because a sibling installer test happened to co-locate in the same shard and install FTS into the shared ~/.lbdb first. At 3 shards, lbug-core-adapter landed in a shard with no installer sibling, so its load-only loadFTSExtension() failed deterministically on macOS+Windows shard 2/3 under GITNEXUS_REQUIRE_FTS=1. Make the gate self-sufficient: on a load-only miss under REQUIRE_FTS, install FTS with `auto` (LOAD-first, then one bounded network INSTALL) before treating it as a hard failure — mirroring withTestIndexedDB. A pre-installed extension still costs no network (auto is LOAD-first); offline/local runs (no env var) still skip gracefully. Verified: with a fresh HOME (no pre-installed FTS) + REQUIRE_FTS=1, lbug-core-adapter now passes 15/15 (previously threw). Addresses the 3-shard CI failure surfaced while validating PR #2394's F1 fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: warm-cache the LadybugDB FTS extension across platform shards (#2394) Follow-up to the FTS self-install fix: cache ~/.lbdb/extension per OS + lockfile so a warm run skips the network install entirely and the parallel shards share one download across runs. Pure reliability/speed — on a cache miss the tests still self-install FTS on demand (test/helpers/fts-availability.ts), so this is never a correctness dependency, just a way to cut the network-install surface that made the sharded FTS tests flaky. Keyed by lockfile hash (a LadybugDB version bump re-installs); per-OS since the extension is a native binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): pass shard via env to clear zizmor template-injection (#2394) Interpolating ${{ matrix.shard }} (now sourced from the shard-plan job output) directly into the run: shell tripped zizmor's template-injection audit (code-scanning alert #824, ci-tests.yml:147). Move the value into a SHARD env var — assigned via ${{ }} but referenced as "$SHARD" in the shell, which is not an injection sink — and set shell: bash so the expansion is uniform across the windows + macOS matrix (the default run shell is pwsh on Windows, where $SHARD would be empty and trip the new malformed-shard fail-loud). Verified locally with zizmor: the :147 template-injection finding is gone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: shard the ubuntu coverage job and merge blobs before the threshold gate (#2394) The coverage job ran the full suite unsharded (~16 min). Shard it like the cross-platform matrix, then merge the per-shard coverage before enforcing the threshold gate: - shard-plan now also single-sources the coverage shard count (cov_total / cov_shards), so the coverage matrix + /N denominator can't drift. - The `tests` job becomes a coverage shard matrix: each shard runs `vitest run --shard --coverage --reporter=blob` with thresholds forced to 0 (a single shard's partial coverage can never meet the gate) and uploads its blob. FTS self-installs per shard, so sharding the full suite is safe. - New `coverage-merge` job (needs: tests) reduces the blobs with `vitest --mergeReports`, enforcing the REAL config thresholds on the combined ('new') coverage — this is the gate. It also emits the merged test-results.json and runs the unsharded web + docker suites, so the `test-reports` artifact keeps the exact shape ci-report.yml consumes for its base-branch ('baseline') vs new coverage delta. The shard arg goes through a SHARD env var + shell: bash (no template-injection). Validated locally: shard blobs write and merge into a coverage-summary.json + merged test-results.json; the merge enforces thresholds on the union. CI Gate still aggregates the coverage-merge result via the reusable-workflow call. Note: the coverage check names change (ubuntu / coverage 1/3 … + merge) — update any pinned branch-protection required checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): include hidden files when uploading the coverage blob (#2394) The coverage shards write their blob to gitnexus/.vitest-reports/ (a dotdir). actions/upload-artifact excludes hidden files by default, so the coverage-blob-* artifacts uploaded empty — the merge job then downloaded 0 artifacts and vitest --mergeReports failed with ENOENT scandir '.vitest-reports'. Set include-hidden-files: true on the blob upload so the blobs actually ship. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): group shard-plan GITHUB_OUTPUT writes to satisfy shellcheck SC2129 (#2394) Adding the coverage shard outputs (cov_shards/cov_total) made the shard-plan gen step write four individual `>> "$GITHUB_OUTPUT"` redirects, which shellcheck (run by the actionlint check) flags as SC2129. Group the echoes into a single `{ …; } >> "$GITHUB_OUTPUT"` block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(test): cost-balanced shard sequencer to cut CPU contention (#2394) vitest's default --shard hashes file paths and splits by file COUNT, which clustered the spawn-heavy suites onto one runner (Windows platform shard 1 ran ~4x the others). Add a custom sequence.sequencer that overrides only shard() and balances by estimated WORK instead: - specWeight() weights the fileParallelism:false spawn-heavy suites (cli-e2e, lbug-db — already isolated to run sequentially) far above the parallel default files, plus file size as a cheap finer signal. Deterministic per checkout. - assignShards() does greedy longest-processing-time bin-packing (heaviest file into the currently-lightest shard). The partition stays complete and disjoint — verified: on the 74-file cross-platform set the three shards weigh 7611/7610/8064 (the sequential-heavy files spread ~7/7/8) with zero overlap and no file dropped, vs the hash split's count-only balance. sort() is left to the base sequencer so project groupOrder / duration-cache ordering is untouched. Pure logic split into shard-balance.ts with a unit test locking the disjoint+complete, balance, and determinism properties. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): install + cache FTS up front on the coverage (and cross-platform) shards (#2394) coverage 3/3 failed on extension-binary-real.test.ts: it uses the file-path FTS gate (requireFtsResourceOrSkip), which resolves ~/.lbdb/extension at MODULE LOAD and cannot self-install the way the load-path gate (skipUnlessFtsAvailable, U8) does. The coverage job had no FTS cache and relied on an installer test running first in the shard — the balancing sequencer reshuffled the shards and dropped extension-binary-real into a shard with no installer, so FTS was absent. Remove the ordering dependency: add scripts/ensure-fts.ts (init a throwaway lbug db, loadFTSExtension with policy:auto → LOAD-first, INSTALL on miss) and run it up front on every coverage AND cross-platform shard, after restoring the per-OS FTS cache. The coverage job now shares that same cache key (it previously had none — this is the "share the cached FTS with coverage" the failure pointed at). Cold cache installs once; warm cache is a no-network load. Verified locally: ensure-fts installs FTS into a fresh HOME and is a no-op when already present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1620 lines
65 KiB
TypeScript
1620 lines
65 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 } 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<string, string> = {}) {
|
||
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<string, string>,
|
||
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 `<parentTmp>/<basename>`
|
||
* 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;
|
||
onStdout: (params: {
|
||
stdoutBuffer: string;
|
||
isSettled: () => boolean;
|
||
settle: (fn: () => void) => void;
|
||
resolve: () => void;
|
||
reject: (err: Error) => void;
|
||
}) => void | Promise<void>;
|
||
},
|
||
): Promise<void> {
|
||
return new Promise<void>((resolve, reject) => {
|
||
const child = spawn(process.execPath, [...CLI_SPAWN_PREFIX, 'eval-server', ...spawnArgs], {
|
||
cwd: MINI_REPO,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
env: cliEnv(),
|
||
});
|
||
|
||
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,
|
||
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() || '<empty>'}`,
|
||
),
|
||
),
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 }, 60000);
|
||
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 }, 60000);
|
||
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);
|
||
}
|
||
}, 60_000);
|
||
|
||
// ─── analyze --name <alias> + --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 <alias> 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 <target> (#664) ─────────────────────────────
|
||
//
|
||
// End-to-end regression guard for the remove command:
|
||
// 1. `remove <alias>` without --force is a dry-run (exit 0, preserves state)
|
||
// 2. `remove <alias> --force` deletes the .gitnexus/ directory
|
||
// AND unregisters from the global registry
|
||
// 3. `remove <unknown>` is idempotent (exit 0 with a warning)
|
||
// 4. `remove <ambiguous>` (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 <target> (#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: <alias>` 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 <repo>/.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
|
||
// `<entry.path>/.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 <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 <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 {
|
||
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<void>((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<void>((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('emits READY signal containing the bound host 127.0.0.1', () => {
|
||
return runEvalServerHostFlagTest(
|
||
['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'],
|
||
{
|
||
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 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => {
|
||
return runEvalServerHostFlagTest(
|
||
['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'],
|
||
{
|
||
timeoutMsg: 'eval-server --host 0.0.0.0 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: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;
|
||
}
|
||
|
||
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
|
||
try {
|
||
const res = await fetch(`http://127.0.0.1:${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 0.0.0.0 but /health unreachable 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'],
|
||
{
|
||
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:<path>:<name>`.
|
||
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');
|
||
});
|
||
});
|