Files
paseo/packages/server/src/utils/executable.test.ts
Mohamed Boudra c4e4a28bc0 Run server tests on Linux+Windows matrix (#809)
* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
2026-05-08 10:57:22 +08:00

274 lines
8.9 KiB
TypeScript

import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
executableExists,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
import { isPlatform } from "../test-utils/platform.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-executable-test-"));
tempDirs.push(dir);
return dir;
}
function prependPath(...dirs: string[]): void {
process.env.PATH = [...dirs, originalEnv.PATH].filter(Boolean).join(path.delimiter);
}
function writeExecutable(filePath: string, content: string): string {
writeFileSync(filePath, content);
if (!isPlatform("win32")) {
chmodSync(filePath, 0o755);
}
return filePath;
}
function writeBrokenAbsoluteFixture(dir: string): string {
const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken");
writeFileSync(filePath, "not executable");
if (!isPlatform("win32")) {
chmodSync(filePath, 0o644);
}
return filePath;
}
function expectWindowsPathsEqual(actual: string | null, expected: string): void {
expect(actual?.toLowerCase()).toBe(expected.toLowerCase());
}
afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("findExecutable", () => {
describe.skipIf(isPlatform("win32"))("POSIX", () => {
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
const executableDir = makeTempDir();
const nonExecutableDir = makeTempDir();
const executable = writeExecutable(path.join(executableDir, "foo"), "#!/bin/sh\necho 0.1\n");
const nonExecutable = path.join(nonExecutableDir, "foo");
writeFileSync(nonExecutable, "#!/bin/sh\necho broken\n");
chmodSync(nonExecutable, 0o644);
prependPath(nonExecutableDir, executableDir);
await expect(findExecutable("foo")).resolves.toBe(executable);
});
});
describe.runIf(isPlatform("win32"))("Windows", () => {
test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
const dir = makeTempDir();
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
const brokenExe = path.join(dir, "foo.exe");
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
writeFileSync(brokenExe, "");
prependPath(dir);
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
});
test("returns null when the only candidate is a broken .exe", async () => {
const dir = makeTempDir();
process.env.PATHEXT = ".EXE";
writeFileSync(path.join(dir, "foo.exe"), "");
prependPath(dir);
await expect(findExecutable("foo")).resolves.toBeNull();
});
test("returns a .cmd when it is the only candidate", async () => {
const dir = makeTempDir();
process.env.PATHEXT = ".CMD";
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
prependPath(dir);
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
});
});
test("returns an invokable absolute path", async () => {
await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath);
});
test("returns null for an absolute path that cannot spawn", async () => {
const dir = makeTempDir();
const fixture = writeBrokenAbsoluteFixture(dir);
await expect(findExecutable(fixture)).resolves.toBeNull();
});
test("returns null when the command is not on PATH", async () => {
const dir = makeTempDir();
prependPath(dir);
await expect(findExecutable("paseo-definitely-missing-command")).resolves.toBeNull();
});
});
describe("executableExists", () => {
test("returns the path when it already exists", () => {
const exists = (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, then .cmd for extensionless paths", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", writable: true });
try {
const exists = (candidate: string) => candidate === "C:\\tools\\codex.cmd";
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
}
});
test("on Windows, ignores PowerShell scripts for extensionless paths", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", writable: true });
try {
const exists = (candidate: string) => candidate === "C:\\tools\\codex.ps1";
expect(executableExists("C:\\tools\\codex", exists)).toBeNull();
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
}
});
test("returns null when no matching path exists", () => {
expect(executableExists("/missing/codex", () => false)).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", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
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", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("escapes ampersands", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
});
test("escapes pipes", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
});
test("does not double percent signs", () => {
setPlatform("win32");
// cmd.exe only collapses %% → % inside batch files; on the command line
// it stays literal, which corrupts git --format atoms etc.
expect(quoteWindowsCommand("100%")).toBe("100%");
});
test("preserves git --format atoms verbatim", () => {
setPlatform("win32");
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
"--format=%^(refname^)%09%^(committerdate:unix^)",
);
});
test("escapes carets", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
});
test("escapes multiple metacharacters", () => {
setPlatform("win32");
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
"build^&^(test^|deploy^)^!^<output^>",
);
});
test("quotes commands with spaces after escaping metacharacters", () => {
setPlatform("win32");
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", () => {
setPlatform("darwin");
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", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
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", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});