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().with_cargo_path();
17//!     TestConfig::new(shell_options)
18//!         .with_match_kind(MatchKind::Precise)
19//!         .with_output(TestOutputConfig::Verbose)
20//! }
21//!
22//! // Usage in tests:
23//! #[test]
24//! fn help_command() {
25//!     config().test("tests/__snapshots__/help.svg", &["my-command --help"]);
26//! }
27//! ```
28//!
29//! Use [`TestConfig::test_transcript()`] for more complex scenarios or increased control:
30//!
31//! ```
32//! use term_transcript::{test::TestConfig, ShellOptions, Transcript, UserInput};
33//! # use term_transcript::svg::{Template, TemplateOptions};
34//! use std::io;
35//!
36//! fn read_svg_file() -> anyhow::Result<impl io::BufRead> {
37//!     // snipped...
38//! #   let transcript = Transcript::from_inputs(
39//! #        &mut ShellOptions::default(),
40//! #        vec![UserInput::command(r#"echo "Hello world!""#)],
41//! #   )?;
42//! #   let mut writer = vec![];
43//! #   Template::new(TemplateOptions::default()).render(&transcript, &mut writer)?;
44//! #   Ok(io::Cursor::new(writer))
45//! }
46//!
47//! # fn main() -> anyhow::Result<()> {
48//! let reader = read_svg_file()?;
49//! let transcript = Transcript::from_svg(reader)?;
50//! TestConfig::new(ShellOptions::default()).test_transcript(&transcript);
51//! # Ok(())
52//! # }
53//! ```
54
55use std::process::Command;
56#[cfg(feature = "svg")]
57use std::{env, ffi::OsStr};
58
59use termcolor::ColorChoice;
60
61pub use self::{
62    config_impl::compare_transcripts,
63    parser::{LocatedParseError, ParseError, Parsed},
64};
65#[cfg(feature = "svg")]
66use crate::svg::Template;
67use crate::{traits::SpawnShell, ShellOptions, Transcript};
68
69mod color_diff;
70mod config_impl;
71mod parser;
72#[cfg(test)]
73mod tests;
74mod utils;
75
76/// Configuration of output produced during testing.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
78#[non_exhaustive]
79pub enum TestOutputConfig {
80    /// Do not output anything.
81    Quiet,
82    /// Output normal amount of details.
83    #[default]
84    Normal,
85    /// Output more details.
86    Verbose,
87}
88
89/// Strategy for saving a new snapshot on a test failure within [`TestConfig::test()`] and
90/// related methods.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92#[non_exhaustive]
93#[cfg(feature = "svg")]
94#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
95pub enum UpdateMode {
96    /// Never create a new snapshot on test failure.
97    Never,
98    /// Always create a new snapshot on test failure.
99    Always,
100}
101
102#[cfg(feature = "svg")]
103impl UpdateMode {
104    /// Reads the update mode from the `TERM_TRANSCRIPT_UPDATE` env variable.
105    ///
106    /// If the `TERM_TRANSCRIPT_UPDATE` variable is not set, the output depends on whether
107    /// the executable is running in CI (which is detected by the presence of
108    /// the `CI` env variable):
109    ///
110    /// - In CI, the method returns [`Self::Never`].
111    /// - Otherwise, the method returns [`Self::Always`].
112    ///
113    /// # Panics
114    ///
115    /// If the `TERM_TRANSCRIPT_UPDATE` env variable is set to an unrecognized value
116    /// (something other than `never` or `always`), this method will panic.
117    pub fn from_env() -> Self {
118        const ENV_VAR: &str = "TERM_TRANSCRIPT_UPDATE";
119
120        match env::var_os(ENV_VAR) {
121            Some(s) => Self::from_os_str(&s).unwrap_or_else(|| {
122                panic!(
123                    "Cannot read update mode from env variable {ENV_VAR}: `{}` is not a valid value \
124                     (use one of `never` or `always`)",
125                    s.to_string_lossy()
126                );
127            }),
128            None => {
129                if env::var_os("CI").is_some() {
130                    Self::Never
131                } else {
132                    Self::Always
133                }
134            }
135        }
136    }
137
138    fn from_os_str(s: &OsStr) -> Option<Self> {
139        match s {
140            s if s == "never" => Some(Self::Never),
141            s if s == "always" => Some(Self::Always),
142            _ => None,
143        }
144    }
145
146    fn should_create_snapshot(self) -> bool {
147        match self {
148            Self::Always => true,
149            Self::Never => false,
150        }
151    }
152}
153
154/// Testing configuration.
155///
156/// # Examples
157///
158/// See the [module docs](crate::test) for the examples of usage.
159#[derive(Debug)]
160pub struct TestConfig<Cmd = Command, F = fn(&mut Transcript)> {
161    shell_options: ShellOptions<Cmd>,
162    match_kind: MatchKind,
163    output: TestOutputConfig,
164    color_choice: ColorChoice,
165    #[cfg(feature = "svg")]
166    update_mode: UpdateMode,
167    #[cfg(feature = "svg")]
168    template: Template,
169    transform: F,
170}
171
172impl<Cmd: SpawnShell> TestConfig<Cmd> {
173    /// Creates a new config.
174    ///
175    /// # Panics
176    ///
177    /// - Panics if the `svg` crate feature is enabled and the `TERM_TRANSCRIPT_UPDATE` variable
178    ///   is set to an incorrect value. See [`UpdateMode::from_env()`] for more details.
179    pub fn new(shell_options: ShellOptions<Cmd>) -> Self {
180        Self {
181            shell_options,
182            match_kind: MatchKind::TextOnly,
183            output: TestOutputConfig::Normal,
184            color_choice: ColorChoice::Auto,
185            #[cfg(feature = "svg")]
186            update_mode: UpdateMode::from_env(),
187            #[cfg(feature = "svg")]
188            template: Template::default(),
189            transform: |_| { /* do nothing */ },
190        }
191    }
192
193    /// Sets the transcript transform for these options. This can be used to transform the captured transcript
194    /// (e.g., to remove / replace uncontrollably varying data) before it's compared to the snapshot.
195    #[must_use]
196    pub fn with_transform<F>(self, transform: F) -> TestConfig<Cmd, F>
197    where
198        F: FnMut(&mut Transcript),
199    {
200        TestConfig {
201            shell_options: self.shell_options,
202            match_kind: self.match_kind,
203            output: self.output,
204            color_choice: self.color_choice,
205            #[cfg(feature = "svg")]
206            update_mode: self.update_mode,
207            #[cfg(feature = "svg")]
208            template: self.template,
209            transform,
210        }
211    }
212}
213
214impl<Cmd: SpawnShell, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
215    /// Sets the matching kind applied.
216    #[must_use]
217    pub fn with_match_kind(mut self, kind: MatchKind) -> Self {
218        self.match_kind = kind;
219        self
220    }
221
222    /// Sets coloring of the output.
223    ///
224    /// On Windows, `color_choice` has slightly different semantics than its usage
225    /// in the `termcolor` crate. Namely, if colors can be used (stdout is a tty with
226    /// color support), ANSI escape sequences will always be used.
227    #[must_use]
228    pub fn with_color_choice(mut self, color_choice: ColorChoice) -> Self {
229        self.color_choice = color_choice;
230        self
231    }
232
233    /// Configures test output.
234    #[must_use]
235    pub fn with_output(mut self, output: TestOutputConfig) -> Self {
236        self.output = output;
237        self
238    }
239
240    /// Sets the template for rendering new snapshots.
241    #[cfg(feature = "svg")]
242    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
243    #[must_use]
244    pub fn with_template(mut self, template: Template) -> Self {
245        self.template = template;
246        self
247    }
248
249    /// Overrides the strategy for saving new snapshots for failed tests.
250    ///
251    /// By default, the strategy is determined from the execution environment
252    /// using [`UpdateMode::from_env()`].
253    #[cfg(feature = "svg")]
254    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
255    #[must_use]
256    pub fn with_update_mode(mut self, update_mode: UpdateMode) -> Self {
257        self.update_mode = update_mode;
258        self
259    }
260}
261
262/// Kind of terminal output matching.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
264#[non_exhaustive]
265pub enum MatchKind {
266    /// Relaxed matching: compare only output text, but not coloring.
267    TextOnly,
268    /// Precise matching: compare output together with colors.
269    Precise,
270}
271
272/// Stats of a single snapshot test output by [`TestConfig::test_transcript_for_stats()`].
273#[derive(Debug, Clone)]
274pub struct TestStats {
275    // Match kind per each user input.
276    matches: Vec<Option<MatchKind>>,
277}
278
279impl TestStats {
280    /// Returns the number of successfully matched user inputs with at least the specified
281    /// `match_level`.
282    pub fn passed(&self, match_level: MatchKind) -> usize {
283        self.matches
284            .iter()
285            .filter(|&&kind| kind >= Some(match_level))
286            .count()
287    }
288
289    /// Returns the number of user inputs that do not match with at least the specified
290    /// `match_level`.
291    pub fn errors(&self, match_level: MatchKind) -> usize {
292        self.matches.len() - self.passed(match_level)
293    }
294
295    /// Returns match kinds per each user input of the tested [`Transcript`]. `None` values
296    /// mean no match.
297    ///
298    /// [`Transcript`]: crate::Transcript
299    pub fn matches(&self) -> &[Option<MatchKind>] {
300        &self.matches
301    }
302
303    /// Panics if these stats contain errors.
304    #[allow(clippy::missing_panics_doc)]
305    pub fn assert_no_errors(&self, match_level: MatchKind) {
306        assert_eq!(self.errors(match_level), 0, "There were test errors");
307    }
308}