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//! ## `test`
79//!
80//! *(On by default)*
81//!
82//! Exposes [the eponymous module](crate::test) that allows parsing [`Transcript`]s
83//! from SVG files and testing them.
84//!
85//! ## `pretty_assertions`
86//!
87//! *(On by default)*
88//!
89//! Uses [the eponymous crate][`pretty_assertions`] when testing SVG files.
90//! Only really makes sense together with the `test` feature.
91//!
92//! ## `tracing`
93//!
94//! *(Off by default)*
95//!
96//! Uses [the eponymous facade][`tracing`] to trace main operations, which could be useful
97//! for debugging. Tracing is mostly performed on the `DEBUG` level.
98//!
99//! [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG
100//! [SGR]: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR
101//! [CSI]: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
102//! [`isatty`]: https://man7.org/linux/man-pages/man3/isatty.3.html
103//! [Handlebars]: https://handlebarsjs.com/
104//! [`pretty_assertions`]: https://docs.rs/pretty_assertions/
105//! [`portable-pty`]: https://docs.rs/portable-pty/
106//! [`tracing`]: https://docs.rs/tracing/
107//!
108//! # Examples
109//!
110//! Creating a terminal [`Transcript`] and rendering it to SVG.
111//!
112//! ```
113//! use term_transcript::{
114//!     svg::{Template, TemplateOptions}, ShellOptions, Transcript, UserInput,
115//! };
116//! # use std::str;
117//!
118//! # fn main() -> anyhow::Result<()> {
119//! let transcript = Transcript::from_inputs(
120//!     &mut ShellOptions::default(),
121//!     vec![UserInput::command(r#"echo "Hello world!""#)],
122//! )?;
123//! let mut writer = vec![];
124//! // ^ Any `std::io::Write` implementation will do, such as a `File`.
125//! Template::new(TemplateOptions::default()).render(&transcript, &mut writer)?;
126//! println!("{}", str::from_utf8(&writer)?);
127//! # Ok(())
128//! # }
129//! ```
130//!
131//! Snapshot testing. See the [`test` module](crate::test) for more examples.
132//!
133//! ```no_run
134//! use term_transcript::{test::TestConfig, ShellOptions};
135//!
136//! #[test]
137//! fn echo_works() {
138//!     TestConfig::new(ShellOptions::default()).test(
139//!         "tests/__snapshots__/echo.svg",
140//!         &[r#"echo "Hello world!""#],
141//!     );
142//! }
143//! ```
144
145// Documentation settings.
146#![doc(html_root_url = "https://docs.rs/term-transcript/0.4.0")]
147#![cfg_attr(docsrs, feature(doc_cfg))]
148// Linter settings.
149#![warn(missing_debug_implementations, missing_docs, bare_trait_objects)]
150#![warn(clippy::all, clippy::pedantic)]
151#![allow(clippy::must_use_candidate, clippy::module_name_repetitions)]
152
153use std::{borrow::Cow, error::Error as StdError, fmt, io, num::ParseIntError};
154
155#[cfg(feature = "portable-pty")]
156mod pty;
157mod shell;
158#[cfg(feature = "svg")]
159#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
160pub mod svg;
161mod term;
162#[cfg(feature = "test")]
163#[cfg_attr(docsrs, doc(cfg(feature = "test")))]
164pub mod test;
165pub mod traits;
166mod utils;
167mod write;
168
169#[cfg(feature = "portable-pty")]
170pub use self::pty::{PtyCommand, PtyShell};
171pub use self::{
172    shell::{ShellOptions, StdShell},
173    term::{Captured, TermOutput},
174};
175
176/// Errors that can occur when processing terminal output.
177#[derive(Debug)]
178#[non_exhaustive]
179pub enum TermError {
180    /// Unfinished escape sequence.
181    UnfinishedSequence,
182    /// Unrecognized escape sequence (not a CSI or OSC one). The enclosed byte
183    /// is the first byte of the sequence (excluding `0x1b`).
184    UnrecognizedSequence(u8),
185    /// Invalid final byte for an SGR escape sequence.
186    InvalidSgrFinalByte(u8),
187    /// Unfinished color spec.
188    UnfinishedColor,
189    /// Invalid type of a color spec.
190    InvalidColorType(String),
191    /// Invalid ANSI color index.
192    InvalidColorIndex(ParseIntError),
193    /// IO error.
194    Io(io::Error),
195}
196
197impl fmt::Display for TermError {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        match self {
200            Self::UnfinishedSequence => formatter.write_str("Unfinished ANSI escape sequence"),
201            Self::UnrecognizedSequence(byte) => {
202                write!(
203                    formatter,
204                    "Unrecognized escape sequence (first byte is {byte})"
205                )
206            }
207            Self::InvalidSgrFinalByte(byte) => {
208                write!(
209                    formatter,
210                    "Invalid final byte for an SGR escape sequence: {byte}"
211                )
212            }
213            Self::UnfinishedColor => formatter.write_str("Unfinished color spec"),
214            Self::InvalidColorType(ty) => {
215                write!(formatter, "Invalid type of a color spec: {ty}")
216            }
217            Self::InvalidColorIndex(err) => {
218                write!(formatter, "Failed parsing color index: {err}")
219            }
220            Self::Io(err) => write!(formatter, "I/O error: {err}"),
221        }
222    }
223}
224
225impl StdError for TermError {
226    fn source(&self) -> Option<&(dyn StdError + 'static)> {
227        match self {
228            Self::InvalidColorIndex(err) => Some(err),
229            Self::Io(err) => Some(err),
230            _ => None,
231        }
232    }
233}
234
235/// Transcript of a user interacting with the terminal.
236#[derive(Debug, Clone)]
237pub struct Transcript<Out: TermOutput = Captured> {
238    interactions: Vec<Interaction<Out>>,
239}
240
241impl<Out: TermOutput> Default for Transcript<Out> {
242    fn default() -> Self {
243        Self {
244            interactions: vec![],
245        }
246    }
247}
248
249impl<Out: TermOutput> Transcript<Out> {
250    /// Creates an empty transcript.
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// Returns interactions in this transcript.
256    pub fn interactions(&self) -> &[Interaction<Out>] {
257        &self.interactions
258    }
259
260    /// Returns a mutable reference to interactions in this transcript.
261    pub fn interactions_mut(&mut self) -> &mut [Interaction<Out>] {
262        &mut self.interactions
263    }
264}
265
266impl Transcript {
267    /// Manually adds a new interaction to the end of this transcript.
268    ///
269    /// This method allows capturing interactions that are difficult or impossible to capture
270    /// using more high-level methods: [`Self::from_inputs()`] or [`Self::capture_output()`].
271    /// The resulting transcript will [render](svg) just fine, but there could be issues
272    /// with [testing](crate::test) it.
273    pub fn add_existing_interaction(&mut self, interaction: Interaction) -> &mut Self {
274        self.interactions.push(interaction);
275        self
276    }
277
278    /// Manually adds a new interaction to the end of this transcript.
279    ///
280    /// This is a shortcut for calling [`Self::add_existing_interaction(_)`].
281    pub fn add_interaction(
282        &mut self,
283        input: impl Into<UserInput>,
284        output: impl Into<String>,
285    ) -> &mut Self {
286        self.add_existing_interaction(Interaction::new(input, output))
287    }
288}
289
290/// Portable, platform-independent version of [`ExitStatus`] from the standard library.
291///
292/// # Capturing `ExitStatus`
293///
294/// Some shells have means to check whether the input command was executed successfully.
295/// For example, in `sh`-like shells, one can compare the value of `$?` to 0, and
296/// in PowerShell to `True`. The exit status can be captured when creating a [`Transcript`]
297/// by setting a *checker* in [`ShellOptions::with_status_check()`]:
298///
299/// # Examples
300///
301/// ```
302/// # use term_transcript::{ExitStatus, ShellOptions, Transcript, UserInput};
303/// # fn test_wrapper() -> anyhow::Result<()> {
304/// let options = ShellOptions::default();
305/// let mut options = options.with_status_check("echo $?", |captured| {
306///     // Parse captured string to plain text. This transform
307///     // is especially important in transcripts captured from PTY
308///     // since they can contain a *wild* amount of escape sequences.
309///     let captured = captured.to_plaintext().ok()?;
310///     let code: i32 = captured.trim().parse().ok()?;
311///     Some(ExitStatus(code))
312/// });
313///
314/// let transcript = Transcript::from_inputs(&mut options, [
315///     UserInput::command("echo \"Hello world\""),
316///     UserInput::command("some-non-existing-command"),
317/// ])?;
318/// let status = transcript.interactions()[0].exit_status();
319/// assert!(status.unwrap().is_success());
320/// // The assertion above is equivalent to:
321/// assert_eq!(status, Some(ExitStatus(0)));
322///
323/// let status = transcript.interactions()[1].exit_status();
324/// assert!(!status.unwrap().is_success());
325/// # Ok(())
326/// # }
327/// # // We can compile test in any case, but it successfully executes only on *nix.
328/// # #[cfg(unix)] fn main() { test_wrapper().unwrap() }
329/// # #[cfg(not(unix))] fn main() { }
330/// ```
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
332pub struct ExitStatus(pub i32);
333
334impl ExitStatus {
335    /// Checks if this is the successful status.
336    pub fn is_success(self) -> bool {
337        self.0 == 0
338    }
339}
340
341/// One-time interaction with the terminal.
342#[derive(Debug, Clone)]
343pub struct Interaction<Out: TermOutput = Captured> {
344    input: UserInput,
345    output: Out,
346    exit_status: Option<ExitStatus>,
347}
348
349impl Interaction {
350    /// Creates a new interaction.
351    pub fn new(input: impl Into<UserInput>, output: impl Into<String>) -> Self {
352        Self {
353            input: input.into(),
354            output: Captured::from(output.into()),
355            exit_status: None,
356        }
357    }
358
359    /// Assigns an exit status to this interaction.
360    #[must_use]
361    pub fn with_exit_status(mut self, exit_status: ExitStatus) -> Self {
362        self.exit_status = Some(exit_status);
363        self
364    }
365}
366
367impl<Out: TermOutput> Interaction<Out> {
368    /// Input provided by the user.
369    pub fn input(&self) -> &UserInput {
370        &self.input
371    }
372
373    /// Output to the terminal.
374    pub fn output(&self) -> &Out {
375        &self.output
376    }
377
378    /// Sets the output for this interaction.
379    pub fn set_output(&mut self, output: Out) {
380        self.output = output;
381    }
382
383    /// Returns exit status of the interaction, if available.
384    pub fn exit_status(&self) -> Option<ExitStatus> {
385        self.exit_status
386    }
387}
388
389/// User input during interaction with a terminal.
390#[derive(Debug, Clone, PartialEq, Eq)]
391#[cfg_attr(feature = "svg", derive(serde::Serialize))]
392pub struct UserInput {
393    text: String,
394    prompt: Option<Cow<'static, str>>,
395    hidden: bool,
396}
397
398impl UserInput {
399    #[cfg(feature = "test")]
400    pub(crate) fn intern_prompt(prompt: String) -> Cow<'static, str> {
401        match prompt.as_str() {
402            "$" => Cow::Borrowed("$"),
403            ">>>" => Cow::Borrowed(">>>"),
404            "..." => Cow::Borrowed("..."),
405            _ => Cow::Owned(prompt),
406        }
407    }
408
409    /// Creates a command input.
410    pub fn command(text: impl Into<String>) -> Self {
411        Self {
412            text: text.into(),
413            prompt: Some(Cow::Borrowed("$")),
414            hidden: false,
415        }
416    }
417
418    /// Creates a standalone / starting REPL command input with the `>>>` prompt.
419    pub fn repl(text: impl Into<String>) -> Self {
420        Self {
421            text: text.into(),
422            prompt: Some(Cow::Borrowed(">>>")),
423            hidden: false,
424        }
425    }
426
427    /// Creates a REPL command continuation input with the `...` prompt.
428    pub fn repl_continuation(text: impl Into<String>) -> Self {
429        Self {
430            text: text.into(),
431            prompt: Some(Cow::Borrowed("...")),
432            hidden: false,
433        }
434    }
435
436    /// Returns the prompt part of this input.
437    pub fn prompt(&self) -> Option<&str> {
438        self.prompt.as_deref()
439    }
440
441    /// Marks this input as hidden (one that should not be displayed in the rendered transcript).
442    #[must_use]
443    pub fn hide(mut self) -> Self {
444        self.hidden = true;
445        self
446    }
447}
448
449/// Returns the command part of the input without the prompt.
450impl AsRef<str> for UserInput {
451    fn as_ref(&self) -> &str {
452        &self.text
453    }
454}
455
456/// Calls [`Self::command()`] on the provided string reference.
457impl From<&str> for UserInput {
458    fn from(command: &str) -> Self {
459        Self::command(command)
460    }
461}
462
463#[cfg(doctest)]
464doc_comment::doctest!("../README.md");