test: stop test runs leaving temp dirs behind (#2622)

Every full test run left about 40 directories in the temp dir, even when it
passed. On the Windows dev machine about 16,000 had piled up by mid-October.
A full run with TEMP pointed at an empty folder showed where each one came
from:

- function-ref (20) and ts-field-classification (3): each case's `finally`
  cleared `tmpDir` right after `cg.destroy()`, so the `afterEach` that removes
  the dir found nothing to remove.
- npm-sdk (3) and liveness-watchdog (3) never removed the dirs they made.
- extraction's #1502 suite (3) made a dir in `beforeEach` and had no
  `afterEach`; pr19-improvements' schema-version case (1) made one inline.
- setup-home-sandbox (6 on Windows): Vitest runs no hooks for a file whose
  every test is skipped, so each platform-gated file left its home sandbox.
  Each sandbox now records itself in a per-run registry, and a global
  teardown removes whatever is still listed and names any dir still in use.

With the fix, the same full run leaves nothing behind but Node's own reused
compile cache. test-home-sandbox gains two tests: a file's home is listed in
the run's registry, and the teardown removes every listed home, then the
registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-16 17:00:12 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1981354476
commit 2344ce0722
10 changed files with 117 additions and 36 deletions
+4
View File
@@ -13721,6 +13721,10 @@ describe('Unsupported-language projects report what they skipped (#1502)', () =>
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('counts files it could not index, by extension, on the git path', async () => {
const runGit = (...args: string[]) =>
execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' });
-20
View File
@@ -108,7 +108,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -152,7 +151,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -182,7 +180,6 @@ describe('Function-as-value capture (#756)', () => {
expect(target?.filePath.endsWith('handlers.ts')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -207,7 +204,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, edges)).not.toContain('wire');
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -235,7 +231,6 @@ describe('Function-as-value capture (#756)', () => {
expect(target?.filePath.endsWith('real.c')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -260,7 +255,6 @@ describe('Function-as-value capture (#756)', () => {
expect(fnRef).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -284,7 +278,6 @@ describe('Function-as-value capture (#756)', () => {
expect(selfLoops).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -349,7 +342,6 @@ describe('Function-as-value capture (#756)', () => {
}
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -406,7 +398,6 @@ describe('Function-as-value capture (#756)', () => {
]);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -456,7 +447,6 @@ describe('Function-as-value capture (#756)', () => {
}
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -496,7 +486,6 @@ describe('Function-as-value capture (#756)', () => {
).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -543,7 +532,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -588,7 +576,6 @@ describe('Function-as-value capture (#756)', () => {
expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -617,7 +604,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, edges)).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -661,7 +647,6 @@ describe('Function-as-value capture (#756)', () => {
expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -702,7 +687,6 @@ describe('Function-as-value capture (#756)', () => {
expect(sourceNames(cg, fnRefEdgesInto(cg, 'load'))).toEqual(['sorter']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -743,7 +727,6 @@ describe('Function-as-value capture (#756)', () => {
expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -823,7 +806,6 @@ describe('Function-as-value capture (#756)', () => {
expect(callers.some((c) => c.node.name === 'get_serializer_class')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -856,7 +838,6 @@ describe('Function-as-value capture (#756)', () => {
expect(fnRefEdgesInto(cg, 'unknown_thing')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -1598,7 +1579,6 @@ func Unknown(obj interface{}) { Submit(obj.Fetch) }
expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Remove the home sandboxes `setup-home-sandbox.ts` could not.
*
* Each test file removes its own sandbox in an `afterAll`, but Vitest runs no
* hooks at all for a file whose every test is skipped — a platform-gated suite
* skips that way, and each one left its home dir behind on every run — and a
* straggling child can still hold a file there when `afterAll` runs. So every
* sandbox records its path in this run's registry dir, and the end of the run
* removes whatever is still listed.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { GlobalSetupContext } from 'vitest/node';
declare module 'vitest' {
export interface ProvidedContext {
/** The dir each test file's home sandbox records its path in. */
homeSandboxRegistry: string;
}
}
export default function setup({ provide }: GlobalSetupContext): () => void {
const registry = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-homes-'));
provide('homeSandboxRegistry', registry);
return () => {
const held: string[] = [];
for (const entry of fs.readdirSync(registry)) {
const home = fs.readFileSync(path.join(registry, entry), 'utf8');
try {
fs.rmSync(home, { recursive: true, force: true, maxRetries: 3 });
} catch {
held.push(home);
}
}
fs.rmSync(registry, { recursive: true, force: true });
if (held.length > 0) {
process.stderr.write(`[test teardown] ${held.length} home sandbox dir(s) still in use, left behind:\n`
+ held.map((home) => ` ${home}\n`).join(''));
}
};
}
+17 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
@@ -140,6 +140,19 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
// when the watched DB files keep advancing (a slow synchronous SQLite
// statement on degraded storage). ---
// The dirs holding each case's watched file, removed after it.
const progressDirs: string[] = [];
afterEach(() => {
for (const dir of progressDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
/** A fresh path for a case's watchdog to watch, in its own temp dir. */
function progressFile(): string {
const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-'));
progressDirs.push(dir);
return path.join(dir, 'db-wal');
}
/** Grow `file` every 150ms for `forMs`; resolves when done. */
function growFile(file: string, forMs: number): Promise<void> {
return new Promise((resolve) => {
@@ -149,7 +162,7 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
}
it('does NOT kill a blocked loop while the watched files advance (slow store, not a wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
const tmp = progressFile();
fs.writeFileSync(tmp, 'seed');
// Base timeout 500ms; the loop blocks for 2.5s (5 timeouts, under the 10×
// cap) while the test process grows the watched file. Old behavior: killed
@@ -168,7 +181,7 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
}, 15000);
it('still kills a blocked loop when the watched files do NOT advance (a true wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
const tmp = progressFile();
fs.writeFileSync(tmp, 'seed');
// Same blocked loop, nobody grows the file: the base timeout kills it long
// before its own exit(5) at 2.5s.
@@ -182,7 +195,7 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
}, 15000);
it('kills at the hard cap even with ongoing file activity (bounded deferral)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
const tmp = progressFile();
fs.writeFileSync(tmp, 'seed');
// Base timeout 300ms ⇒ cap 3s. The loop blocks for 8s with continuous file
// growth: deferral carries it past 300ms but the cap kills it around ~3s,
+11 -2
View File
@@ -13,7 +13,7 @@
* hermetically with no real bundle, network, or registry.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
@@ -23,8 +23,13 @@ const SDK_SRC = path.join(__dirname, '..', 'scripts', 'npm-sdk.js');
const target = `${process.platform}-${process.arch}`;
const VERSION = '9.9.9-test';
// Every temp dir a case makes, removed after it.
const tmpDirs: string[] = [];
function mkTmp(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-sdk-${label}-`));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-sdk-${label}-`));
tmpDirs.push(dir);
return dir;
}
// A temp node_modules with the main package (npm-sdk.js + package.json). The
@@ -75,6 +80,10 @@ function requireSdk(mainPkg: string, env: Record<string, string> = {}) {
}
describe('npm-sdk programmatic entry', () => {
afterEach(() => {
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
it('re-exports the installed platform bundle library', () => {
const { root, mainPkg } = makeConsumer();
installPlatformPackage(root, 'platform-lib');
+3 -2
View File
@@ -305,12 +305,13 @@ describe('Schema v2 Migration', () => {
// just makes every schema change edit this test (v9/#1500 was the latest).
// A fresh database records the current version, so nothing is pending;
// ask a version-0 database instead to see the full migration list.
const dbPath = path.join(createTempDir(), 'schema-version.db');
const conn = DatabaseConnection.initialize(dbPath);
const dir = createTempDir();
const conn = DatabaseConnection.initialize(path.join(dir, 'schema-version.db'));
const raw = conn.getDb();
raw.prepare('DELETE FROM schema_versions').run();
const highest = Math.max(...getPendingMigrations(raw).map((m) => m.version));
conn.close();
cleanupTempDir(dir);
expect(CURRENT_SCHEMA_VERSION).toBe(highest);
});
+10 -2
View File
@@ -21,7 +21,7 @@
* Tests that redirect the home dir themselves (spying on `os.homedir`, or
* setting HOME and restoring it) keep working: they restore to this sandbox.
*/
import { afterAll } from 'vitest';
import { afterAll, inject } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -30,6 +30,13 @@ import * as path from 'path';
// `/private/var`) sees the same home everything else does.
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-home-')));
// Recorded for the end of the run, which removes the home if the afterAll
// below never runs (a file whose every test is skipped runs no hooks) or
// cannot (global-setup-home-sandbox.ts).
const registry = inject('homeSandboxRegistry');
const registryEntry = registry ? path.join(registry, path.basename(home)) : undefined;
if (registryEntry) fs.writeFileSync(registryEntry, home);
process.env.HOME = home;
process.env.USERPROFILE = home;
if (process.platform === 'win32') {
@@ -60,7 +67,8 @@ process.env.GIT_CONFIG_GLOBAL = gitConfig;
afterAll(() => {
try {
fs.rmSync(home, { recursive: true, force: true, maxRetries: 3 });
if (registryEntry) fs.rmSync(registryEntry, { force: true });
} catch {
/* a straggling child still holding a file there; it's in the temp dir */
/* a straggling child still holding a file there; the end of the run retries */
}
});
+27 -1
View File
@@ -3,13 +3,15 @@
* nothing a test runs — in-process or spawned — resolves the developer's real
* home, Claude profile, daemon registry or git identity.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, inject } from 'vitest';
import type { GlobalSetupContext } from 'vitest/node';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { claudeTarget } from '../src/installer/targets/claude';
import { getRegistryDir } from '../src/mcp/daemon-registry';
import setupHomeSandboxRun from './global-setup-home-sandbox';
const tmpRoot = fs.realpathSync(os.tmpdir());
const under = (p: string, dir: string): boolean => path.resolve(p).startsWith(dir + path.sep);
@@ -62,4 +64,28 @@ describe('test home sandbox', () => {
expect(under(process.env.APPDATA ?? '', home)).toBe(true);
expect(under(process.env.LOCALAPPDATA ?? '', home)).toBe(true);
});
// A file whose every test is skipped runs no hooks, so its own afterAll never
// removes its home: the end of the run removes every home still listed.
it("records this file's home in the run's registry", () => {
const home = sandboxHome();
const entry = path.join(inject('homeSandboxRegistry'), path.basename(home));
expect(fs.readFileSync(entry, 'utf8')).toBe(home);
});
it('the end of the run removes every home still listed, then the registry', () => {
let registry = '';
const teardown = setupHomeSandboxRun({
provide(_key: string, value: string) { registry = value; },
} as unknown as GlobalSetupContext);
const homes = ['skipped', 'held'].map((label) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-test-home-${label}-`));
fs.writeFileSync(path.join(home, '.gitconfig'), '');
fs.writeFileSync(path.join(registry, path.basename(home)), home);
return home;
});
teardown();
expect(homes.filter((home) => fs.existsSync(home))).toEqual([]);
expect(fs.existsSync(registry)).toBe(false);
});
});
@@ -99,7 +99,6 @@ describe('TS/JS class field classification (#808)', () => {
expect(fontsProp.signature).toBe('Fonts fonts');
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -126,7 +125,6 @@ describe('TS/JS class field classification (#808)', () => {
expect(cg.getNodesByName('onClick')[0]?.kind).toBe('method');
} finally {
cg.destroy();
tmpDir = undefined;
}
});
@@ -153,7 +151,6 @@ describe('TS/JS class field classification (#808)', () => {
expect(fnRefs.length).toBeGreaterThan(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
});
+3 -2
View File
@@ -12,8 +12,9 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
// Suites that spawn the built CLI need a current dist/ (#1879).
globalSetup: ['./__tests__/global-setup-dist.ts'],
// Suites that spawn the built CLI need a current dist/ (#1879), and the end
// of the run removes any home sandbox its own test file could not.
globalSetup: ['./__tests__/global-setup-dist.ts', './__tests__/global-setup-home-sandbox.ts'],
// A throwaway home dir (and git global config) per test file, so nothing
// the suite runs can write to the developer's real one (#2275).
setupFiles: ['./__tests__/setup-home-sandbox.ts'],