fix: centralize Windows exec handling and async migrations (#318)
* Add Windows exec foundation helpers * server: centralize windows exec handling phase 2 * migrate cli daemon status to execCommand * refactor: complete phase 4 async exec migrations * test: cover windows exec helpers * fix: use beforeEach for context.skip in test setup
This commit is contained in:
@@ -1,78 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
|
||||
async function loadExecutableModule(params?: {
|
||||
execFileImpl?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
callback: ExecFileCallback,
|
||||
) => void;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
const execFileMock = vi.fn(
|
||||
params?.execFileImpl ??
|
||||
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(new Error("execFile not mocked"), "", "");
|
||||
}),
|
||||
);
|
||||
Object.assign(execFileMock, {
|
||||
[promisify.custom]: (command: string, args: string[], options: unknown) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFileMock(
|
||||
command,
|
||||
args,
|
||||
options,
|
||||
(error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
vi.doMock("node:child_process", () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
const module = await import("./executable.js");
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
...module,
|
||||
execFileMock,
|
||||
};
|
||||
}
|
||||
|
||||
let findExecutableDependencies: FindExecutableDependencies;
|
||||
describe("findExecutable", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
findExecutableDependencies = createFindExecutableDependencies();
|
||||
});
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
describe("findExecutableSync", () => {
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
|
||||
);
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
|
||||
setPlatform("win32");
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("claude")).resolves.toBe(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
|
||||
);
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
|
||||
const call = findExecutableDependencies.execFileSync.mock.calls[0];
|
||||
expect(execFileMock).toHaveBeenCalledOnce();
|
||||
const call = execFileMock.mock.calls[0];
|
||||
expect(call?.[0]).toBe("where.exe");
|
||||
expect(call?.[1]).toEqual(["claude"]);
|
||||
expect(call?.[2]?.encoding).toBe("utf8");
|
||||
expect(call?.[2]?.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
test("on Windows, preserves the first where.exe match", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
|
||||
);
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe(
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
);
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n");
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], {
|
||||
expect(call?.[2]).toMatchObject({
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
test("on Windows, prefers an executable match from where.exe output", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
|
||||
});
|
||||
|
||||
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
[
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
"C:\\nvm4w\\nodejs\\codex.ps1",
|
||||
"C:\\nvm4w\\nodejs\\codex.cmd",
|
||||
"C:\\nvm4w\\nodejs\\codex.exe",
|
||||
].join("\r\n"),
|
||||
"",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
|
||||
});
|
||||
|
||||
test("on Windows, returns null when where.exe output is empty", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", async () => {
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "/usr/local/bin/codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"which",
|
||||
["codex"],
|
||||
{ encoding: "utf8" },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns direct paths when they exist", () => {
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
test("returns null when which lookup fails", async () => {
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(new Error("which failed"), "", "");
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex",
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executableExists", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("returns the path when it already exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
|
||||
|
||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => {
|
||||
setPlatform("win32");
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
||||
});
|
||||
|
||||
test("returns null when no matching path exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn(() => false);
|
||||
|
||||
expect(executableExists("/missing/codex", exists)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,27 +218,31 @@ describe("quoteWindowsCommand", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
test("quotes a Windows path with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
test("does not double-quote an already-quoted path", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
test("returns the command unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
test("returns the command unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
@@ -123,27 +258,31 @@ describe("quoteWindowsArgument", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
test("quotes a Windows argument with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
test("does not double-quote an already-quoted argument", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
test("returns the argument unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
test("returns the argument unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user