GitNexus/gitnexus/test/integration/pipeline-graph-golden.test.ts
Gergő Magyar d9da7d6692
fix(test): isolate cli-e2e from shared mini-repo fixture (#954)
Deterministic fix for the Windows-flaky pipeline-graph-golden test.

Root cause
  cli-e2e.test.ts wrote into the SHARED fixture directory
  (test/fixtures/mini-repo/) — git init, analyze run that creates
  AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden
  ran in parallel, its `cpSync` of the source directory could capture
  the mid-flight pollution before cli-e2e's afterAll cleanup fired.
  macOS/Ubuntu won the race often enough that the flake presented as
  Windows-only.

Fix
  cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose
  basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by
  basename), runs git-init there, and rm's the whole tmpdir in afterAll.
  The shared fixture source is never touched.

  Fallout from the cwd change: bare `--import tsx` specifiers (2
  spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where
  there is no node_modules. Switched them to the already-existing
  `tsxImportUrl` (absolute file:// URL to the tsx loader), matching
  the `runCliOutsideProject` pattern that was already set up for this
  exact case.

  Updated the "MINI_REPO is inside the project tree" comment in the
  `status on non-indexed repo` test — MINI_REPO is now in os.tmpdir,
  so the rationale for using a separate throwaway tmp git repo is
  different (but still valid: previous tests in the suite create
  MINI_REPO/.gitnexus, which findRepo() would pick up).

  Also updated pipeline-graph-golden's comment explaining WHY it
  copies to tmp — it's now defense-in-depth rather than a necessity,
  so a future test that adds files to the source can't silently
  regress the golden.

Verification
  - 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass
    (deterministic)
  - 3x full suite including pipeline.test: 27/27 pass
  - test/fixtures/mini-repo/ post-run contents: only `src/` —
    zero pollution from any test
  - macOS/Ubuntu behavior unchanged (they were passing; tmpdir
    isolation is purely additive)
2026-04-18 12:54:59 +01:00

209 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Golden-file graph-parity test.
*
* Runs the full ingestion pipeline on the `mini-repo` fixture and compares
* the resulting graph against a committed golden JSON. Guards against silent
* behavioural drift from future refactors (post-U1–U7).
*
* Regenerate the golden file intentionally by running the test with
* `UPDATE_GOLDEN=1` in the environment.
*
* The snapshot captures:
* - totalFileCount
* - symbols (node count) and relationships (edge count)
* - byType: sorted map of NodeLabel -> count
* - byRelType: sorted map of RelationshipType -> count
* - processes count
* - edgeDigest: sha256 of deterministically-sorted `"type|src|dst"` strings
*
* Nothing path-dependent, time-dependent, or id-opaque leaks into the snapshot.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'fs';
import os from 'os';
import crypto from 'crypto';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE_SRC = path.resolve(__dirname, '..', 'fixtures', 'mini-repo');
const GOLDEN_DIR = path.resolve(__dirname, '..', 'fixtures', 'pipeline-golden', 'mini-repo');
const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-graph.json');
const UPDATE = process.env.UPDATE_GOLDEN === '1';
interface GraphSnapshot {
/**
* Tag to clarify provenance and to prime reviewers when the file first
* lands. Rewriting this string has no semantic meaning — only drift of
* the other fields matters for test pass/fail.
*/
capture: string;
fixture: string;
totalFileCount: number;
symbols: number;
relationships: number;
processes: number;
byType: Record<string, number>;
byRelType: Record<string, number>;
edgeDigest: string;
}
/**
* Build a deterministic snapshot from a pipeline result.
*
* All maps are sorted by key so JSON serialization is stable. Edge digest
* is a sha256 over newline-joined, lexicographically-sorted `type|src|dst`
* triples — where `src` and `dst` are symbolic names (node label + property
* name + filePath for files) so that id generation changes don't cause
* spurious digest churn if they don't change the semantic edge set.
*/
function buildSnapshot(result: PipelineResult): GraphSnapshot {
const byType: Record<string, number> = {};
const byRelType: Record<string, number> = {};
// Node id -> stable symbolic key for digest
const nodeKey = new Map<string, string>();
result.graph.forEachNode((n) => {
byType[n.label] = (byType[n.label] ?? 0) + 1;
const props = n.properties as Record<string, unknown>;
const fp = (props.filePath as string | undefined) ?? '';
const nm = (props.name as string | undefined) ?? '';
// Symbolic key: stable across id-format refactors as long as the
// (label, name, filePath) tuple is unchanged.
nodeKey.set(n.id, `${n.label}:${nm}@${fp}`);
});
const edgeTriples: string[] = [];
for (const rel of result.graph.iterRelationships()) {
byRelType[rel.type] = (byRelType[rel.type] ?? 0) + 1;
const src = nodeKey.get(rel.sourceId) ?? `?:${rel.sourceId}`;
const dst = nodeKey.get(rel.targetId) ?? `?:${rel.targetId}`;
// step included for STEP_IN_PROCESS so the digest captures ordering
const step = rel.step !== undefined ? `#${rel.step}` : '';
edgeTriples.push(`${rel.type}${step}|${src}|${dst}`);
}
edgeTriples.sort();
const digest = crypto.createHash('sha256').update(edgeTriples.join('\n')).digest('hex');
return {
capture: 'initial capture (U8, post-U1–U7)',
fixture: 'mini-repo',
totalFileCount: result.totalFileCount,
symbols: result.graph.nodeCount,
relationships: result.graph.relationshipCount,
processes: result.processResult?.stats.totalProcesses ?? 0,
byType: sortObject(byType),
byRelType: sortObject(byRelType),
edgeDigest: digest,
};
}
function sortObject(obj: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {};
for (const k of Object.keys(obj).sort()) out[k] = obj[k];
return out;
}
function formatGolden(snapshot: GraphSnapshot): string {
return JSON.stringify(snapshot, null, 2) + '\n';
}
function diffCounts(
label: string,
actual: Record<string, number>,
expected: Record<string, number>,
): string[] {
const lines: string[] = [];
const keys = new Set([...Object.keys(actual), ...Object.keys(expected)]);
for (const k of [...keys].sort()) {
const a = actual[k] ?? 0;
const e = expected[k] ?? 0;
if (a !== e) lines.push(` ${label}.${k}: expected ${e}, got ${a}`);
}
return lines;
}
describe('pipeline graph golden', () => {
let result: PipelineResult;
let snapshot: GraphSnapshot;
let tmpDir: string;
beforeAll(async () => {
// Copy the fixture to a temp directory as defense-in-depth against
// parallel tests writing into the shared fixture source. cli-e2e
// was the historical offender — it now copies to its own tmpdir
// (see test/integration/cli-e2e.test.ts `beforeAll`) — but this
// cpSync stays as a belt-and-suspenders guarantee that any future
// test adding files to the source won't pollute the golden snapshot.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-'));
fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true });
result = await runPipelineFromRepo(tmpDir, () => {});
snapshot = buildSnapshot(result);
}, 60000);
afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('matches committed golden snapshot on mini-repo', () => {
if (UPDATE || !fs.existsSync(GOLDEN_FILE)) {
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8');
// First run / intentional regen: succeed and report.
// Subsequent CI runs without UPDATE_GOLDEN will diff against this file.
console.log(
`[pipeline-graph-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden file at ${GOLDEN_FILE}`,
);
return;
}
const rawExpected = fs.readFileSync(GOLDEN_FILE, 'utf8');
const expected = JSON.parse(rawExpected) as GraphSnapshot;
const diffs: string[] = [];
if (snapshot.totalFileCount !== expected.totalFileCount) {
diffs.push(
` totalFileCount: expected ${expected.totalFileCount}, got ${snapshot.totalFileCount}`,
);
}
if (snapshot.symbols !== expected.symbols) {
diffs.push(` symbols (nodeCount): expected ${expected.symbols}, got ${snapshot.symbols}`);
}
if (snapshot.relationships !== expected.relationships) {
diffs.push(
` relationships (edgeCount): expected ${expected.relationships}, got ${snapshot.relationships}`,
);
}
if (snapshot.processes !== expected.processes) {
diffs.push(` processes: expected ${expected.processes}, got ${snapshot.processes}`);
}
diffs.push(...diffCounts('byType', snapshot.byType, expected.byType));
diffs.push(...diffCounts('byRelType', snapshot.byRelType, expected.byRelType));
if (snapshot.edgeDigest !== expected.edgeDigest) {
diffs.push(
` edgeDigest changed: the set of (type, source-symbol, target-symbol) edge triples differs from golden. ` +
`Counts may match while edges are rewired — inspect graph manually or re-run with UPDATE_GOLDEN=1 if the change is intentional.`,
);
}
if (diffs.length > 0) {
const msg = [
'Pipeline graph output drifted from golden snapshot.',
`Golden file: ${GOLDEN_FILE}`,
'Changes:',
...diffs,
'',
'If this drift is intentional, regenerate the golden file with:',
' UPDATE_GOLDEN=1 npm --prefix gitnexus test -- pipeline-graph-golden',
].join('\n');
throw new Error(msg);
}
// Belt-and-suspenders: if nothing diffed, the serialized forms should match too.
expect(formatGolden(snapshot)).toBe(rawExpected);
});
});