- Replace single-quoted git commit messages in worktree tests with double quotes (cmd.exe doesn't treat single quotes as delimiters) - Trim stdout in spawn tests to handle \r\n vs \n - Use nonexistent binary name in executable test not-found case
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
|
|
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
|
|
|
async function loadExecutableModule(params?: {
|
|
execFileImpl?: (
|
|
command: string,
|
|
args: string[],
|
|
options: unknown,
|
|
callback: ExecFileCallback,
|
|
) => void;
|
|
}) {
|
|
vi.resetModules();
|
|
|
|
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 {
|
|
...module,
|
|
execFileMock,
|
|
};
|
|
}
|
|
|
|
describe("findExecutable", () => {
|
|
const originalPlatform = process.platform;
|
|
const missingBinaryName = "nonexistent-binary-xyz-12345";
|
|
|
|
function setPlatform(value: string) {
|
|
Object.defineProperty(process, "platform", { value, writable: true });
|
|
}
|
|
|
|
afterEach(() => {
|
|
setPlatform(originalPlatform);
|
|
});
|
|
|
|
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(execFileMock).toHaveBeenCalledOnce();
|
|
const call = execFileMock.mock.calls[0];
|
|
expect(call?.[0]).toBe("where.exe");
|
|
expect(call?.[1]).toEqual(["claude"]);
|
|
expect(call?.[2]).toMatchObject({
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
});
|
|
|
|
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", "");
|
|
},
|
|
});
|
|
|
|
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(missingBinaryName)).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 null when which lookup fails", async () => {
|
|
const { findExecutable } = await loadExecutableModule({
|
|
execFileImpl: (_command, _args, _options, callback) => {
|
|
callback(new Error("which failed"), "", "");
|
|
},
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("quoteWindowsCommand", () => {
|
|
const originalPlatform = process.platform;
|
|
|
|
function setPlatform(value: string) {
|
|
Object.defineProperty(process, "platform", { value, writable: true });
|
|
}
|
|
|
|
afterEach(() => {
|
|
setPlatform(originalPlatform);
|
|
});
|
|
|
|
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", 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", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
|
});
|
|
|
|
test("escapes ampersands", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
|
});
|
|
|
|
test("escapes pipes", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
|
});
|
|
|
|
test("doubles percent signs", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("100%")).toBe("100%%");
|
|
});
|
|
|
|
test("escapes carets", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
|
});
|
|
|
|
test("escapes multiple metacharacters", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
|
"build^&^(test^|deploy^)^!^<output^>",
|
|
);
|
|
});
|
|
|
|
test("quotes commands with spaces after escaping metacharacters", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
|
'"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"',
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("quoteWindowsArgument", () => {
|
|
const originalPlatform = process.platform;
|
|
|
|
function setPlatform(value: string) {
|
|
Object.defineProperty(process, "platform", { value, writable: true });
|
|
}
|
|
|
|
afterEach(() => {
|
|
setPlatform(originalPlatform);
|
|
});
|
|
|
|
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", 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", async () => {
|
|
setPlatform("win32");
|
|
const { quoteWindowsArgument } = await loadExecutableModule();
|
|
expect(quoteWindowsArgument("--version")).toBe("--version");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|