Skip to content

Playwright-like automation framework for terminal TUI applications

License

Notifications You must be signed in to change notification settings

fcoury/termwright

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Termwright

A Playwright-like automation framework for terminal TUI applications.

Termwright enables AI agents and integration tests to interact with and observe terminal user interfaces by wrapping applications in a pseudo-terminal (PTY).

Features

  • PTY Wrapping: Spawn and control any terminal application
  • Screen Reading: Access text, colors, cursor position, and cell attributes
  • Wait Conditions: Wait for text, regex patterns, screen stability, or process exit
  • Input Simulation: Send keystrokes, special keys, and control sequences
  • Multiple Output Formats: Plain text, JSON (for AI agents), and PNG screenshots
  • Box Detection: Detect UI boundaries using box-drawing characters
  • Framework Agnostic: Works with any TUI framework (ratatui, crossterm, ncurses, etc.)

Installation

Add to your Cargo.toml:

[dependencies]
termwright = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Or install the CLI:

cargo install termwright

Quick Start

Library Usage

use termwright::prelude::*;

#[tokio::main]
async fn main() -> Result<()> {
    // Spawn a terminal application
    let term = Terminal::builder()
        .size(80, 24)
        .spawn("vim", &["test.txt"])
        .await?;

    // Wait for the application to be ready
    term.expect("VIM")
        .timeout(Duration::from_secs(5))
        .await?;

    // Send input
    term.send_key(Key::Char('i')).await?;
    term.type_str("Hello, world!").await?;
    term.send_key(Key::Escape).await?;

    // Query screen state
    let screen = term.screen().await;
    assert!(screen.contains("Hello, world!"));

    // Get structured output for AI agents
    println!("{}", screen.to_json()?);

    // Take a screenshot
    term.screenshot().await.save("vim.png")?;

    // Quit the application
    term.type_str(":q!").await?;
    term.enter().await?;
    term.wait_exit().await?;

    Ok(())
}

CLI Usage

Capture terminal output as text:

termwright run -- ls -la

Take a screenshot of a TUI application:

termwright screenshot --wait-for "VIM" -o vim.png -- vim test.txt

Get JSON output for AI processing:

termwright run --format json -- htop

Daemon Usage

The daemon subcommand runs a long-lived terminal session and exposes a local Unix socket for automation. This is useful when you want to keep an app running and interact with it incrementally (similar to how Playwright keeps a browser process alive).

Start a daemon (foreground; blocks until you close it):

termwright daemon -- vim test.txt
# prints a socket path like:
# /tmp/termwright-12345.sock

Start a daemon in the background (returns immediately):

SOCK=$(termwright daemon --background -- vim test.txt)
echo "$SOCK"

Stop a daemon (sends a close request):

printf '{"id":1,"method":"close","params":null}\n' | nc -U "$SOCK"

Shell Scripting Quick Start

The daemon mode makes termwright ideal for shell-based E2E testing of TUI applications. Here's how to get started:

Prerequisites

# Install termwright
cargo install termwright

# Install helper tools
brew install socat jq  # macOS
# or: sudo apt-get install socat jq  # Ubuntu/Debian

Basic Pattern

#!/bin/bash
set -euo pipefail

# 1. Start the daemon with your TUI app
SOCK="/tmp/my-test-$$.sock"
termwright daemon --socket "$SOCK" --cols 80 --rows 24 -- ./my-tui-app &

# Wait for socket
while [ ! -S "$SOCK" ]; do sleep 0.1; done

# 2. Helper function to send commands
tw() {
    echo "$1" | socat - UNIX-CONNECT:"$SOCK"
}

# 3. Wait for app to be ready
tw '{"id":1,"method":"wait_for_text","params":{"text":"Welcome","timeout_ms":5000}}'

# 4. Interact with the app
tw '{"id":2,"method":"press","params":{"key":"Enter"}}'
tw '{"id":3,"method":"type","params":{"text":"hello world"}}'
tw '{"id":4,"method":"hotkey","params":{"ctrl":true,"ch":"s"}}'  # Ctrl+S

# 5. Read screen content
SCREEN=$(tw '{"id":5,"method":"screen","params":{"format":"text"}}' | jq -r '.result')
echo "$SCREEN"

# 6. Take a screenshot
RESULT=$(tw '{"id":6,"method":"screenshot","params":{}}')
echo "$RESULT" | jq -r '.result.png_base64' | base64 -d > screenshot.png

# 7. Clean up
tw '{"id":99,"method":"close","params":null}'

Available Daemon Commands

