From 9b7d3026e072ca0131be3780bf54a4b2f9130173 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 9 May 2026 18:17:44 -0700 Subject: [PATCH] feat(FN-3890): add no-git-subprocess regression test Adds regression tests for the in-process runtime's git subprocess isolation, including hardening git helper mocks and a new no-git-subprocess test case. Fusion-Task-Id: FN-3890 --- .../__tests__/in-process-runtime.test.ts | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts index 6cde4e28fb..d2bf362479 100644 --- a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts +++ b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; +import childProcess from "node:child_process"; import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core"; import { InProcessRuntime } from "../in-process-runtime.js"; import type { ProjectRuntimeConfig } from "../../project-runtime.js"; @@ -20,6 +21,10 @@ const { mockTaskStoreGetTask, mockMessageStoreSetHook, mockSchedulerConfigurePrMonitoring, + mockIsGitRepository, + mockReapOrphanWorktrees, + mockScanIdleWorktrees, + mockGetRegisteredWorktreePaths, } = vi.hoisted(() => ({ mockSelfHealingStart: vi.fn(), mockSelfHealingStop: vi.fn(), @@ -32,6 +37,10 @@ const { mockTaskStoreGetTask: vi.fn().mockResolvedValue(null), mockMessageStoreSetHook: vi.fn(), mockSchedulerConfigurePrMonitoring: vi.fn(), + mockIsGitRepository: vi.fn().mockResolvedValue(true), + mockReapOrphanWorktrees: vi.fn().mockResolvedValue(0), + mockScanIdleWorktrees: vi.fn().mockResolvedValue([]), + mockGetRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set()), })); // Mock the TaskStore class @@ -105,7 +114,7 @@ vi.mock("@fusion/core", async () => { vi.mock("../../worktree-pool.js", async () => { const actual = await vi.importActual("../../worktree-pool.js"); - // The runtime calls these on startup. They normally shell out to `git`, + // FN-3890: The runtime calls these on startup. They normally shell out to `git`, // which (a) does real I/O against a non-git temp dir and (b) interacts // badly with `vi.useFakeTimers()` in this suite — the test-harness // subprocess guard arms a 30s kill timer that can fire under fake-timer @@ -113,10 +122,10 @@ vi.mock("../../worktree-pool.js", async () => { // Stub them out so runtime.start() never spawns git. return { ...actual, - isGitRepository: vi.fn().mockResolvedValue(true), - reapOrphanWorktrees: vi.fn().mockResolvedValue(0), - scanIdleWorktrees: vi.fn().mockResolvedValue([]), - getRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set()), + isGitRepository: mockIsGitRepository, + reapOrphanWorktrees: mockReapOrphanWorktrees, + scanIdleWorktrees: mockScanIdleWorktrees, + getRegisteredWorktreePaths: mockGetRegisteredWorktreePaths, }; }); @@ -217,6 +226,14 @@ describe("InProcessRuntime", () => { } mockTaskStoreGetTask.mockReset(); mockTaskStoreGetTask.mockResolvedValue(null); + mockIsGitRepository.mockReset(); + mockIsGitRepository.mockResolvedValue(true); + mockReapOrphanWorktrees.mockReset(); + mockReapOrphanWorktrees.mockResolvedValue(0); + mockScanIdleWorktrees.mockReset(); + mockScanIdleWorktrees.mockResolvedValue([]); + mockGetRegisteredWorktreePaths.mockReset(); + mockGetRegisteredWorktreePaths.mockResolvedValue(new Set()); // Create a unique temp directory for this test run testDir = mkdtempSync(join(tmpdir(), `fn-test-${randomUUID().slice(0, 8)}-`)); @@ -259,6 +276,37 @@ describe("InProcessRuntime", () => { expect(runtime.getStatus()).toBe("active"); }, 30000); + it("does not spawn real git subprocesses during start()", async () => { + const execSpy = vi.spyOn(childProcess, "exec"); + const execFileSpy = vi.spyOn(childProcess, "execFile"); + const spawnSpy = vi.spyOn(childProcess, "spawn"); + + try { + await runtime.start(); + + const gitExecCalls = execSpy.mock.calls.filter(([command]) => command.includes("git ")); + const gitExecFileCalls = execFileSpy.mock.calls.filter(([file, args]) => { + if (file.includes("git")) return true; + return Array.isArray(args) && args.some((arg) => String(arg).includes("git")); + }); + const gitSpawnCalls = spawnSpy.mock.calls.filter(([command, args]) => { + if (String(command).includes("git")) return true; + return Array.isArray(args) && args.some((arg) => String(arg).includes("git")); + }); + + expect(gitExecCalls).toHaveLength(0); + expect(gitExecFileCalls).toHaveLength(0); + expect(gitSpawnCalls).toHaveLength(0); + expect(mockReapOrphanWorktrees).toHaveBeenCalledWith(testDir); + expect(mockIsGitRepository).toHaveBeenCalledWith(testDir); + expect(mockScanIdleWorktrees).toHaveBeenCalled(); + } finally { + execSpy.mockRestore(); + execFileSpy.mockRestore(); + spawnSpy.mockRestore(); + } + }, 30000); + it("passes executor recovery callbacks into SelfHealingManager", async () => { await runtime.start();