test: enforce test-directory isolation across all packages
Introduce a shared test-utils module and global vitest setup that guarantee tests never write to the real .fusion directory or leak temp directories under /tmp. Infrastructure: - packages/core/src/__test-utils__/workspace.ts — tempWorkspace(), useIsolatedCwd(), trackForCleanup(), assertOutsideRealFusion() with auto-cleanup in afterEach. - packages/core/src/__test-utils__/vitest-setup.ts — per-worker guard: chdirs each worker into an isolated tmp dir, wraps process.chdir to refuse the real .fusion, scopes tmp dirs under fusion-test-workers/ (skips cwd change in thread-pool workers where chdir isn't supported). - packages/core/src/__test-utils__/vitest-teardown.ts — globalSetup hook that wipes the shared parent even when workers are SIGKILLed. - scripts/check-test-isolation.mjs + `test:isolated` / `test:check- isolation` scripts for CI. - @fusion/test-utils alias + setupFiles + globalSetup wired into core, cli, engine, dashboard, tui vitest configs; matching tsconfig paths. Test refactors (no behavior change): - cli provider-settings, auth-paths, provider-auth — switch leaking mkdtempSync calls to tempWorkspace(). - core migration, first-run, store-backward-compat — replace manual process.chdir save/restore with useIsolatedCwd(). - tui fusion-context — replace 9 hardcoded tmp paths (collision-prone under parallelism) with tempWorkspace(). - dashboard useTheme, FileBrowser, TaskCard — resolve source-file reads against a PACKAGE_ROOT computed from import.meta.url instead of cwd, so tests don't depend on the process working directory. Verified: full suite (~15,500 tests across 8 packages + plugins) passes and the orphan-detector reports zero leaked temp directories after a complete run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
packages/core/src/__test-utils__/vitest-setup.ts
Normal file
84
packages/core/src/__test-utils__/vitest-setup.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Global test safety guard. Runs once per worker before any test.
|
||||
*
|
||||
* 1. Records the real project root so helpers know what to protect.
|
||||
* 2. Changes process.cwd() to a per-worker temp dir (main thread only) so any
|
||||
* accidental `process.cwd()` call resolves to a disposable path.
|
||||
* 3. Wraps `process.chdir` to reject attempts to chdir into the real .fusion.
|
||||
*
|
||||
* Worker temp dirs live under a single parent (FUSION_WORKER_ROOT) that is
|
||||
* wiped by the vitest globalTeardown in vitest-teardown.ts — this handles the
|
||||
* case where workers are killed (SIGKILL) and never run their exit handlers.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve, sep } from "node:path";
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
|
||||
const realProjectRootRaw = process.cwd();
|
||||
const realProjectRoot = (() => {
|
||||
try {
|
||||
return realpathSync(realProjectRootRaw);
|
||||
} catch {
|
||||
return resolve(realProjectRootRaw);
|
||||
}
|
||||
})();
|
||||
|
||||
function findRepoRoot(start: string): string {
|
||||
let current = start;
|
||||
while (true) {
|
||||
if (existsSync(join(current, ".fusion")) || existsSync(join(current, "pnpm-workspace.yaml"))) {
|
||||
return current;
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return start;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = findRepoRoot(realProjectRoot);
|
||||
process.env.FUSION_TEST_REAL_ROOT = repoRoot;
|
||||
|
||||
// Shared parent directory for all worker temp dirs in this run.
|
||||
// globalTeardown wipes this at the end of the suite.
|
||||
const WORKER_ROOT = join(tmpdir(), "fusion-test-workers");
|
||||
try { mkdirSync(WORKER_ROOT, { recursive: true }); } catch { /* ignore */ }
|
||||
process.env.FUSION_TEST_WORKER_ROOT = WORKER_ROOT;
|
||||
|
||||
let workerTempDir: string | null = null;
|
||||
if (isMainThread) {
|
||||
workerTempDir = realpathSync(
|
||||
mkdtempSync(join(WORKER_ROOT, `w-${process.pid}-`))
|
||||
);
|
||||
process.chdir(workerTempDir);
|
||||
}
|
||||
|
||||
const originalChdir = process.chdir.bind(process);
|
||||
process.chdir = (target: string) => {
|
||||
const resolvedTarget = (() => {
|
||||
try {
|
||||
return realpathSync(target);
|
||||
} catch {
|
||||
return resolve(target);
|
||||
}
|
||||
})();
|
||||
const realFusion = join(repoRoot, ".fusion");
|
||||
if (resolvedTarget === realFusion || resolvedTarget.startsWith(realFusion + sep)) {
|
||||
throw new Error(
|
||||
`[test-safety] Test attempted process.chdir into real .fusion directory: ${resolvedTarget}\n` +
|
||||
`Use useIsolatedCwd() from __test-utils__/workspace.ts instead.`
|
||||
);
|
||||
}
|
||||
originalChdir(target);
|
||||
};
|
||||
|
||||
process.on("exit", () => {
|
||||
if (!workerTempDir) return;
|
||||
try {
|
||||
originalChdir(tmpdir());
|
||||
rmSync(workerTempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore — globalTeardown sweeps WORKER_ROOT anyway.
|
||||
}
|
||||
});
|
||||
26
packages/core/src/__test-utils__/vitest-teardown.ts
Normal file
26
packages/core/src/__test-utils__/vitest-teardown.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Vitest globalSetup hook. The returned function runs once after the entire
|
||||
* test run completes, regardless of whether individual workers exited cleanly.
|
||||
* Wipes the shared FUSION_TEST_WORKER_ROOT directory that holds per-worker
|
||||
* temp dirs created by vitest-setup.ts.
|
||||
*/
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const WORKER_ROOT = join(tmpdir(), "fusion-test-workers");
|
||||
|
||||
export default function setup(): () => Promise<void> {
|
||||
// Set the env var here too so vitest-setup.ts workers pick it up even if
|
||||
// their own mkdir runs after globalSetup.
|
||||
process.env.FUSION_TEST_WORKER_ROOT = WORKER_ROOT;
|
||||
|
||||
return async function teardown() {
|
||||
try {
|
||||
rmSync(WORKER_ROOT, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore — OS cleans /tmp eventually.
|
||||
}
|
||||
};
|
||||
}
|
||||
110
packages/core/src/__test-utils__/workspace.ts
Normal file
110
packages/core/src/__test-utils__/workspace.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Shared test helpers for creating isolated, disposable workspaces.
|
||||
*
|
||||
* All tests that touch the filesystem or resolve paths from process.cwd()
|
||||
* should use these helpers so they never touch the real user's ~/Projects or
|
||||
* the repo's real .fusion/ directory.
|
||||
*
|
||||
* - `tempWorkspace()` returns a tracked temp dir that is auto-removed in afterEach.
|
||||
* - `useIsolatedCwd()` chdirs into a tracked temp dir for the test and restores after.
|
||||
* - `assertOutsideRealFusion(path)` throws if path would resolve under the real .fusion.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
let realFusionRootCache: string | null = null;
|
||||
function getRealFusionRoot(): string | null {
|
||||
if (realFusionRootCache !== null) return realFusionRootCache;
|
||||
const fromEnv = process.env.FUSION_TEST_REAL_ROOT;
|
||||
if (fromEnv) {
|
||||
try {
|
||||
realFusionRootCache = realpathSync(fromEnv);
|
||||
} catch {
|
||||
realFusionRootCache = resolve(fromEnv);
|
||||
}
|
||||
return realFusionRootCache;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assertOutsideRealFusion(path: string, context = "operation"): void {
|
||||
const realRoot = getRealFusionRoot();
|
||||
if (!realRoot) return;
|
||||
let candidate: string;
|
||||
try {
|
||||
candidate = realpathSync(path);
|
||||
} catch {
|
||||
candidate = resolve(path);
|
||||
}
|
||||
const realFusionDir = join(realRoot, ".fusion");
|
||||
if (candidate === realFusionDir || candidate.startsWith(realFusionDir + sep)) {
|
||||
throw new Error(
|
||||
`[test-safety] ${context} targeted real user .fusion directory: ${candidate}\n` +
|
||||
`Tests must operate inside a temp directory. Use tempWorkspace() or useIsolatedCwd().`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTempDirs = new Set<string>();
|
||||
|
||||
/**
|
||||
* Create a temp directory tracked for auto-cleanup at the end of the current test.
|
||||
* Returns the absolute path (realpath-resolved).
|
||||
*/
|
||||
export function tempWorkspace(prefix = "fusion-test-"): string {
|
||||
const raw = mkdtempSync(join(tmpdir(), prefix));
|
||||
const dir = realpathSync(raw);
|
||||
activeTempDirs.add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const pendingCwdRestorals: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Create a temp workspace and chdir into it for the duration of the current test.
|
||||
* Restores original cwd in afterEach.
|
||||
*/
|
||||
export function useIsolatedCwd(prefix = "fusion-test-cwd-"): string {
|
||||
const dir = tempWorkspace(prefix);
|
||||
const original = process.cwd();
|
||||
process.chdir(dir);
|
||||
pendingCwdRestorals.push(() => {
|
||||
try {
|
||||
process.chdir(original);
|
||||
} catch {
|
||||
// Ignore — original may no longer exist.
|
||||
}
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (pendingCwdRestorals.length > 0) {
|
||||
const restore = pendingCwdRestorals.pop();
|
||||
try { restore?.(); } catch { /* ignore */ }
|
||||
}
|
||||
for (const dir of activeTempDirs) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore — OS will clean /tmp eventually.
|
||||
}
|
||||
}
|
||||
activeTempDirs.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Manually register a path for afterEach cleanup.
|
||||
*/
|
||||
export function trackForCleanup(path: string): void {
|
||||
if (!path) return;
|
||||
try {
|
||||
const resolved = existsSync(path) ? realpathSync(path) : resolve(path);
|
||||
activeTempDirs.add(resolved);
|
||||
} catch {
|
||||
activeTempDirs.add(resolve(path));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user