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,7 +1,7 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import { getFusionAgentDir, getLegacyAgentDir, getPackageManagerAgentDir } from "./auth-paths.js";
function writeJson(path: string, value: Record<string, unknown>): void {
@@ -10,7 +10,7 @@ function writeJson(path: string, value: Record<string, unknown>): void {
describe("getPackageManagerAgentDir", () => {
it("falls back to legacy Pi settings when Fusion settings only contain Fusion metadata", () => {
const home = mkdtempSync(join(tmpdir(), "fusion-agent-dir-"));
const home = tempWorkspace("fusion-agent-dir-");
const fusionAgentDir = getFusionAgentDir(home);
const legacyAgentDir = getLegacyAgentDir(home);
@@ -27,7 +27,7 @@ describe("getPackageManagerAgentDir", () => {
});
it("prefers Fusion settings when they contain package-manager settings", () => {
const home = mkdtempSync(join(tmpdir(), "fusion-agent-dir-"));
const home = tempWorkspace("fusion-agent-dir-");
const fusionAgentDir = getFusionAgentDir(home);
const legacyAgentDir = getLegacyAgentDir(home);

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
function makeAuthStorage(credentials: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number }> = {}) {
@@ -87,7 +87,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = join(tmpdir(), `fusion-provider-auth-${process.pid}-${Date.now()}`);
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");
const legacyAgentAuth = join(legacyAgentDir, "auth.json");
const missingLegacyAuth = join(tempDir, ".pi", "auth.json");
@@ -101,7 +101,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
it("reads non-expired OAuth credentials from legacy auth JSON", async () => {
const tempDir = join(tmpdir(), `fusion-provider-auth-oauth-${process.pid}-${Date.now()}`);
const tempDir = tempWorkspace("fusion-provider-auth-oauth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");
const legacyAgentAuth = join(legacyAgentDir, "auth.json");
mkdirSync(legacyAgentDir, { recursive: true });

View File

@@ -1,7 +1,7 @@
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
function writeJson(path: string, value: Record<string, unknown>): void {
@@ -10,7 +10,7 @@ function writeJson(path: string, value: Record<string, unknown>): void {
describe("createReadOnlyProviderSettingsView", () => {
it("reads provider package settings from .fusion/settings.json", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
const agentDir = join(root, "agent");
@@ -40,7 +40,7 @@ describe("createReadOnlyProviderSettingsView", () => {
});
it("returns empty project settings when .fusion/settings.json does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
const agentDir = join(root, "agent");
@@ -57,7 +57,7 @@ describe("createReadOnlyProviderSettingsView", () => {
});
it("merges legacy Pi and Fusion agent settings with Fusion taking precedence", () => {
const home = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const home = tempWorkspace("fusion-provider-settings-");
const cwd = join(home, "project");
const fusionAgentDir = join(home, ".fusion", "agent");
const legacyAgentDir = join(home, ".pi", "agent");
@@ -91,7 +91,7 @@ describe("createReadOnlyProviderSettingsView", () => {
describe("createProjectSettingsPersistence", () => {
it("reads from .fusion/settings.json when it exists", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
@@ -110,7 +110,7 @@ describe("createProjectSettingsPersistence", () => {
});
it("returns empty object when .fusion/settings.json does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
@@ -122,7 +122,7 @@ describe("createProjectSettingsPersistence", () => {
});
it("writes to .fusion/settings.json", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
@@ -135,7 +135,7 @@ describe("createProjectSettingsPersistence", () => {
});
it("replaces existing settings when writing (read before write for merge)", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
@@ -154,7 +154,7 @@ describe("createProjectSettingsPersistence", () => {
});
it("creates .fusion directory if it does not exist", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
@@ -167,7 +167,7 @@ describe("createProjectSettingsPersistence", () => {
});
it("returns correct settings path via getSettingsPath", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-provider-settings-"));
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });

View File

@@ -3,7 +3,10 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
}
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**/*"]

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
const defaultMaxWorkers = 2;
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
@@ -6,8 +7,15 @@ const maxWorkers = Math.max(1, Math.min(2, Number.isFinite(requestedMaxWorkers)
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
export default defineConfig({
resolve: {
alias: {
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/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 } },
// build-exe and build-exe-cross suites both operate on packages/cli/dist/

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

View File

@@ -1,16 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
import { FirstRunExperience, createFirstRunExperience } from "../first-run.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-first-run-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".fusion"), { recursive: true });
@@ -24,7 +19,7 @@ describe("FirstRunExperience", () => {
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
tempDir = tempWorkspace("kb-first-run-test-");
centralCore = new CentralCore(tempDir);
await centralCore.init();
// Create GlobalSettingsStore with temp directory for isolation
@@ -38,7 +33,6 @@ describe("FirstRunExperience", () => {
afterEach(() => {
try {
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}

View File

@@ -1,16 +1,11 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
import { TaskStore } from "../store.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-compat-test-"));
}
// Helper to create a fake fusion project structure for the current store implementation
function createFakeFusionProject(dir: string): void {
const fusionDir = join(dir, ".fusion");
@@ -26,7 +21,7 @@ describe("TaskStore Backward Compatibility", () => {
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
tempDir = tempWorkspace("kb-compat-test-");
centralCore = new CentralCore(tempDir);
await centralCore.init();
originalCwd = process.cwd();
@@ -36,7 +31,6 @@ describe("TaskStore Backward Compatibility", () => {
try {
process.chdir(originalCwd);
await centralCore.close();
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
@@ -187,13 +181,13 @@ describe("TaskStore Backward Compatibility", () => {
await store.init();
expect(store).toBeInstanceOf(TaskStore);
// Should be able to create tasks
const task = await store.createTask({
description: "Test task",
column: "triage",
});
expect(task.id).toBeDefined();
expect(task.description).toBe("Test task");
});

View File

@@ -3,9 +3,10 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
import {
FirstRunDetector,
MigrationCoordinator,
@@ -16,11 +17,6 @@ import {
import { needsCentralMigration, autoMigrateToCentral, detectExistingProjects as detectExistingProjectsFromDbMigrate } from "./db-migrate.js";
import { CentralCore } from "./central-core.js";
// Helper to create temp directories
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
@@ -46,62 +42,47 @@ async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
describe("FirstRunDetector", () => {
let tempGlobalDir: string;
let originalCwd: string;
beforeEach(() => {
tempGlobalDir = tempDir("kb-migration-test-");
originalCwd = process.cwd();
});
afterEach(() => {
// Cleanup
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
process.chdir(originalCwd);
tempGlobalDir = tempWorkspace("kb-migration-test-");
});
describe("detectFirstRunState", () => {
it("should detect fresh-install when no central DB and no local .fusion/", async () => {
const tempProjectDir = tempDir("kb-fresh-");
process.chdir(tempProjectDir);
useIsolatedCwd("kb-fresh-");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect needs-migration when local .fusion/ exists but no central DB", async () => {
const tempProjectDir = tempDir("kb-needs-migration-");
const tempProjectDir = useIsolatedCwd("kb-needs-migration-");
createFakeKbProject(tempProjectDir);
process.chdir(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect needs-migration from nested directory inside an existing project", async () => {
const tempProjectDir = tempDir("kb-needs-migration-nested-");
const tempProjectDir = tempWorkspace("kb-needs-migration-nested-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "features", "deep");
mkdirSync(nestedDir, { recursive: true });
const originalCwd = process.cwd();
process.chdir(nestedDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
try {
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
rmSync(tempProjectDir, { recursive: true, force: true });
expect(state).toBe("needs-migration");
} finally {
process.chdir(originalCwd);
}
});
it("should detect setup-wizard when central DB exists but is empty", async () => {
@@ -110,53 +91,46 @@ describe("FirstRunDetector", () => {
await central.init();
await central.close();
const tempProjectDir = tempDir("kb-setup-wizard-");
process.chdir(tempProjectDir);
useIsolatedCwd("kb-setup-wizard-");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("setup-wizard");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect normal-operation when central DB has projects", async () => {
// Create a separate global dir for this test to avoid conflicts with beforeEach's tempGlobalDir
const testGlobalDir = tempDir("kb-normal-op-global-");
const testGlobalDir = tempWorkspace("kb-normal-op-global-");
// Create and initialize central
const testCentral = new CentralCore(testGlobalDir);
await testCentral.init();
// Register a project
const projectDir = tempDir("kb-test-project-");
const projectDir = tempWorkspace("kb-test-project-");
await testCentral.registerProject({
name: "Test Project",
path: projectDir,
});
// Create a temp dir for the cwd
const tempProjectDir = tempDir("kb-normal-op-");
process.chdir(tempProjectDir);
useIsolatedCwd("kb-normal-op-");
// Pass existing central to avoid concurrent connection issues
const detector = new FirstRunDetector(testGlobalDir);
const state = await detector.detectFirstRunState(testCentral);
try {
// Pass existing central to avoid concurrent connection issues
const detector = new FirstRunDetector(testGlobalDir);
const state = await detector.detectFirstRunState(testCentral);
expect(state).toBe("normal-operation");
// Cleanup
await testCentral.close();
rmSync(tempProjectDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
rmSync(testGlobalDir, { recursive: true, force: true });
expect(state).toBe("normal-operation");
} finally {
await testCentral.close();
}
});
it("should fall back to needs-migration when central DB exists but is unreadable", async () => {
const tempProjectDir = tempDir("kb-corrupt-central-");
const tempProjectDir = useIsolatedCwd("kb-corrupt-central-");
createFakeKbProject(tempProjectDir);
process.chdir(tempProjectDir);
mkdirSync(tempGlobalDir, { recursive: true });
writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database");
@@ -165,13 +139,10 @@ describe("FirstRunDetector", () => {
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should return fresh-install when central DB exists but is unreadable and no local project is found", async () => {
const tempProjectDir = tempDir("kb-corrupt-central-no-local-");
process.chdir(tempProjectDir);
useIsolatedCwd("kb-corrupt-central-no-local-");
mkdirSync(tempGlobalDir, { recursive: true });
writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database");
@@ -180,8 +151,6 @@ describe("FirstRunDetector", () => {
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
@@ -203,7 +172,7 @@ describe("FirstRunDetector", () => {
describe("detectExistingProjects", () => {
it("should detect project in cwd", async () => {
const tempProjectDir = tempDir("kb-detect-");
const tempProjectDir = tempWorkspace("kb-detect-");
createFakeKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
@@ -212,12 +181,10 @@ describe("FirstRunDetector", () => {
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
expect(projects[0].hasDb).toBe(true);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should walk up directory tree to find .fusion/", async () => {
const tempProjectDir = tempDir("kb-parent-");
const tempProjectDir = tempWorkspace("kb-parent-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "components");
mkdirSync(nestedDir, { recursive: true });
@@ -227,8 +194,6 @@ describe("FirstRunDetector", () => {
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should stop safely at home/root boundaries when no project is found", async () => {
@@ -240,7 +205,7 @@ describe("FirstRunDetector", () => {
});
it("should still check the starting directory when cwd matches the stop boundary", async () => {
const fakeHome = tempDir("kb-home-boundary-");
const fakeHome = tempWorkspace("kb-home-boundary-");
createFakeKbProject(fakeHome);
const detector = new FirstRunDetector(fakeHome);
@@ -248,56 +213,46 @@ describe("FirstRunDetector", () => {
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(fakeHome);
rmSync(fakeHome, { recursive: true, force: true });
});
it("should return empty array when no project found", async () => {
const emptyDir = tempDir("kb-empty-");
const emptyDir = tempWorkspace("kb-empty-");
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(emptyDir);
expect(projects).toHaveLength(0);
rmSync(emptyDir, { recursive: true, force: true });
});
});
describe("generateProjectName", () => {
it("should use directory basename when no git remote", async () => {
const tempProjectDir = tempDir("my-awesome-project-");
const tempProjectDir = tempWorkspace("my-awesome-project-");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toContain("my-awesome-project");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should extract repo name from HTTPS git remote", async () => {
const tempProjectDir = tempDir("kb-git-https-");
const tempProjectDir = tempWorkspace("kb-git-https-");
await initGitRepo(tempProjectDir, "https://github.com/owner/my-repo.git");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-repo");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should extract repo name from SSH git remote", async () => {
const tempProjectDir = tempDir("kb-git-ssh-");
const tempProjectDir = tempWorkspace("kb-git-ssh-");
await initGitRepo(tempProjectDir, "git@github.com:owner/my-ssh-repo");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-ssh-repo");
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
@@ -311,8 +266,8 @@ describe("FirstRunDetector", () => {
describe("db-migrate wrappers", () => {
it("should forward detectExistingProjects through db-migrate wrapper", async () => {
const tempGlobalDir = tempDir("kb-dbmigrate-detect-global-");
const tempProjectDir = tempDir("kb-dbmigrate-detect-project-");
const tempGlobalDir = tempWorkspace("kb-dbmigrate-detect-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-detect-project-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "nested");
mkdirSync(nestedDir, { recursive: true });
@@ -321,14 +276,11 @@ describe("db-migrate wrappers", () => {
expect(detected).toHaveLength(1);
expect(detected[0].path).toBe(tempProjectDir);
rmSync(tempGlobalDir, { recursive: true, force: true });
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should autoMigrateToCentral and register the project", async () => {
const tempGlobalDir = tempDir("kb-dbmigrate-auto-global-");
const tempProjectDir = tempDir("kb-dbmigrate-auto-project-");
const tempGlobalDir = tempWorkspace("kb-dbmigrate-auto-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-auto-project-");
createFakeKbProject(tempProjectDir);
const central = new CentralCore(tempGlobalDir);
@@ -345,14 +297,12 @@ describe("db-migrate wrappers", () => {
expect(project!.status).toBe("active");
} finally {
await central.close();
rmSync(tempGlobalDir, { recursive: true, force: true });
rmSync(tempProjectDir, { recursive: true, force: true });
}
});
it("should autoMigrateToCentral idempotently on repeat runs", async () => {
const tempGlobalDir = tempDir("kb-dbmigrate-idempotent-global-");
const tempProjectDir = tempDir("kb-dbmigrate-idempotent-project-");
const tempGlobalDir = tempWorkspace("kb-dbmigrate-idempotent-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-idempotent-project-");
createFakeKbProject(tempProjectDir);
const central = new CentralCore(tempGlobalDir);
@@ -367,35 +317,27 @@ describe("db-migrate wrappers", () => {
expect(result1.projectsRegistered[0]).toBe(result2.projectsRegistered[0]);
} finally {
await central.close();
rmSync(tempGlobalDir, { recursive: true, force: true });
rmSync(tempProjectDir, { recursive: true, force: true });
}
});
});
describe("needsCentralMigration", () => {
it("should detect migration need from nested directory inside a project", () => {
const tempGlobalDir = tempDir("kb-needs-central-global-");
const tempProjectDir = tempDir("kb-needs-central-project-");
const tempGlobalDir = tempWorkspace("kb-needs-central-global-");
const tempProjectDir = tempWorkspace("kb-needs-central-project-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "nested");
mkdirSync(nestedDir, { recursive: true });
expect(needsCentralMigration(nestedDir, tempGlobalDir)).toBe(true);
rmSync(tempGlobalDir, { recursive: true, force: true });
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect migration need from the project root itself", () => {
const tempGlobalDir = tempDir("kb-needs-central-root-global-");
const tempProjectDir = tempDir("kb-needs-central-root-project-");
const tempGlobalDir = tempWorkspace("kb-needs-central-root-global-");
const tempProjectDir = tempWorkspace("kb-needs-central-root-project-");
createFakeKbProject(tempProjectDir);
expect(needsCentralMigration(tempProjectDir, tempGlobalDir)).toBe(true);
rmSync(tempGlobalDir, { recursive: true, force: true });
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
@@ -404,23 +346,18 @@ describe("MigrationCoordinator", () => {
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-coordinator-test-");
tempGlobalDir = tempWorkspace("kb-coordinator-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("registerSingleProject", () => {
it("should register a new project successfully", async () => {
const tempProjectDir = tempDir("kb-register-");
const tempProjectDir = tempWorkspace("kb-register-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
@@ -435,12 +372,10 @@ describe("MigrationCoordinator", () => {
expect(project).toBeDefined();
expect(project!.path).toBe(tempProjectDir);
expect(project!.status).toBe("active");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempDir("kb-idempotent-");
const tempProjectDir = tempWorkspace("kb-idempotent-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
@@ -454,8 +389,6 @@ describe("MigrationCoordinator", () => {
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
expect(result2.errors).toHaveLength(0);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should reject relative paths", async () => {
@@ -468,7 +401,7 @@ describe("MigrationCoordinator", () => {
});
it("should reject absolute paths that are not valid kb projects", async () => {
const tempProjectDir = tempDir("kb-invalid-project-");
const tempProjectDir = tempWorkspace("kb-invalid-project-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(tempProjectDir);
@@ -476,12 +409,10 @@ describe("MigrationCoordinator", () => {
expect(result.success).toBe(false);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors[0]).toContain("not a valid kb project");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should handle duplicate names by appending suffix", async () => {
const tempRoot = tempDir("kb-duplicate-names-");
const tempRoot = tempWorkspace("kb-duplicate-names-");
const tempProjectDir1 = join(tempRoot, "same-project");
const tempProjectDir2 = join(tempRoot, "group", "same-project");
mkdirSync(tempProjectDir1, { recursive: true });
@@ -500,12 +431,10 @@ describe("MigrationCoordinator", () => {
const project2 = await central.getProject(result2.projectsRegistered[0]);
expect(project1!.name).toBe("same-project");
expect(project2!.name).toBe("same-project-1");
rmSync(tempRoot, { recursive: true, force: true });
});
it("should reject nested project registration when parent is already registered", async () => {
const parentProjectDir = tempDir("kb-parent-project-");
const parentProjectDir = tempWorkspace("kb-parent-project-");
createFakeKbProject(parentProjectDir);
const nestedProjectDir = join(parentProjectDir, "apps", "nested-project");
mkdirSync(nestedProjectDir, { recursive: true });
@@ -518,12 +447,10 @@ describe("MigrationCoordinator", () => {
expect(parentResult.success).toBe(true);
expect(nestedResult.success).toBe(false);
expect(nestedResult.errors[0]).toContain("overlaps an existing registered project");
rmSync(parentProjectDir, { recursive: true, force: true });
});
it("should register the detected ancestor project root when called from a nested directory", async () => {
const tempProjectDir = tempDir("kb-nested-register-");
const tempProjectDir = tempWorkspace("kb-nested-register-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "packages", "feature");
mkdirSync(nestedDir, { recursive: true });
@@ -539,15 +466,13 @@ describe("MigrationCoordinator", () => {
const projects = await central.listProjects();
expect(projects).toHaveLength(1);
expect(projects[0].path.endsWith(tempProjectDir)).toBe(true);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("completeSetup", () => {
it("should register multiple projects from wizard", async () => {
const tempProjectDir1 = tempDir("kb-setup1-");
const tempProjectDir2 = tempDir("kb-setup2-");
const tempProjectDir1 = tempWorkspace("kb-setup1-");
const tempProjectDir2 = tempWorkspace("kb-setup2-");
createFakeKbProject(tempProjectDir1);
createFakeKbProject(tempProjectDir2);
@@ -562,13 +487,10 @@ describe("MigrationCoordinator", () => {
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(2);
expect(result.errors).toHaveLength(0);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should skip already registered projects", async () => {
const tempProjectDir = tempDir("kb-setup-existing-");
const tempProjectDir = tempWorkspace("kb-setup-existing-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
@@ -582,13 +504,11 @@ describe("MigrationCoordinator", () => {
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should reject invalid setup project paths", async () => {
const validProjectDir = tempDir("kb-setup-valid-");
const invalidProjectDir = tempDir("kb-setup-invalid-");
const validProjectDir = tempWorkspace("kb-setup-valid-");
const invalidProjectDir = tempWorkspace("kb-setup-invalid-");
createFakeKbProject(validProjectDir);
const inputs: ProjectSetupInput[] = [
@@ -603,15 +523,12 @@ describe("MigrationCoordinator", () => {
expect(result.projectsRegistered).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("not a valid kb project");
rmSync(validProjectDir, { recursive: true, force: true });
rmSync(invalidProjectDir, { recursive: true, force: true });
});
});
describe("coordinateMigration", () => {
it("should auto-register an existing local project when no projects are registered", async () => {
const tempProjectDir = tempDir("kb-coordinate-migration-");
const tempProjectDir = tempWorkspace("kb-coordinate-migration-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "feature");
mkdirSync(nestedDir, { recursive: true });
@@ -631,24 +548,49 @@ describe("MigrationCoordinator", () => {
expect(registered[0].path.endsWith(tempProjectDir)).toBe(true);
} finally {
process.chdir(originalCwd);
rmSync(tempProjectDir, { recursive: true, force: true });
}
});
it("should return success for fresh-install state", async () => {
// Close and remove central to simulate fresh state
await central.close();
const { rmSync } = await import("node:fs");
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
// Create fresh temp dir with no .fusion/
const tempFreshDir = tempDir("kb-fresh-coord-");
central = new CentralCore(tempGlobalDir);
await central.init();
// Change to fresh dir (no .fusion/)
const originalCwd = process.cwd();
process.chdir(tempFreshDir);
useIsolatedCwd("kb-fresh-coord-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it("should be a no-op in setup-wizard state when no local project exists", async () => {
useIsolatedCwd("kb-setup-wizard-coord-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it("should be a no-op in normal-operation when projects already exist", async () => {
const existingProjectDir = tempWorkspace("kb-normal-op-existing-");
await central.registerProject({
name: "Existing Project",
path: existingProjectDir,
});
const localProjectDir = useIsolatedCwd("kb-normal-op-local-");
createFakeKbProject(localProjectDir);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
@@ -657,55 +599,8 @@ describe("MigrationCoordinator", () => {
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
process.chdir(originalCwd);
rmSync(tempFreshDir, { recursive: true, force: true });
});
it("should be a no-op in setup-wizard state when no local project exists", async () => {
const tempFreshDir = tempDir("kb-setup-wizard-coord-");
const originalCwd = process.cwd();
process.chdir(tempFreshDir);
try {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
} finally {
process.chdir(originalCwd);
rmSync(tempFreshDir, { recursive: true, force: true });
}
});
it("should be a no-op in normal-operation when projects already exist", async () => {
const existingProjectDir = tempDir("kb-normal-op-existing-");
await central.registerProject({
name: "Existing Project",
path: existingProjectDir,
});
const localProjectDir = tempDir("kb-normal-op-local-");
createFakeKbProject(localProjectDir);
const originalCwd = process.cwd();
process.chdir(localProjectDir);
try {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
const registered = await central.listProjects();
expect(registered).toHaveLength(1);
} finally {
process.chdir(originalCwd);
rmSync(existingProjectDir, { recursive: true, force: true });
rmSync(localProjectDir, { recursive: true, force: true });
}
const registered = await central.listProjects();
expect(registered).toHaveLength(1);
});
});
});
@@ -715,23 +610,18 @@ describe("BackwardCompat", () => {
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-compat-test-");
tempGlobalDir = tempWorkspace("kb-compat-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("resolveProjectContext", () => {
it("should use explicit project ID when provided", async () => {
const tempProjectDir = tempDir("kb-explicit-");
const tempProjectDir = tempWorkspace("kb-explicit-");
const project = await central.registerProject({
name: "Explicit Project",
path: tempProjectDir,
@@ -743,12 +633,10 @@ describe("BackwardCompat", () => {
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should auto-use single project when no explicit ID provided", async () => {
const tempProjectDir = tempDir("kb-single-");
const tempProjectDir = tempWorkspace("kb-single-");
const project = await central.registerProject({
name: "Single Project",
path: tempProjectDir,
@@ -760,13 +648,11 @@ describe("BackwardCompat", () => {
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
const tempProjectDir1 = tempDir("kb-multi1-");
const tempProjectDir2 = tempDir("kb-multi2-");
const tempProjectDir1 = tempWorkspace("kb-multi1-");
const tempProjectDir2 = tempWorkspace("kb-multi2-");
await central.registerProject({ name: "Project 1", path: tempProjectDir1 });
await central.registerProject({ name: "Project 2", path: tempProjectDir2 });
@@ -782,13 +668,10 @@ describe("BackwardCompat", () => {
expect(err).toBeInstanceOf(ProjectRequiredError);
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
}
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should find project by name (case-insensitive)", async () => {
const tempProjectDir = tempDir("kb-byname-");
const tempProjectDir = tempWorkspace("kb-byname-");
const project = await central.registerProject({
name: "My Project",
path: tempProjectDir,
@@ -798,8 +681,6 @@ describe("BackwardCompat", () => {
const context = await compat.resolveProjectContext("/some/dir", "my project");
expect(context.projectId).toBe(project.id);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should throw when project not found", async () => {
@@ -820,6 +701,7 @@ describe("BackwardCompat", () => {
it("should return true when no central DB", async () => {
// Close and remove central DB
await central.close();
const { rmSync } = await import("node:fs");
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
// Need to re-init CentralCore for it to work
@@ -836,23 +718,18 @@ describe("CentralCore migration helpers", () => {
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-central-migration-test-");
tempGlobalDir = tempWorkspace("kb-central-migration-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("autoRegisterProject", () => {
it("should auto-register a project with generated name", async () => {
const tempProjectDir = tempDir("kb-autoreg-");
const tempProjectDir = tempWorkspace("kb-autoreg-");
createFakeKbProject(tempProjectDir);
const project = await central.autoRegisterProject(tempProjectDir);
@@ -862,12 +739,10 @@ describe("CentralCore migration helpers", () => {
expect(project.isolationMode).toBe("in-process");
expect(project.status).toBe("active");
expect(project.name).toContain("kb-autoreg"); // Based on directory name
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should reject nested auto-registration when parent project is already registered", async () => {
const parentProjectDir = tempDir("kb-central-parent-");
const parentProjectDir = tempWorkspace("kb-central-parent-");
createFakeKbProject(parentProjectDir);
const nestedProjectDir = join(parentProjectDir, "packages", "nested");
mkdirSync(nestedProjectDir, { recursive: true });
@@ -876,12 +751,10 @@ describe("CentralCore migration helpers", () => {
await central.autoRegisterProject(parentProjectDir);
await expect(central.autoRegisterProject(nestedProjectDir)).rejects.toThrow(/overlaps an existing registered project/);
rmSync(parentProjectDir, { recursive: true, force: true });
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempDir("kb-autoreg-dup-");
const tempProjectDir = tempWorkspace("kb-autoreg-dup-");
createFakeKbProject(tempProjectDir);
const project1 = await central.autoRegisterProject(tempProjectDir);
@@ -889,24 +762,20 @@ describe("CentralCore migration helpers", () => {
expect(project1.id).toBe(project2.id);
expect(project1.name).toBe(project2.name);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("isProjectRegistered", () => {
it("should return false for unregistered project", async () => {
const tempProjectDir = tempDir("kb-unreg-");
const tempProjectDir = tempWorkspace("kb-unreg-");
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should return true for registered project", async () => {
const tempProjectDir = tempDir("kb-registered-");
const tempProjectDir = tempWorkspace("kb-registered-");
await central.registerProject({
name: "Registered",
path: tempProjectDir,
@@ -915,8 +784,6 @@ describe("CentralCore migration helpers", () => {
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(true);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
@@ -927,7 +794,7 @@ describe("CentralCore migration helpers", () => {
});
it("should return normal-operation when projects exist", async () => {
const tempProjectDir = tempDir("kb-state-test-");
const tempProjectDir = tempWorkspace("kb-state-test-");
await central.registerProject({
name: "State Test",
path: tempProjectDir,
@@ -936,8 +803,6 @@ describe("CentralCore migration helpers", () => {
const state = await central.getFirstRunState();
expect(state).toBe("normal-operation");
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
});

View File

@@ -3,7 +3,11 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"paths": {
"@fusion/test-utils": ["./src/__test-utils__/workspace.ts"]
}
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/__test-utils__/**/*"]
}

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
const defaultMaxWorkers = 2;
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
@@ -6,8 +7,15 @@ const maxWorkers = Math.max(1, Math.min(2, Number.isFinite(requestedMaxWorkers)
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
export default defineConfig({
resolve: {
alias: {
"@fusion/test-utils": resolve(__dirname, "./src/__test-utils__/workspace.ts"),
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: ["./src/__test-utils__/vitest-setup.ts"],
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,

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,

View File

@@ -3,7 +3,10 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
}
},
"include": ["src/**/*"]
}

View File

@@ -10,10 +10,13 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/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,

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;

View File

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

View File

@@ -6,15 +6,21 @@ const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? St
const maxWorkers = Math.max(1, Math.min(2, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers));
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
const coreSourceEntry = fileURLToPath(new URL("../core/src/index.ts", import.meta.url));
const testUtilsEntry = fileURLToPath(new URL("../core/src/__test-utils__/workspace.ts", import.meta.url));
const testSetupEntry = fileURLToPath(new URL("../core/src/__test-utils__/vitest-setup.ts", import.meta.url));
const testTeardownEntry = fileURLToPath(new URL("../core/src/__test-utils__/vitest-teardown.ts", import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@fusion/core": coreSourceEntry,
"@fusion/test-utils": testUtilsEntry,
},
},
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
setupFiles: [testSetupEntry],
globalSetup: [testTeardownEntry],
passWithNoTests: true,
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },