UNPKG

bun-pty

Version:

Cross-platform pseudoterminal (PTY) implementation for Bun with native performance

487 lines (369 loc) 14 kB
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"); }); });