test(helpers): extract the temp-repo lifecycle, collapsing five hand-rolled cleanups into one

Four cfg integration tests each hand-rolled a `tmpDirs` array, a
mkdtemp-and-register step, and an `afterAll` rmSync. It is actually five
registrations across six creation sites — `pipeline-pdg.test.ts` keeps a
second pool for its C-family fixtures.

Seeding genuinely varies four ways (recursive cpSync, single copyFileSync,
inline mkdir+writeFile, and nothing at all), so a fixture-copier helper
would have fitted about half the sites and made things worse. Extracted the
LIFECYCLE instead — mkdtemp, register, afterAll cleanup — which is
byte-identical at all five registrations and is the correctness-critical
part. `dir()` returns an empty registered directory for callers that seed
themselves; `fromFixture()` covers the common case. That fits 6/6.

The duplication had already produced a latent defect: `cFamilyTmpDirs` was
cleaned by TWO `afterAll` blocks, harmless only because `rmSync` was called
with `force: true`. Now one hook.

`createTempDirPool` is a function called from each test file's module scope
rather than a top-level hook in the helper, because under ESM caching a
module-level `afterAll` would register once, against whichever file
imported it first. That hazard is documented in the helper.

Raw line count is roughly neutral (-44 across the tests, +62 for the
helper, 29 of which are the rationale). The win is that a cleanup invariant
went from five copies to one.

Cleanup verified empirically, including the failure path: a throwaway suite
whose `beforeAll` throws still has its directory removed, and every
temp directory created by the four migrated files is gone after a run.
46 tests pass across the four files.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 11:21:00 +00:00
parent a188d6457d
commit ec36c6dda3
5 changed files with 83 additions and 65 deletions

View file

@ -0,0 +1,62 @@
/**
* Temp-directory lifecycle for pipeline-level integration tests.
*
* The pipeline mutates the repo it is handed (parse caches, `.gitnexus/`), so
* these tests each run against a throwaway copy of a fixture. Every consumer
* had hand-rolled the SAME three parts a `string[]` of created dirs, a
* `mkdtempSync` that pushes onto it, and an `afterAll` that `rmSync`s the lot.
* Extracted at the fourth consumer (`pipeline-pdg`, `pipeline-pdg-streaming`,
* `interproc-taint`, `pdg-chained-receiver-callees`); the copies had already
* drifted `pipeline-pdg` registered two cleanup hooks over one array.
*
* Only the LIFECYCLE is shared, deliberately: seeding differs per test (a
* recursive fixture copy, a single file, an inline-written source, or nothing
* at all), so `dir()` hands back an empty registered directory and the caller
* fills it however it likes. `fromFixture()` is the common case.
*
* `createTempDirPool` calls `afterAll` itself, so it must be called from a
* test file's module scope (not from this module's top level ESM caching
* would register the hook once, for whichever file imported it first).
* Directories are registered at creation, before any seeding runs, so a
* fixture copy or a pipeline run that throws still leaves them cleaned up.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll } from 'vitest';
export interface TempDirPool {
/** A fresh empty temp dir, registered for cleanup. Seed it yourself. */
dir(): string;
/** A fresh temp dir seeded with a recursive copy of `fixture`. */
fromFixture(fixture: string): string;
}
/**
* Create a pool of temp directories that are removed after the calling test
* file finishes. `prefix` is the `mkdtemp` prefix (e.g. `'gn-pdg-'`), kept
* per-pool so a leaked directory still names the suite that made it.
*/
export function createTempDirPool(prefix: string): TempDirPool {
const created: string[] = [];
const dir = (): string => {
const made = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
created.push(made);
return made;
};
afterAll(() => {
for (const d of created) fs.rmSync(d, { recursive: true, force: true });
});
return {
dir,
fromFixture(fixture: string): string {
const made = dir();
fs.cpSync(fixture, made, { recursive: true });
return made;
},
};
}

View file

