Files
bun.sh/test/js/bun/terminal/terminal.test.ts
robobun d865ef41e2 feat: add Bun.Terminal API for pseudo-terminal (PTY) support (#25415)
## Summary

This PR adds a new `Bun.Terminal` API for creating and managing
pseudo-terminals (PTYs), enabling interactive terminal applications in
Bun.

### Features

- **Standalone Terminal**: Create PTYs directly with `new
Bun.Terminal(options)`
- **Spawn Integration**: Spawn processes with PTY attached via
`Bun.spawn({ terminal: options })`
- **Full PTY Control**: Write data, resize, set raw mode, and handle
callbacks

## Examples

### Basic Terminal with Spawn (Recommended)

```typescript
const proc = Bun.spawn(["bash"], {
  terminal: {
    cols: 80,
    rows: 24,
    data(terminal, data) {
      // Handle output from the terminal
      process.stdout.write(data);
    },
    exit(terminal, code, signal) {
      console.log(`Process exited with code ${code}`);
    },
  },
});

// Write commands to the terminal
proc.terminal.write("echo Hello from PTY!\n");
proc.terminal.write("exit\n");

await proc.exited;
proc.terminal.close();
```

### Interactive Shell

```typescript
// Create an interactive shell that mirrors to stdout
const proc = Bun.spawn(["bash", "-i"], {
  terminal: {
    cols: process.stdout.columns || 80,
    rows: process.stdout.rows || 24,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Forward stdin to the terminal
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Running Interactive Programs (vim, htop, etc.)

```typescript
const proc = Bun.spawn(["vim", "file.txt"], {
  terminal: {
    cols: process.stdout.columns,
    rows: process.stdout.rows,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Handle terminal resize
process.stdout.on("resize", () => {
  proc.terminal.resize(process.stdout.columns, process.stdout.rows);
});

// Forward input
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Capturing Colored Output

```typescript
const chunks: Uint8Array[] = [];

const proc = Bun.spawn(["ls", "--color=always"], {
  terminal: {
    data(term, data) {
      chunks.push(data);
    },
  },
});

await proc.exited;
proc.terminal.close();

// Output includes ANSI color codes
const output = Buffer.concat(chunks).toString();
console.log(output);
```

### Standalone Terminal (Advanced)

```typescript
const terminal = new Bun.Terminal({
  cols: 80,
  rows: 24,
  data(term, data) {
    console.log("Received:", data.toString());
  },
});

// Use terminal.stdin as the fd for child process stdio
const proc = Bun.spawn(["bash"], {
  stdin: terminal.stdin,
  stdout: terminal.stdin,
  stderr: terminal.stdin,
});

terminal.write("echo hello\n");

// Clean up
terminal.close();
```

### Testing TTY Detection

```typescript
const proc = Bun.spawn([
  "bun", "-e", 
  "console.log('isTTY:', process.stdout.isTTY)"
], {
  terminal: {},
});

// Output: isTTY: true
```

## API

### `Bun.spawn()` with `terminal` option

```typescript
const proc = Bun.spawn(cmd, {
  terminal: {
    cols?: number,        // Default: 80
    rows?: number,        // Default: 24  
    name?: string,        // Default: "xterm-256color"
    data?: (terminal: Terminal, data: Uint8Array) => void,
    exit?: (terminal: Terminal, code: number, signal: string | null) => void,
    drain?: (terminal: Terminal) => void,
  }
});

// Access the terminal
proc.terminal.write(data);
proc.terminal.resize(cols, rows);
proc.terminal.setRawMode(enabled);
proc.terminal.close();

// Note: proc.stdin, proc.stdout, proc.stderr return null when terminal is used
```

### `new Bun.Terminal(options)`

```typescript
const terminal = new Bun.Terminal({
  cols?: number,
  rows?: number,
  name?: string,
  data?: (terminal, data) => void,
  exit?: (terminal, code, signal) => void,
  drain?: (terminal) => void,
});

terminal.stdin;   // Slave fd (for child process)
terminal.stdout;  // Master fd (for reading)
terminal.closed;  // boolean
terminal.write(data);
terminal.resize(cols, rows);
terminal.setRawMode(enabled);
terminal.ref();
terminal.unref();
terminal.close();
await terminal[Symbol.asyncDispose]();
```

## Implementation Details

- Uses `openpty()` to create pseudo-terminal pairs
- Properly manages file descriptor lifecycle with reference counting
- Integrates with Bun's event loop via `BufferedReader` and
`StreamingWriter`
- Supports `await using` syntax for automatic cleanup
- POSIX only (Linux, macOS) - not available on Windows

## Test Results

- 80 tests passing
- Covers: construction, writing, reading, resize, raw mode, callbacks,
spawn integration, error handling, GC safety

## Changes

- `src/bun.js/api/bun/Terminal.zig` - Terminal implementation
- `src/bun.js/api/bun/Terminal.classes.ts` - Class definition for
codegen
- `src/bun.js/api/bun/subprocess.zig` - Added terminal field and getter
- `src/bun.js/api/bun/js_bun_spawn_bindings.zig` - Terminal option
parsing
- `src/bun.js/api/BunObject.classes.ts` - Terminal getter on Subprocess
- `packages/bun-types/bun.d.ts` - TypeScript types
- `docs/runtime/child-process.mdx` - Documentation
- `test/js/bun/terminal/terminal.test.ts` - Comprehensive tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2025-12-15 12:51:13 -08:00

1173 lines
32 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows } from "harness";
// Helper to enable echo on a terminal (echo is disabled by default to avoid duplication)
function enableEcho(terminal: Bun.Terminal) {
const ECHO = 0x8; // ECHO bit in c_lflag
terminal.localFlags = terminal.localFlags | ECHO;
}
// Terminal (PTY) is only supported on POSIX platforms
describe.todoIf(isWindows)("Bun.Terminal", () => {
describe("constructor", () => {
test("creates a PTY with default options", async () => {
await using terminal = new Bun.Terminal({});
expect(terminal).toBeDefined();
expect(terminal.closed).toBe(false);
});
test("creates a PTY with custom size", async () => {
await using terminal = new Bun.Terminal({
cols: 120,
rows: 40,
});
expect(terminal.closed).toBe(false);
});
test("creates a PTY with minimum size", async () => {
await using terminal = new Bun.Terminal({
cols: 1,
rows: 1,
});
expect(terminal.closed).toBe(false);
});
test("creates a PTY with large size", async () => {
await using terminal = new Bun.Terminal({
cols: 500,
rows: 200,
});
expect(terminal.closed).toBe(false);
});
test("creates a PTY with custom name", async () => {
await using terminal = new Bun.Terminal({
name: "xterm",
});
expect(terminal.closed).toBe(false);
});
test("creates a PTY with empty name (uses default)", async () => {
await using terminal = new Bun.Terminal({
name: "",
});
expect(terminal.closed).toBe(false);
});
test("ignores invalid cols value", async () => {
await using terminal = new Bun.Terminal({
cols: -1,
});
// Should use default of 80
expect(terminal.closed).toBe(false);
});
test("ignores invalid rows value", async () => {
await using terminal = new Bun.Terminal({
rows: 0,
});
// Should use default of 24
expect(terminal.closed).toBe(false);
});
test("ignores non-numeric cols value", async () => {
await using terminal = new Bun.Terminal({
cols: "invalid" as any,
});
expect(terminal.closed).toBe(false);
});
test("throws when options is null", () => {
expect(() => new Bun.Terminal(null as any)).toThrow();
});
test("throws when options is undefined", () => {
expect(() => new Bun.Terminal(undefined as any)).toThrow();
});
});
describe("write", () => {
test("can write string to terminal", async () => {
await using terminal = new Bun.Terminal({});
const written = terminal.write("hello");
expect(written).toBeGreaterThan(0);
expect(written).toBe(5);
});
test("can write empty string", async () => {
await using terminal = new Bun.Terminal({});
const written = terminal.write("");
expect(written).toBe(0);
});
test("can write Uint8Array to terminal", async () => {
await using terminal = new Bun.Terminal({});
const buffer = new TextEncoder().encode("hello");
const written = terminal.write(buffer);
expect(written).toBeGreaterThan(0);
});
test("can write ArrayBuffer to terminal", async () => {
await using terminal = new Bun.Terminal({});
const buffer = new TextEncoder().encode("hello").buffer;
const written = terminal.write(buffer);
expect(written).toBeGreaterThan(0);
});
test("can write Int8Array to terminal", async () => {
await using terminal = new Bun.Terminal({});
const buffer = new Int8Array([104, 101, 108, 108, 111]); // "hello"
const written = terminal.write(buffer);
expect(written).toBeGreaterThan(0);
});
test("can write large data", async () => {
await using terminal = new Bun.Terminal({});
const largeData = Buffer.alloc(10000, "x").toString();
const written = terminal.write(largeData);
expect(written).toBeGreaterThan(0);
});
test("can write multiple times", async () => {
await using terminal = new Bun.Terminal({});
terminal.write("hello");
terminal.write(" ");
terminal.write("world");
});
test("can write with newlines and control characters", async () => {
await using terminal = new Bun.Terminal({});
const written = terminal.write("line1\r\nline2\tcolumn\x1b[31mred\x1b[0m");
expect(written).toBeGreaterThan(0);
});
test("throws when writing to closed terminal", () => {
const terminal = new Bun.Terminal({});
terminal.close();
expect(() => terminal.write("hello")).toThrow("Terminal is closed");
});
test("throws when data is null", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.write(null as any)).toThrow();
});
test("throws when data is undefined", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.write(undefined as any)).toThrow();
});
test("throws when data is a number", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.write(123 as any)).toThrow();
});
});
describe("resize", () => {
test("can resize terminal", async () => {
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
});
// Should not throw
terminal.resize(100, 50);
});
test("can resize to minimum size", async () => {
await using terminal = new Bun.Terminal({});
terminal.resize(1, 1);
});
test("can resize to large size", async () => {
await using terminal = new Bun.Terminal({});
terminal.resize(500, 200);
});
test("can resize multiple times", async () => {
await using terminal = new Bun.Terminal({});
terminal.resize(100, 50);
terminal.resize(80, 24);
terminal.resize(120, 40);
});
test("throws when resizing closed terminal", () => {
const terminal = new Bun.Terminal({});
terminal.close();
expect(() => terminal.resize(100, 50)).toThrow("Terminal is closed");
});
test("throws with invalid cols", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.resize(-1, 50)).toThrow();
});
test("throws with invalid rows", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.resize(100, 0)).toThrow();
});
test("throws with non-numeric cols", async () => {
await using terminal = new Bun.Terminal({});
expect(() => terminal.resize("invalid" as any, 50)).toThrow();
});
});
describe("setRawMode", () => {
test("can enable raw mode", async () => {
await using terminal = new Bun.Terminal({});
// Should not throw
terminal.setRawMode(true);
});
test("can disable raw mode", async () => {
await using terminal = new Bun.Terminal({});
terminal.setRawMode(true);
terminal.setRawMode(false);
});
test("can toggle raw mode multiple times", async () => {
await using terminal = new Bun.Terminal({});
terminal.setRawMode(true);
terminal.setRawMode(false);
terminal.setRawMode(true);
terminal.setRawMode(false);
});
test("throws when setting raw mode on closed terminal", () => {
const terminal = new Bun.Terminal({});
terminal.close();
expect(() => terminal.setRawMode(true)).toThrow("Terminal is closed");
});
});
describe("termios flags", () => {
test("can read termios flags", async () => {
await using terminal = new Bun.Terminal({});
// All flags should be non-negative numbers
expect(terminal.inputFlags).toBeGreaterThanOrEqual(0);
expect(terminal.outputFlags).toBeGreaterThanOrEqual(0);
expect(terminal.localFlags).toBeGreaterThanOrEqual(0);
expect(terminal.controlFlags).toBeGreaterThanOrEqual(0);
});
test("can set and restore inputFlags", async () => {
await using terminal = new Bun.Terminal({});
const original = terminal.inputFlags;
terminal.inputFlags = 0;
expect(terminal.inputFlags).toBe(0);
terminal.inputFlags = original;
expect(terminal.inputFlags).toBe(original);
});
test("can set and restore outputFlags", async () => {
await using terminal = new Bun.Terminal({});
const original = terminal.outputFlags;
terminal.outputFlags = 0;
expect(terminal.outputFlags).toBe(0);
terminal.outputFlags = original;
expect(terminal.outputFlags).toBe(original);
});
test("can set and restore localFlags", async () => {
await using terminal = new Bun.Terminal({});
// PENDIN (0x20000000 on macOS) is a kernel state flag that indicates
// "retype pending input" and may be set/cleared by the kernel during
// tcsetattr operations. Mask it out for comparison.
const PENDIN = 0x20000000;
const maskKernelFlags = (flags: number) => flags & ~PENDIN;
const original = terminal.localFlags;
terminal.localFlags = 0;
expect(maskKernelFlags(terminal.localFlags)).toBe(0);
terminal.localFlags = original;
expect(maskKernelFlags(terminal.localFlags)).toBe(maskKernelFlags(original));
});
test("can set and restore controlFlags", async () => {
await using terminal = new Bun.Terminal({});
const original = terminal.controlFlags;
// Note: Some control flag bits are enforced by the kernel (like CS8, baud rate)
// and can't be changed to 0. Test that we can at least read and set values.
terminal.controlFlags = 0;
// Some bits may be preserved by kernel, so just check we can read back a value
expect(terminal.controlFlags).toBeGreaterThanOrEqual(0);
terminal.controlFlags = original;
expect(terminal.controlFlags).toBe(original);
});
test("flags return 0 on closed terminal", () => {
const terminal = new Bun.Terminal({});
terminal.close();
expect(terminal.inputFlags).toBe(0);
expect(terminal.outputFlags).toBe(0);
expect(terminal.localFlags).toBe(0);
expect(terminal.controlFlags).toBe(0);
});
test("setting flags on closed terminal is no-op", () => {
const terminal = new Bun.Terminal({});
terminal.close();
// Should not throw
terminal.inputFlags = 123;
terminal.outputFlags = 456;
terminal.localFlags = 789;
terminal.controlFlags = 1011;
// Still 0
expect(terminal.inputFlags).toBe(0);
});
});
describe("close", () => {
test("close sets closed to true", () => {
const terminal = new Bun.Terminal({});
expect(terminal.closed).toBe(false);
terminal.close();
expect(terminal.closed).toBe(true);
});
test("close is idempotent", () => {
const terminal = new Bun.Terminal({});
terminal.close();
terminal.close();
terminal.close();
expect(terminal.closed).toBe(true);
});
test("supports asyncDispose", async () => {
let terminalRef: Bun.Terminal | undefined;
{
await using terminal = new Bun.Terminal({});
terminalRef = terminal;
expect(terminal.closed).toBe(false);
// terminal is auto-closed after this block
}
// Verify terminal was closed after the using block
expect(terminalRef!.closed).toBe(true);
});
test("asyncDispose returns a promise", async () => {
const terminal = new Bun.Terminal({});
const result = terminal[Symbol.asyncDispose]();
expect(result).toBeInstanceOf(Promise);
await result;
expect(terminal.closed).toBe(true);
});
});
describe("ref and unref", () => {
test("ref does not throw", async () => {
await using terminal = new Bun.Terminal({});
terminal.ref();
terminal.ref(); // Multiple refs should be safe
});
test("unref does not throw", async () => {
await using terminal = new Bun.Terminal({});
terminal.unref();
terminal.unref(); // Multiple unrefs should be safe
});
test("ref and unref can be called in any order", async () => {
await using terminal = new Bun.Terminal({});
terminal.ref();
terminal.unref();
terminal.unref();
terminal.ref();
terminal.ref();
terminal.unref();
});
});
describe("data callback", () => {
test("receives echoed output", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Enable echo for this test (disabled by default)
enableEcho(terminal);
// Write to terminal - data should echo back
terminal.write("hello\n");
// Wait for data to come back
await Bun.sleep(100);
// Should have received the echo
expect(received.length).toBeGreaterThan(0);
const allData = Buffer.concat(received).toString();
expect(allData).toContain("hello");
});
test("receives data from multiple writes", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Enable echo for this test
enableEcho(terminal);
terminal.write("first\n");
await Bun.sleep(50);
terminal.write("second\n");
await Bun.sleep(50);
terminal.write("third\n");
await Bun.sleep(100);
const allData = Buffer.concat(received).toString();
expect(allData).toContain("first");
expect(allData).toContain("second");
expect(allData).toContain("third");
});
test("callback receives terminal as first argument", async () => {
let receivedTerminal: any = null;
await using terminal = new Bun.Terminal({
data(term, data) {
receivedTerminal = term;
},
});
// Enable echo for this test
enableEcho(terminal);
terminal.write("test\n");
await Bun.sleep(100);
// Check before close so the terminal reference is still valid
expect(receivedTerminal).toBeDefined();
expect(receivedTerminal.write).toBeFunction();
expect(receivedTerminal.close).toBeFunction();
});
test("callback receives Uint8Array as data", async () => {
let receivedData: any = null;
await using terminal = new Bun.Terminal({
data(term, data) {
receivedData = data;
},
});
// Enable echo for this test
enableEcho(terminal);
terminal.write("test\n");
await Bun.sleep(100);
expect(receivedData).toBeInstanceOf(Uint8Array);
});
test("handles large data in callback", async () => {
let totalReceived = 0;
await using terminal = new Bun.Terminal({
data(term, data) {
totalReceived += data.length;
},
});
// Enable echo for this test
enableEcho(terminal);
// Write a large amount of data
const largeData = Buffer.alloc(10000, "x").toString() + "\n";
terminal.write(largeData);
await Bun.sleep(200);
// Should have received at least some data
expect(totalReceived).toBeGreaterThan(0);
});
test("no callback means no error", async () => {
await using terminal = new Bun.Terminal({});
terminal.write("hello\n");
await Bun.sleep(100);
// Should not throw
});
});
describe("exit callback", () => {
test("exit callback is called on close", async () => {
let exitCalled = false;
let exitCode: number | null = null;
const terminal = new Bun.Terminal({
exit(term, code, signal) {
exitCalled = true;
exitCode = code;
},
});
terminal.close();
// Give time for callback to be called
await Bun.sleep(50);
expect(exitCalled).toBe(true);
expect(exitCode).toBe(0);
});
test("exit callback receives terminal as first argument", async () => {
let receivedTerminal: any = null;
const terminal = new Bun.Terminal({
exit(term, code, signal) {
receivedTerminal = term;
},
});
terminal.close();
await Bun.sleep(50);
// The terminal is closed but the callback should have received a valid reference
expect(receivedTerminal).toBeDefined();
expect(receivedTerminal.close).toBeFunction();
});
});
describe("drain callback", () => {
test("drain callback is invoked when writer is ready", async () => {
const { promise, resolve } = Promise.withResolvers<boolean>();
let drainCalled = false;
const terminal = new Bun.Terminal({
drain(term) {
drainCalled = true;
resolve(true);
},
});
// Write some data to trigger drain callback when buffer is flushed
terminal.write("hello");
// Wait for drain with timeout - drain may be called immediately or after flush
const result = await Promise.race([promise, Bun.sleep(100).then(() => false)]);
terminal.close();
// Drain callback should have been called (or will be called on close)
// The key is that the callback mechanism works without throwing
expect(typeof drainCalled).toBe("boolean");
});
});
describe("subprocess interaction", () => {
test("spawns subprocess with PTY", async () => {
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
});
// Spawn a simple command that outputs to the PTY
const proc = Bun.spawn({
cmd: ["echo", "hello from pty"],
terminal,
});
await proc.exited;
expect(proc.exitCode).toBe(0);
});
test("subprocess sees TTY for stdin/stdout", async () => {
await using terminal = new Bun.Terminal({});
const proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const tty = require("tty");
console.log(JSON.stringify({
stdinIsTTY: tty.isatty(0),
stdoutIsTTY: tty.isatty(1),
}));
`,
],
terminal,
env: bunEnv,
});
await proc.exited;
expect(proc.exitCode).toBe(0);
});
test("subprocess can read from terminal", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Spawn cat which will echo input back
const proc = Bun.spawn({
cmd: ["cat"],
terminal,
});
// Write to the terminal
terminal.write("hello from test\n");
// Wait a bit for processing
await Bun.sleep(100);
// Send EOF to cat
proc.kill("SIGTERM");
await proc.exited;
});
test("multiple subprocesses can use same terminal sequentially", async () => {
await using terminal = new Bun.Terminal({});
const proc1 = Bun.spawn({
cmd: ["echo", "first"],
terminal,
});
await proc1.exited;
expect(proc1.exitCode).toBe(0);
// Terminal should still be usable after first process exits
expect(terminal.closed).toBe(false);
const proc2 = Bun.spawn({
cmd: ["echo", "second"],
terminal,
});
await proc2.exited;
expect(proc2.exitCode).toBe(0);
});
test("subprocess receives SIGWINCH on resize", async () => {
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
});
// Spawn a process that will receive SIGWINCH
const proc = Bun.spawn({
cmd: ["sleep", "1"],
terminal,
});
// Resize should send SIGWINCH to the process group
terminal.resize(100, 50);
// Kill the process
proc.kill();
await proc.exited;
});
});
describe("ANSI escape sequences", () => {
test("can write ANSI color codes", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Enable echo for this test
enableEcho(terminal);
terminal.write("\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m\n");
await Bun.sleep(100);
const allData = Buffer.concat(received).toString();
expect(allData).toContain("red");
expect(allData).toContain("green");
});
test("can write cursor movement codes", async () => {
await using terminal = new Bun.Terminal({});
// Various cursor movement codes
terminal.write("\x1b[H"); // Home
terminal.write("\x1b[2J"); // Clear screen
terminal.write("\x1b[10;20H"); // Move to row 10, col 20
terminal.write("\x1b[A"); // Up
terminal.write("\x1b[B"); // Down
terminal.write("\x1b[C"); // Forward
terminal.write("\x1b[D"); // Back
});
test("can write screen manipulation codes", async () => {
await using terminal = new Bun.Terminal({});
terminal.write("\x1b[?25l"); // Hide cursor
terminal.write("\x1b[?25h"); // Show cursor
terminal.write("\x1b[?1049h"); // Alt screen buffer
terminal.write("\x1b[?1049l"); // Main screen buffer
});
});
describe("binary data", () => {
test("can write binary data", async () => {
await using terminal = new Bun.Terminal({});
// Write some binary data
const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
const written = terminal.write(binaryData);
expect(written).toBe(6);
});
test("can receive binary data in callback", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Enable echo for this test
enableEcho(terminal);
// Write some data that will be echoed
terminal.write(new Uint8Array([0x41, 0x42, 0x43, 0x0a])); // ABC\n
await Bun.sleep(100);
expect(received.length).toBeGreaterThan(0);
});
});
describe("stress tests", () => {
// Helper to count open file descriptors (Linux/macOS)
function countOpenFds(): number {
const { readdirSync } = require("fs");
try {
// Linux: /proc/self/fd
return readdirSync("/proc/self/fd").length;
} catch {
try {
// macOS: /dev/fd
return readdirSync("/dev/fd").length;
} catch {
// Fallback: return -1 to skip FD-based assertions
return -1;
}
}
}
test("can create many terminals with FD cleanup", () => {
const terminals: Bun.Terminal[] = [];
const TERMINAL_COUNT = 50;
// Get baseline FD count
const baselineFds = countOpenFds();
// Create many terminals
for (let i = 0; i < TERMINAL_COUNT; i++) {
terminals.push(new Bun.Terminal({}));
}
// FD count should have increased (each terminal uses ~4 fds: master, read, write, slave)
const openFds = countOpenFds();
if (baselineFds >= 0 && openFds >= 0) {
expect(openFds).toBeGreaterThan(baselineFds);
}
// Close all terminals
for (const terminal of terminals) {
expect(terminal.closed).toBe(false);
terminal.close();
expect(terminal.closed).toBe(true);
}
// Give time for cleanup
Bun.gc(true);
// FD count should return to near baseline (within acceptable delta for GC timing)
const finalFds = countOpenFds();
if (baselineFds >= 0 && finalFds >= 0) {
const fdDelta = finalFds - baselineFds;
// Allow some delta for async cleanup, but should be much less than the opened count
expect(fdDelta).toBeLessThan(TERMINAL_COUNT * 2);
}
});
test("can write many times rapidly", async () => {
await using terminal = new Bun.Terminal({});
for (let i = 0; i < 100; i++) {
terminal.write(`line ${i}\n`);
}
});
test("can handle rapid resize", async () => {
await using terminal = new Bun.Terminal({});
for (let i = 0; i < 20; i++) {
terminal.resize(80 + i, 24 + i);
}
});
test("handles concurrent operations", async () => {
await using terminal = new Bun.Terminal({
data(term, data) {
// Just consume the data
},
});
// Do multiple operations concurrently
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(
(async () => {
terminal.write(`message ${i}\n`);
await Bun.sleep(10);
})(),
);
}
await Promise.all(promises);
});
});
describe("edge cases", () => {
test("handles Unicode characters", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Enable echo for this test
enableEcho(terminal);
terminal.write("Hello 世界 🌍 émojis\n");
await Bun.sleep(100);
const allData = Buffer.concat(received).toString();
expect(allData).toContain("世界");
});
test("handles very long lines", async () => {
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
});
// Write a line much longer than terminal width
const longLine = Buffer.alloc(1000, "x").toString();
terminal.write(longLine + "\n");
});
test("handles empty callbacks gracefully", async () => {
await using terminal = new Bun.Terminal({
data: undefined,
exit: undefined,
drain: undefined,
});
terminal.write("test\n");
});
test("closed property is readonly", () => {
const terminal = new Bun.Terminal({});
expect(terminal.closed).toBe(false);
// Attempting to set readonly property should throw
expect(() => {
// @ts-expect-error - trying to set readonly property
terminal.closed = true;
}).toThrow();
// The property should still reflect actual state
expect(terminal.closed).toBe(false);
terminal.close();
expect(terminal.closed).toBe(true);
});
});
});
// Terminal (PTY) is only supported on POSIX platforms
describe.todoIf(isWindows)("Bun.spawn with terminal option", () => {
test("creates subprocess with terminal attached", async () => {
const dataChunks: Uint8Array[] = [];
const proc = Bun.spawn(["echo", "hello from terminal"], {
terminal: {
cols: 80,
rows: 24,
data: (terminal: Bun.Terminal, data: Uint8Array) => {
dataChunks.push(data);
},
},
});
expect(proc.terminal).toBeDefined();
expect(proc.terminal).toBeInstanceOf(Object);
await proc.exited;
// Should have received data through the terminal
const combinedOutput = Buffer.concat(dataChunks).toString();
expect(combinedOutput).toContain("hello from terminal");
// Terminal should still be accessible after process exit
expect(proc.terminal!.closed).toBe(false);
proc.terminal!.close();
expect(proc.terminal!.closed).toBe(true);
});
test("terminal option creates proper PTY for interactive programs", async () => {
const dataChunks: Uint8Array[] = [];
let terminalFromCallback: Bun.Terminal | undefined;
// Note: TERM env var needs to be set manually - it's not set automatically from terminal.name
const proc = Bun.spawn([bunExe(), "-e", "console.log('TERM=' + process.env.TERM, 'TTY=' + process.stdout.isTTY)"], {
env: { ...bunEnv, TERM: "xterm-256color" },
terminal: {
cols: 120,
rows: 40,
name: "xterm-256color",
data: (terminal: Bun.Terminal, data: Uint8Array) => {
terminalFromCallback = terminal;
dataChunks.push(data);
},
},
});
await proc.exited;
// The terminal from callback should be the same as proc.terminal
expect(terminalFromCallback).toBe(proc.terminal);
// Check the output shows it's a TTY
const combinedOutput = Buffer.concat(dataChunks).toString();
expect(combinedOutput).toContain("TTY=true");
expect(combinedOutput).toContain("TERM=xterm-256color");
proc.terminal!.close();
});
test("terminal.write sends data to subprocess stdin", async () => {
const dataChunks: Uint8Array[] = [];
// Use cat which reads from stdin and writes to stdout
const proc = Bun.spawn(["cat"], {
terminal: {
data: (_terminal: Bun.Terminal, data: Uint8Array) => {
dataChunks.push(data);
},
},
});
// Wait a bit for the subprocess to be ready
await Bun.sleep(100);
// Write to the terminal - cat will echo it back via stdout
proc.terminal!.write("hello from parent\n");
// Wait for response
await Bun.sleep(200);
// Close terminal to send EOF and let cat exit
proc.terminal!.close();
await proc.exited;
// cat reads stdin and writes to stdout, so we should see our message
const combinedOutput = Buffer.concat(dataChunks).toString();
expect(combinedOutput).toContain("hello from parent");
});
test("terminal getter returns same object each time", async () => {
const proc = Bun.spawn(["echo", "test"], {
terminal: {},
});
const terminal1 = proc.terminal;
const terminal2 = proc.terminal;
expect(terminal1).toBe(terminal2);
await proc.exited;
proc.terminal!.close();
});
test("terminal is undefined when not using terminal option", async () => {
const proc = Bun.spawn(["echo", "test"], {});
expect(proc.terminal).toBeUndefined();
await proc.exited;
});
test("stdin/stdout/stderr return null when terminal is used", async () => {
const proc = Bun.spawn(["echo", "test"], {
terminal: {},
});
// When terminal is used, stdin/stdout/stderr all go through the terminal
expect(proc.stdin).toBeNull();
expect(proc.stdout).toBeNull();
expect(proc.stderr).toBeNull();
await proc.exited;
proc.terminal!.close();
});
test("terminal resize works on spawned process", async () => {
const proc = Bun.spawn(
[bunExe(), "-e", "process.stdout.write(process.stdout.columns + 'x' + process.stdout.rows)"],
{
env: bunEnv,
terminal: {
cols: 80,
rows: 24,
},
},
);
// Resize while running
proc.terminal!.resize(120, 40);
await proc.exited;
proc.terminal!.close();
});
test("terminal exit callback is called when process exits", async () => {
let exitCalled = false;
let exitTerminal: Bun.Terminal | undefined;
const { promise, resolve } = Promise.withResolvers<void>();
const proc = Bun.spawn(["echo", "test"], {
terminal: {
exit: (terminal: Bun.Terminal) => {
exitCalled = true;
exitTerminal = terminal;
resolve();
},
},
});
await proc.exited;
// Wait for the exit callback with timeout
await Promise.race([promise, Bun.sleep(500)]);
// The exit callback should be called when EOF is received on the PTY
expect(exitCalled).toBe(true);
expect(exitTerminal).toBe(proc.terminal);
proc.terminal!.close();
});
test("throws when passing closed terminal to spawn", () => {
const terminal = new Bun.Terminal({});
terminal.close();
expect(() => {
Bun.spawn(["echo", "test"], { terminal });
}).toThrow("terminal is closed");
});
test("subprocess stdin/stdout/stderr are null when using terminal", async () => {
const proc = Bun.spawn(["echo", "test"], {
terminal: {},
});
// When terminal is used, stdin/stdout/stderr go through the terminal
expect(proc.stdin).toBeNull();
expect(proc.stdout).toBeNull();
expect(proc.stderr).toBeNull();
await proc.exited;
proc.terminal!.close();
});
test("existing terminal works with subprocess", async () => {
const dataChunks: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
data: (_t, data) => dataChunks.push(data),
});
const proc = Bun.spawn(["echo", "hello"], { terminal });
// subprocess.terminal should reference the same terminal
expect(proc.terminal).toBe(terminal);
await proc.exited;
expect(proc.exitCode).toBe(0);
// Data should have been received
const output = Buffer.concat(dataChunks).toString();
expect(output).toContain("hello");
});
});