testty 0.6.10

Rust-native TUI end-to-end testing framework using PTY-driven semantic assertions and VHS screenshot capture.
Documentation

testty

Rust-native TUI end-to-end testing framework. Drives a real TUI binary in a pseudo-terminal, captures location-aware terminal state with vt100, and provides a semantic assertion API for text, style, color, and region checks. Scenarios can also be compiled into VHS tapes for visual screenshot capture.

Quick start

Add testty as a dev-dependency in the crate that contains your E2E tests:

[dev-dependencies]
testty = "0.6"
tempfile = "3"

Inside this workspace, keep using shared workspace dependencies instead:

# crates/my-app/Cargo.toml
[dev-dependencies]
testty = { workspace = true }
tempfile = { workspace = true }

Note: workspace = true requires matching entries in the root Cargo.toml under [workspace.dependencies].

Write a test that launches your binary, interacts with it, and asserts on the terminal state:

use testty::recipe;
use testty::scenario::Scenario;
use testty::session::PtySessionBuilder;

#[test]
fn startup_shows_welcome() {
    // Arrange
    let temp = tempfile::TempDir::new().unwrap();
    let builder = PtySessionBuilder::new("/path/to/my-binary")
        .size(80, 24)
        .env("MY_APP_ROOT", temp.path().to_string_lossy())
        .workdir(temp.path());

    let scenario = Scenario::new("startup")
        .wait_for_stable_frame(500, 5000)
        .capture();

    // Act
    let frame = scenario.run(builder).expect("scenario failed");

    // Assert
    recipe::expect_instruction_visible(&frame, "Welcome");
}

Run the test:

cargo test -p my-app --test e2e

Core concepts

Scenario

A Scenario is an ordered sequence of Step actions that describe a user journey. Built with a fluent API, then executed in a PTY or compiled into a VHS tape.

use std::time::Duration;
use testty::scenario::Scenario;

let scenario = Scenario::new("tab_navigation")
    .wait_for_stable_frame(500, 5000)   // Wait for app to render
    .press_key("Tab")                    // Switch tab
    .wait_for_stable_frame(300, 3000)   // Wait for re-render
    .capture();                          // Snapshot the terminal

Steps

Each Step represents a single user action or wait condition:

Step Description Example
WriteText Type text into the terminal .write_text("hello world")
PressKey Send a named key press .press_key("Enter")
Sleep Pause for a fixed duration .sleep_ms(200)
WaitForText Poll until text appears .wait_for_text("Ready", 5000)
WaitForStableFrame Wait for rendering to stabilize .wait_for_stable_frame(500, 5000)
Capture Snapshot the current terminal state .capture()

Supported key names: Enter, Tab, Escape / Esc, Backspace, Up, Down, Left, Right, Home, End, Delete, PageUp, PageDown, Space, Ctrl+<letter> (e.g., Ctrl+C). Unknown keys are sent as raw bytes.

PtySession and PtySessionBuilder

PtySession spawns the binary in a real PTY using portable-pty. Configure it with PtySessionBuilder:

use testty::session::PtySessionBuilder;

let builder = PtySessionBuilder::new("/path/to/binary")
    .size(120, 40)                          // Terminal dimensions (default: 80x24)
    .env("DATABASE_URL", "sqlite::memory:") // Environment variables
    .env("LOG_LEVEL", "debug")
    .workdir("/tmp/test-workspace");        // Working directory

// Option 1: Use with a Scenario
let frame = scenario.run(builder).expect("failed");

// Option 2: Manual control
let mut session = builder.spawn().expect("spawn failed");
session.write_text("hello")?;
session.press_key("Enter")?;
let frame = session.wait_for_text("response", std::time::Duration::from_secs(5))?;

TerminalFrame

A TerminalFrame is a snapshot of the terminal state parsed through vt100. It provides structured access to text, colors, and styles:

use testty::frame::TerminalFrame;

