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}