# 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](https://github.com/charmbracelet/vhs) tapes for visual screenshot
capture.
## Installation
Add `testty` as a dev-dependency in the crate that contains your E2E tests:
```toml
[dev-dependencies]
testty = "0.8"
tempfile = "3"
```
Inside this workspace, keep using shared workspace dependencies instead:
```toml
# 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](#proof-pipeline) backends and the
[snapshot testing](#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`\]:
```rust
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:
```sh
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.
```rust
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:
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)` | | `Eventually` | Poll a frame
predicate until it succeeds | `.eventually(timeout, poll, predicate)` | | `Capture` |
Snapshot the current terminal state | `.capture()` | | `CaptureLabeled` | Labeled
snapshot for proof reports | `.capture_labeled("init", "App launched")` |
### Predicate-driven waits with `eventually`
`Step::eventually` polls a closure against the live PTY frame on every tick and returns
the moment the predicate returns `Ok(())`. On timeout, the executor surfaces the last
[`AssertionFailure`](https://docs.rs/testty/latest/testty/assertion/struct.AssertionFailure.html)
the predicate produced through `PtySessionError::Assertion`, so the structured failure
context flows into the proof report instead of a generic timeout panic.
Use it whenever you would otherwise fight to pick a `stable_ms`/`timeout_ms` that is
neither too long (wasted wall time) nor too short (flaky CI). It composes any
`MatchResult`-returning predicate, so you can wait on `assertion::match_*` matchers,
recipe checks, or your own frame inspection logic:
```rust
use std::time::Duration;
use testty::prelude::*;
let scenario = Scenario::new("counter_eventually")
.write_text("+++")
// Step::Sleep guesses how long the UI takes to repaint and risks
// returning before the counter increments.
.sleep_ms(200)
// Step::WaitForText only checks for a literal substring and cannot
// express richer predicates such as "selected tab is highlighted".
.wait_for_text("Counter:", 5_000)
// Step::eventually polls the predicate every 50ms and returns the
// instant `Counter: 3` appears anywhere in the frame.
.eventually(
Duration::from_secs(5),
Duration::from_millis(50),
|frame| {
assertion::match_text_in_region(
frame,
"Counter: 3",
&Region::full(80, 24),
)
},
)
.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`:
```rust
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:
```rust
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(®ion); // 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", ®ion); // Find in region
```
### Region
A `Region` defines a rectangular area for scoped assertions:
```rust
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:
```rust
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)
```rust
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", ®ion);
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.
```rust
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", ®ion);
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.
### Soft-batched assertions (`SoftAssertions`)
`SoftAssertions` collects multiple `match_*` failures against one captured frame and
panics once at scope end with every recorded message in record order, instead of failing
fast on the first miss. Pass each `MatchResult` to `check`; passing results are dropped
and failing ones are stored.
```rust
use testty::assertion::{self, SoftAssertions};
use testty::region::Region;
let region = Region::top_row(80);
{
let mut soft = SoftAssertions::new();
soft.check(assertion::match_text_in_region(&frame, "Projects", ®ion));
soft.check(assertion::match_text_in_region(&frame, "Sessions", ®ion));
soft.check(assertion::match_not_visible(&frame, "Error"));
// Drop at the end of this block panics with one aggregated message
// listing every failure that was recorded above.
}
```
Use `SoftAssertions::into_failures` to take ownership of the recorded failures before
the accumulator goes out of scope; that path suppresses the end-of-scope panic so the
caller can render the failures themselves.
To attach every recorded failure to the most recent `ProofCapture::assertions` entry on
a `ProofReport`, construct the accumulator with `SoftAssertions::with_report`. The
report must already hold at least one capture — call `ProofReport::add_capture` first,
otherwise `with_report` panics at the bind site so misconfigurations surface immediately
instead of silently dropping failures from the proof report.
```rust
use testty::assertion::{self, SoftAssertions};
use testty::proof::report::ProofReport;
use testty::region::Region;
let mut report = ProofReport::new("scenario");
report.add_capture("startup", "Startup snapshot", &frame);
let mut soft = SoftAssertions::with_report(&mut report);
soft.check(assertion::match_text_in_region(&frame, "Projects", &Region::top_row(80)));
soft.check(assertion::match_not_visible(&frame, "Error"));
// Each recorded failure also lands on the latest capture's assertions
// list so structured proof backends can render every failure together.
let _ = soft.into_failures();
```
### Recipe helpers (`recipe` module)
High-level, composable helpers for common TUI patterns. Prefer these over raw
assertions:
```rust
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...");
```
Each `expect_*` recipe has a `match_*` sibling that returns
[`MatchResult`](https://docs.rs/testty) instead of panicking, so recipes compose with
`SoftAssertions` and `ProofReport` flows the same way the underlying
`assertion::match_*` matchers do:
```rust
use testty::assertion::SoftAssertions;
use testty::recipe;
let mut soft = SoftAssertions::new();
soft.check(recipe::match_selected_tab(&frame, "Projects"));
soft.check(recipe::match_keybinding_hint(&frame, "Quit"));
let _ = soft.into_failures();
```
## 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:
```rust
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:
```rust
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:
```sh
TUI_TEST_UPDATE=1 cargo test -p my-app --test e2e
```
The variable name is configurable via
[`SnapshotConfig::with_update_env_var`](https://docs.rs/testty/latest/testty/snapshot/struct.SnapshotConfig.html);
the default is exposed as `testty::snapshot::DEFAULT_UPDATE_ENV_VAR`.
For tests and other programmatic callers,
[`SnapshotConfig::with_update_mode`](https://docs.rs/testty/latest/testty/snapshot/struct.SnapshotConfig.html)
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:
```rust
let config = SnapshotConfig::new("tests/baselines", "tests/artifacts")
.with_update_mode(true);
```
### Snapshot config tuning
For visual snapshots, configure pixel-level comparison thresholds:
```rust
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`:
```rust
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
`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:
```rust
use testty::journey::{Journey, StartupWait};
use testty::scenario::Scenario;
let startup = Journey::wait_for_startup_default();
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);
// Pick a named profile for projects that boot faster or slower than the
// balanced default, or fall back to raw values via `StartupWait::Custom`.
let _ = Journey::wait_for_startup_preset(StartupWait::FastNative);
let _ = Journey::wait_for_startup_preset(StartupWait::SlowNode);
let _ = Journey::wait_for_startup_preset(StartupWait::Custom {
stable_ms: 250,
timeout_ms: 4_000,
});
```
### Built-in journeys
preset (`StartupWait::Default` = `300ms` / `5_000ms`) | |
`wait_for_startup_preset(StartupWait)` | 1 | Wait for app to render using a named preset
(`Default`, `FastNative`, `SlowNode`, or `Custom`) | |
`wait_for_startup(stable_ms, timeout_ms)` | 1 | Legacy raw-number entry point retained
for back-compat; routes through `StartupWait::Custom` | |
`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 |
### Startup-wait presets
`5_000` | Balanced profile that matches the historical defaults. | |
`StartupWait::FastNative` | `200` | `3_000` | Native binaries that reach a stable frame
quickly (for example, Rust TUIs). | | `StartupWait::SlowNode` | `500` | `10_000` |
Slow-starting TUIs (for example, Node-based CLIs). | | `StartupWait::Custom` | caller |
caller | Explicit `(stable_ms, timeout_ms)` for cases that no preset matches. |
## Frame diffing
The diff engine computes cell-level differences between terminal frames and generates
human-readable change summaries:
```rust
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
`WaitForText`, `WaitForStableFrame`, `Eventually`, `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:
```rust
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:
```sh
# 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.