feat(FN-4737): parameterize worktree-escape test to use configured worktree
Adds parameterized test cases to the worktree-liveness test to exercise different configured `worktreesDir` paths, improving coverage of the worktree escape/isolation path. Fusion-Task-Id: FN-4737 Fusion-Task-Lineage: 40e262ea-2507-461e-b9f5-940b3b4490f9
This commit is contained in:
committed by
gsxdsm
parent
f761f771d1
commit
477f79f3eb
@@ -21,7 +21,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx scripts/dev.ts",
|
"dev": "tsx scripts/dev.ts",
|
||||||
"build": "tsx scripts/build.ts",
|
"build": "tsx scripts/build.ts",
|
||||||
"test": "pnpm --filter @fusion/core build && pnpm --filter @fusion/dashboard build && vitest run --silent=passed-only --reporter=dot",
|
"test": "tsx scripts/test.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"generate:icons": "tsx scripts/generate-icons.ts",
|
"generate:icons": "tsx scripts/generate-icons.ts",
|
||||||
"pack": "electron-builder --dir",
|
"pack": "electron-builder --dir",
|
||||||
|
|||||||
@@ -1,43 +1,15 @@
|
|||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { buildDashboardClient, packageRoot, workspaceRoot } from "./workspace-tools";
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const packageRoot = resolve(__dirname, "..");
|
|
||||||
const workspaceRoot = resolve(packageRoot, "..", "..");
|
|
||||||
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
||||||
const desktopDistDir = join(packageRoot, "dist");
|
const desktopDistDir = join(packageRoot, "dist");
|
||||||
const desktopClientDistDir = join(desktopDistDir, "client");
|
const desktopClientDistDir = join(desktopDistDir, "client");
|
||||||
const externalMainProcessPackages = ["electron", "@fusion/core", "@fusion/dashboard"];
|
const externalMainProcessPackages = ["electron", "@fusion/core", "@fusion/dashboard"];
|
||||||
|
|
||||||
function run(command: string, args: string[], cwd: string): Promise<void> {
|
|
||||||
return new Promise((resolvePromise, rejectPromise) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
cwd,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
rejectPromise(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("exit", (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolvePromise();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDashboardBuild(): Promise<void> {
|
async function ensureDashboardBuild(): Promise<void> {
|
||||||
console.log("[desktop:build] Building dashboard client...");
|
console.log("[desktop:build] Building dashboard client...");
|
||||||
await run("pnpm", ["--filter", "@fusion/dashboard", "build:client"], workspaceRoot);
|
await buildDashboardClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(dashboardClientDir);
|
await stat(dashboardClientDir);
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { packageRoot, workspaceRoot } from "./workspace-tools";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const packageRoot = resolve(__dirname, "..");
|
|
||||||
const workspaceRoot = resolve(packageRoot, "..", "..");
|
|
||||||
const distDir = join(packageRoot, "dist");
|
const distDir = join(packageRoot, "dist");
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -94,19 +90,14 @@ async function main(): Promise<void> {
|
|||||||
const dashboardPort = dashboardUrl.port || (dashboardUrl.protocol === "https:" ? "443" : "80");
|
const dashboardPort = dashboardUrl.port || (dashboardUrl.protocol === "https:" ? "443" : "80");
|
||||||
|
|
||||||
console.log(`[desktop:dev] Starting dashboard Vite dev server on ${dashboardUrl.origin}...`);
|
console.log(`[desktop:dev] Starting dashboard Vite dev server on ${dashboardUrl.origin}...`);
|
||||||
const viteProcess = run(
|
const viteProcess = spawn(
|
||||||
"pnpm",
|
join(workspaceRoot, "packages", "dashboard", "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite"),
|
||||||
[
|
["dev", "--host", dashboardHost, "--port", dashboardPort, "--strictPort"],
|
||||||
"--filter",
|
{
|
||||||
"@fusion/dashboard",
|
cwd: join(workspaceRoot, "packages", "dashboard"),
|
||||||
"dev:serve",
|
env: process.env,
|
||||||
"--host",
|
stdio: "inherit",
|
||||||
dashboardHost,
|
},
|
||||||
"--port",
|
|
||||||
dashboardPort,
|
|
||||||
"--strictPort",
|
|
||||||
],
|
|
||||||
workspaceRoot,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let isShuttingDown = false;
|
let isShuttingDown = false;
|
||||||
|
|||||||
17
packages/desktop/scripts/test.ts
Normal file
17
packages/desktop/scripts/test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { buildCore, buildDashboard, packageRoot, runWorkspaceBin } from "./workspace-tools";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log("[desktop:test] Building @fusion/core...");
|
||||||
|
await buildCore();
|
||||||
|
|
||||||
|
console.log("[desktop:test] Building @fusion/dashboard...");
|
||||||
|
await buildDashboard();
|
||||||
|
|
||||||
|
console.log("[desktop:test] Running desktop Vitest suite...");
|
||||||
|
await runWorkspaceBin("vitest", ["run", "--silent=passed-only", "--reporter=dot"], packageRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
console.error("[desktop:test] Test run failed", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
52
packages/desktop/scripts/workspace-tools.ts
Normal file
52
packages/desktop/scripts/workspace-tools.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const packageRoot = resolve(__dirname, "..");
|
||||||
|
export const workspaceRoot = resolve(packageRoot, "..", "..");
|
||||||
|
|
||||||
|
function resolveBin(command: string, cwd: string): string {
|
||||||
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
||||||
|
const localBin = resolve(cwd, "node_modules", ".bin", `${command}${suffix}`);
|
||||||
|
if (existsSync(localBin)) {
|
||||||
|
return localBin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(workspaceRoot, "node_modules", ".bin", `${command}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWorkspaceBin(command: string, args: string[], cwd: string): Promise<void> {
|
||||||
|
return new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
const child = spawn(resolveBin(command, cwd), args, {
|
||||||
|
cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", rejectPromise);
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolvePromise();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCore(): Promise<void> {
|
||||||
|
await runWorkspaceBin("tsc", [], resolve(workspaceRoot, "packages", "core"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDashboard(): Promise<void> {
|
||||||
|
const dashboardRoot = resolve(workspaceRoot, "packages", "dashboard");
|
||||||
|
await runWorkspaceBin("vite", ["build"], dashboardRoot);
|
||||||
|
await runWorkspaceBin("tsc", [], dashboardRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDashboardClient(): Promise<void> {
|
||||||
|
await runWorkspaceBin("vite", ["build"], resolve(workspaceRoot, "packages", "dashboard"));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import "./executor-test-helpers.js";
|
import "./executor-test-helpers.js";
|
||||||
import { TaskExecutor } from "../executor.js";
|
import { TaskExecutor } from "../executor.js";
|
||||||
import * as worktreePool from "../worktree-pool.js";
|
import * as worktreePool from "../worktree-pool.js";
|
||||||
|
import { resolveWorktreesDir } from "../worktree-paths.js";
|
||||||
import { createMockStore, mockedCreateFnAgent, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
|
import { createMockStore, mockedCreateFnAgent, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||||
|
|
||||||
function task(overrides: Record<string, unknown> = {}) {
|
function task(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -62,16 +63,39 @@ describe("FN-4114 worktree liveness assertion", () => {
|
|||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
|
expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FN-4114 aborts when worktree path escapes .worktrees", async () => {
|
it.each([
|
||||||
|
{ name: "default worktreesDir", settings: {}, outsidePath: "/repo/not-a-worktree" },
|
||||||
|
{ name: "absolute worktreesDir", settings: { worktreesDir: "/custom/trees" }, outsidePath: "/repo/not-a-worktree" },
|
||||||
|
{ name: "relative worktreesDir", settings: { worktreesDir: "custom-trees" }, outsidePath: "/repo/not-a-worktree" },
|
||||||
|
])("FN-4114 enforces configured worktreesDir ($name)", async ({ settings, outsidePath }) => {
|
||||||
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
|
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(task({ worktree: "/repo/not-a-worktree" }));
|
const baseSettings = await store.getSettings();
|
||||||
|
const mergedSettings = { ...baseSettings, ...settings };
|
||||||
|
store.getSettings.mockResolvedValue(mergedSettings);
|
||||||
|
|
||||||
const executor = new TaskExecutor(store as any, "/repo");
|
const allowedWorktree = `${resolveWorktreesDir("/repo", mergedSettings as any)}/fn-4114`;
|
||||||
await executor.execute(task({ worktree: "/repo/not-a-worktree" }) as any);
|
store.getTask.mockResolvedValue(task({ worktree: outsidePath }));
|
||||||
|
|
||||||
|
const rejectExecutor = new TaskExecutor(store as any, "/repo");
|
||||||
|
await rejectExecutor.execute(task({ worktree: outsidePath }) as any);
|
||||||
|
|
||||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
|
expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
|
||||||
|
|
||||||
|
mockedCreateFnAgent.mockReset();
|
||||||
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
||||||
|
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() },
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
store.moveTask.mockReset();
|
||||||
|
store.getTask.mockResolvedValue(task({ worktree: allowedWorktree }));
|
||||||
|
|
||||||
|
const acceptExecutor = new TaskExecutor(store as any, "/repo");
|
||||||
|
await acceptExecutor.execute(task({ worktree: allowedWorktree }) as any);
|
||||||
|
|
||||||
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FN-4114 accepts usable pool-acquired worktrees", async () => {
|
it("FN-4114 accepts usable pool-acquired worktrees", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user