term_transcript/test/
mod.rs

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