// Create from raw ANSI bytes (done automatically by PtySession)
let frame = TerminalFrame::new(80, 24, b"\x1b[1mBold Title\x1b[0m\r\nBody text");

// Read text
let all_text = frame.all_text();          // All visible text
let row_text = frame.row_text(0);         // Text from row 0
let region_text = frame.text_in_region(&region); // Text in a region

// Search for text (returns Vec<MatchedSpan>)
let matches = frame.find_text("Title");   // Find everywhere
let matches = frame.find_text_in_region("Title", &region); // Find in region

Region

A Region defines a rectangular area for scoped assertions:

use testty::region::Region;

// Named constructors
let header  = Region::top_row(80);           // First row, full width
let footer  = Region::footer(80, 24);        // Last row, full width
let full    = Region::full(80, 24);          // Entire terminal
let left    = Region::left_panel(80, 24);    // Left half
let right   = Region::right_panel(80, 24);   // Right half
let top_l   = Region::top_left(80, 24);      // Top-left quadrant
let top_r   = Region::top_right(80, 24);     // Top-right quadrant

// Percentage-based (col%, row%, width%, height%, cols, rows)
let upper = Region::percent(0, 0, 100, 60, 80, 24); // Top 60%

// Explicit coordinates
let custom = Region::new(10, 5, 30, 3);      // col=10, row=5, 30x3

MatchedSpan

When text is found in a frame, you get a MatchedSpan with position, color, and style metadata:

let matches = frame.find_text("Projects");
let span = &matches[0];

span.text;           // "Projects"
span.rect;           // Region { col, row, width, height }
span.foreground;     // Option<CellColor>
span.background;     // Option<CellColor>
span.style;          // CellStyle (bold, italic, underline, inverse)
span.is_bold();      // true if bold
span.is_highlighted(); // true if bold, inverse, or has background color
span.has_fg(&color); // true if foreground matches
span.has_bg(&color); // true if background matches

Assertion API

Low-level assertions (assertion module)

use testty::assertion;
use testty::frame::CellColor;
use testty::region::Region;

let region = Region::top_row(80);

// Text presence
assertion::assert_text_in_region(&frame, "Projects", &region);
assertion::assert_not_visible(&frame, "Error");
assertion::assert_match_count(&frame, "Tab", 2);

// Style checks
assertion::assert_span_is_highlighted(&frame, "Selected");
assertion::assert_span_is_not_highlighted(&frame, "Inactive");

// Color checks
assertion::assert_text_has_fg_color(&frame, "Error", &CellColor::new(128, 0, 0));
assertion::assert_text_has_bg_color(&frame, "Active", &CellColor::new(0, 0, 128));

All assertion functions panic with detailed messages on failure, including the match position, actual colors/styles, and region contents.

Recipe helpers (recipe module)

High-level, composable helpers for common TUI patterns. Prefer these over raw assertions:

use testty::recipe;

// Tabs
recipe::expect_selected_tab(&frame, "Projects");     // In header, highlighted
recipe::expect_unselected_tab(&frame, "Sessions");    // In header, not highlighted

// Footer
recipe::expect_keybinding_hint(&frame, "Tab");        // Hint in footer row
recipe::expect_footer_action(&frame, "Quit");         // Action in footer row

// Content
recipe::expect_instruction_visible(&frame, "Press Enter to start");
recipe::expect_dialog_title(&frame, "Confirm Delete"); // Upper 60% of terminal
recipe::expect_status_message(&frame, "Saved");        // Anywhere in frame

// Absence
recipe::expect_not_visible(&frame, "Loading...");

Snapshot testing

The framework supports two snapshot modes: frame text (semantic) and visual screenshot (pixel-level via VHS).

Frame text snapshots

Compare the terminal text content against a committed baseline:

use testty::snapshot::{self, SnapshotConfig};

