Integration testing for terminal programs, done the way you'd test a web app: spawn the real thing in a real PTY, let a VT emulator render its output into an in-memory screen grid, and assert or snapshot on the rendered screen instead of scraping raw bytes. Playwright for the terminal.
cargo add termlens --dev
cargo add insta --dev # used by the snapshot assertions belowuse std::time::Duration;
use termlens::{Key, Terminal};
#[test]
fn quits_from_the_main_screen() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(80, 24)
.env_clear() // hermetic: no host env leaks in
.timeout(Duration::from_secs(5)) // every wait_* has this deadline
.spawn(env!("CARGO_BIN_EXE_myapp"))?;
t.wait_until(|screen| screen.contains("Ready"))?;
insta::assert_snapshot!(t.screen()); // snapshot the rendered grid
t.send(Key::Char('q'));
assert!(t.wait_exit()?.success());
Ok(())
}When a wait times out, the error embeds the screen — your CI log shows exactly what the app was displaying, not "assertion failed: false".
- Not an expect-style stream matcher — rexpect and expectrl already do that well. Byte streams can't answer "is the cursor on the third menu item?".
- Not an SVG transcript generator for pretty docs — that's term-transcript.
- It is: a real PTY + an emulated screen + snapshot assertions, so you test what a user would see.
flowchart TB
test["your test<br/>drive · wait · assert"]
subgraph proc["your test process · cargo test"]
subgraph tt["termlens"]
api["Terminal<br/>send · resize · wait_until / wait_idle / wait_exit"]
reader["reader thread<br/>drains continuously — output is never lost between waits"]
emu["VT emulator<br/>vt100 behind a small internal trait, swappable"]
screen["Screen<br/>immutable grid snapshots · cells · cursor · styles"]
end
end
subgraph kernel["kernel"]
PTY["real PTY<br/>line discipline · TIOCSWINSZ → SIGWINCH"]
end
app["your app, unmodified<br/>believes it owns a terminal"]
test -->|"send(Key) · send_str"| api
api -->|"xterm byte sequences"| PTY
api -.->|"resize · kernel delivers SIGWINCH"| PTY
PTY -->|stdin| app
app -->|"stdout · escape sequences"| PTY
PTY -->|bytes| reader
reader -->|"process, under one lock"| emu
emu -->|"snapshot"| screen
screen -->|"predicates · insta snapshots · screen dumps in every timeout"| test
classDef ours fill:#2563eb,color:#ffffff,stroke:#1d4ed8,stroke-width:1px;
class api,reader,emu,screen ours
The reader thread drains the PTY into the emulator continuously — the kernel buffer can't fill up and stall your app, and no output is lost between assertions. Screens are immutable snapshots taken under the same lock the reader writes through, so every assertion sees a consistent instant. Four layers, one small internal trait between emulator and screen so the backend can be swapped; details in docs/DESIGN.md.
| Tool | Real PTY | Screen grid | Snapshots | Notes |
|---|---|---|---|---|
| termlens | ✔ | ✔ | ✔ | this crate |
| rexpect / expectrl | ✔ | ✗ | ✗ | stream matching, no rendered screen |
| term-transcript | ✗ | ~ | SVG | transcripts for docs, not assertions |
ratatui TestBackend |
✗ | ✔ | ~ | in-process only: your real binary, PTY layer, and non-ratatui output stay untested |
| teatest (Go) | ✔ | ✔ | ✔ | same idea, Bubble Tea / Go ecosystem |
PTYs are asynchronous; a harness that pretends otherwise is flaky by design. termlens's position:
- Prefer
wait_untilon visible content. It re-checks on every chunk of output and is exact: the condition either becomes true or you get a screen-carrying timeout. wait_idle(quiet)is an honest heuristic. It resolves when nothing arrived forquietand the stream isn't mid-escape-sequence. Silence is evidence a render finished — not proof. Use it for "the app settled", not for precise sequencing. DEC mode 2026 (synchronized output) gives real frame boundaries; await_framebuilt on it is on the roadmap.- Hermetic environments.
env_clear()blocks inheritance,TERM=xterm-256coloris pinned by default, fixtures draw no clocks and no animations. The CI suite runs a 100-iteration stress workflow on Linux and macOS — wait/timing changes don't merge without surviving it.
- No scrollback assertions, and resizing does not reflow scrollback — the visible grid is the testable surface.
- Unix only for now (Linux + macOS in CI). The PTY layer (
portable-pty) supports ConPTY, so Windows is planned, not designed out. - A child that writes and exits within its first milliseconds can lose
output to the OS PTY teardown (macOS especially). Long-lived TUIs are
unaffected; for run-and-exit programs, end the script with a
readand release it after asserting — see the "instant-exit caveat" in docs/DESIGN.md. - Styles (colors/bold/…) are captured per-cell and queryable, but not part
of the text snapshot format yet (
with_styles()planned for v0.2). - Exotic grapheme clusters render as the vt100 crate renders them; the unicode-torture fixture pins the current behavior.
Rust 1.85 (driven by the default insta feature's dependency tree;
checked in CI against the committed lockfile). MSRV bumps are minor
releases.
PRs welcome — see CONTRIBUTING.md (dev setup, testing policy, DCO sign-off, AI tooling policy) and docs/DESIGN.md before touching wait semantics. Security reports: SECURITY.md.
Licensed under either of Apache License, Version 2.0 or MIT license at your option — the Rust ecosystem's standard dual license. Apache-2.0 carries an express patent grant; MIT is maximally simple and GPLv2-compatible. Offering both lets every downstream user pick whichever their project or policy needs. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.