feat(FN-2606): merge fusion/fn-2606 (auto-resolved)

- fix(FN-2606): complete Step 4 — clean up mkdtemp test directories
- test(FN-2606): complete Step 3 — add isolation guard coverage
- feat(FN-2606): complete Step 2 — add engine/dashboard HOME test isolation setup
This commit is contained in:
Fusion
2026-04-26 13:00:56 -07:00
committed by gsxdsm
parent 545c8a69f4
commit 429b9671b2
11 changed files with 149 additions and 27 deletions

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const TEMP_HOME_PREFIX = "fn-test-home-";
@@ -26,4 +28,20 @@ describe("test isolation setup", () => {
expect(dir).toContain(tmpdir());
expect(dir).toMatch(/fn-test-home-.*[\\/]\.fusion$/);
});
it("GlobalSettingsStore() without explicit dir throws under VITEST guard", async () => {
const { GlobalSettingsStore } = await import("../global-settings.js");
expect(() => new GlobalSettingsStore()).toThrow(
"resolveGlobalDir() called without explicit dir during test execution. Pass a temp directory to avoid writing to real ~/.fusion/",
);
});
it("cwd is not inside the repository .fusion directory", () => {
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "../../../../");
const repoFusionDir = join(repoRoot, ".fusion");
expect(process.cwd().startsWith(repoFusionDir)).toBe(false);
});
});

View File

