Skip to content

termlens

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.

CI crates.io docs.rs MSRV license

cargo add termlens --dev
cargo add insta --dev    # used by the snapshot assertions below

Example

use 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".

What it is (and is not)

  • 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.

How it works

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
Loading

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.

Comparison

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

Determinism

PTYs are asynchronous; a harness that pretends otherwise is flaky by design. termlens's position:

  • Prefer wait_until on 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 for quiet and 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; a wait_frame built on it is on the roadmap.
  • Hermetic environments. env_clear() blocks inheritance, TERM=xterm-256color is 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.

Known limitations (v0.1)

  • 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 read and 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.

MSRV

Rust 1.85 (driven by the default insta feature's dependency tree; checked in CI against the committed lockfile). MSRV bumps are minor releases.

Contributing

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.

License

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.

About

Headless PTY test harness for CLI/TUI apps — spawn in a real PTY, assert on the rendered screen

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages