1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
//! Headless PTY test harness for CLI/TUI applications.
//!
//! `termlens` spawns your program in a **real pseudo-terminal**, feeds its
//! output through a **VT emulator** into an in-memory **screen grid**, and
//! lets tests **assert and snapshot on the rendered screen** instead of raw
//! bytes — Playwright for the terminal.
//!
//! - It is *not* an expect-style stream matcher (see `rexpect`/`expectrl`).
//! - It is *not* an SVG transcript generator for docs (see `term-transcript`).
//! - It *is*: real PTY + emulated screen + snapshot assertions.
//!
//! # Example
//!
//! ```
//! use std::time::Duration;
//! use termlens::{Key, Terminal};
//!
//! # fn main() -> termlens::Result<()> {
//! let mut t = Terminal::builder()
//! .size(80, 24)
//! .env("TERM", "xterm-256color") // the default; shown for completeness
//! .timeout(Duration::from_secs(10))
//! .args(["-c", r#"read line; echo "got: $line"; read quit"#])
//! .spawn("sh")?;
//!
//! t.send_str("hello")?;
//! t.send(Key::Enter)?;
//! t.wait_until(|screen| screen.contains("got: hello"))?;
//!
//! t.send(Key::Enter)?; // release `read quit`; the script finishes
//! let status = t.wait_exit()?;
//! assert!(status.success());
//! # Ok(())
//! # }
//! ```
//!
//! Every `wait_*` call runs under a deadline — the builder's
//! [`timeout`](TerminalBuilder::timeout) (default 5s) or a per-call one
//! ([`wait_until_for`](Terminal::wait_until_for) and friends) — and a
//! timeout error [embeds the screen](Error::Timeout) so a CI log alone
//! shows what the application was displaying. A background reader thread
//! drains the PTY continuously — no output is lost between waits — and
//! answers the queries a real terminal answers, so capability-probing apps
//! run instead of hanging.
//!
//! Where the application brackets its repaints in DEC 2026 synchronized
//! updates, [`wait_frame`](Terminal::wait_frame) evaluates predicates only
//! on **complete frames** and returns the one it matched — never a torn
//! repaint, and each call observes a frame no earlier call did. Content
//! that scrolls off the top is retained as well, so
//! [`full_text`](Screen::full_text) answers "this reached the terminal"
//! without the test having to know which region currently holds it.
//!
//! Input is mode-aware: [mouse clicks](Terminal::click),
//! [pastes](Terminal::paste), modifier [chords](Chord), and cursor keys
//! are encoded exactly as the application configured its terminal — and a
//! [drag](Terminal::drag) reports one motion per cell crossed, so an
//! application that acts along the path sees the path. [Focus
//! events](Terminal::focus_out) go the other way, reaching an application
//! that enabled mode 1004 so the unfocused branch of a UI can be driven at
//! all. The terminal's out-of-band state — the window title, the
//! alternate-screen flag, the input modes, the last `OSC 52`
//! [clipboard](Screen::clipboard) write, the
//! [cursor shape](Screen::cursor_shape) the application asked for, and the
//! `OSC 8` [hyperlinks](Screen::links) it emitted — is readable from every
//! [`Screen`] as plain accessors. Both of those last two leave the grid
//! identical: a bar cursor and a block cursor draw the same cells, and a
//! hyperlink's label renders as ordinary text with its URL nowhere on the
//! screen, so a test asserting a link used to pass against an application
//! that emitted none.
//!
//! Behaviour that leaves the screen **identical** is assertable too, which
//! no content predicate can manage: [`repaints`](Screen::repaints) counts
//! completed frames (so "one input became four repaints" is catchable),
//! [`bells`](Screen::bells) counts `BEL`, and
//! [`graphics`](Screen::graphics) counts the inline images an application
//! transmitted — often to assert that it transmitted *none*.
//! [`frame_timings`](Terminal::frame_timings) adds what each repaint cost,
//! so a suite can hold a performance line as well as a correctness one.
//!
//! Needles are matched by what the terminal draws rather than by how it is
//! spelled: [`contains`](Screen::contains) and [`find`](Screen::find) fold
//! both sides to NFC, so a needle typed in an editor finds text an
//! application normalized the other way. The grid keeps exactly the
//! codepoints the application sent.
//!
//! With the default `insta` feature, snapshot-test whole screens — after
//! waiting for what the application paints and for the picture to hold
//! still, which [`snapshot_after`](Terminal::snapshot_after) does in one
//! call:
//!
//! ```no_run
//! # fn main() -> termlens::Result<()> {
//! # let mut t = termlens::Terminal::builder().spawn("true")?;
//! let screen = t.snapshot_after(|s| s.contains("Ready"))?;
//! #[cfg(feature = "insta")]
//! insta::assert_snapshot!(screen); // or termlens::assert_screen_snapshot!(screen)
//! # Ok(())
//! # }
//! ```
//!
//! Testing a binary of your own package? [`bin!`] spawns
//! `CARGO_BIN_EXE_<name>` under the harness defaults — a fixed grid, a
//! cleared environment, a deadline — with builder calls after the name to
//! override any of them.
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;
pub use Signal;
pub use ;
/// What [`assert_screen_snapshot!`] snapshots: a [`Terminal`], settled, or a
/// [`Screen`] as it is. Implemented for `&mut Terminal` and `&Screen`, so the
/// macro's method-call syntax borrows a `Terminal` mutably and a `Screen`
/// immutably — whichever it was handed.
/// Re-export of [`insta`](https://insta.rs) (feature `insta`, on by
/// default), so [`assert_screen_snapshot!`] always agrees with the `insta`
/// version doing the snapshotting.
pub use insta;
/// Snapshot a terminal's screen the way a TUI snapshot has to be taken:
/// **settled**, **with its styles**, through [`insta::assert_snapshot!`].
///
/// ```no_run
/// # fn main() -> termlens::Result<()> {
/// # let mut t = termlens::Terminal::builder().spawn("true")?;
/// termlens::assert_screen_snapshot!(t); // settle 100ms, styles on
/// termlens::assert_screen_snapshot!(t, styles = false); // text only
/// termlens::assert_screen_snapshot!(t, after = |s| s.contains("Ready")); // wait for it, then settle
/// termlens::assert_screen_snapshot!(t.screen()); // a Screen you already hold
/// termlens::assert_screen_snapshot!(t, @""); // inline, filled by `cargo insta review`
/// # Ok(())
/// # }
/// ```
///
/// # The three decisions it makes
///
/// A snapshot of a TUI needs three decisions every time, and forgetting any
/// one produces a test that passes for the wrong reason:
///
/// 1. **Wait for the picture to settle.** `wait_until(pred)` guarantees the
/// bytes that made `pred` true were processed — and nothing more. A
/// repaint has no end marker, so the predicate can fire on a half-painted
/// screen, including half a row. Given a [`Terminal`], this macro takes
/// the screen after it has held still for 100 ms
/// ([`wait_stable`](Terminal::wait_stable)); with `after = pred` it waits
/// for the predicate first and then for the stillness
/// ([`snapshot_after`](Terminal::snapshot_after)). Name the *last* thing
/// the application paints, and rule 2 of `docs/DESIGN.md` §2 is met.
/// 2. **Snapshot the styles, not only the text.** A TUI regression is as often
/// a colour as a character — a highlight on the wrong row, a masked field
/// printed in clear — and the text rendering cannot see either. Styles are
/// on by default; `styles = false` is the text-only snapshot.
/// 3. **Snapshot one instant.** Every accessor of the [`Screen`] the macro
/// records reads the same snapshot, so what insta shows is one consistent
/// picture, never two waits' worth.
///
/// Given a [`Screen`] instead of a terminal, the macro records it as it is
/// (`after =` is refused: an instant has nothing to wait for). Failures come
/// from insta unchanged; review them with `cargo insta review`. The macro
/// uses `?`, so the test returns [`Result`] — which every test should, since
/// the `Display` of every error carries the screen.
///
/// `insta::assert_snapshot!(t.screen())` remains the low-level spelling for
/// a screen already waited for by hand.
/// Spawn one of this package's binaries under the harness defaults.
///
/// `termlens::bin!("myapp")` is the chain every integration test of a
/// binary starts from:
///
/// ```ignore
/// Terminal::builder()
/// .size(80, 24) // a fixed grid, so snapshots are stable
/// .env_clear() // nothing on the host leaks into the app
/// .timeout(Duration::from_secs(5)) // a hang is a readable failure, not a stuck job
/// .spawn(env!("CARGO_BIN_EXE_myapp"))
/// ```
///
/// Any builder method can follow the name as a call, and later calls
/// override the defaults:
///
/// ```ignore
/// let mut t = termlens::bin!("myapp")?;
/// let mut t = termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?;
/// let mut t = termlens::bin!("myapp", timeout(Duration::from_secs(30)), args(["--fast"]))?;
/// ```
///
/// `CARGO_BIN_EXE_<name>` is set by Cargo for the integration tests of the
/// package that owns the binary, so this works from that package's `tests/`
/// and a misspelled name is a compile error naming the variable rather than
/// a spawn failure at run time. The examples above are not compiled as
/// doctests for the same reason: this crate has no binary called `myapp`.
/// To spawn a program that is not one of your own binaries, or with
/// different defaults, use [`Terminal::builder`] directly — the macro adds
/// nothing else.