@@ -1,9 +1,9 @@
// @vitest-environment node
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
PRIORITY_SCRIPTS,
detectDevServerCommands,
@@ -23,8 +23,21 @@ async function writePackageJson(
}
describe("devserver-detect", () => {
const tempRoots = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(tempRoots).map((root) => rm(root, { recursive: true, force: true })));
tempRoots.clear();
});
const createTempRoot = async (prefix: string) => {
const root = await mkdtemp(join(tmpdir(), prefix));
tempRoots.add(root);
return root;
};
it("detects priority scripts from root package.json", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
await writePackageJson(root, {
scripts: {
dev: "vite",
@@ -46,7 +59,7 @@ describe("devserver-detect", () => {
});
it("only returns priority scripts", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
await writePackageJson(root, {
scripts: {
dev: "vite",
@@ -62,7 +75,7 @@ describe("devserver-detect", () => {
});
it("scans nested package.json files in apps/* and packages/*", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
await writePackageJson(root, { scripts: {} });
await writePackageJson(
root,
@@ -82,7 +95,7 @@ describe("devserver-detect", () => {
});
it("sorts results by PRIORITY_SCRIPTS order", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
expect(PRIORITY_SCRIPTS.indexOf("dev")).toBeLessThan(PRIORITY_SCRIPTS.indexOf("serve"));
await writePackageJson(root, {
scripts: {
@@ -99,7 +112,7 @@ describe("devserver-detect", () => {
});
it("returns empty array when scripts is empty", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
await writePackageJson(root, { scripts: {} });
const detected = await detectDevServerCommands(root);
@@ -107,14 +120,14 @@ describe("devserver-detect", () => {
});
it("returns empty array when root package.json is missing", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
const detected = await detectDevServerCommands(root);
expect(detected).toEqual([]);
});
it("returns empty array for malformed package.json without throwing", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
const filePath = join(root, "package.json");
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, '{ "invalid json', "utf-8");
@@ -124,7 +137,7 @@ describe("devserver-detect", () => {
});
it("deeply nested package.json (2+ levels) is NOT scanned", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const root = await createTempRoot("devserver-detect-");
await writePackageJson(root, { scripts: {} });
// Create a deeply nested package.json (2 levels deep)
await writePackageJson(
@@ -144,7 +157,7 @@ describe("devserver-detect", () => {
});
it("persists and reloads devserver configs", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-config-"));
const root = await createTempRoot("devserver-config-");
const configs: DevServerConfig[] = [
{
id: createDevServerId("cfg-1"),

View File

@@ -1,16 +1,28 @@
// @vitest-environment node
import { mkdir, writeFile } from "node:fs/promises";
import { mkdtemp } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { loadDevServerConfigs, saveDevServerConfigs } from "../devserver-persistence.js";
import { createDevServerId, type DevServerConfig } from "../devserver-types.js";
describe("devserver-persistence", () => {
const tempRoots = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(tempRoots).map((root) => rm(root, { recursive: true, force: true })));
tempRoots.clear();
});
const createTempRoot = async (prefix: string) => {
const root = await mkdtemp(join(tmpdir(), prefix));
tempRoots.add(root);
return root;
};
it("saves and reloads multiple dev server configs", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-"));
const root = await createTempRoot("devserver-persist-");
const configs: DevServerConfig[] = [
{
id: createDevServerId("server-1"),
@@ -46,7 +58,7 @@ describe("devserver-persistence", () => {
});
it("preserves exact config properties on reload", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-exact-"));
const root = await createTempRoot("devserver-persist-exact-");
const config: DevServerConfig = {
id: createDevServerId("exact-test"),
name: "Exact Config",
@@ -66,14 +78,14 @@ describe("devserver-persistence", () => {
});
it("returns empty array when no configs have been saved", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-empty-"));
const root = await createTempRoot("devserver-persist-empty-");
const loaded = await loadDevServerConfigs(root);
expect(loaded).toEqual([]);
});
it("handles corrupted devserver.json gracefully", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-corrupt-"));
const root = await createTempRoot("devserver-persist-corrupt-");
const configPath = join(root, ".fusion", "devserver.json");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, '{ "invalid json', "utf-8");
@@ -83,7 +95,7 @@ describe("devserver-persistence", () => {
});
it("handles empty devserver.json", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-empty-file-"));
const root = await createTempRoot("devserver-persist-empty-file-");
const configPath = join(root, ".fusion", "devserver.json");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, '', "utf-8");
@@ -93,7 +105,7 @@ describe("devserver-persistence", () => {
});
it("handles devserver.json with missing configs array", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-missing-"));
const root = await createTempRoot("devserver-persist-missing-");
const configPath = join(root, ".fusion", "devserver.json");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, '{"other": "data"}', "utf-8");
@@ -103,7 +115,7 @@ describe("devserver-persistence", () => {
});
it("persists three or more servers correctly", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-multi-"));
const root = await createTempRoot("devserver-persist-multi-");
const configs: DevServerConfig[] = [
{ id: createDevServerId("multi-1"), name: "Server 1", command: "npm run dev", cwd: root },
{ id: createDevServerId("multi-2"), name: "Server 2", command: "npm run storybook", cwd: root },
@@ -119,7 +131,7 @@ describe("devserver-persistence", () => {
});
it("filters out invalid configs during load", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-filter-"));
const root = await createTempRoot("devserver-persist-filter-");
const configPath = join(root, ".fusion", "devserver.json");
await mkdir(dirname(configPath), { recursive: true });
// Write manually with an invalid config (missing required fields)
@@ -144,7 +156,7 @@ describe("devserver-persistence", () => {
});
it("overwrites existing configs when saving", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-persist-overwrite-"));
const root = await createTempRoot("devserver-persist-overwrite-");
const initialConfigs: DevServerConfig[] = [
{ id: createDevServerId("overwrite-1"), name: "Initial", command: "npm run dev", cwd: root },

View File

@@ -0,0 +1,12 @@
/**
* Global test isolation: prevents dashboard tests from writing to the real ~/.fusion/ directory.
*
* This runs in every Vitest worker before shared setup. By forcing process.env.HOME
* to a fresh temp directory, homedir()-derived paths resolve to isolated locations.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
describe("test isolation guard", () => {
it("overrides HOME to a temp fn-test-home directory", () => {
const home = process.env.HOME;
expect(home).toBeDefined();
expect(home).toContain(tmpdir());
expect(home).toContain("fn-test-home-");
});
it("does not run from the real project root cwd", () => {
const thisFile = fileURLToPath(import.meta.url);
const projectRoot = resolve(dirname(thisFile), "../../../../");
const projectFusionDir = join(projectRoot, ".fusion");
expect(process.cwd()).not.toBe(projectRoot);
expect(process.cwd().startsWith(projectFusionDir)).toBe(false);
});
});

View File

@@ -25,6 +25,7 @@ export default defineConfig({
globals: true,
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
setupFiles: [
"./src/__tests__/setup-test-isolation.ts",
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
"./vitest.setup.ts",
],

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
@@ -15,7 +15,11 @@ describe("createFusionAuthStorage", () => {
process.env.HOME = homeDir;
});
afterEach(() => {
afterEach(async () => {
if (homeDir) {
await rm(homeDir, { recursive: true, force: true });
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {

View File

@@ -191,7 +191,7 @@ import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
import { createFnAgent } from "../pi.js";
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
import { execSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findWorktreeUser, aiMergeTask } from "../merger.js";
@@ -8242,6 +8242,7 @@ describe("Workflow Steps Execution", () => {
expect(promptContent).toContain("Quality gate hard failure");
vi.useRealTimers();
await rm(tempRoot, { recursive: true, force: true });
});
it("skips script-mode step when scriptName is missing", async () => {

View File

@@ -0,0 +1,12 @@
/**
* Global test isolation: prevents engine tests from writing to the real ~/.fusion/ directory.
*
* This runs in every Vitest worker before shared setup. By forcing process.env.HOME
* to a fresh temp directory, homedir()-derived paths resolve to isolated locations.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { tmpdir } from "node:os";
import { getFusionAuthPath } from "../auth-storage.js";
describe("test isolation guard", () => {
it("overrides HOME to a temp fn-test-home directory", () => {
const home = process.env.HOME;
expect(home).toBeDefined();
expect(home).toContain(tmpdir());
expect(home).toContain("fn-test-home-");
});
it("resolves Fusion auth path under temp HOME", () => {
const home = process.env.HOME;
const authPath = getFusionAuthPath();
expect(home).toBeDefined();
expect(authPath).toContain("fn-test-home-");
expect(authPath.startsWith(home!)).toBe(true);
expect(authPath).toContain(".fusion");
});
});

View File

@@ -16,7 +16,10 @@ export default defineConfig({
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
setupFiles: [
"./src/__tests__/setup-test-isolation.ts",
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 } },