testty 0.8.10

Rust-native TUI end-to-end testing framework using PTY-driven semantic assertions, native frame rendering, and VHS-driven GIF 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.

Installation

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

[dev-dependencies]
testty = "0.8"
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].

Upgrading

The curated stable surface lives at testty::prelude and is locked down by the tests/public_api.rs tripwire test. Any breaking change to a prelude item or to the documented auxiliary items (testty::recipe, testty::snapshot::DEFAULT_UPDATE_ENV_VAR, SnapshotConfig::with_update_env_var, SnapshotConfig::with_update_mode, SnapshotConfig::is_update_mode) requires a deliberate update to that test and a testty major version bump.

When upgrading from earlier 0.x releases:

  • Replace use testty::scenario::Scenario; / use testty::session::*; imports with use testty::prelude::*;.
  • The artifact, calibration, and overlay modules have been removed. Use the proof pipeline backends and the snapshot testing helpers instead.
  • testty::snapshot::is_update_mode() has been removed. Use SnapshotConfig::is_update_mode(&self) instead, which now resolves the per-config env var or the programmatic override set via SnapshotConfig::with_update_env_var / SnapshotConfig::with_update_mode.
  • Snapshot baseline updates still default to TUI_TEST_UPDATE=1. Custom CI integrations can override the trigger via SnapshotConfig::with_update_env_var and read the default from testty::snapshot::DEFAULT_UPDATE_ENV_VAR.
  • SnapshotConfig is now #[non_exhaustive]. Replace any struct-literal construction (SnapshotConfig { .. }) with SnapshotConfig::new(..) plus the with_* builder chain.
  • SnapshotError and its struct-shaped variants (Mismatch, MissingBaseline, FrameMismatch) are now #[non_exhaustive]. match arms must include a fallback _ arm and any field destructuring must use the .. rest-pattern. The MissingBaseline variant also carries a new update_env_var field that surfaces the configured trigger in the error message.
  • feature::GifStatus is now #[non_exhaustive] and gained Fresh { gif_path, hash } and Stale { gif_path, current, committed } variants used by the new FeatureDemo::gif_mode(GifMode::CheckOnly) freshness mode. CheckOnly is a read-only verification path: it never invokes VHS and never mutates the filesystem, so it works on read-only CI mounts and against missing output directories. Existing match arms must add a fallback _ arm and any field destructuring must use the .. rest-pattern. The companion helpers feature::compute_frame_hash and feature::hash_sidecar_path are now public so external tooling can reproduce the same hash that FeatureDemo writes to the on-disk sidecar.
  • The feature-demo surface (FeatureDemo, FeatureMeta, FeatureResult, GifMode, GifStatus) is now re-exported from testty::prelude, so use testty::prelude::*; consumers can call FeatureDemo::new(..).gif_mode(..) without an explicit use testty::feature::..; import.

Quick start

Most users only need the curated [testty::prelude]:

use testty::prelude::*;

#[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
    testty::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() | | CaptureLabeled | Labeled snapshot for proof reports | .capture_labeled("init", "App launched") |

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)
    .args(["--help"])                       // Forward CLI args to the binary
    .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 — wide-character continuation cells are skipped (the leading
// cell already supplies the full glyph), while real blank columns stay as
// spaces so column positions in the rendered terminal are preserved.
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 assert_* functions panic with detailed messages on failure, including the match position, actual colors/styles, and region contents.

Result-returning matchers (match_*)

Every assert_* has a match_* sibling that returns Result<(), Box<AssertionFailure>> (aliased as MatchResult) instead of panicking. The failure is boxed so the Ok path stays a single pointer-sized return and the Result enum stays small. Use the matcher form when you need to compose, retry, soft-batch, or surface failures into a proof report — for example, when polling the live PTY frame for a condition or when collecting multiple expectations against one capture.

use testty::assertion::{self, AssertionFailure, Expected, MatchResult};
use testty::region::Region;

let region = Region::top_row(80);

let result: MatchResult = assertion::match_text_in_region(&frame, "Projects", &region);
if let Err(failure) = result {
    // `failure.message` is the same byte-compatible string the panic adapter
    // would have produced, while `failure.expected`, `failure.region`, and
    // `failure.matched_spans` carry the structured context for renderers.
    match &failure.expected {
        Expected::TextInRegion { needle } => eprintln!("missing: {needle}"),
        _ => {}
    }
}

Adding the match_* siblings is a strictly additive change: all existing assert_* callers keep working without modification, so this evolution does not require a major version bump for downstream crates.

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

The variable name is configurable via SnapshotConfig::with_update_env_var; the default is exposed as testty::snapshot::DEFAULT_UPDATE_ENV_VAR.

For tests and other programmatic callers, SnapshotConfig::with_update_mode injects the update-mode decision directly. This bypasses the environment variable, which is the recommended way to drive update behavior from a test without mutating process-global env state through unsafe std::env::set_var calls:

let config = SnapshotConfig::new("tests/baselines", "tests/artifacts")
    .with_update_mode(true);

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
    );

