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}