Files
paseo/packages/server/src/terminal/terminal.test.ts
T
paseo-bot[bot]andKiraPC 404d08d24b Report failed terminal launches and remove early exits (#6792)
* fix(server): report a terminal whose command cannot start instead of leaving a dead tab

Opening a terminal profile whose command is not installed left a tab that could
never be closed. Two problems sat on the same path.

A terminal that dies during creation was resurrected by its own create. The
worker sends terminalCreated, the createTerminal response and terminalExit in
the same tick, so the parent drains all three before the awaiting create
continuation resumes; that continuation called registerRecord a second time and
put back a terminal the exit had just removed. Removal is owned solely by the
terminalExit event and that event never comes twice, so the record outlived
every close attempt: getTerminals kept listing it, capture returned nothing,
kill was a silent no-op that still reported success, and only a daemon restart
cleared it.

The parent now tracks the ids with a create in flight and keeps the finished
record when an exit removes one of them, so the create continuation returns the
exited session instead of registering a live one. Removal still happens exactly
once, in the terminalExit path.

With the stuck tab gone, a missing command produced nothing at all: no tab and
no message. node-pty spawns directly through execve on POSIX, with no shell, so
a command that does not exist fails inside the spawn helper and surfaces as exit
code 1 with no output -- exactly what `false` produces, so the exit carries no
usable signal. createTerminal now resolves the command before spawning and fails
the create when it cannot be found, so the daemon answers create_terminal with a
null terminal and "<command>: command not found", which the app already renders.
A command that starts and then exits non-zero still opens its tab.

That resolution has to match what execve will do in the child, or a working
profile is refused: it uses the terminal's own environment, built first so the
check sees the per-cwd registered PATH and the entries buildTerminalEnvironment
prepends, and it resolves a command containing a separator against the
terminal's cwd rather than through PATH. Windows keeps resolving inside
resolveTerminalSpawnCommand, which has its own shim handling and fallback.

* fix(terminal): check exact executable names and verify launch output

---------

Co-authored-by: KiraPC <pasqualecarmine.carbone@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 23:02:39 +02:00

1577 lines
54 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import {
buildTerminalEnvironment,
assertTerminalCommandCanStart,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
resolveTerminalSpawnCommand,
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
type TerminalSession,
} from "./terminal.js";
import {
chmodSync,
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import * as pty from "node-pty";
import xterm from "@xterm/headless";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
import { stripVTControlCharacters } from "node:util";
const hasZsh = existsSync("/bin/zsh");
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
function rowToText(row: TerminalRow): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
// Extract text from a single row
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
return rowToText(state.grid[rowIndex]);
}
// Extract all visible lines as array (trimmed, empty lines included)
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
return state.grid.map(rowToText);
}
function rowHasChar(row: TerminalRow, char: string): boolean {
return row.some((cell) => cell.char === char);
}
// Wait for terminal state to match expected lines
async function waitForLines(
session: TerminalSession,
expectedLines: string[],
timeoutMs = 5000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const lines = getLines(session.getState());
let matches = true;
for (let i = 0; i < expectedLines.length; i++) {
if (lines[i] !== expectedLines[i]) {
matches = false;
break;
}
}
if (matches) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const actual = getLines(session.getState()).slice(0, expectedLines.length);
throw new Error(
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
);
}
async function waitForState(
session: TerminalSession,
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = session.getState();
if (predicate(state)) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timeout waiting for terminal state predicate to match");
}
async function waitForTitle(
session: TerminalSession,
predicate: (title: string | undefined) => boolean,
timeoutMs = 5000,
): Promise<string | undefined> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const title = session.getTitle();
if (predicate(title)) {
return title;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("Timeout waiting for terminal title predicate to match");
}
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
}
const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
/**
* `kill()` only signals; the shell exits asynchronously and keeps writing on
* the way out (zsh drops a history file and a compdump into `$ZDOTDIR`).
* Deleting its directory before then loses the race as `ENOTEMPTY`: the
* recursive walk empties the directory, the dying shell recreates a file in it,
* and the final rmdir fails.
*
* Waiting for the exit shrinks that window but does not close it: some of those
* writes come from processes zsh forked, which outlive the pty and answer to
* nothing we hold. Retries alone did not close it either.
*
* The known post-retry `ENOTEMPTY` race is reported and tolerated because it
* belongs to a descendant shell outliving the tested terminal. Any other
* cleanup error still fails the test that owns the directory.
*/
async function waitForSessionExit(session: TerminalSession): Promise<void> {
const exited = new Promise<void>((resolve) => {
const dispose = session.onExit(() => {
dispose();
resolve();
});
session.kill();
});
await Promise.race([exited, delay(2_000)]);
}
afterEach(async () => {
vi.useRealTimers();
await Promise.all(sessions.map(waitForSessionExit));
sessions.length = 0;
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 25 });
} catch (error) {
if (
typeof error !== "object" ||
error === null ||
!("code" in error) ||
error.code !== "ENOTEMPTY"
) {
throw error;
}
console.warn(`leaving temp dir behind after ENOTEMPTY retries: ${dir}`);
}
}
}
});
function trackSession(session: TerminalSession): TerminalSession {
sessions.push(session);
return session;
}
async function waitForScheduledTimers(expectedTimerCount: number): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
if (vi.getTimerCount() === expectedTimerCount) {
return;
}
await waitForImmediate();
}
throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`);
}
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("passes profile commands through untouched on non-Windows", async () => {
const resolveExecutable = vi.fn(async () => "/usr/local/bin/claude");
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "linux",
resolveExecutable,
});
expect(resolved).toEqual({ command: "claude", args: ["--foo"] });
// Non-Windows must not pay the executable-resolution cost.
expect(resolveExecutable).not.toHaveBeenCalled();
});
it.runIf(isPlatform("linux", "darwin"))(
"passes an unescaped multi-line prompt through byte-for-byte on POSIX",
async () => {
// node-pty spawns directly via execve on POSIX (no shell), so a prompt
// substituted into `args` is never re-parsed by anything -- it must
// reach the target process exactly as typed, including raw newlines,
// quotes, and shell metacharacters that would be dangerous under a
// shell but are inert here.
const nastyPrompt =
'fix the bug\nwhere `git commit -m "wip"` fails\nif $HOME contains a & or | char';
const resolveExecutable = vi.fn(async () => "/usr/local/bin/claude");
const resolvedLinux = await resolveTerminalSpawnCommand("claude", [nastyPrompt], {
platform: "linux",
resolveExecutable,
});
const resolvedDarwin = await resolveTerminalSpawnCommand("claude", [nastyPrompt], {
platform: "darwin",
resolveExecutable,
});
expect(resolvedLinux).toEqual({ command: "claude", args: [nastyPrompt] });
expect(resolvedDarwin).toEqual({ command: "claude", args: [nastyPrompt] });
expect(resolveExecutable).not.toHaveBeenCalled();
},
);
it("keeps the original command when it cannot be resolved on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", [], {
platform: "win32",
resolveExecutable: async () => null,
});
// Falls back to the bare command so the terminal surfaces the error itself.
expect(resolved).toEqual({ command: "claude", args: [] });
});
it("routes resolved .cmd shims through cmd.exe on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "win32",
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" },
resolveExecutable: async () => "C:\\npm\\claude.cmd",
});
// The `.cmd` branch now hands node-pty a single pre-escaped command line
// (string) instead of an argv array, so that node-pty's own
// MSVCRT-style array quoting never runs a second time over an already
// cmd.exe-escaped argument. See escapeCmdExeArgument in terminal.ts.
expect(resolved).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
args: '/d /s /c "C:\\npm\\claude.cmd ^^^"--foo^^^""',
});
});
describe(".cmd shim argument escaping on Windows", () => {
async function resolveCmdShimArgs(promptArg: string): Promise<string> {
const resolved = await resolveTerminalSpawnCommand("claude", [promptArg], {
platform: "win32",
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" },
resolveExecutable: async () => "C:\\npm\\claude.cmd",
});
// A real narrow rather than a cast: the `.cmd` branch is the one case
// that returns a pre-escaped command line instead of argv, so if that
// contract ever changes these tests should fail loudly here rather than
// assert against a stringified array.
const { args } = resolved;
if (typeof args !== "string") {
throw new Error(`expected a cmd.exe command line, got ${JSON.stringify(args)}`);
}
return args;
}
// Each expected string is the exact cmd.exe command line the escaping
// must produce so a real `cmd.exe /c` invocation reconstructs the
// original prompt as a single argv element for the target process
// (modulo the documented newline-to-space normalization below).
it("neutralizes cmd.exe's own metacharacters instead of letting them execute", async () => {
expect(await resolveCmdShimArgs("a & b | c")).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"a^^^ ^^^&^^^ b^^^ ^^^|^^^ c^^^""',
);
expect(await resolveCmdShimArgs("100% done %PATH%")).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"100^^^%^^^ done^^^ ^^^%PATH^^^%^^^""',
);
expect(await resolveCmdShimArgs("a^b")).toBe('/d /s /c "C:\\npm\\claude.cmd ^^^"a^^^^b^^^""');
});
it("escapes embedded double quotes and backslash runs preceding them", async () => {
expect(await resolveCmdShimArgs('say "hello" now')).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"say^^^ \\^^^"hello\\^^^"^^^ now^^^""',
);
expect(await resolveCmdShimArgs('C:\\path\\ "quoted"')).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"C:\\path\\^^^ \\^^^"quoted\\^^^"^^^""',
);
});
// cmd.exe has no way to escape a literal CR/LF inside one command line
// -- its parser treats newlines as command separators regardless of
// quoting -- so a raw newline is normalized to a space before escaping.
// This is a deliberate choice, not an oversight: it avoids the prompt
// ever splitting into a second, attacker-controlled cmd.exe command, at
// the cost of losing the original line breaks.
it("normalizes embedded newlines to spaces instead of letting them split the command", async () => {
expect(await resolveCmdShimArgs("line one\nline two")).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"line^^^ one^^^ line^^^ two^^^""',
);
expect(await resolveCmdShimArgs("line one\r\nline two")).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"line^^^ one^^^ line^^^ two^^^""',
);
});
it("handles a multi-line prompt combining quotes, backslashes, and every cmd.exe metacharacter", async () => {
const prompt = 'multi\nline "quoted \\path\\" & piped | percent %VAR% ^caret';
expect(await resolveCmdShimArgs(prompt)).toBe(
'/d /s /c "C:\\npm\\claude.cmd ^^^"multi^^^ line^^^ \\^^^"quoted^^^ \\path\\\\\\^^^"^^^ ^^^&^^^ piped^^^ ^^^|^^^ percent^^^ ^^^%VAR^^^%^^^ ^^^^caret^^^""',
);
});
});
it("uses the resolved .exe path directly on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "win32",
resolveExecutable: async () => "C:\\tools\\claude.exe",
});
expect(resolved).toEqual({ command: "C:\\tools\\claude.exe", args: ["--foo"] });
});
it.runIf(isPlatform("linux", "darwin"))(
"spawns without shell interpretation on POSIX, so argv reaches the child byte-for-byte",
async () => {
// The composer substitutes {{{prompt}}} directly into `args` before this
// ever reaches the daemon, so a raw multi-line prompt containing quotes,
// backslashes, and shell metacharacters must arrive at the spawned
// process exactly as typed. node-pty's POSIX backend spawns via execve
// with no shell, so per-arg escaping is unnecessary there -- this test
// proves that end-to-end through createTerminal (not just at the
// resolveTerminalSpawnCommand unit level) by reading the argv the
// spawned process actually received back out of the pty.
const nastyPrompt =
'line one\nline two: git commit -m "wip" && echo $HOME | grep ^x > out.txt';
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 500,
command: process.execPath,
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
}),
);
const state = await waitForState(session, (s) => getRowText(s, 0).startsWith("["));
expect(JSON.parse(getRowText(state, 0))).toEqual([nastyPrompt]);
},
);
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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("reports per-row soft-wrap flags only when wrap flags are requested", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 40,
rows: 10,
command: process.execPath,
// 100 chars with no newline soft-wraps across three rows at 40 cols.
args: ["-e", "process.stdout.write('A'.repeat(100)); setInterval(() => {}, 100000);"],
}),
);
const start = Date.now();
while (Date.now() - start < 5000) {
if (rowToText(session.getState().grid[2]).startsWith("A")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Rows 0 and 1 continue onto the next row; row 2 is the end of the logical line.
const withFlags = session.getState({ includeWrapFlags: true });
expect(withFlags.gridWrapped?.slice(0, 3)).toEqual([true, true, false]);
// Back-compat gate: without the capability the daemon must not attach the new
// fields, so an old strict-schema client still parses the snapshot.
const withoutFlags = session.getState();
expect(withoutFlags.gridWrapped).toBeUndefined();
expect(withoutFlags.scrollbackWrapped).toBeUndefined();
});
it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => {
// Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair
// whose second cell has no chars. Capturing that continuation as " " made
// every restored syllable three columns wide (paseo#3434).
const text = "통합 구현은 전체 테스트를 통과했습니다";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 80,
rows: 10,
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`,
],
}),
);
const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다"));
// Replay the snapshot the way the client does on tab switch and read the
// row back the way a selection copy would.
const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true });
try {
await new Promise<void>((resolve) =>
restored.write(renderTerminalSnapshotToAnsi(state), resolve),
);
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text);
} finally {
restored.dispose();
}
// The continuation cell must stay empty so the restore path can skip it.
expect(state.grid[0][0].char).toBe("통");
expect(state.grid[0][1].char).toBe("");
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
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);
});
it("retains the final lines after output far exceeds the recent-output char cap", async () => {
// Emit many small chunks whose total length is well past the 16000-char
// recent-output cap, so the chunk-trimming path runs repeatedly. The final
// distinctive lines must survive in the exit summary. The process is kept
// alive (no self-exit) so we can wait until the last chunk has actually been
// parsed by the headless terminal before killing — otherwise the exit
// summary races the async xterm parse and reads a stale buffer.
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: [
"-e",
"for (let i = 0; i < 3000; i++) { process.stdout.write(`line-${i}\\n`); } setInterval(() => {}, 1000);",
],
}),
);
// Poll the parsed buffer until the final line lands, so the exit summary
// (which reads the headless buffer) can't race the async xterm parse.
const finalLineParsed = (): boolean => {
const state = session.getState();
const rowText = (row: { char: string }[]): string => row.map((cell) => cell.char).join("");
return [...state.scrollback, ...state.grid].some((row) => rowText(row).includes("line-2999"));
};
const deadline = Date.now() + 10000;
while (!finalLineParsed()) {
if (Date.now() > deadline) {
throw new Error("Timed out waiting for the final line to be parsed");
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
session.kill();
},
);
expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0);
expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999");
});
it.runIf(isPlatform("linux", "darwin"))(
"rejects a profile command that is not installed instead of opening a terminal",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a command found only on the PATH carried by the env option",
async () => {
// execve resolves the command against the child's PATH, which carries the
// per-cwd registered environment. A version-manager shim lives there and
// not necessarily on the daemon's own PATH.
const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-"));
temporaryDirs.push(binDir);
const toolPath = join(binDir, "paseo-qa-envpath-tool");
writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(toolPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Shim",
command: "paseo-qa-envpath-tool",
args: [],
env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` },
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"reports command not found when the command is on neither PATH",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` },
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a relative command path resolved against the terminal cwd",
async () => {
// execve resolves a path-ish command against the child's cwd, never the
// daemon's own working directory.
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-"));
temporaryDirs.push(projectDir);
mkdirSync(join(projectDir, "scripts"));
const scriptPath = join(projectDir, "scripts", "dev.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
name: "Dev script",
command: "./scripts/dev.sh",
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-"));
temporaryDirs.push(projectDir);
const scriptPath = join(projectDir, "run.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Absolute",
command: scriptPath,
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
});
it("leaves the command check to the terminal itself on Windows", async () => {
// Windows resolves inside resolveTerminalSpawnCommand, which has its own
// shim handling and its own fallback for an unresolved command.
await expect(
assertTerminalCommandCanStart({
command: "paseo-qa-absent-codex",
cwd: realpathSync(tmpdir()),
env: { PATH: "/paseo-qa-empty" },
platform: "win32",
}),
).resolves.toBeUndefined();
});
it.runIf(isPlatform("linux", "darwin"))(
"still opens a terminal for an installed command that exits non-zero",
async () => {
// A profile running a failing test suite must keep opening a tab with its
// output. Only a command that cannot start at all is reported as an error,
// and "exited non-zero" does not distinguish the two: a missing binary and
// `false` both surface as exit code 1 from node-pty.
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Failing suite",
command: "false",
args: [],
}),
);
const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => {
session.onExit((info) => resolve({ exitCode: info.exitCode }));
});
expect(exitInfo).toEqual({ exitCode: 1 });
},
);
});
describe.skipIf(isPlatform("win32"))("send input", () => {
it("executes a simple echo command", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
// Wait for initial prompt, then send command
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "echo hello\r" });
// After running "echo hello", terminal should show:
// Line 0: "$ echo hello"
// Line 1: "hello"
// Line 2: "$"
await waitForLines(session, ["$ echo hello", "hello", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ echo hello");
expect(getRowText(state, 1)).toBe("hello");
expect(getRowText(state, 2)).toBe("$");
});
it("captures output from pwd in specified cwd", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "pwd\r" });
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ pwd");
expect(getRowText(state, 1)).toBe("/tmp");
expect(getRowText(state, 2)).toBe("$");
});
});
describe.skipIf(isPlatform("win32"))("terminal title", () => {
it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
mkdirSync(realZdotdir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
});
it("emits the initial title from command args to title listeners", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForTitle(session, (title) => title === "npm run dev");
await waitForState(session, (state) => state.title === "npm run dev");
expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("npm run dev");
expect(session.getState().title).toBe("npm run dev");
unsubscribeTitle();
});
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log");
unsubscribeTitle();
});
it("keeps preset titles instead of applying OSC title updates", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
title: "typecheck",
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(session.getTitle()).toBe("typecheck");
expect(session.getState().title).toBe("typecheck");
});
it("emits command completion from VS Code OSC 633 without visible output", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-command-finished.sh");
writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([7]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
unsubscribeCommandFinished();
});
it("ignores malformed VS Code OSC 633 command completion payloads", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh");
writeFileSync(
scriptPath,
"#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n",
);
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([3]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
unsubscribeCommandFinished();
});
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const seenMessages: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
const unsubscribeMessages = session.subscribe((message) => {
if (message.type === "titleChange") {
seenMessages.push(message.title);
}
});
await waitForLines(session, ["$"]);
session.send({
type: "input",
data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
});
await waitForTitle(session, (title) => title === "Final");
expect(seenTitles).toEqual(["Final"]);
expect(seenMessages).toEqual(["Final"]);
unsubscribeMessages();
unsubscribeTitle();
});
it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
await waitForLines(session, ["$"]);
await waitForTitle(session, (title) => title === "~/dev/faro");
session.send({ type: "input", data: "sleep 1\r" });
await waitForTitle(session, (title) => title === "sleep 1");
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
});
it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-"));
temporaryDirs.push(homeDir);
writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n");
const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources");
const inaccessiblePackagedIntegrationDir = join(
fakeAppRoot,
"app.asar",
"node_modules",
"@getpaseo",
"server",
"dist",
"server",
"terminal",
"shell-integration",
"zsh",
);
const unpackedIntegrationDir = join(
fakeAppRoot,
"app.asar.unpacked",
"node_modules",
"@getpaseo",
"server",
"dist",
"server",
"terminal",
"shell-integration",
"zsh",
);
mkdirSync(unpackedIntegrationDir, { recursive: true });
cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true });
writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder");
const env = buildTerminalEnvironment({
shell: "/bin/zsh",
env: {
HOME: homeDir,
},
zshShellIntegrationDir: inaccessiblePackagedIntegrationDir,
});
const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], {
cwd: homeDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> ");
});
it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "false\r" });
await waitForState(session, () => commandCompletions.includes(1));
expect(commandCompletions).toEqual([1]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
unsubscribeCommandFinished();
});
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
vi.useFakeTimers();
session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" });
await waitForScheduledTimers(1);
session.setTitle("User terminal");
await vi.advanceTimersByTimeAsync(250);
vi.useRealTimers();
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
it("ignores later OSC title updates after setting a user title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
session.setTitle("User terminal");
session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" });
await new Promise((resolve) => setTimeout(resolve, 250));
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
it("trims user-set titles and treats empty titles as no-ops", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.setTitle(" ");
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
session.setTitle(" User terminal ");
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
});
describe.skipIf(isPlatform("win32"))("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[31m = ANSI red (color 1)
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
// The "$" after RED should have default color
expect(outputRow[3].char).toBe("$");
expect(outputRow[3].fg).toBe(undefined);
expect(outputRow[3].fgMode).toBe(undefined);
});
it("captures true color RGB (mode 3)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
const state = session.getState();
const outputRow = state.grid[1];
// Check R cell
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
const expectedPacked = (255 << 16) | (128 << 8) | 64;
expect(outputRow[0].fg).toBe(expectedPacked);
});
it("captures background colors", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[41m = ANSI red background
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("B");
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
});
});
describe("resize", () => {
it("updates terminal dimensions on resize", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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({
workspaceId: "ws-test",
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" });
});
});
describe("terminal activity interruption", () => {
it.each([
["Ctrl-C", "\x03"],
["Escape", "\x1b"],
])("clears working activity when %s is sent", async (_name, data) => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);
session.setActivity("working");
session.send({ type: "input", data });
expect(session.getActivity()).toBeNull();
});
it.each([
["an arrow sequence", "\x1b[A"],
["pasted text containing Ctrl-C", "before\x03after"],
["ordinary input", "hello"],
])("keeps a working terminal active for %s", async (_name, data) => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);
session.setActivity("working");
session.send({ type: "input", data });
expect(session.getActivity()).toMatchObject({ state: "working" });
});
});
// The escaping tests above assert the command line we generate. This one runs
// it: a real cmd.exe launches a real .cmd shim, which echoes the argv it was
// handed. It is the only check that proves cmd.exe's tokenizer reconstructs
// the prompt instead of executing part of it, which is the failure the
// escaping exists to prevent (the CVE-2024-27980 class).
describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () => {
const argvMarker = "__PASEO_ARGV__";
function writeArgvEchoShim(): { dir: string; cmdPath: string } {
const dir = mkdtempSync(join(tmpdir(), "paseo-cmd-shim-"));
temporaryDirs.push(dir);
const cmdPath = join(dir, "echoargv.cmd");
const scriptPath = join(dir, "echoargv.js");
writeFileSync(
scriptPath,
`process.stdout.write(${JSON.stringify(argvMarker)} + JSON.stringify(process.argv.slice(2)) + "\\n");\n`,
);
writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`);
return { dir, cmdPath };
}
function runCommandLine(command: string, args: string, cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const terminal = pty.spawn(command, args, { cwd, cols: 500, rows: 30 });
let output = "";
const dataDisposable = terminal.onData((data) => {
output += data;
});
const timeout = setTimeout(() => {
terminal.kill();
reject(new Error("Timed out waiting for cmd.exe fixture"));
}, 10_000);
timeout.unref();
terminal.onExit(({ exitCode }) => {
clearTimeout(timeout);
dataDisposable.dispose();
if (exitCode !== 0) {
reject(new Error(`cmd.exe fixture exited ${exitCode}: ${output}`));
return;
}
resolve(output);
});
});
}
async function roundTrip(promptArg: string): Promise<string[]> {
const { dir, cmdPath } = writeArgvEchoShim();
const resolved = await resolveTerminalSpawnCommand("echoargv", [promptArg], {
platform: "win32",
env: process.env,
resolveExecutable: async () => cmdPath,
});
if (typeof resolved.args !== "string") {
throw new Error(`expected a cmd.exe command line, got ${JSON.stringify(resolved.args)}`);
}
const output = await runCommandLine(resolved.command, resolved.args, dir);
const cleanOutput = stripVTControlCharacters(output);
const markerIndex = cleanOutput.lastIndexOf(argvMarker);
if (markerIndex === -1) {
throw new Error(`expected argv marker, got ${output}`);
}
const argvJson = cleanOutput.slice(markerIndex + argvMarker.length).split(/\r?\n/, 1)[0];
const argv: unknown = JSON.parse(argvJson);
if (!Array.isArray(argv) || !argv.every((arg) => typeof arg === "string")) {
throw new Error(`expected string argv, got ${output}`);
}
return argv;
}
it("passes cmd.exe metacharacters through as literal text", async () => {
expect(await roundTrip("a & b | c")).toEqual(["a & b | c"]);
expect(await roundTrip("100% done %PATH%")).toEqual(["100% done %PATH%"]);
expect(await roundTrip("a^b")).toEqual(["a^b"]);
expect(await roundTrip('say "hi"')).toEqual(['say "hi"']);
expect(await roundTrip("c:\\path\\to\\thing")).toEqual(["c:\\path\\to\\thing"]);
});
it("does not let a metacharacter start a second command", async () => {
expect(await roundTrip("safe & echo pwned")).toEqual(["safe & echo pwned"]);
});
it("normalizes newlines to spaces rather than splitting the command", async () => {
expect(await roundTrip("first\nsecond")).toEqual(["first second"]);
});
});
it.runIf(isPlatform("linux", "darwin"))(
"opens a profile command from a PATH entry relative to the terminal cwd",
async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-"));
mkdirSync(join(projectDir, "bin"));
const script = join(projectDir, "bin", "paseo-relative-path-tool");
writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n");
chmodSync(script, 0o755);
let session: TerminalSession | undefined;
try {
session = await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
command: "paseo-relative-path-tool",
env: { PATH: "bin" },
});
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
expect(session.cwd).toBe(realpathSync(projectDir));
expect(session.getExitInfo()).toBeNull();
} finally {
await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 });
rmSync(projectDir, { recursive: true, force: true });
}
},
);
it.runIf(isPlatform("linux", "darwin"))(
"checks the exact command rather than accepting a trimmed executable name",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: " node ",
}),
).rejects.toThrow(" node : command not found");
},
);