Integration testing for terminal programs, the way you would test a web app. termlens spawns your real binary in a real pseudo-terminal, renders its output through a VT emulator into an in-memory screen grid, and lets a test wait on, assert against and snapshot that grid — what a user would see, not the bytes that produced it.
Ratatui's snapshot-testing recipe
includes a termlens example for testing a compiled application through a PTY.
Use it alongside TestBackend snapshots to cover interactions and process
behaviour.
An in-process widget test renders your draw function into a buffer. It is
fast and precise, and it cannot see anything between draw and the user's
eyes. termlens is the second layer, for a small number of end-to-end flows
through the real binary:
- Terminal state — raw mode and the alternate screen entered and left,
the cursor restored after
qor a panic, the modes the application turned on and whether it turned them off. - What is actually drawn — box drawing, colours, wide characters, a
password field that is concealed rather than printed in clear, a stray
println!or panic message that never went through the framework. - Real input and real signals — keys, mouse and paste encoded exactly as
the terminal encodes them under the modes the application enabled; a
SIGWINCHthat the application must survive. - Behaviour that changes no cell — a repaint that drew nothing, a bell on a rejected key, an image that went out as pixels when it should have been text.
Every wait is deadline-bounded, and every failure carries the screen: a CI
log shows what the application was displaying, not assertion failed: false.
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"))?;
// Wait for the app's ready marker, let the picture settle, then snapshot
// it with its styles — the three decisions every TUI snapshot needs.
termlens::assert_screen_snapshot!(t, after = |s| s.contains("Ready"));
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}That builder chain is what every test of a package's own binary starts
from, so it has a name: termlens::bin!("myapp") spawns
CARGO_BIN_EXE_myapp at 80×24 with a cleared environment and a five-second
deadline; builder calls after the name override any of it —
termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?.
Optional features: decode (inline images as pixels), regex (patterns over
screen rows), serde (a Screen as JSON). insta is on by default and
provides assert_screen_snapshot!.
flowchart LR
subgraph yours["your test · cargo test"]
test["test function<br/>drive · wait · assert"]
end
subgraph lens["termlens"]
direction TB
api["Terminal<br/>send · click · paste · resize<br/>wait_until · wait_frame · snapshot_after"]
reader["reader thread<br/>drains the PTY continuously<br/>answers the queries a terminal answers"]
emu["VT emulator<br/>vt100 behind a small internal trait"]
screen["Screen<br/>immutable snapshot — cells · styles · cursor<br/>modes · counters · images"]
api --- reader
reader -->|"under one lock"| emu
emu -->|"snapshot"| screen
end
subgraph os["kernel"]
pty["PTY<br/>line discipline · SIGWINCH on resize"]
end
app["your application, unmodified<br/>it believes it owns a terminal"]
test -->|"keys · mouse · paste · resize"| api
api -->|"xterm byte sequences"| pty
pty <-->|"stdin · stdout"| app
pty -->|"bytes"| reader
screen -->|"predicates · insta snapshots<br/>the screen inside every timeout"| test
classDef lens fill:#1f6feb,stroke:#0d419d,color:#ffffff,stroke-width:1px
classDef yours fill:#57606a,stroke:#32383f,color:#ffffff,stroke-width:1px
classDef os fill:#9a6700,stroke:#7d4e00,color:#ffffff,stroke-width:1px
class api,reader,emu,screen lens
class test,app yours
class pty os
style yours fill:none,stroke:#8b949e,stroke-dasharray:4 3
style lens fill:none,stroke:#1f6feb
style os fill:none,stroke:#9a6700,stroke-dasharray:4 3
Four layers. A reader thread drains the PTY into the emulator
continuously, so the kernel buffer never fills and stalls the application,
and no output is lost between assertions. It also answers the queries a
real terminal answers — cursor position, device attributes, window size,
background colour, DECRQM mode probes, XTGETTCAP — so
capability-probing applications run instead of hanging; anything left
unanswered is named inside the next timeout. Every Screen is an
immutable snapshot taken under the same lock the reader writes through, so
an assertion sees one consistent instant. The emulator sits behind a small
internal trait; vt100 is the backend, with a second parser recovering the
three style attributes it drops (blink, conceal, strikethrough). The
mechanics, and the reasoning behind each decision, are in
docs/DESIGN.md.
PTYs are asynchronous. There is no sleep in termlens, and every wait
returns either the thing you asked for or a timeout carrying the screen.
| Call | Resolves when | Reach for it when |
|---|---|---|
wait_until(pred) |
pred(&screen) is true — re-checked on every chunk of output |
content appears; the default |
wait_frame(pred) |
a complete DEC 2026 frame satisfies pred; returns that frame, and each call sees a frame no earlier call did |
the application brackets repaints in synchronized updates (crossterm's BeginSynchronizedUpdate) |
snapshot_after(pred) |
pred holds and the picture has then held still; returns that screen |
a whole-screen snapshot — what assert_screen_snapshot! uses |
wait_stable(quiet) |
no cell, cursor or size change for quiet; returns the screen |
settling after a resize, or without a predicate |
wait_idle(quiet) |
no bytes for quiet, not mid-sequence, no update open |
output silence is itself the fact; a heuristic, and documented as one |
wait_exit() |
the process ended; returns its ExitStatus |
the end of every test |
Every wait has a _for(…, timeout) twin for one slow step. Input calls are
bounded too: typing into an application that has stopped reading, or into a
child that has exited, is an error carrying the screen rather than a hang.
The three rules for race-free waits — one predicate per instant, wait on
the last thing painted, settle before whole-screen snapshots — are
docs/DESIGN.md §2.
A clock in the title bar or a PID in the status line would break a snapshot on every run, and a text filter over the rendering shifts every column after it. Mask the grid instead — width, styles and cursor stay put:
let s = t.snapshot_after(|s| s.contains("Ready"))?;
insta::assert_snapshot!(s.mask_matching("12:34:56", '▒')); // a literal…
insta::assert_snapshot!(s.mask_rect(70.., ..1)); // …a rectangle…
// …or a pattern, with the `regex` feature:
insta::assert_snapshot!(s.mask_matches(®ex::Regex::new(r"\d\d:\d\d:\d\d")?, '▒'));Screen::diff(&other) reports what changed between two screens cell by
cell; Screen::parse reads a saved snapshot back, so the text termlens
prints — an insta .snap, the block a wait error leaves in a log — is also
its input format.
| Question | Accessors |
|---|---|
| What does the user see? | text(), row_text(row), cell(row, col), contains, find, find_all; with regex: matches, find_match, wait_until_matches |
| Is it styled as claimed? | cell(..).style() — colours, bold/dim, italic, underline, reverse, blink, conceal, strikethrough |
| Where is the cursor, and what shape? | cursor(), cursor_shape(), cursor_blink() |
| Which modes did the application turn on? | alternate_screen(), bracketed_paste(), mouse_mode(), focus_events(), application_cursor(), insert_mode() |
| What did it tell the terminal out of band? | title(), clipboard() (OSC 52), links() (OSC 8) |
| Did something happen that changed no cell? | repaints(), bells(), visual_bells(), graphics(), frame_timings() |
| What scrolled off? | scrollback_text(), full_text(), locate(needle), logical_text(), row_wrapped(row) |
| Did the emulator understand everything it was sent? | unsupported() — every sequence it did not implement, so a plausible grid can be told from a right one |
Input is mode-aware. send(Key), modifier chords, paste, click,
drag (one motion per cell crossed), scroll with modifiers, focus_in
/focus_out, resize and signal are encoded exactly as the application
configured its terminal — SGR mouse, bracketed paste, DECCKM — because the
emulator knows which modes it enabled.
Images are captured, not composited. graphics() reports every kitty
and sixel transmission — placement, declared size and cell extent, format,
id — and with the decode feature a payload decodes into a Bitmap:
let seen = screen.graphics();
let image = seen.last().expect("the chart went out as an image");
assert_eq!(image.cells(), Some((106, 7))); // on the cells reserved
assert_eq!(image.at(), (4, 5)); // at the grid's origin
assert_eq!(image.decode()?.pixel(9, 9), Some([0x39, 0xd3, 0x53, 0xff]));Scrollback is retained (1,000 rows by default; text only unless
scrollback_styles(true)), so pagers and log views that hand finished
output back to the terminal stay testable.
cargo install termlens-cli gives the same harness as a command:
termlens inspect --size 120x40 myapp # run a program, print its screen
termlens diff old.snap new.snap.new # the cell diff; exit 1 if anything changed
termlens render --svg failing.snap # a saved screen as SVG, HTML, ANSI or textIn CI, set TERMLENS_ARTIFACT_DIR on the test step and every screen a
failing wait embeds is also written there. The report action then puts
those screens, and every .snap.new with its diff, into the pull request's
step summary:
- run: cargo test
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
- uses: vyncint/termlens/.github/actions/report@v0.10.1
if: failure()| Tool | Real PTY | Screen grid | Snapshots | Notes |
|---|---|---|---|---|
| termlens | ✔ | ✔ | ✔ | this crate |
| rexpect / expectrl | ✔ | ✗ | ✗ | stream matching; termlens's regex feature offers wait_until_matches over a row of the screen |
| term-transcript | ✗ | ~ | SVG | transcripts for documentation, not assertions |
ratatui TestBackend |
✗ | ✔ | ~ | in-process: the real binary, the PTY layer and non-ratatui output stay untested |
| teatest (Go) | ✔ | ✔ | ✔ | the same idea for Bubble Tea |
TestBackend is the right tool for layout and rendering logic, and
termlens does not replace it. What it structurally cannot observe, and the
termlens assertion that does:
Invisible to TestBackend |
The assertion that sees it |
|---|---|
| raw-mode entry and exit, the alternate screen | t.wait_until(|s| s.alternate_screen()), and !alternate_screen() after q |
| a resize reaching the application | t.resize(60, 14)?; t.wait_frame(|s| s.contains("60x14")) |
output outside ratatui — a println!, a logger, a panic |
s.contains("panicked"), or a snapshot of the whole grid |
| a torn frame | wait_frame returns complete DEC 2026 frames only |
| capability probes and the modes they turn on | answer_queries replies as a terminal would; s.mouse_mode(), s.bracketed_paste() say what was asked for |
| mouse and paste bytes under the enabled modes | t.click(col, row), t.scroll(…), t.paste(…) encode for the mode the app turned on |
| a masked field that is really printed in clear | cell.style().conceal — identical text, different picture |
| the terminal state after exit | t.wait_exit()? then t.screen(): !alternate_screen(), cursor visible again |
fixtures/ratatui-app is the worked example: one draw rendered through
the PTY by termlens and in-process by TestBackend, diffed cell by cell
with Screen::diff at two sizes with a resize between. Where they disagree,
the bug is in the terminal layer — the layer nothing else tests.
| Linux | macOS | Windows (ConPTY) | |
|---|---|---|---|
Screen assertions — grid, styles, cursor, modes, title, links, resize, typed input, wait_until / wait_stable / snapshot_after, bin! |
✔ | ✔ | ✔ |
Frame assertions — wait_frame, frame_timings, record |
✔ | ✔ | — |
Graphics, mouse modes, focus events, Terminal::signal, the responder's outbound claims |
✔ | ✔ | — |
ConPTY renders the child's output into a screen of its own and re-emits
that, so what termlens claims on Windows is what survives the re-render.
The whole suite runs on windows-latest as a required check; each test the
platform cannot honour is #[cfg_attr(windows, ignore = "…")] with the
reason. The measurement is tests/conpty_probe.rs; the decision is
docs/STABILITY.md §1.
termlens is 0.x. docs/STABILITY.md states what 1.0
means — three decisions written down with the measurement behind each —
and which public items the compatibility promise covers. 0.11 is the
stability candidate: from that release no promised item changes
incompatibly before 1.0, and 1.0 follows the readiness criteria tracked in
#335, not a date.
MSRV is Rust 1.85, set by the default insta feature's dependency tree
and checked in CI against the committed lockfile; a bump is a minor
release. The ratatui-app fixture alone needs 1.88 and is not part of the
published crate.
The short list; the full one, with the reason behind each entry, is docs/LIMITATIONS.md.
- Terminal dimensions are 2–1000 cells per axis.
wait_frameneeds the application to bracket repaints in DEC 2026 synchronized updates; the last 8 completed frames are retained.- Scrollback is bounded (1,000 rows), text-only unless asked, and not reflowed on resize.
- Graphics are captured and decodable, never composited onto the grid; PNG payloads are reported as unsupported rather than decoded.
- Hyperlinks are recorded as spans, not attributed to cells.
- Overline and double underline are not modelled; bold and dim are one intensity state.
- A child that writes and exits within its first milliseconds can lose
output to PTY teardown (macOS especially); end such scripts with a
read.
Agents write terminal tests badly in predictable ways — a sleep where a
wait belongs, a snapshot taken mid-repaint, (row, col) handed to a method
that wants (col, row). skills/termlens/SKILL.md
is the counter to each: the model, the rules, the API on one page and four
recipes. Every Rust block in it is compiled against the crate in CI.
mkdir -p ~/.claude/skills/termlens && curl -sSL https://raw.githubusercontent.com/vyncint/termlens/main/skills/termlens/SKILL.md -o ~/.claude/skills/termlens/SKILL.mdOther agents take the same file — a Cursor rule, or a reference from
.github/copilot-instructions.md.
| docs/DESIGN.md | the four layers, wait semantics and the three rules, the snapshot text format, why the emulator is where it is |
| docs/STABILITY.md | what 1.0 means, and what the promise covers |
| docs/LIMITATIONS.md | everything termlens does not model or claim, with reasons |
| docs/BACKENDS.md | the emulator comparison behind keeping vt100 |
| skills/termlens/SKILL.md | the agent skill: rules, API cheat sheet, recipes |
| CHANGELOG.md | every release, breaking changes under Changed / Removed |
| docs.rs | the API reference |
Pull requests are welcome — CONTRIBUTING.md has the dev
setup, the testing policy and the DCO sign-off. Three things to know before
starting: every change lands with tests; anything touching wait semantics
must pass the 100-iteration stress workflow
on Linux, macOS and Windows; snapshot updates are reviewed diffs
(cargo insta review), never blind accepts. Security reports go to
SECURITY.md.
Licensed under either of Apache License, Version 2.0 or MIT license at your option. 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.