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

@@ -1,10 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FileBrowser } from "../FileBrowser";
import type { FileNode } from "../../api";
// Resolve paths relative to this test file so tests pass regardless of cwd
// (a global test safety guard may change cwd to a per-worker temp dir).
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
// ── Mocks ───────────────────────────────────────────────────────────────
vi.mock("lucide-react", async () => {
@@ -370,7 +375,7 @@ describe("FileBrowser", () => {
});
it("defines mobile-friendly touch targets for context menu items", () => {
const cssPath = resolve(process.cwd(), "app/styles.css");
const cssPath = resolve(PACKAGE_ROOT, "app/styles.css");
const css = readFileSync(cssPath, "utf8");
expect(css).toMatch(/\.file-browser-context-menu__item\s*\{[^}]*min-height:\s*36px;/);
});

View File

@@ -5,6 +5,12 @@ import type { Column, Task, TaskDetail } from "@fusion/core";
import { TaskCard } from "../TaskCard";
import React, { useState } from "react";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Resolve paths relative to this test file so tests pass regardless of cwd
// (a global test safety guard may change cwd to a per-worker temp dir).
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
@@ -3599,7 +3605,7 @@ describe("TaskCard agent badge", () => {
expect(text).toBeInTheDocument();
});
const styles = readFileSync("app/styles.css", "utf-8");
const styles = readFileSync(resolve(PACKAGE_ROOT, "app/styles.css"), "utf-8");
expect(styles).toMatch(/\.card-agent-badge\s*\{[^}]*flex-shrink:\s*0;/);
expect(styles).toMatch(/\.card-agent-badge\s*\{[^}]*max-width:\s*120px;/);
expect(styles).toMatch(/\.card-agent-badge-text\s*\{[^}]*text-overflow:\s*ellipsis;/);

View File

@@ -1,10 +1,16 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { COLOR_THEMES, type Settings } from "@fusion/core";
import { useTheme, getThemeInitScript } from "../useTheme";
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
// Resolve paths relative to this test file so tests pass regardless of cwd
// (a global test safety guard may change cwd to a per-worker temp dir).
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
@@ -433,8 +439,8 @@ describe("useTheme", () => {
it("applies factory-specific design tokens from the stylesheet", () => {
// Load both base styles and theme data (theme blocks are in a separate file)
const style = document.createElement("style");
const baseCss = readFileSync("app/styles.css", "utf8");
const themeDataCss = readFileSync("app/public/theme-data.css", "utf8");
const baseCss = readFileSync(resolve(PACKAGE_ROOT, "app/styles.css"), "utf8");
const themeDataCss = readFileSync(resolve(PACKAGE_ROOT, "app/public/theme-data.css"), "utf8");
style.textContent = baseCss + "\n" + themeDataCss;
document.head.appendChild(style);
@@ -1145,7 +1151,7 @@ describe("getThemeInitScript", () => {
});
it("keeps index.html inline theme validation in sync with supported themes", () => {
const indexHtml = readFileSync("app/index.html", "utf8");
const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8");
COLOR_THEMES.forEach((theme) => {
expect(indexHtml).toContain(`'${theme}'`);
@@ -1164,7 +1170,7 @@ describe("getThemeInitScript", () => {
it("index.html uses correct URL replacement pattern", () => {
// Verify that the inline script in index.html uses the correct URL replacement
// pattern (handle both directory paths and filename paths) rather than buggy concatenation
const indexHtml = readFileSync("app/index.html", "utf8");
const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8");
// The correct pattern: check if base ends with '/' and use slice or replace accordingly
// The buggy pattern: base.substring(0, 7) + dirPath + 'theme-data.css'

View File

@@ -4,7 +4,10 @@
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
}
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**/*"]

View File

@@ -13,13 +13,18 @@ export default defineConfig({
alias: {
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
},
},
test: {
environment: "jsdom",
globals: true,
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
setupFiles: ["./vitest.setup.ts"],
setupFiles: [
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
"./vitest.setup.ts",
],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,