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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user