Proof pipeline

The proof pipeline captures labeled terminal states during scenario execution and renders them through swappable backends. Use capture_labeled() steps and run_with_proof() to collect a ProofReport:

use testty::scenario::Scenario;
use testty::session::PtySessionBuilder;
use testty::proof::frame_text::FrameTextBackend;
use testty::proof::strip::ScreenshotStripBackend;
use testty::proof::gif::GifBackend;
use testty::proof::html::HtmlBackend;

let scenario = Scenario::new("startup_proof")
    .wait_for_stable_frame(300, 5000)
    .capture_labeled("launched", "App reached stable state")
    .press_key("Tab")
    .wait_for_stable_frame(200, 3000)
    .capture_labeled("navigated", "Switched to second tab");

let builder = PtySessionBuilder::new("/path/to/binary").size(80, 24);
let (_frame, report) = scenario.run_with_proof(builder).expect("failed");

// Render through any backend:
report.save(&FrameTextBackend, Path::new("proof.txt")).unwrap();
report.save(&ScreenshotStripBackend, Path::new("proof.png")).unwrap();
report.save(&GifBackend::default(), Path::new("proof.gif")).unwrap();
report.save(&HtmlBackend, Path::new("proof.html")).unwrap();

Proof formats

| Format | Backend | Output | Use case | |--------|---------|--------|----------| | Frame text | FrameTextBackend | .txt | CI logs, quick inspection | | PNG strip | ScreenshotStripBackend | .png | Review comments, docs | | Animated GIF | GifBackend | .gif | PR descriptions, demos | | HTML report | HtmlBackend | .html | Detailed review with diffs and assertions |

Composable journeys

Journey provides reusable building blocks for declarative scenario authoring. Instead of repeating low-level step sequences, compose scenarios from pre-built journeys:

use testty::journey::Journey;
use testty::scenario::Scenario;

let startup = Journey::wait_for_startup(300, 5000);
let navigate = Journey::navigate_with_key("Tab", "Settings", 3000);
let input = Journey::type_and_confirm("search query");
let snapshot = Journey::capture_labeled("final", "End state");

let scenario = Scenario::new("settings_search")
    .compose(&startup)
    .compose(&navigate)
    .compose(&input)
    .compose(&snapshot);

Built-in journeys

| Journey | Steps | Description | |---------|-------|-------------| | wait_for_startup(stable_ms, timeout_ms) | 1 | Wait for app to render and stabilize | | navigate_with_key(key, expected, timeout) | 2 | Press key, wait for expected text | | type_and_confirm(text) | 2 | Type text and press Enter | | press_and_wait(key, ms) | 2 | Press key and sleep briefly | | capture_labeled(label, desc) | 1 | Capture with label for proofs |

Frame diffing

The diff engine computes cell-level differences between terminal frames and generates human-readable change summaries:

use testty::diff::FrameDiff;
use testty::frame::TerminalFrame;

let before = TerminalFrame::new(80, 24, b"Counter: 0");
let after = TerminalFrame::new(80, 24, b"Counter: 42");

let diff = FrameDiff::compute(&before, &after);
assert!(!diff.is_identical());

// Get changed regions with row/col spans
for region in diff.changed_regions() {
    println!("Row {}, cols {}..{}: {:?}",
        region.region.row, region.region.col,
        region.region.col + region.region.width,
        region.change_type);
}

// Human-readable summaries
for line in diff.summary() {
    println!("{line}");
}

Diffs are automatically computed between consecutive captures in a ProofReport and displayed in the HTML proof backend.

Module overview

| Module | Purpose | |--------|---------| | scenario | Fluent builder for composing test scenarios from steps | | step | Step enum: WriteText, PressKey, Sleep, WaitForText, WaitForStableFrame, Capture, CaptureLabeled | | 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 | | proof | Proof pipeline: report collector, backend trait, and format implementations | | diff | Cell-level frame diffing with changed region detection | | journey | Composable journey building blocks for declarative test authoring | | feature | FeatureDemo builder: scenario execution with hash-cached VHS GIF generation | | prelude | Curated re-exports for the common testty workflow (use testty::prelude::*;) |

Full example

A complete E2E test exercising tab navigation:

use testty::prelude::*;
use testty::{recipe, snapshot};
use testty::snapshot::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");
}

Examples

Run the showcase examples to see the framework features in action:

# Full proof pipeline: captures → frame-text, PNG strip, GIF, HTML
cargo run --example proof_pipeline -p testty

# Frame diffing with changed region detection
cargo run --example frame_diffing -p testty

# Composable journeys and scenario building
cargo run --example journey_composition -p testty

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.