Status: archived, not published. This crate exists as a reference extraction, not as a live package on crates.io. See Why this is archived below. If you want a maintained PTY-based TUI testing library, use
terminal-testlib(akaratatui-testlibon crates.io) — it is a superset of what this crate aimed at. If you want the minimal grid-reconstruction primitive for your own test helpers, readsrc/lib.rs— it is ~280 lines, one dependency, Apache-2.0, copy freely.
Terminal cell-grid reconstruction for end-to-end testing of ratatui TUIs (and any other terminal application that positions output explicitly via ANSI/VT100 escape sequences).
End-to-end testing a TUI usually means: spawn the binary in a pseudo-terminal,
send keystrokes, read raw VT100 bytes back, then assert that the right thing
rendered. The first three steps are well-served by existing crates
(portable-pty, expectrl, raw nix::pty::forkpty, etc.). The last
step is where most test suites collapse into liveness smoke tests like
assert!(raw.contains("foo")) or assert!(raw.len() > 100) — because
reconstructing what the terminal actually rendered from a stream of escape
sequences is annoying enough that everyone reaches for the escape hatch.
TermGrid does that reconstruction. Drive a vte::Parser over your raw
PTY bytes and you get a queryable N×M grid you can write real behavioral
assertions against.
[dev-dependencies]
ratatui-e2e = "0.1"use ratatui_e2e::TermGrid;
// Imagine you spawned `mytui` in a PTY and drained these raw bytes...
let raw = b"\x1b[24;1H\x1b[1;7m claude-opus-4-6 \x1b[0m | ctx 5%\x1b[K";
let mut grid = TermGrid::new(80, 24);
grid.feed(raw);
assert!(grid.contains("claude-opus-4-6"));
assert!(grid.row(23).contains("ctx 5%"));
assert_eq!(grid.find("claude-opus-4-6"), Some((23, 1)));TermGrid parses the CSI sequences ratatui actually emits:
- Cursor positioning: CUP/HVP (
H/f), CUU/CUD/CUF/CUB (A/B/C/D), CHA (G), VPA (d) - Erase: ED (
J— cursor-to-end / start-to-cursor / entire screen), EL (K— same modes for line) - Save/restore cursor: CSI
s/uand ESC7/8(DECSC/DECRC) - Control chars: LF (newline, advances row), CR (column = 0), BS (column -= 1)
- SGR (
m): parsed and discarded — behavioral assertions care about content, not color/bold/reverse
- Auto-wrap (we clamp the cursor at width-1; ratatui positions everything via CUP so this is fine for it, but a non-TUI command-line tool that relies on terminal wrap will need a different approach)
- Scrolling regions
- Alternate screen buffers (the TUI's switch into the alternate buffer is transparent —
TermGridjust sees the bytes that flow) - Wide characters (CJK, emoji) as double-column cells — they're treated as one column
- Mouse tracking, sixel graphics, OSC color queries
If your TUI relies on any of those, this crate is not enough. Open an issue or send a PR.
The public surface is intentionally small:
impl TermGrid {
pub fn new(width: usize, height: usize) -> Self;
pub fn feed(&mut self, bytes: &[u8]);
// Queries
pub fn width(&self) -> usize;
pub fn height(&self) -> usize;
pub fn cursor(&self) -> (usize, usize);
pub fn row(&self, r: usize) -> String;
pub fn cell(&self, r: usize, c: usize) -> Option<char>;
pub fn text(&self) -> String;
pub fn find(&self, needle: &str) -> Option<(usize, usize)>;
pub fn contains(&self, needle: &str) -> bool;
pub fn row_contains(&self, r: usize, needle: &str) -> bool;
pub fn non_blank_cells(&self) -> usize;
}TermGrid also implements Display (renders the full grid via text()), so
you can drop it into assertion failure messages without ceremony:
assert!(
grid.contains("opus"),
"status bar should contain a model name; grid:\n{}",
grid
);ratatui-e2e was extracted from the ostk test suite. While building and
tightening the tests we surfaced four classes of bug that any PTY-based TUI
test suite is likely to hit. They're worth knowing about up front.
A test like this:
assert!(text.contains("opus") || raw.len() > 200, "...");passes whenever the TUI emits any bytes — including a launch splash that contains zero of the expected content. Strict assertions catch the regressions you actually care about. When you tighten an existing escape hatch and the test starts failing, you've usually just surfaced a real bug.
If your TUI renders a boot screen / POST checks / splash before the interactive input loop, tests that send keystrokes before the boot screen dismisses will have those keystrokes consumed by the boot-screen state machine, not by the post-boot input handler. The fix is to drain until a marker only present in the post-boot UI appears (status bar element, footer, etc.) before sending any interactive input. Don't synchronize on byte count; synchronize on semantic markers.
// Bad: races against boot screen rendering
let _ = drain_until(&pty, Duration::from_secs(8), |buf| buf.len() > 100);
send_keys(&pty, b"hello"); // may be consumed by boot screen
// Good: waits for the post-boot marker
let _ = drain_until(&pty, Duration::from_secs(10), |buf| {
let s = String::from_utf8_lossy(buf);
s.contains("@p+") || s.contains("status:") // <-- post-boot only
});
send_keys(&pty, b"hello"); // now hits the input barIf your test sends keystrokes byte-by-byte (typically with a small delay
between bytes so the TUI has time to process each one), multi-byte escape
sequences like \x1b? (Alt+?) get split across the delay window. Crossterm
has a short escape-sequence reassembly timeout; exceeding it causes the
sequence to be delivered as two separate key events (Esc then ?) instead
of one Alt+? event. The Alt+? handler in your TUI will never fire.
// Bad: byte-by-byte with delays splits the escape sequence
for &b in b"\x1b?" {
pty.write_stdin(&[b]).unwrap();
thread::sleep(Duration::from_millis(10));
}
// Good: write multi-byte escape sequences as a single call
pty.write_stdin(b"\x1b?").unwrap();If your spawn helper mutates global env vars (e.g. unsetting an API key so
the TUI doesn't make real network calls), wrap the mutation in a
static ENV_LOCK: Mutex<()>. Cargo runs tests in parallel within a single
test binary; without serialization, the env state at fork time is
non-deterministic and tests pass in isolation but flake under load.
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn spawn_without_api_keys(cmd: &[String]) -> Result<Pty, Error> {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// SAFETY: serialized by ENV_LOCK
let saved = unsafe { std::env::var("ANTHROPIC_API_KEY").ok() };
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
let pty = Pty::spawn(cmd); // forks here, child inherits stripped env
if let Some(val) = saved {
unsafe { std::env::set_var("ANTHROPIC_API_KEY", val) };
}
pty
}ratatui-e2e deliberately stays out of the PTY business — bring your own.
Recommended pairings:
portable-pty(cross-platform, well-maintained, used by wezterm)expectrl(expect-style scripting on top of a PTY)nix::pty::forkpty(raw, unix-only, full control)
A future ratatui-e2e release may add an optional pty-helpers feature that
ships the drain_until / send_keys / env-isolation patterns documented in
the lessons-learned section. For now, copy them out of the section above and
adapt to your PTY library.
This crate was extracted from the ostk test suite on 2026-04-07 after a
long conversation traced a feeling Scott had that "we built a TUI test
framework once and discarded it, and now we need it again." The extraction
was clean: TermGrid is ~280 lines, tests pass, CI is green, docs are
complete. Then, after publishing the repo and going to design a companion
pty-helpers module, I (the assistant helping Scott) searched crates.io for
prior art and discovered terminal-testlib — already published December
2025, by Raibid Labs, maintained, MIT-licensed, a superset of what
ratatui-e2e aimed at. It uses portable-pty + termwiz (the wezterm
terminal state machine, more correct on edge cases than our hand-rolled
CSI handler) + vtparse + insta snapshot testing + async harness via
tokio + Bevy ECS integration + Sixel graphics.
We stopped and thought about it honestly. The conclusion was that publishing
ratatui-e2e as a competing crate would be splintering the ecosystem for a
marginal dep-footprint win, and that publishing is a maintenance commitment
neither Scott nor I were going to honor weekly. Unmaintained published
crates are vanity. Instead:
TermGridstays inline in ostk's test suite attests/tui_pty_integration.rs. It serves ostk's dogfooding needs and keeps test/runtime PTY symmetry (ostk's daemon uses its ownPtyCapture, notportable-pty; our tests exercise the same layer).- The lessons-learned section below is the irreducible value. It documents four classes of bug we surfaced while building and tightening this framework, each with bad-vs-good code examples. The knowledge is universal to PTY-based TUI testing, regardless of which library you use.
- This repo stays as an archived reference. The git history is clean,
the CI run is green, the code works. If you want to vendor it into your
own project as a starting point, or read it to understand how to do
grid reconstruction in a tiny dep footprint, it's here. But it will
not be published to crates.io, and no
v0.2.0is planned.
The pattern this episode named in ostk's decision log is
EXTRACT_WISDOM_NOT_CODE: when prior art subsumes an extraction target,
contribute the findings (or preserve them internally), do not publish a
competing surface. Search for prior art before extracting, not after. The
failure mode this prevents is parallel discovery without prior-art search,
which is a sibling of the discard-without-capture failure mode we'd named
earlier the same day.
If this README is useful to you, the lessons-learned section is the part that matters. Take it.
Apache-2.0. See LICENSE-APACHE.
This crate was extracted from the ostk test suite and is maintained as a focused community contribution. Issues and PRs welcome at github.com/os-tack/ratatui-e2e.