term_transcript/test/
mod.rs

1//! Snapshot testing tools for [`Transcript`]s.
2//!
3//! # Examples
4//!
5//! Simple scenario in which the tested transcript calls to one or more Cargo binaries / examples
6//! by their original names.
7//!
8//! ```no_run
9//! use term_transcript::{
10//!     ShellOptions, Transcript,
11//!     test::{MatchKind, TestConfig, TestOutputConfig},
12//! };
13//!
14//! // Test configuration that can be shared across tests.
15//! fn config() -> TestConfig {
16//!     let shell_options = ShellOptions::default()
17//!         .with_cargo_path_for("my-command");
18//!     TestConfig::new(shell_options)
19//!         .with_match_kind(MatchKind::Precise)
20//!         .with_output(TestOutputConfig::Verbose)
21//! }
22//!
23//! // Usage in tests:
24//! #[test]
25//! fn help_command() {
26//!     config().test("tests/__snapshots__/help.svg", &["my-command --help"]);
27//! }
28//! ```
29//!
30//! ## Lower-level testing
31//!
32//! Use [`TestConfig::test_transcript()`] for more complex scenarios or increased control:
33//!
34//! ```
35//! use term_transcript::{test::TestConfig, ShellOptions, Transcript, UserInput};
36//! # use term_transcript::svg::Template;
37//! use std::io;
38//!
39//! fn read_svg_file() -> anyhow::Result<impl io::BufRead> {
40//!     // snipped...
41//! #   let transcript = Transcript::from_inputs(
42//! #        &mut ShellOptions::default(),
43//! #        vec![UserInput::command(r#"echo "Hello world!""#)],
44//! #   )?;
45//! #   let mut writer = vec![];
46//! #   Template::default().render(&transcript, &mut writer)?;
47//! #   Ok(io::Cursor::new(writer))
48//! }
49//!
50//! # fn main() -> anyhow::Result<()> {
51//! let reader = read_svg_file()?;
52//! let transcript = Transcript::from_svg(reader)?;
53//! TestConfig::new(ShellOptions::default()).test_transcript(&transcript);
54//! # Ok(())
55//! # }
56//! ```
57//!
58//! ## Testing with custom capture logic
59//!
60//! Use [`TestConfig::test_captured()`] if you need to customize snapshot capture logic.
61//!
62//! ```no_run
63//! use term_transcript::{test::TestConfig, Transcript, UserInput};
64//!
65//! #[test]
66//! fn captured_snapshot() {
67//!     let mut captured = Transcript::default();
68//!     let test_output = "result: [[bold green!]]OK[[/]]".parse().unwrap();
69//!     captured.add_interaction(UserInput::command("test").hide(), test_output);
70//!     TestConfig::new(())
71//!         .test_captured("tests/__snapshots__/test.svg", captured);
72//! }
73//! ```
74
75use std::{borrow::Cow, fmt, io, process::Command};
76#[cfg(feature = "svg")]
77use std::{env, ffi::OsStr};
78
79use anstream::ColorChoice;
80
81pub use self::{
82    config_impl::compare_transcripts,
83    parser::{LocatedParseError, ParseError},
84};
85#[cfg(feature = "svg")]
86use crate::svg::Template;
87use crate::{ShellOptions, Transcript, UserInput, traits::SpawnShell};
88
89mod config_impl;
90mod parser;
91#[cfg(test)]
92mod tests;
93mod utils;
94
95/// Configuration of output produced during testing.
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
97#[non_exhaustive]
98pub enum TestOutputConfig {
99    /// Do not output anything.
100    Quiet,
101    /// Output normal amount of details.
102    #[default]
103    Normal,
104    /// Output more details.
105    Verbose,
106}
107
108/// Strategy for saving a new snapshot on a test failure within [`TestConfig::test()`] and
109/// related methods.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111#[non_exhaustive]
112#[cfg(feature = "svg")]
113#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
114pub enum UpdateMode {
115    /// Never create a new snapshot on test failure.
116    Never,
117    /// Always create a new snapshot on test failure.
118    Always,
119}
120
121#[cfg(feature = "svg")]
122impl UpdateMode {
123    /// Reads the update mode from the `TERM_TRANSCRIPT_UPDATE` env variable.
124    ///
125    /// If the `TERM_TRANSCRIPT_UPDATE` variable is not set, the output depends on whether
126    /// the executable is running in CI (which is detected by the presence of
127    /// the `CI` env variable):
128    ///
129    /// - In CI, the method returns [`Self::Never`].
130    /// - Otherwise, the method returns [`Self::Always`].
131    ///
132    /// # Panics
133    ///
134    /// If the `TERM_TRANSCRIPT_UPDATE` env variable is set to an unrecognized value
135    /// (something other than `never` or `always`), this method will panic.
136    pub fn from_env() -> Self {
137        const ENV_VAR: &str = "TERM_TRANSCRIPT_UPDATE";
138
139        match env::var_os(ENV_VAR) {
140            Some(s) => Self::from_os_str(&s).unwrap_or_else(|| {
141                panic!(
142                    "Cannot read update mode from env variable {ENV_VAR}: `{}` is not a valid value \
143                     (use one of `never` or `always`)",
144                    s.to_string_lossy()
145                );
146            }),
147            None => {
148                if env::var_os("CI").is_some() {
149                    Self::Never
150                } else {
151                    Self::Always
152                }
153            }
154        }
155    }
156
157    fn from_os_str(s: &OsStr) -> Option<Self> {
158        match s {
159            s if s == "never" => Some(Self::Never),
160            s if s == "always" => Some(Self::Always),
161            _ => None,
162        }
163    }
164
165    fn should_create_snapshot(self) -> bool {
166        match self {
167            Self::Always => true,
168            Self::Never => false,
169        }
170    }
171}
172
173/// Command executed during snapshot testing in [`TestConfig`] to reproduce a snapshot.
174///
175/// Two provided implementations are:
176///
177/// - [`ShellOptions`], which reproduces snapshots based on [`UserInput`]s and the provided shell.
178///   Used in [`TestConfig::test()`] and related lower-level methods.
179/// - `()`, which requires a captured [`Transcript`], i.e., delegates reproduction to the user code.
180///   Used in [`TestConfig::test_captured()`].
181///
182/// The contents of this trait are implementation details.
183pub trait TestCommand {
184    #[doc(hidden)] // implementation detail
185    type Inputs: fmt::Debug;
186
187    #[doc(hidden)] // implementation detail
188    fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]>;
189
190    #[doc(hidden)] // implementation detail
191    fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result<Transcript>;
192}
193
194impl<Cmd: SpawnShell> TestCommand for ShellOptions<Cmd> {
195    type Inputs = Vec<UserInput>;
196
197    fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]> {
198        Cow::Borrowed(inputs)
199    }
200
201    fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result<Transcript> {
202        Transcript::from_inputs(self, inputs)
203    }
204}
205
206impl TestCommand for () {
207    type Inputs = Transcript;
208
209    fn extract_inputs(inputs: &Self::Inputs) -> Cow<'_, [UserInput]> {
210        let inputs = inputs
211            .interactions()
212            .iter()
213            .map(|interaction| interaction.input().clone());
214        Cow::Owned(inputs.collect())
215    }
216
217    fn reproduce(&mut self, inputs: Self::Inputs) -> io::Result<Transcript> {
218        Ok(inputs)
219    }
220}
221
222/// Testing configuration.
223///
224/// # Examples
225///
226/// See the [module docs](crate::test) for the examples of usage.
227#[derive(Debug)]
228pub struct TestConfig<Cmd = ShellOptions<Command>, F = fn(&mut Transcript)> {
229    command: Cmd,
230    match_kind: MatchKind,
231    output: TestOutputConfig,
232    color_choice: ColorChoice,
233    #[cfg(feature = "svg")]
234    update_mode: UpdateMode,
235    #[cfg(feature = "svg")]
236    template: Template,
237    transform: F,
238}
239
240impl<Cmd: TestCommand> TestConfig<Cmd> {
241    /// Creates a new config.
242    ///
243    /// # Panics
244    ///
245    /// - Panics if the `svg` crate feature is enabled and the `TERM_TRANSCRIPT_UPDATE` variable
246    ///   is set to an incorrect value. See [`UpdateMode::from_env()`] for more details.
247    pub fn new(command: Cmd) -> Self {
248        Self {
249            command,
250            match_kind: MatchKind::TextOnly,
251            output: TestOutputConfig::Normal,
252            color_choice: ColorChoice::Auto,
253            #[cfg(feature = "svg")]
254            update_mode: UpdateMode::from_env(),
255            #[cfg(feature = "svg")]
256            template: Template::default(),
257            transform: |_| { /* do nothing */ },
258        }
259    }
260
261    /// Sets the transcript transform for these options. This can be used to transform the captured transcript
262    /// (e.g., to remove / replace uncontrollably varying data) before it's compared to the snapshot.
263    #[must_use]
264    pub fn with_transform<F>(self, transform: F) -> TestConfig<Cmd, F>
265    where
266        F: FnMut(&mut Transcript),
267    {
268        TestConfig {
269            command: self.command,
270            match_kind: self.match_kind,
271            output: self.output,
272            color_choice: self.color_choice,
273            #[cfg(feature = "svg")]
274            update_mode: self.update_mode,
275            #[cfg(feature = "svg")]
276            template: self.template,
277            transform,
278        }
279    }
280}
281
282impl<Cmd: TestCommand, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
283    /// Sets the matching kind applied.
284    #[must_use]
285    pub fn with_match_kind(mut self, kind: MatchKind) -> Self {
286        self.match_kind = kind;
287        self
288    }
289
290    /// Sets coloring of the output.
291    ///
292    /// On Windows, `color_choice` has slightly different semantics than its usage
293    /// in the `termcolor` crate. Namely, if colors can be used (stdout is a tty with
294    /// color support), ANSI escape sequences will always be used.
295    #[must_use]
296    pub fn with_color_choice(mut self, color_choice: ColorChoice) -> Self {
297        self.color_choice = color_choice;
298        self
299    }
300
301    /// Configures test output.
302    #[must_use]
303    pub fn with_output(mut self, output: TestOutputConfig) -> Self {
304        self.output = output;
305        self
306    }
307
308    /// Sets the template for rendering new snapshots.
309    #[cfg(feature = "svg")]
310    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
311    #[must_use]
312    pub fn with_template(mut self, template: Template) -> Self {
313        self.template = template;
314        self
315    }
316
317    /// Overrides the strategy for saving new snapshots for failed tests.
318    ///
319    /// By default, the strategy is determined from the execution environment
320    /// using [`UpdateMode::from_env()`].
321    #[cfg(feature = "svg")]
322    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
323    #[must_use]
324    pub fn with_update_mode(mut self, update_mode: UpdateMode) -> Self {
325        self.update_mode = update_mode;
326        self
327    }
328}
329
330/// Kind of terminal output matching.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
332#[non_exhaustive]
333pub enum MatchKind {
334    /// Relaxed matching: compare only output text, but not coloring.
335    TextOnly,
336    /// Precise matching: compare output together with colors.
337    Precise,
338}
339
340/// Stats of a single snapshot test output by [`TestConfig::test_transcript_for_stats()`].
341#[derive(Debug, Clone)]
342pub struct TestStats {
343    // Match kind per each user input.
344    matches: Vec<Option<MatchKind>>,
345}
346
347impl TestStats {
348    /// Returns the number of successfully matched user inputs with at least the specified
349    /// `match_level`.
350    pub fn passed(&self, match_level: MatchKind) -> usize {
351        self.matches
352            .iter()
353            .filter(|&&kind| kind >= Some(match_level))
354            .count()
355    }
356
357    /// Returns the number of user inputs that do not match with at least the specified
358    /// `match_level`.
359    pub fn errors(&self, match_level: MatchKind) -> usize {
360        self.matches.len() - self.passed(match_level)
361    }
362
363    /// Returns match kinds per each user input of the tested [`Transcript`]. `None` values
364    /// mean no match.
365    ///
366    /// [`Transcript`]: crate::Transcript
367    pub fn matches(&self) -> &[Option<MatchKind>] {
368        &self.matches
369    }
370
371    /// Panics if these stats contain errors.
372    #[allow(clippy::missing_panics_doc)]
373    pub fn assert_no_errors(&self, match_level: MatchKind) {
374        assert_eq!(self.errors(match_level), 0, "There were test errors");
375    }
376}