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:
gsxdsm
2026-04-18 23:41:47 -07:00
parent c77a4a572e
commit bc841e9bf6
25 changed files with 550 additions and 374 deletions

View 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.
}
});

View 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.
}
};
}

View 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));
}
}