Files
paseo/packages/server/src/utils/spawn.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

219 lines
6.3 KiB
TypeScript

import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import * as fs from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { buildSelfNodeCommand } from "../server/paseo-env.js";
import { execCommand, spawnProcess } from "./spawn.js";
const printEnvScript = `
const keys = [
"CUSTOM",
"ELECTRON_NO_ATTACH_CONSOLE",
"ELECTRON_RUN_AS_NODE",
"PASEO_DESKTOP_MANAGED",
"PASEO_NODE_ENV",
"PASEO_SUPERVISED",
];
const values = Object.fromEntries(keys.map((key) => [key, process.env[key] ?? null]));
console.log(JSON.stringify(values));
`;
function parsePrintedEnv(stdout: string): Record<string, string | null> {
return JSON.parse(stdout.trim()) as Record<string, string | null>;
}
describe("execCommand", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs) {
rmSync(tempDir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
test("returns stdout and stderr for a successful command", async () => {
const result = await execCommand("echo", ["hello"]);
expect(result.stdout.trim()).toBe("hello");
expect(result.stderr).toBe("");
});
test("rejects when the command times out", async () => {
const command =
process.platform === "win32"
? {
command: process.execPath,
args: ["-e", "setTimeout(() => {}, 10_000)"],
}
: { command: "sleep", args: ["10"] };
await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow();
});
test("runs the command in the provided cwd", async () => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-")));
tempDirs.push(cwd);
const command =
process.platform === "win32"
? {
command: process.execPath,
args: ["-e", "console.log(process.cwd())"],
}
: { command: "pwd", args: [] };
const result = await execCommand(command.command, command.args, { cwd });
expect(realpathSync(result.stdout.trim())).toBe(cwd);
expect(result.stderr).toBe("");
});
test("treats env as the replacement base and finalizes external command env", async () => {
const result = await execCommand(process.execPath, ["-e", printEnvScript], {
baseEnv: {
ELECTRON_RUN_AS_NODE: "0",
CUSTOM: "from-base",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
PASEO_SUPERVISED: "1",
},
env: {
CUSTOM: "from-env",
ELECTRON_NO_ATTACH_CONSOLE: "1",
PASEO_DESKTOP_MANAGED: "1",
PASEO_NODE_ENV: "test",
},
envOverlay: {
CUSTOM: "from-overlay",
ELECTRON_RUN_AS_NODE: undefined,
},
});
expect(parsePrintedEnv(result.stdout)).toEqual({
CUSTOM: "from-overlay",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: null,
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: null,
PASEO_SUPERVISED: null,
});
});
test("does not inherit process.env when env replacement is supplied", async () => {
process.env.PASEO_TEST_SHOULD_NOT_LEAK = "leaked";
try {
const result = await execCommand(
process.execPath,
[
"-e",
"console.log(JSON.stringify({ leaked: process.env.PASEO_TEST_SHOULD_NOT_LEAK ?? null }))",
],
{
env: {
PATH: process.env.PATH,
},
},
);
expect(JSON.parse(result.stdout.trim())).toEqual({ leaked: null });
} finally {
delete process.env.PASEO_TEST_SHOULD_NOT_LEAK;
}
});
test("spawnProcess finalizes external command env", async () => {
const child = spawnProcess(process.execPath, ["-e", printEnvScript], {
baseEnv: {
ELECTRON_RUN_AS_NODE: "0",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
},
envOverlay: {
CUSTOM: "spawn-overlay",
PASEO_SUPERVISED: "1",
},
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
const exitCode = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(Buffer.concat(stderrChunks).toString()).toBe("");
expect(exitCode).toBe(0);
expect(parsePrintedEnv(Buffer.concat(stdoutChunks).toString())).toEqual({
CUSTOM: "spawn-overlay",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: null,
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: null,
PASEO_SUPERVISED: null,
});
});
test("internal env mode preserves Paseo-owned launcher env", async () => {
const result = await execCommand(process.execPath, ["-e", printEnvScript], {
envMode: "internal",
baseEnv: {
ELECTRON_RUN_AS_NODE: "1",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
},
envOverlay: {
CUSTOM: "internal",
PASEO_SUPERVISED: "1",
},
});
expect(parsePrintedEnv(result.stdout)).toEqual({
CUSTOM: "internal",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: "1",
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: "production",
PASEO_SUPERVISED: "1",
});
});
test("does not realpath commands while finalizing external command env", async () => {
const realpathSpy = vi.spyOn(fs.realpathSync, "native");
await execCommand("/some/random/binary", ["--version"], {
env: {
PATH: process.env.PATH,
},
timeout: 100,
}).catch(() => {});
expect(realpathSpy).not.toHaveBeenCalled();
});
test("self node command explicitly enables Electron node mode", async () => {
const command = buildSelfNodeCommand(["-e", printEnvScript], {
CUSTOM: "from-helper",
});
const result = await execCommand(command.command, command.args, {
env: command.env,
envMode: "internal",
});
expect(parsePrintedEnv(result.stdout)).toEqual({
CUSTOM: "from-helper",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: "1",
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: null,
PASEO_SUPERVISED: null,
});
});
});