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.
This commit is contained in:
Mohamed Boudra
2026-05-08 17:24:52 +07:00
parent b7d2e6d3ec
commit b131876e69
4 changed files with 142 additions and 41 deletions
@@ -74,7 +74,7 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
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(
@@ -157,7 +157,7 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
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", () => {
@@ -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);
});
@@ -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<SpawnResult> {
@@ -136,36 +138,102 @@ async function runFixture(params: {
return collectChild(child);
}
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
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 <command>.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<T>(dir: string, run: () => Promise<T>): Promise<T> {
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");
});
});