term_transcript/lib.rs
1//! Snapshot testing for CLI / REPL applications, in a fun way.
2//!
3//! # What it does
4//!
5//! This crate allows to:
6//!
7//! - Create [`Transcript`]s of interacting with a terminal, capturing both the output text
8//! and [ANSI-compatible color info][SGR].
9//! - Save these transcripts in the [SVG] format, so that they can be easily embedded as images
10//! into HTML / Markdown documents. (Output format customization
11//! [is also supported](svg::Template#customization) via [Handlebars] templates.)
12//! - Parse transcripts from SVG
13//! - Test that a parsed transcript actually corresponds to the terminal output (either as text
14//! or text + colors).
15//!
16//! The primary use case is easy to create and maintain end-to-end tests for CLI / REPL apps.
17//! Such tests can be embedded into a readme file.
18//!
19//! # Design decisions
20//!
21//! - **Static capturing.** Capturing dynamic interaction with the terminal essentially
22//! requires writing / hacking together a new terminal, which looks like an overkill
23//! for the motivating use case (snapshot testing).
24//!
25//! - **(Primarily) static SVGs.** Animated SVGs create visual noise and make simple things
26//! (e.g., copying text from an SVG) harder than they should be.
27//!
28//! - **Self-contained tests.** Unlike generic snapshot files, [`Transcript`]s contain
29//! both user inputs and outputs. This allows using them as images with little additional
30//! explanation.
31//!
32//! # Limitations
33//!
34//! - Terminal coloring only works with ANSI escape codes. (Since ANSI escape codes
35//! are supported even on Windows nowadays, this shouldn't be a significant problem.)
36//! - ANSI escape sequences other than [SGR] ones are either dropped (in case of [CSI]
37//! and OSC sequences), or lead to [`TermError::UnrecognizedSequence`].
38//! - By default, the crate exposes APIs to perform capture via OS pipes.
39//! Since the terminal is not emulated in this case, programs dependent on [`isatty`] checks
40//! or getting term size can produce different output than if launched in an actual shell
41//! (no coloring, no line wrapping etc.).
42//! - It is possible to capture output from a pseudo-terminal (PTY) using the `portable-pty`
43//! crate feature. However, since most escape sequences are dropped, this is still not a good
44//! option to capture complex outputs (e.g., ones moving cursor).
45//!
46//! # Alternatives / similar tools
47//!
48//! - [`insta`](https://crates.io/crates/insta) is a generic snapshot testing library, which
49//! is amazing in general, but *kind of* too low-level for E2E CLI testing.
50//! - [`rexpect`](https://crates.io/crates/rexpect) allows testing CLI / REPL applications
51//! by scripting interactions with them in tests. It works in Unix only.
52//! - [`trybuild`](https://crates.io/crates/trybuild) snapshot-tests output
53//! of a particular program (the Rust compiler).
54//! - [`trycmd`](https://crates.io/crates/trycmd) snapshot-tests CLI apps using
55//! a text-based format.
56//! - Tools like [`termtosvg`](https://github.com/nbedos/termtosvg) and
57//! [Asciinema](https://asciinema.org/) allow recording terminal sessions and save them to SVG.
58//! The output of these tools is inherently *dynamic* (which, e.g., results in animated SVGs).
59//! This crate [intentionally chooses](#design-decisions) a simpler static format, which
60//! makes snapshot testing easier.
61//!
62//! # Crate features
63//!
64//! ## `portable-pty`
65//!
66//! *(Off by default)*
67//!
68//! Allows using pseudo-terminal (PTY) to capture terminal output rather than pipes.
69//! Uses [the eponymous crate][`portable-pty`] under the hood.
70//!
71//! ## `svg`
72//!
73//! *(On by default)*
74//!
75//! Exposes [the eponymous module](svg) that allows rendering [`Transcript`]s
76//! into the SVG format.
77//!
78//! ## `font-subset`
79//!
80//! *(Off by default)*
81//!
82//! Enables subsetting and embedding OpenType fonts into snapshots. Requires the `svg` feature.
83//!
84//! ## `test`
85//!
86//! *(On by default)*
87//!
88//! Exposes [the eponymous module](crate::test) that allows parsing [`Transcript`]s
89//! from SVG files and testing them.
90//!
91//! ## `pretty_assertions`
92//!
93//! *(On by default)*
94//!
95//! Uses [the eponymous crate][`pretty_assertions`] when testing SVG files.
96//! Only really makes sense together with the `test` feature.
97//!
98//! ## `tracing`
99//!
100//! *(Off by default)*
101//!
102//! Uses [the eponymous facade][`tracing`] to trace main operations, which could be useful
103//! for debugging. Tracing is mostly performed on the `DEBUG` level.
104//!
105//! [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG
106//! [SGR]: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR
107//! [CSI]: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
108//! [`isatty`]: https://man7.org/linux/man-pages/man3/isatty.3.html
109//! [Handlebars]: https://handlebarsjs.com/
110//! [`pretty_assertions`]: https://docs.rs/pretty_assertions/
111//! [`portable-pty`]: https://docs.rs/portable-pty/
112//! [`tracing`]: https://docs.rs/tracing/
113//!
114//! # Examples
115//!
116//! Creating a terminal [`Transcript`] and rendering it to SVG.
117//!
118//! ```
119//! use term_transcript::{
120//! svg::{Template, TemplateOptions}, ShellOptions, Transcript, UserInput,
121//! };
122//! # use std::str;
123//!
124//! # fn main() -> anyhow::Result<()> {
125//! let transcript = Transcript::from_inputs(
126//! &mut ShellOptions::default(),
127//! vec![UserInput::command(r#"echo "Hello world!""#)],
128//! )?;
129//! let mut writer = vec![];
130//! // ^ Any `std::io::Write` implementation will do, such as a `File`.
131//! Template::default().render(&transcript, &mut writer)?;
132//! println!("{}", str::from_utf8(&writer)?);
133//! # Ok(())
134//! # }
135//! ```
136//!
137//! Snapshot testing. See the [`test` module](crate::test) for more examples.
138//!
139//! ```no_run
140//! use term_transcript::{test::TestConfig, ShellOptions};
141//!
142//! #[test]
143//! fn echo_works() {
144//! TestConfig::new(ShellOptions::default()).test(
145//! "tests/__snapshots__/echo.svg",
146//! &[r#"echo "Hello world!""#],
147//! );
148//! }
149//! ```
150
151// Documentation settings.
152#![doc(html_root_url = "https://docs.rs/term-transcript/0.4.0")]
153#![cfg_attr(docsrs, feature(doc_cfg))]
154
155use std::{borrow::Cow, error::Error as StdError, fmt, io, num::ParseIntError, str::Utf8Error};
156
157#[cfg(feature = "portable-pty")]
158pub use self::pty::{PtyCommand, PtyShell};
159pub use self::{
160 shell::{ShellOptions, StdShell},
161 term::{Captured, TermOutput},
162};
163
164#[cfg(feature = "portable-pty")]
165mod pty;
166mod shell;
167mod style;
168#[cfg(feature = "svg")]
169#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
170pub mod svg;
171mod term;
172#[cfg(feature = "test")]
173#[cfg_attr(docsrs, doc(cfg(feature = "test")))]
174pub mod test;
175pub mod traits;
176mod utils;
177
178pub(crate) type BoxedError = Box<dyn std::error::Error + Send + Sync>;
179
180/// Errors that can occur when processing terminal output.
181#[derive(Debug)]
182#[non_exhaustive]
183pub enum TermError {
184 /// Unfinished escape sequence.
185 UnfinishedSequence,
186 /// Unrecognized escape sequence (not a CSI or OSC one). The enclosed byte
187 /// is the first byte of the sequence (excluding `0x1b`).
188 UnrecognizedSequence(u8),
189 /// Invalid final byte for an SGR escape sequence.
190 InvalidSgrFinalByte(u8),
191 /// Unfinished color spec.
192 UnfinishedColor,
193 /// Invalid type of a color spec.
194 InvalidColorType(String),
195 /// Invalid ANSI color index.
196 InvalidColorIndex(ParseIntError),
197 /// UTF-8 decoding error.
198 Utf8(Utf8Error),
199 /// IO error.
200 Io(io::Error),
201 /// Font embedding error.
202 FontEmbedding(BoxedError),
203}
204
205impl fmt::Display for TermError {
206 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207 match self {
208 Self::UnfinishedSequence => formatter.write_str("Unfinished ANSI escape sequence"),
209 Self::UnrecognizedSequence(byte) => {
210 write!(
211 formatter,
212 "Unrecognized escape sequence (first byte is {byte})"
213 )
214 }
215 Self::InvalidSgrFinalByte(byte) => {
216 write!(
217 formatter,
218 "Invalid final byte for an SGR escape sequence: {byte}"
219 )
220 }
221 Self::UnfinishedColor => formatter.write_str("Unfinished color spec"),
222 Self::InvalidColorType(ty) => {
223 write!(formatter, "Invalid type of a color spec: {ty}")
224 }
225 Self::InvalidColorIndex(err) => {
226 write!(formatter, "Failed parsing color index: {err}")
227 }
228 Self::Utf8(err) => write!(formatter, "UTF-8 decoding error: {err}"),
229 Self::Io(err) => write!(formatter, "I/O error: {err}"),
230 Self::FontEmbedding(err) => write!(formatter, "font embedding error: {err}"),
231 }
232 }
233}
234
235impl StdError for TermError {
236 fn source(&self) -> Option<&(dyn StdError + 'static)> {
237 match self {
238 Self::InvalidColorIndex(err) => Some(err),
239 Self::Utf8(err) => Some(err),
240 Self::Io(err) => Some(err),
241 _ => None,
242 }
243 }
244}
245
246impl From<Utf8Error> for TermError {
247 fn from(err: Utf8Error) -> Self {
248 Self::Utf8(err)
249 }
250}
251
252/// Transcript of a user interacting with the terminal.
253#[derive(Debug, Clone)]
254pub struct Transcript<Out: TermOutput = Captured> {
255 interactions: Vec<Interaction<Out>>,
256}
257
258impl<Out: TermOutput> Default for Transcript<Out> {
259 fn default() -> Self {
260 Self {
261 interactions: vec![],
262 }
263 }
264}
265
266impl<Out: TermOutput> Transcript<Out> {
267 /// Creates an empty transcript.
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 /// Returns interactions in this transcript.
273 pub fn interactions(&self) -> &[Interaction<Out>] {
274 &self.interactions
275 }
276
277 /// Returns a mutable reference to interactions in this transcript.
278 pub fn interactions_mut(&mut self) -> &mut [Interaction<Out>] {
279 &mut self.interactions
280 }
281}
282
283impl Transcript {
284 /// Manually adds a new interaction to the end of this transcript.
285 ///
286 /// This method allows capturing interactions that are difficult or impossible to capture
287 /// using more high-level methods: [`Self::from_inputs()`] or [`Self::capture_output()`].
288 /// The resulting transcript will [render](svg) just fine, but there could be issues
289 /// with [testing](crate::test) it.
290 pub fn add_existing_interaction(&mut self, interaction: Interaction) -> &mut Self {
291 self.interactions.push(interaction);
292 self
293 }
294
295 /// Manually adds a new interaction to the end of this transcript.
296 ///
297 /// This is a shortcut for calling [`Self::add_existing_interaction()`].
298 pub fn add_interaction(
299 &mut self,
300 input: impl Into<UserInput>,
301 output: impl Into<String>,
302 ) -> &mut Self {
303 self.add_existing_interaction(Interaction::new(input, output))
304 }
305}
306
307/// Portable, platform-independent version of [`ExitStatus`] from the standard library.
308///
309/// # Capturing `ExitStatus`
310///
311/// Some shells have means to check whether the input command was executed successfully.
312/// For example, in `sh`-like shells, one can compare the value of `$?` to 0, and
313/// in PowerShell to `True`. The exit status can be captured when creating a [`Transcript`]
314/// by setting a *checker* in [`ShellOptions::with_status_check()`]:
315///
316/// # Examples
317///
318/// ```
319/// # use term_transcript::{ExitStatus, ShellOptions, Transcript, UserInput};
320/// # fn test_wrapper() -> anyhow::Result<()> {
321/// let options = ShellOptions::default();
322/// let mut options = options.with_status_check("echo $?", |captured| {
323/// // Parse captured string to plain text. This transform
324/// // is especially important in transcripts captured from PTY
325/// // since they can contain a *wild* amount of escape sequences.
326/// let captured = captured.to_plaintext().ok()?;
327/// let code: i32 = captured.trim().parse().ok()?;
328/// Some(ExitStatus(code))
329/// });
330///
331/// let transcript = Transcript::from_inputs(&mut options, [
332/// UserInput::command("echo \"Hello world\""),
333/// UserInput::command("some-non-existing-command"),
334/// ])?;
335/// let status = transcript.interactions()[0].exit_status();
336/// assert!(status.unwrap().is_success());
337/// // The assertion above is equivalent to:
338/// assert_eq!(status, Some(ExitStatus(0)));
339///
340/// let status = transcript.interactions()[1].exit_status();
341/// assert!(!status.unwrap().is_success());
342/// # Ok(())
343/// # }
344/// # // We can compile test in any case, but it successfully executes only on *nix.
345/// # #[cfg(unix)] fn main() { test_wrapper().unwrap() }
346/// # #[cfg(not(unix))] fn main() { }
347/// ```
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub struct ExitStatus(pub i32);
350
351impl ExitStatus {
352 /// Checks if this is the successful status.
353 pub fn is_success(self) -> bool {
354 self.0 == 0
355 }
356}
357
358/// One-time interaction with the terminal.
359#[derive(Debug, Clone)]
360pub struct Interaction<Out: TermOutput = Captured> {
361 input: UserInput,
362 output: Out,
363 exit_status: Option<ExitStatus>,
364}
365
366impl Interaction {
367 /// Creates a new interaction.
368 pub fn new(input: impl Into<UserInput>, output: impl Into<String>) -> Self {
369 Self {
370 input: input.into(),
371 output: Captured::from(output.into()),
372 exit_status: None,
373 }
374 }
375
376 /// Sets an exit status for this interaction.
377 pub fn set_exit_status(&mut self, exit_status: Option<ExitStatus>) {
378 self.exit_status = exit_status;
379 }
380
381 /// Assigns an exit status to this interaction.
382 #[must_use]
383 pub fn with_exit_status(mut self, exit_status: ExitStatus) -> Self {
384 self.exit_status = Some(exit_status);
385 self
386 }
387}
388
389impl<Out: TermOutput> Interaction<Out> {
390 /// Input provided by the user.
391 pub fn input(&self) -> &UserInput {
392 &self.input
393 }
394
395 /// Output to the terminal.
396 pub fn output(&self) -> &Out {
397 &self.output
398 }
399
400 /// Sets the output for this interaction.
401 pub fn set_output(&mut self, output: Out) {
402 self.output = output;
403 }
404
405 /// Returns exit status of the interaction, if available.
406 pub fn exit_status(&self) -> Option<ExitStatus> {
407 self.exit_status
408 }
409}
410
411/// User input during interaction with a terminal.
412#[derive(Debug, Clone, PartialEq, Eq)]
413#[cfg_attr(feature = "svg", derive(serde::Serialize))]
414pub struct UserInput {
415 text: String,
416 prompt: Option<Cow<'static, str>>,
417 hidden: bool,
418}
419
420impl UserInput {
421 #[cfg(feature = "test")]
422 pub(crate) fn intern_prompt(prompt: String) -> Cow<'static, str> {
423 match prompt.as_str() {
424 "$" => Cow::Borrowed("$"),
425 ">>>" => Cow::Borrowed(">>>"),
426 "..." => Cow::Borrowed("..."),
427 _ => Cow::Owned(prompt),
428 }
429 }
430
431 /// Creates a command input.
432 pub fn command(text: impl Into<String>) -> Self {
433 Self {
434 text: text.into(),
435 prompt: Some(Cow::Borrowed("$")),
436 hidden: false,
437 }
438 }
439
440 /// Creates a standalone / starting REPL command input with the `>>>` prompt.
441 pub fn repl(text: impl Into<String>) -> Self {
442 Self {
443 text: text.into(),
444 prompt: Some(Cow::Borrowed(">>>")),
445 hidden: false,
446 }
447 }
448
449 /// Creates a REPL command continuation input with the `...` prompt.
450 pub fn repl_continuation(text: impl Into<String>) -> Self {
451 Self {
452 text: text.into(),
453 prompt: Some(Cow::Borrowed("...")),
454 hidden: false,
455 }
456 }
457
458 /// Returns the prompt part of this input.
459 pub fn prompt(&self) -> Option<&str> {
460 self.prompt.as_deref()
461 }
462
463 /// Marks this input as hidden (one that should not be displayed in the rendered transcript).
464 #[must_use]
465 pub fn hide(mut self) -> Self {
466 self.hidden = true;
467 self
468 }
469}
470
471/// Returns the command part of the input without the prompt.
472impl AsRef<str> for UserInput {
473 fn as_ref(&self) -> &str {
474 &self.text
475 }
476}
477
478/// Calls [`Self::command()`] on the provided string reference.
479impl From<&str> for UserInput {
480 fn from(command: &str) -> Self {
481 Self::command(command)
482 }
483}
484
485#[cfg(doctest)]
486doc_comment::doctest!("../README.md");