Files
paseo/packages/server/src/terminal/terminal.test.ts
T
Mohamed BoudraandGitHub 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

275 lines
7.9 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import {
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
humanizeProcessTitle,
normalizeProcessTitle,
type TerminalSession,
} from "./terminal.js";
import {
chmodSync,
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
}
const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
afterEach(async () => {
for (const session of sessions) {
session.kill();
}
sessions.length = 0;
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
function trackSession(session: TerminalSession): TerminalSession {
sessions.push(session);
return session;
}
describe("createTerminal", () => {
it("keeps full process titles while stripping path prefixes", () => {
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe(
"node npm-cli.js run dev",
);
expect(normalizeProcessTitle("")).toBeUndefined();
});
it("humanizes interpreter-backed package manager commands", () => {
expect(
humanizeProcessTitle(
"/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev",
),
).toBe("npm run dev");
expect(
humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"),
).toBe("npm test");
});
it("drops common interpreter prefixes for direct scripts", () => {
expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe(
"server.py --port 3000",
);
expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh");
});
// macOS-only: node-pty ships the spawn-helper prebuild only for darwin.
it.runIf(isPlatform("darwin"))("ensures darwin prebuild spawn-helper is executable", () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
temporaryDirs.push(packageRoot);
const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`);
mkdirSync(prebuildDir, { recursive: true });
const helperPath = join(prebuildDir, "spawn-helper");
writeFileSync(helperPath, "#!/bin/sh\necho helper\n");
chmodSync(helperPath, 0o644);
ensureNodePtySpawnHelperExecutableForCurrentPlatform({
packageRoot,
platform: "darwin",
force: true,
});
expect(statSync(helperPath).mode & 0o111).toBe(0o111);
});
it("uses cmd.exe-compatible default shell on Windows", () => {
expect(resolveDefaultTerminalShell({ platform: "win32", env: {} })).toBe(
"C:\\Windows\\System32\\cmd.exe",
);
expect(
resolveDefaultTerminalShell({
platform: "win32",
env: { ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
}),
).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
});
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
}),
);
expect(session.id).toBeDefined();
expect(typeof session.id).toBe("string");
expect(session.id.length).toBeGreaterThan(0);
expect(session.name).toBe("Terminal");
expect(session.cwd).toBe(realpathSync(tmpdir()));
});
it("uses custom name when provided", async () => {
const shell = isPlatform("win32")
? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe")
: "/bin/sh";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
name: "Dev Server",
}),
);
expect(session.name).toBe("Dev Server");
});
it("uses default shell if not specified", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
}),
);
expect(session.id).toBeDefined();
});
it("uses default rows and cols", async () => {
const shell = isPlatform("win32")
? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe")
: "/bin/sh";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
}),
);
const state = session.getState();
expect(state.rows).toBe(24);
expect(state.cols).toBe(80);
});
it("respects custom rows and cols", async () => {
const shell = isPlatform("win32")
? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe")
: "/bin/sh";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
rows: 40,
cols: 120,
}),
);
const state = session.getState();
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: [
"-e",
"process.stdout.write('launch failed\\ncommand missing\\n'); process.exit(127);",
],
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.exitCode).toBe(127);
expect(exitInfo.signal).toBeNull();
// lastOutputLines may be empty if the process exits before xterm processes the data write
expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true);
expect(session.getExitInfo()).toEqual(exitInfo);
});
});
describe("resize", () => {
it("updates terminal dimensions on resize", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
}),
);
session.send({ type: "resize", rows: 40, cols: 120 });
await new Promise((resolve) => setTimeout(resolve, 100));
const state = session.getState();
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
it("grid reflects new dimensions after resize", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
}),
);
session.send({ type: "resize", rows: 10, cols: 40 });
await new Promise((resolve) => setTimeout(resolve, 100));
const state = session.getState();
expect(state.grid.length).toBe(10);
expect(state.grid[0].length).toBe(40);
});
it("exposes the current size without extracting full state", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
}),
);
expect(session.getSize()).toEqual({ rows: 24, cols: 80 });
session.send({ type: "resize", rows: 10, cols: 40 });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(session.getSize()).toEqual({ rows: 10, cols: 40 });
});
});
describe("mouse events", () => {
it("accepts mouse events without throwing", async () => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
}),
);
// Should not throw
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" });
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" });
});
});