From b131876e69b0167dc4da262f95f400c3f21d67ae Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 17:24:52 +0700 Subject: [PATCH] test(server): cover .exe/.cmd/.bat end-to-end launch on Windows Add three end-to-end tests in spawn.launch-regression.test.ts that exercise findExecutable -> spawnProcess for each Windows-supported extension. Each test creates a uniquely-named binary in a tempdir, prepends it to PATH, verifies findExecutable returns the fixture path, then launches via spawnProcess and asserts an exact JSON-arg roundtrip plus stdout/exit code. The previous "does not detect a PowerShell shim from PATH" test is removed: its expectation was satisfied via PATH-prepend (not isolation), so global claude.CMD on CI made it return non-null. The supported-extension contract matches which's hardcoded fallback (.EXE;.CMD;.BAT;.COM); .ps1 is not supported by spawnProcess and not surfaced by which without explicit PATHEXT. Also add maxRetries: 5 / retryDelay: 100 to rmSync cleanups in three Claude integration tests to absorb the brief Windows cwd lock after Claude exits. --- ...ude-agent.event-stream.integration.test.ts | 2 +- .../claude-agent.integration.test.ts | 2 +- .../providers/claude-sdk-behavior.test.ts | 2 +- .../src/utils/spawn.launch-regression.test.ts | 177 ++++++++++++++---- 4 files changed, 142 insertions(+), 41 deletions(-) diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index f88e0a365..c10495c24 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -74,7 +74,7 @@ async function createSession(params?: { async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { await handle.session.close().catch(() => undefined); - rmSync(handle.cwd, { recursive: true, force: true }); + rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } async function startTurnAndCollectEvents( diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 262ed37f3..964be3ee8 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -157,7 +157,7 @@ async function createSession(params?: { async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { await handle.session.close().catch(() => undefined); - rmSync(handle.cwd, { recursive: true, force: true }); + rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } describe("ClaudeAgentSession integration", () => { diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index 4a3c9dc23..fb18171ec 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -160,7 +160,7 @@ describe("Claude SDK direct behavior", () => { expect(sawResult || responseText.length === 0).toBe(true); } finally { input.end(); - rmSync(cwd, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 120000); }); diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index f1a03d7f5..0ab928a92 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -19,11 +19,26 @@ interface SpawnResult { const tempDirs: string[] = []; const JSON_ARG = '{"key":"value with spaces","nested":{"quote":"\\"yes\\""}}'; -function makeFixture(options?: { includeCmdShim?: boolean }): { +const ASSERT_SCRIPT_BODY = ` +if (process.argv.includes("--version")) { + console.log("fake-cli 1.0.0"); + process.exit(0); +} +const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON); +const sliceFrom = process.env.PASEO_ARGV_SLICE_FROM ? Number(process.env.PASEO_ARGV_SLICE_FROM) : 2; +const actual = process.argv.slice(sliceFrom); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error("ARGV_MISMATCH"); + console.error(JSON.stringify({ expected, actual })); + process.exit(42); +} +console.log("LAUNCH_OK"); +`; + +function makeFixture(): { root: string; fakeDaemonNode: string; shim: string; - powerShellShim: string; assertScript: string; expectedArgs: string[]; } { @@ -55,25 +70,12 @@ console.log("ARGV_OK"); ); const shim = path.join(root, "claude.cmd"); - if (options?.includeCmdShim !== false) { - writeFileSync( - shim, - ["@echo off", "setlocal", `"${fakeDaemonNode}" "${assertScript}" %*`, ""].join("\r\n"), - ); - } - - const powerShellShim = path.join(root, "claude.ps1"); writeFileSync( - powerShellShim, - [ - "$ErrorActionPreference = 'Stop'", - `& '${fakeDaemonNode.replace(/'/g, "''")}' '${assertScript.replace(/'/g, "''")}' @args`, - "exit $LASTEXITCODE", - "", - ].join("\r\n"), + shim, + ["@echo off", "setlocal", `"${fakeDaemonNode}" "${assertScript}" %*`, ""].join("\r\n"), ); - return { root, fakeDaemonNode, shim, powerShellShim, assertScript, expectedArgs }; + return { root, fakeDaemonNode, shim, assertScript, expectedArgs }; } function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise { @@ -136,36 +138,102 @@ async function runFixture(params: { return collectChild(child); } -function withWindowsPathEntry(dir: string, run: () => Promise): Promise { +interface LaunchFixture { + root: string; + command: string; + binaryPath: string; + args: string[]; + expectedArgvJson: string; + sliceFrom: number; +} + +function makeLaunchFixture(ext: "exe" | "cmd" | "bat"): LaunchFixture { + const root = mkdtempSync(path.join(tmpdir(), `paseo-launch-${ext}-`)); + tempDirs.push(root); + + // Unique base name so a globally installed binary cannot satisfy findExecutable. + const command = `paseo-launch-fake-${path.basename(root)}`; + const userArgs = ["--config", JSON_ARG]; + const expectedArgvJson = JSON.stringify(userArgs); + + if (ext === "exe") { + // Copy node.exe to .exe and run our assert body via -e. + // With -e there is no script slot, so process.argv = [node, ...userArgs] → slice(1). + const binaryPath = path.join(root, `${command}.exe`); + copyFileSync(process.execPath, binaryPath); + return { + root, + command, + binaryPath, + args: ["-e", ASSERT_SCRIPT_BODY, ...userArgs], + expectedArgvJson, + sliceFrom: 1, + }; + } + + // .cmd / .bat: a shim that invokes node with our assert script. + // process.argv = [node, scriptPath, ...userArgs] → slice(2). + const fakeNode = path.join(root, "Fake Node.exe"); + copyFileSync(process.execPath, fakeNode); + const assertScript = path.join(root, "assert-argv.js"); + writeFileSync(assertScript, ASSERT_SCRIPT_BODY); + + const binaryPath = path.join(root, `${command}.${ext}`); + writeFileSync( + binaryPath, + ["@echo off", "setlocal", `"${fakeNode}" "${assertScript}" %*`, ""].join("\r\n"), + ); + return { root, command, binaryPath, args: userArgs, expectedArgvJson, sliceFrom: 2 }; +} + +function withPathPrepended(dir: string, run: () => Promise): Promise { const pathKey = isPlatform("win32") ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") : "PATH"; const previousPath = process.env[pathKey]; - const previousPathExt = process.env.PATHEXT; - process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir; - process.env.PATHEXT = previousPathExt?.toLowerCase().includes(".ps1") - ? previousPathExt - : `${previousPathExt ?? ""};.PS1`; - return run().finally(() => { if (previousPath === undefined) { delete process.env[pathKey]; } else { process.env[pathKey] = previousPath; } - - if (previousPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = previousPathExt; - } }); } +async function findAndLaunch(fixture: LaunchFixture): Promise<{ + found: string | null; + result: SpawnResult | null; +}> { + return withPathPrepended(fixture.root, async () => { + const found = await findExecutable(fixture.command); + if (found === null) { + return { found: null, result: null }; + } + + const child = spawnProcess(found, fixture.args, { + env: { + ...process.env, + PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson, + PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + return { found, result: await collectChild(child) }; + }); +} + +function expectWindowsPathsEqual(actual: string, expected: string): void { + if (isPlatform("win32")) { + expect(actual.toLowerCase()).toBe(expected.toLowerCase()); + } else { + expect(actual).toBe(expected); + } +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); @@ -217,13 +285,46 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => { expect(result.stdout.trim()).toBe("ARGV_OK"); }); - test("does not detect a PowerShell shim from PATH", async () => { - const fixture = makeFixture({ includeCmdShim: false }); + test("finds a .exe on PATH and launches it via spawnProcess", async () => { + const fixture = makeLaunchFixture("exe"); + const { found, result } = await findAndLaunch(fixture); - await withWindowsPathEntry(fixture.root, async () => { - const detected = await findExecutable("claude"); - expect(detected).toBeNull(); - }); + expect(found).not.toBeNull(); + expectWindowsPathsEqual(found!, fixture.binaryPath); + expect(result).not.toBeNull(); + expect(result!.error).toBeNull(); + expect(result!.code).toBe(0); + expect(result!.signal).toBeNull(); + expect(result!.stderr).toBe(""); + expect(result!.stdout.trim()).toBe("LAUNCH_OK"); + }); + + test("finds a .cmd on PATH and launches it via spawnProcess", async () => { + const fixture = makeLaunchFixture("cmd"); + const { found, result } = await findAndLaunch(fixture); + + expect(found).not.toBeNull(); + expectWindowsPathsEqual(found!, fixture.binaryPath); + expect(result).not.toBeNull(); + expect(result!.error).toBeNull(); + expect(result!.code).toBe(0); + expect(result!.signal).toBeNull(); + expect(result!.stderr).toBe(""); + expect(result!.stdout.trim()).toBe("LAUNCH_OK"); + }); + + test("finds a .bat on PATH and launches it via spawnProcess", async () => { + const fixture = makeLaunchFixture("bat"); + const { found, result } = await findAndLaunch(fixture); + + expect(found).not.toBeNull(); + expectWindowsPathsEqual(found!, fixture.binaryPath); + expect(result).not.toBeNull(); + expect(result!.error).toBeNull(); + expect(result!.code).toBe(0); + expect(result!.signal).toBeNull(); + expect(result!.stderr).toBe(""); + expect(result!.stdout.trim()).toBe("LAUNCH_OK"); }); });