let config = SnapshotConfig::new(
    "tests/e2e_baselines",  // Committed baseline directory
    "tests/e2e_artifacts",  // Failure artifact output (gitignored)
);

snapshot::assert_frame_snapshot_matches(
    &config,
    "startup_projects_tab",  // Snapshot name
    &frame.all_text(),
).expect("frame snapshot should match");

Visual snapshots (VHS)

Compile a scenario into a VHS tape for pixel-level screenshot capture:

let tape = scenario.to_vhs_tape(
    &binary_path,
    Path::new("/tmp/screenshot.png"),
    &[("MY_ENV", "value")],
);

// Write tape to file
tape.write_to(Path::new("/tmp/test.tape"))?;

// Execute the tape (requires VHS installed)
tape.execute(Path::new("/tmp/test.tape"))?;

Updating baselines

Set TUI_TEST_UPDATE=1 to overwrite baselines with the current output:

TUI_TEST_UPDATE=1 cargo test -p my-app --test e2e

Snapshot config tuning

For visual snapshots, configure pixel-level comparison thresholds:

let config = SnapshotConfig::new("tests/baselines", "tests/artifacts")
    .with_thresholds(
        30.0,  // Per-pixel color distance threshold (Euclidean RGB)
        10.0,  // Maximum percentage of differing pixels allowed
    );

Module overview

Module Purpose
scenario Fluent builder for composing test scenarios from steps
step Step enum: WriteText, PressKey, Sleep, WaitForText, WaitForStableFrame, Capture
session PTY executor: PtySession + PtySessionBuilder
frame Terminal state parser: TerminalFrame, CellColor, CellStyle
region Rectangular region definitions with named anchors
locator MatchedSpan with text, position, color, and style metadata
assertion Structured assertion functions with detailed failure messages
recipe High-level helpers for tabs, footer, dialogs, status messages
snapshot Baseline management: frame text and visual image comparison
vhs VHS tape compiler for visual screenshot capture
calibration Cell-to-pixel geometry mapping for overlay rendering
artifact Failure artifact directory and capture storage
overlay Pixel-level drawing on screenshots for visual debugging

Full example

A complete E2E test exercising tab navigation:

use testty::recipe;
use testty::scenario::Scenario;
use testty::session::PtySessionBuilder;
use testty::snapshot::{self, SnapshotConfig};

#[test]
fn tab_key_switches_tabs() {
    // Arrange
    let temp = tempfile::TempDir::new().unwrap();
    let builder = PtySessionBuilder::new(env!("CARGO_BIN_EXE_myapp"))
        .size(80, 24)
        .env("APP_ROOT", temp.path().to_string_lossy())
        .workdir(temp.path());

    let scenario = Scenario::new("tab_switch")
        .wait_for_stable_frame(500, 5000)
        .press_key("Tab")
        .wait_for_stable_frame(300, 3000)
        .capture();

    // Act
    let frame = scenario.run(builder).expect("scenario failed");

    // Assert
    recipe::expect_selected_tab(&frame, "Sessions");
    recipe::expect_unselected_tab(&frame, "Projects");

    // Optional: frame snapshot
    let config = SnapshotConfig::new("tests/e2e_baselines", "tests/e2e_artifacts");
    snapshot::assert_frame_snapshot_matches(&config, "tab_switch", &frame.all_text())
        .expect("frame snapshot should match");
}

Tips

  • Use wait_for_stable_frame instead of sleep_ms when waiting for rendering. It adapts to actual render speed rather than hard-coding delays.
  • Use recipe helpers over raw assertions. They encode common TUI layout patterns (header tabs, footer hints) so you don't rebuild locator logic.
  • Set deterministic terminal size (e.g., 80x24) to keep frame snapshots stable across machines.
  • Isolate state with tempfile::TempDir and environment variables so tests don't interfere with each other or the real app data.
  • E2E tests run automatically with cargo test. Cargo builds the binary before running integration tests, so CARGO_BIN_EXE_* is always available.