Method Params Description
handshake null Get daemon info (pid, version)
screen {"format":"text"|"json"} Get current screen content
screenshot {} Get PNG screenshot as base64
press {"key":"Enter"} Press a key (Enter, Escape, Tab, Up, Down, etc.)
type {"text":"..."} Type text
hotkey {"ctrl":true,"ch":"c"} Send Ctrl/Alt combinations
wait_for_text {"text":"...","timeout_ms":5000} Wait for text to appear
wait_for_idle {"idle_ms":500,"timeout_ms":5000} Wait for screen to stabilize
status null Check if process is still running
close null Terminate the daemon and child process

Reusable Test Library

For multiple tests, create a shared library (e.g., lib.sh):

#!/bin/bash
# lib.sh - Shared test helpers

REQUEST_ID=1

next_id() {
    local id=$REQUEST_ID
    REQUEST_ID=$((REQUEST_ID + 1))
    echo $id
}

# Send command with auto-incrementing ID
tw_auto() {
    local sock="$1"
    local method="$2"
    local params="${3:-null}"
    local id=$(next_id)
    echo "{\"id\":$id,\"method\":\"$method\",\"params\":$params}" | \
        socat - UNIX-CONNECT:"$sock"
}

# Convenience wrappers
get_screen() { tw_auto "$1" "screen" '{"format":"text"}' | jq -r '.result'; }
press()      { tw_auto "$1" "press" "{\"key\":\"$2\"}" > /dev/null; }
type_text()  { tw_auto "$1" "type" "{\"text\":\"$2\"}" > /dev/null; }
ctrl()       { tw_auto "$1" "hotkey" "{\"ctrl\":true,\"ch\":\"$2\"}" > /dev/null; }
wait_idle()  { tw_auto "$1" "wait_for_idle" "{\"idle_ms\":${2:-500},\"timeout_ms\":${3:-5000}}"; }

# Assert screen contains text
assert_contains() {
    local sock="$1" expected="$2" desc="${3:-contains '$2'}"
    if get_screen "$sock" | grep -q "$expected"; then
        echo "PASS: $desc"
        return 0
    else
        echo "FAIL: $desc"
        return 1
    fi
}

Example Test Script

#!/bin/bash
# test_my_app.sh
source "$(dirname "$0")/lib.sh"

SOCK="/tmp/test-$$.sock"
termwright daemon --socket "$SOCK" -- ./my-app &
while [ ! -S "$SOCK" ]; do sleep 0.1; done

# Wait for app to initialize
wait_idle "$SOCK" > /dev/null

# Run tests
assert_contains "$SOCK" "Main Menu" "App shows main menu"

press "$SOCK" "Enter"
wait_idle "$SOCK" > /dev/null
assert_contains "$SOCK" "Settings" "Enter opens settings"

ctrl "$SOCK" "q"
echo "All tests passed!"

Key Names Reference

Common key names for the press command:

  • Navigation: Up, Down, Left, Right, Home, End, PageUp, PageDown
  • Actions: Enter, Escape, Tab, Backspace, Delete, Insert
  • Function keys: F1 through F12
  • Characters: Any single character like a, 1, ?

CLI Reference

termwright fonts

List available font families on the system (helpful for selecting a monospace font for screenshots).

termwright fonts

termwright run

Run a command and capture its output.

termwright run [OPTIONS] -- <COMMAND> [ARGS]...

Options:
  --cols <COLS>          Terminal width [default: 80]
  --rows <ROWS>          Terminal height [default: 24]
  --wait-for <TEXT>      Wait for this text to appear before capturing
  --delay <MS>           Delay in milliseconds before capturing [default: 500]
  --format <FORMAT>      Output format: text, json, json-compact [default: text]
  --timeout <SECS>       Timeout for wait conditions [default: 30]

termwright screenshot

Take a PNG screenshot of a terminal application.

termwright screenshot [OPTIONS] -- <COMMAND> [ARGS]...

Options:
  --cols <COLS>          Terminal width [default: 80]
  --rows <ROWS>          Terminal height [default: 24]
  --wait-for <TEXT>      Wait for this text to appear before capturing
  --delay <MS>           Delay in milliseconds before capturing [default: 500]
  -o, --output <PATH>    Output file path (defaults to stdout)
  --font <NAME>          Font name for rendering
  --font-size <SIZE>     Font size in pixels [default: 14]
  --timeout <SECS>       Timeout for wait conditions [default: 30]

termwright run-steps

Run a YAML or JSON steps file for end-to-end testing.

termwright run-steps [OPTIONS] <FILE>

Options:
  --connect <PATH>       Connect to an existing daemon socket instead of spawning
  --trace                Write a trace.json file in the artifacts directory

