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

@@ -2,18 +2,18 @@
* Tests for FusionContext provider and project detection.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import React from "react";
import { render } from "ink";
import { Writable } from "node:stream";
import { detectProjectDir } from "../project-detect";
import { FusionProvider, useFusion, FusionContext } from "../fusion-context";
import { TaskStore } from "@fusion/core";
import { mkdir, writeFile, remove } from "fs/promises";
import { mkdir, writeFile } from "fs/promises";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Track temp directories for cleanup
const tempDirs: string[] = [];
import { tempWorkspace } from "@fusion/test-utils";
function createSinkStream(): NodeJS.WriteStream {
const stream = new Writable({
@@ -36,18 +36,6 @@ function renderTest(node: React.ReactNode) {
});
}
afterEach(async () => {
// Clean up temp directories
for (const dir of tempDirs) {
try {
await remove(dir);
} catch {
// Ignore cleanup errors
}
}
tempDirs.length = 0;
});
// Mock TaskStore to avoid actual filesystem operations in most tests
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual("@fusion/core");
@@ -61,22 +49,8 @@ vi.mock("@fusion/core", async () => {
});
describe("detectProjectDir", () => {
afterEach(async () => {
// Clean up temp directories
for (const dir of tempDirs) {
try {
await remove(dir);
} catch {
// Ignore cleanup errors
}
}
tempDirs.length = 0;
});
it("returns project root when .fusion/fusion.db exists in start directory", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-test-project-1");
tempDirs.push(projectDir);
const projectDir = tempWorkspace("fusion-test-project-1-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
@@ -86,10 +60,8 @@ describe("detectProjectDir", () => {
});
it("returns project root when .fusion/fusion.db exists in a parent directory", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-test-project-2");
const projectDir = tempWorkspace("fusion-test-project-2-");
const subDir = join(projectDir, "src", "components");
tempDirs.push(projectDir);
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
@@ -100,10 +72,8 @@ describe("detectProjectDir", () => {
});
it("returns null when no .fusion/ exists anywhere up to root", async () => {
const os = await import("os");
// Use a directory that definitely won't have .fusion above it
const startDir = join(os.tmpdir(), "no-fusion-project");
tempDirs.push(startDir);
const startDir = tempWorkspace("no-fusion-project-");
await mkdir(startDir, { recursive: true });
@@ -112,9 +82,7 @@ describe("detectProjectDir", () => {
});
it("returns null when .fusion/ exists but no fusion.db", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-test-project-3");
tempDirs.push(projectDir);
const projectDir = tempWorkspace("fusion-test-project-3-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
// Don't create fusion.db
@@ -125,24 +93,10 @@ describe("detectProjectDir", () => {
});
describe("FusionProvider", () => {
afterEach(async () => {
// Clean up temp directories
for (const dir of tempDirs) {
try {
await remove(dir);
} catch {
// Ignore cleanup errors
}
}
tempDirs.length = 0;
});
it("initializes TaskStore and provides it via context when project dir is valid", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-provider-test-1");
const projectDir = tempWorkspace("fusion-provider-test-1-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
tempDirs.push(projectDir);
let capturedStore: TaskStore | null = null;
let capturedPath: string | null = null;
@@ -170,8 +124,8 @@ describe("FusionProvider", () => {
});
it("sets error state when no project directory is found", async () => {
const os = await import("os");
const nonExistentDir = join(os.tmpdir(), "non-existent-fusion-project");
// Compute a path that does not exist without creating it.
const nonExistentDir = join(tmpdir(), `non-existent-fusion-project-${Date.now()}-${Math.random().toString(36).slice(2)}`);
function TestComponent() {
const { store } = useFusion();
@@ -192,14 +146,15 @@ describe("FusionProvider", () => {
// and the error message is available
instance.unmount();
// Defensive: remove the dir if something created it.
try { rmSync(nonExistentDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("calls store.close() on unmount", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-provider-test-2");
const projectDir = tempWorkspace("fusion-provider-test-2-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
tempDirs.push(projectDir);
let closeCalled = false;
@@ -236,11 +191,9 @@ describe("FusionProvider", () => {
});
it("accepts explicit projectDir prop and uses it instead of auto-detection", async () => {
const os = await import("os");
const explicitDir = join(os.tmpdir(), "fusion-explicit-project");
const explicitDir = tempWorkspace("fusion-explicit-project-");
await mkdir(join(explicitDir, ".fusion"), { recursive: true });
await writeFile(join(explicitDir, ".fusion", "fusion.db"), "");
tempDirs.push(explicitDir);
let capturedPath: string | null = null;
@@ -266,18 +219,6 @@ describe("FusionProvider", () => {
});
describe("useFusion hook", () => {
afterEach(async () => {
// Clean up temp directories
for (const dir of tempDirs) {
try {
await remove(dir);
} catch {
// Ignore cleanup errors
}
}
tempDirs.length = 0;
});
it("throws error when used outside of FusionProvider", () => {
// Ink captures render errors and displays them in the output rather than throwing.
// The test output shows:
@@ -290,11 +231,9 @@ describe("useFusion hook", () => {
});
it("returns context value when used inside FusionProvider", async () => {
const os = await import("os");
const projectDir = join(os.tmpdir(), "fusion-hook-test");
const projectDir = tempWorkspace("fusion-hook-test-");
await mkdir(join(projectDir, ".fusion"), { recursive: true });
await writeFile(join(projectDir, ".fusion", "fusion.db"), "");
tempDirs.push(projectDir);
let contextValue: { store: TaskStore; projectPath: string } | null = null;