@ -12,33 +12,23 @@
* the parse worker, and a stale dist is a spurious red.
*/
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import { describe, it, expect } from 'vitest';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
const FIXTURE = path.join(__dirname, 'fixtures', 'interproc-repo');
const tmpDirs: string[] = [];
function freshRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-interproc-'));
fs.cpSync(FIXTURE, dir, { recursive: true });
tmpDirs.push(dir);
return dir;
}
const repos = createTempDirPool('gn-interproc-');
const freshRepo = (): string => repos.fromFixture(FIXTURE);
function taintPaths(result: PipelineResult) {
return [...result.graph.iterRelationships()].filter((r) => r.type === 'TAINT_PATH');
}
describe('U9 — end-to-end interprocedural taint (--pdg)', () => {
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('with --pdg: composes a cross-file source→sink into a TAINT_PATH edge', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const paths = taintPaths(result);

View file

@ -47,11 +47,11 @@
* fixture is shared by eight suites including a snapshot test, so growing it to
* cover one seam churns unrelated expectations.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
// The PRODUCTION reader of the cell: splits on `CALLEE_ID_SEP`
// (src/core/ingestion/cfg/emit.ts) and drops the truncation sentinel. Both the
// statement-precise bridge and the inter-procedural descent go through it, so
@ -225,7 +225,7 @@ interface BlockCell {
readonly ids: readonly string[];
}
const tmpDirs: string[] = [];
const repos = createTempDirPool('gn-pdg-chain-');
let blocks: readonly BlockCell[] = [];
function blocksFor(marker: string): readonly BlockCell[] {
@ -251,10 +251,9 @@ function assertChainReachesPdg(shape: ReceiverShape): void {
describe('PDG calleeIds — chained receiver calls by receiver form (#2802 follow-up)', () => {
beforeAll(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-chain-'));
const dir = repos.dir();
fs.mkdirSync(path.join(dir, path.dirname(FIXTURE_PATH)));
fs.writeFileSync(path.join(dir, FIXTURE_PATH), CHAINED_SOURCE);
tmpDirs.push(dir);
const result = await runPipelineFromRepo(dir, () => {}, { pdg: true });
const collected: BlockCell[] = [];
@ -268,10 +267,6 @@ describe('PDG calleeIds — chained receiver calls by receiver form (#2802 follo
blocks = collected;
}, 180000);
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('every receiver shape contributes exactly one chained-call block', () => {
const counts = Object.fromEntries(
RECEIVER_SHAPES.map((s) => [s.name, blocksFor(s.marker).length]),

View file

@ -15,13 +15,13 @@
* for its CSV dir), differing ONLY in `streamPdgEmit`, so streaming is the only
* variable.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import { loadParseCache } from '../../../src/storage/parse-cache.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
const FIXTURE = path.join(__dirname, 'fixtures', 'pdg-repo');
// A `.vue` SFC importing a `.ts` module: the TS module is PDG-emitted in BOTH
@ -36,18 +36,10 @@ const PDG_EDGE_TYPES = new Set([
'SANITIZES',
]);
const tmpDirs: string[] = [];
function freshRepo(fixture: string = FIXTURE): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-stream-'));
fs.cpSync(fixture, dir, { recursive: true });
tmpDirs.push(dir);
return dir;
}
function freshStorage(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-store-'));
tmpDirs.push(dir);
return dir;
}
const repos = createTempDirPool('gn-pdg-stream-');
const storages = createTempDirPool('gn-pdg-store-');
const freshRepo = (fixture: string = FIXTURE): string => repos.fromFixture(fixture);
const freshStorage = (): string => storages.dir();
function pdgCounts(result: PipelineResult): { basicBlocks: number; pdgEdges: number } {
let basicBlocks = 0;
@ -62,10 +54,6 @@ function pdgCounts(result: PipelineResult): { basicBlocks: number; pdgEdges: num
}
describe('#2202 — streaming PDG emit end-to-end', () => {
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('streams the PDG layer out of the graph while preserving the emitted set', async () => {
// ── Baseline: --pdg on, streaming OFF (durable cache, same as streamed) ──
const baseStorage = freshStorage();

View file

@ -1,12 +1,12 @@
import { describe, it, expect, afterAll } from 'vitest';
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
import { fixtureTaintTotals } from '../../helpers/taint-fixture.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
@ -45,19 +45,10 @@ function counts(result: PipelineResult): {
return { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes, cdg };
}
const tmpDirs: string[] = [];
function freshRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-'));
fs.cpSync(FIXTURE, dir, { recursive: true });
tmpDirs.push(dir);
return dir;
}
const repos = createTempDirPool('gn-pdg-');
const freshRepo = (): string => repos.fromFixture(FIXTURE);
describe('U7 — end-to-end --pdg pipeline', () => {
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('with --pdg on: emits BasicBlock nodes + CFG edges into the graph', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const { basicBlocks, cfgEdges, reachingDefs } = counts(result);
@ -288,11 +279,11 @@ const REMAINING_LANGS: ReadonlyArray<{
{ lang: 'Vue', fixture: 'vue-hazards.vue', hazard: 'shouldStop' }, // eventLoop: while(true)
];
const cFamilyTmpDirs: string[] = [];
// Single-file seeding, so this pool uses `dir()` rather than `fromFixture()`.
const langRepos = createTempDirPool('gn-pdg-lang-');
function freshLangRepo(fixture: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-lang-'));
const dir = langRepos.dir();
fs.copyFileSync(path.join(C_FAMILY_FIXTURES, fixture), path.join(dir, fixture));
cFamilyTmpDirs.push(dir);
return dir;
}
@ -396,10 +387,6 @@ function cdgSourcedInHazardFunction(result: PipelineResult, hazardMarker: string
}
describe('U7 — C-family worker-mode --pdg pipeline', () => {
afterAll(() => {
for (const d of cFamilyTmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
for (const { lang, fixture, hazard } of C_FAMILY) {
it(`${lang}: --pdg on emits BasicBlock + CFG + REACHING_DEF + CDG (> 0) via the worker`, async () => {
const result = await runPipelineFromRepo(freshLangRepo(fixture), () => {}, WORKER_PDG);
@ -478,10 +465,6 @@ describe('U7 — C-family worker-mode --pdg pipeline', () => {
});
describe('U7 — remaining languages worker-mode --pdg pipeline (#2195 capstone)', () => {
afterAll(() => {
for (const d of cFamilyTmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
for (const { lang, fixture, hazard, vendored } of REMAINING_LANGS) {
// Vendored grammars (Swift/Kotlin/Dart) may lack a prebuild on the CI
// platform — skip rather than fail when the grammar can't load (#2197 U4).