termwright exec

Execute a single daemon request and print the response.

termwright exec --socket <PATH> --method <NAME> [--params <JSON>]

termwright hub

Start or stop multiple daemon sessions for parallel agents.

termwright hub start --count <N> [--cols <COLS>] [--rows <ROWS>] [--output <FILE>] -- <COMMAND> [ARGS]...
termwright hub stop --socket <PATH>... [--input <FILE>]

termwright daemon

Run a single TUI session and expose it over a Unix socket.

termwright daemon [OPTIONS] -- <COMMAND> [ARGS]...

Options:
  --cols <COLS>          Terminal width [default: 80]
  --rows <ROWS>          Terminal height [default: 24]
  --socket <PATH>        Unix socket path (defaults to a temp path)
  --background           Start daemon in the background

The command prints the socket path to stdout.

Daemon User Guide

Connecting from Rust

use std::time::Duration;
use termwright::prelude::*;

#[tokio::main]
async fn main() -> Result<()> {
    let client = DaemonClient::connect_unix("/tmp/termwright-12345.sock").await?;

    // Sanity check: talk to the server
    let info = client.handshake().await?;
    println!("daemon pid={}", info.pid);

    // Wait and read screen
    client.wait_for_text("VIM", Some(Duration::from_secs(5))).await?;
    println!("{}", client.screen_text().await?);

    // Keyboard
    client.press("Escape").await?;
    client.hotkey_ctrl('r').await?;
    client.r#type(":q!\n").await?;

    // Mouse (row/col are 0-based cell coordinates)
    client.mouse_move(10, 10).await?;
    client.mouse_click(10, 10, MouseButton::Left).await?;

    // Shut down daemon + child process
    client.close().await?;
    Ok(())
}

Notes / Caveats

  • The daemon is local-only: it listens on a Unix socket you control.
  • Mouse events are best-effort: many TUIs ignore mouse input unless they explicitly enable mouse reporting.
  • Coordinate system for mouse_move/mouse_click is row/col in terminal cells (0-based).

API Overview

Terminal

The main entry point for controlling terminal applications:

let term = Terminal::builder()
    .size(80, 24)
    .spawn("vim", &["file.txt"])
    .await?;

// Input
term.type_str("hello").await?;
term.send_key(Key::Enter).await?;
term.enter().await?;  // Shorthand for Enter key

// Screen access
let screen = term.screen().await;

// Wait conditions
term.expect("Ready").timeout(Duration::from_secs(5)).await?;
term.wait_exit().await?;

// Screenshots
term.screenshot().await.save("output.png")?;

Screen

Query the terminal screen state:

let screen = term.screen().await;

// Text access
let text = screen.text();
let line = screen.line(0);
assert!(screen.contains("hello"));

// Cell-level access
let cell = screen.cell(0, 0);
println!("Char: {}, FG: {:?}, BG: {:?}", cell.char, cell.fg, cell.bg);

// Cursor position
let cursor = screen.cursor();
println!("Cursor at row={}, col={}", cursor.row, cursor.col);

// Region extraction
let region = screen.region(0..10, 0..5);

// Pattern matching
if let Some(pos) = screen.find_text("error") {
    println!("Found at row={}, col={}", pos.row, pos.col);
}

// Box detection (UI boundaries)
let boxes = screen.detect_boxes();

// Output formats
println!("{}", screen.to_json()?);        // Pretty JSON
println!("{}", screen.to_json_compact()?); // Compact JSON

Keys

Available key types for input:

Key::Char('a')      // Regular characters
Key::Enter          // Enter/Return
Key::Tab            // Tab
Key::Escape         // Escape
Key::Backspace      // Backspace
Key::Up, Key::Down, Key::Left, Key::Right  // Arrow keys
Key::Home, Key::End
Key::PageUp, Key::PageDown
Key::Insert, Key::Delete
Key::F(1)..Key::F(12)  // Function keys
Key::Ctrl('c')      // Ctrl combinations
Key::Alt('x')       // Alt combinations

Requirements

  • Rust 1.85.0 or later (Edition 2024)
  • macOS or Linux (Windows not supported)
  • For screenshots: A monospace font (uses system fonts via font-kit)

Use Cases

  • AI Agents: Enable LLMs to observe and interact with terminal UIs via JSON output
  • Integration Testing: Automated testing of TUI applications
  • Documentation: Generate screenshots for documentation
  • Accessibility: Extract text content from visual terminal applications

License

MIT License - see LICENSE for details.

About

Playwright-like automation framework for terminal TUI applications

Resources

License

Stars

Watchers

Forks

Packages

No packages published