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