bun-pty
Version:
Cross-platform pseudoterminal (PTY) implementation for Bun with native performance
487 lines (369 loc) • 14 kB
text/typescript
import { describe, expect, test, afterEach } from "bun:test";
import { Terminal } from "./terminal";
import type { IExitEvent } from "./interfaces";
// This is an integration test file that runs tests against the actual Rust backend.
// Only run if the environment variable RUN_INTEGRATION_TESTS is set to "true"
const runIntegrationTests = process.env.RUN_INTEGRATION_TESTS === "true";
// Platform detection for cross-platform tests
const isWindows = process.platform === "win32";
// Cross-platform command helpers
const shell = isWindows ? "cmd.exe" : "sh";
const shellExecFlag = isWindows ? "/c" : "-c";
const sleepCommand = (seconds: number) =>
isWindows
? { cmd: "timeout", args: ["/t", String(seconds), "/nobreak", ">nul"] }
: { cmd: "sleep", args: [String(seconds)] };
const echoCommand = (text: string) =>
isWindows
? { cmd: "cmd.exe", args: ["/c", `echo ${text}`] }
: { cmd: "echo", args: [text] };
const exitWithCodeCommand = (code: number) =>
isWindows
? { cmd: "cmd.exe", args: ["/c", `exit ${code}`] }
: { cmd: "sh", args: ["-c", `exit ${code}`] };
describe.skipIf(!runIntegrationTests)("Integration Tests", () => {
// Keep track of terminals created so they can be cleaned up
const terminals: Terminal[] = [];
afterEach(() => {
// Clean up any terminals created during tests
for (const term of terminals) {
try {
term.kill();
} catch (e) {
// Ignore errors during cleanup
}
}
terminals.length = 0;
});
test("Terminal can spawn a real process", () => {
const { cmd, args } = sleepCommand(1);
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
expect(terminal.pid).toBeGreaterThan(0);
});
test("Terminal can receive data from a real process", async () => {
const { cmd, args } = echoCommand("Hello from Bun PTY");
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
let dataReceived = "";
let hasExited = false;
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
const timeout = isWindows ? 5000 : 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(dataReceived).toContain("Hello from Bun PTY");
});
test.skipIf(isWindows)("Terminal can send data to a real process (Unix)", async () => {
let dataReceived = "";
let hasExited = false;
// Use cat to echo back input (Unix only)
const terminal = new Terminal("cat");
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("[TEST] Sending input: Hello from Bun PTY");
terminal.write("Hello from Bun PTY\n");
await new Promise((resolve) => setTimeout(resolve, 200));
terminal.write("\x04"); // Send EOF (Ctrl+D) to close cat
const timeout = 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(dataReceived).toContain("Hello from Bun PTY");
});
test("Terminal can run interactive shell session", async () => {
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal(shell);
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
// Give the shell time to start
await new Promise((resolve) => setTimeout(resolve, isWindows ? 500 : 100));
// Send commands
if (isWindows) {
terminal.write("echo Interactive Test\r\n");
await new Promise((resolve) => setTimeout(resolve, 500));
terminal.write("exit\r\n");
} else {
terminal.write("echo Hello\n");
await new Promise((resolve) => setTimeout(resolve, 100));
terminal.write("echo World\n");
await new Promise((resolve) => setTimeout(resolve, 100));
terminal.write("exit\n");
}
const timeout = isWindows ? 5000 : 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
if (isWindows) {
expect(dataReceived).toContain("Interactive Test");
} else {
expect(dataReceived).toContain("Hello");
expect(dataReceived).toContain("World");
}
});
test("Terminal can resize a real terminal", async () => {
const { cmd, args } = sleepCommand(1);
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
// Should not throw
terminal.resize(100, 40);
expect(terminal.cols).toBe(100);
expect(terminal.rows).toBe(40);
// Wait for process to exit
await new Promise((resolve) => setTimeout(resolve, 1200));
});
test("Terminal can kill a real process", async () => {
const { cmd, args } = sleepCommand(10);
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
let exitEvent: IExitEvent | null = null;
terminal.onExit((event) => {
console.log("[TEST] Process exited with event:", event);
exitEvent = event;
});
// Give it a moment to start
await new Promise((resolve) => setTimeout(resolve, 200));
// Kill the process
terminal.kill();
const timeout = isWindows ? 5000 : 2000;
const start = Date.now();
while (!exitEvent && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(exitEvent).not.toBeNull();
});
test("Terminal can retrieve the correct process ID", () => {
const { cmd, args } = sleepCommand(5);
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
const pid = terminal.pid;
console.log("[TEST] Process ID:", pid);
expect(pid).toBeGreaterThan(0);
// Verify this PID actually exists in the system
let pidExists = false;
try {
// Sending signal 0 checks if process exists without affecting it
process.kill(pid, 0);
pidExists = true;
console.log("[TEST] Process ID exists in system");
} catch (error) {
console.error("[TEST] Error checking process:", error);
}
expect(pidExists).toBe(true);
terminal.kill();
});
test("Terminal can detect non-zero exit codes", async () => {
let exitEvent: IExitEvent | null = null;
// Run a command that exits with code 1
const { cmd, args } = isWindows
? { cmd: "cmd.exe", args: ["/c", "exit 1"] }
: { cmd: "false", args: [] as string[] };
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
terminal.onExit((event) => {
console.log("[TEST] Process exited with event:", event);
exitEvent = event;
});
const timeout = isWindows ? 5000 : 2000;
const start = Date.now();
while (!exitEvent && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(exitEvent).not.toBeNull();
const event = exitEvent!;
expect(event.exitCode).not.toBe(0);
});
test.skipIf(isWindows)("Terminal handles large output without data loss (Unix)", async () => {
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal("sh");
terminals.push(terminal);
terminal.onData((data) => {
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
// Send command to generate 1000 numbered lines
terminal.write(
'for i in $(seq 1 1000); do echo "Line $i: This is a test line to verify that no data is lost when reading from the PTY"; done\n'
);
await new Promise((resolve) => setTimeout(resolve, 2000));
terminal.write("exit\n");
const timeout = 5000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 200));
const lines = dataReceived.split("\n").filter((line) => line.includes("Line "));
console.log(`[TEST] Received ${lines.length} lines of output`);
const missingLines: number[] = [];
for (let i = 1; i <= 1000; i++) {
if (!dataReceived.includes(`Line ${i}:`)) {
missingLines.push(i);
}
}
if (missingLines.length > 0) {
console.error(`[TEST] Missing lines: ${missingLines.join(", ")}`);
}
expect(missingLines.length).toBe(0);
expect(lines.length).toBeGreaterThanOrEqual(1000);
});
test("Terminal preserves arguments with spaces correctly", async () => {
let dataReceived = "";
let hasExited = false;
const { cmd, args } = echoCommand("hello world");
const terminal = new Terminal(cmd, args);
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
const timeout = isWindows ? 5000 : 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("[TEST] Full output received:", JSON.stringify(dataReceived));
expect(dataReceived).toContain("hello world");
const matches = dataReceived.match(/hello world/g);
expect(matches).not.toBeNull();
if (matches) {
console.log(`[TEST] Found ${matches.length} occurrence(s) of "hello world"`);
}
});
test.skipIf(isWindows)("Terminal preserves arguments with special characters correctly (Unix)", async () => {
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal("echo", ["file name (1).txt"]);
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
const timeout = 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("[TEST] Full output received:", JSON.stringify(dataReceived));
expect(dataReceived).toContain("file name (1).txt");
});
test.skipIf(isWindows)("Terminal handles Windows paths with spaces (Windows)", async () => {
// This test name is misleading but kept for compatibility - it tests paths with spaces
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal("echo", ["C:\\Program Files\\Test App"]);
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
const timeout = 2000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(dataReceived).toContain("Program Files");
});
// Windows-specific: Test PowerShell
test.skipIf(!isWindows)("Terminal receives output from PowerShell", async () => {
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal("powershell.exe", [
"-Command",
"Write-Output 'Hello from PowerShell'",
]);
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
// PowerShell startup can be slow
const timeout = 10000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 200));
expect(dataReceived).toContain("Hello from PowerShell");
});
// Windows-specific: Test environment variables
test.skipIf(!isWindows)("Terminal passes environment variables on Windows", async () => {
let dataReceived = "";
let hasExited = false;
const terminal = new Terminal("cmd.exe", ["/c", "echo %TEST_VAR%"], {
name: "xterm",
env: {
TEST_VAR: "HelloFromEnv",
},
});
terminals.push(terminal);
terminal.onData((data) => {
console.log("[TEST] Received data:", data);
dataReceived += data;
});
terminal.onExit(() => {
console.log("[TEST] Process exited");
hasExited = true;
});
const timeout = 5000;
const start = Date.now();
while (!hasExited && Date.now() - start < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 200));
expect(dataReceived).toContain("HelloFromEnv");
});
});