term_transcript/test/
config_impl.rs

1//! Implementation details for `TestConfig`.
2
3use termcolor::{Color, ColorSpec, NoColor, WriteColor};
4
5use std::{
6    fmt,
7    fs::File,
8    io::{self, BufReader, Write},
9    path::Path,
10    str,
11};
12
13use super::{
14    color_diff::{ColorDiff, ColorSpan},
15    parser::Parsed,
16    utils::{ColorPrintlnWriter, IndentingWriter},
17    MatchKind, TestConfig, TestOutputConfig, TestStats,
18};
19use crate::{traits::SpawnShell, Interaction, TermError, Transcript, UserInput};
20
21impl<Cmd: SpawnShell + fmt::Debug> TestConfig<Cmd> {
22    /// Tests a snapshot at the specified path with the provided inputs.
23    ///
24    /// If the path is relative, it is resolved relative to the current working dir,
25    /// which in the case of tests is the root directory of the including crate (i.e., the dir
26    /// where the crate manifest is located). You may specify an absolute path
27    /// using env vars that Cargo sets during build, such as [`env!("CARGO_MANIFEST_DIR")`].
28    ///
29    /// Similar to other kinds of snapshot testing, a new snapshot will be generated if
30    /// there is no existing snapshot or there are mismatches between inputs or outputs
31    /// in the original and reproduced transcripts. This new snapshot will have the same path
32    /// as the original snapshot, but with the `.new.svg` extension. As an example,
33    /// if the snapshot at `snapshots/help.svg` is tested, the new snapshot will be saved at
34    /// `snapshots/help.new.svg`.
35    ///
36    /// Generation of new snapshots will only happen if the `svg` crate feature is enabled
37    /// (which it is by default), and if the [update mode](Self::with_update_mode())
38    /// is not [`UpdateMode::Never`], either because it was set explicitly or
39    /// [inferred] from the execution environment.
40    ///
41    /// The snapshot template can be customized via [`Self::with_template()`].
42    ///
43    /// # Panics
44    ///
45    /// - Panics if there is no snapshot at the specified path, or if the path points
46    ///   to a directory.
47    /// - Panics if an error occurs during reproducing the transcript or processing
48    ///   its output.
49    /// - Panics if there are mismatches between inputs or outputs in the original and reproduced
50    ///   transcripts.
51    ///
52    /// [`env!("CARGO_MANIFEST_DIR")`]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
53    /// [`UpdateMode::Never`]: crate::test::UpdateMode::Never
54    /// [inferred]: crate::test::UpdateMode::from_env()
55    #[cfg_attr(
56        feature = "tracing",
57        tracing::instrument(skip(snapshot_path, inputs), fields(snapshot_path, inputs))
58    )]
59    pub fn test<I: Into<UserInput>>(
60        &mut self,
61        snapshot_path: impl AsRef<Path>,
62        inputs: impl IntoIterator<Item = I>,
63    ) {
64        let inputs: Vec<_> = inputs.into_iter().map(Into::into).collect();
65        let snapshot_path = snapshot_path.as_ref();
66        #[cfg(feature = "tracing")]
67        tracing::Span::current()
68            .record("snapshot_path", tracing::field::debug(snapshot_path))
69            .record("inputs", tracing::field::debug(&inputs));
70
71        if snapshot_path.is_file() {
72            #[cfg(feature = "tracing")]
73            tracing::debug!(snapshot_path.is_file = true);
74
75            let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
76                panic!("Cannot open `{snapshot_path:?}`: {err}");
77            });
78            let snapshot = BufReader::new(snapshot);
79            let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
80                panic!("Cannot parse snapshot from `{snapshot_path:?}`: {err}");
81            });
82            self.compare_and_test_transcript(snapshot_path, &transcript, &inputs);
83        } else if snapshot_path.exists() {
84            panic!("Snapshot path `{snapshot_path:?}` exists, but is not a file");
85        } else {
86            #[cfg(feature = "tracing")]
87            tracing::debug!(snapshot_path.is_file = false);
88
89            let new_snapshot_message =
90                self.create_and_write_new_snapshot(snapshot_path, inputs.into_iter());
91            panic!("Snapshot `{snapshot_path:?}` is missing\n{new_snapshot_message}");
92        }
93    }
94
95    #[cfg_attr(
96        feature = "tracing",
97        tracing::instrument(level = "debug", skip(self, transcript))
98    )]
99    fn compare_and_test_transcript(
100        &mut self,
101        snapshot_path: &Path,
102        transcript: &Transcript<Parsed>,
103        expected_inputs: &[UserInput],
104    ) {
105        let actual_inputs: Vec<_> = transcript
106            .interactions()
107            .iter()
108            .map(Interaction::input)
109            .collect();
110
111        if !actual_inputs.iter().copied().eq(expected_inputs) {
112            let new_snapshot_message =
113                self.create_and_write_new_snapshot(snapshot_path, expected_inputs.iter().cloned());
114            panic!(
115                "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
116                 got {actual_inputs:?}\n{new_snapshot_message}"
117            );
118        }
119
120        let (stats, reproduced) = self
121            .test_transcript_for_stats(transcript)
122            .unwrap_or_else(|err| panic!("{err}"));
123        if stats.errors(self.match_kind) > 0 {
124            let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
125            panic!("There were test failures\n{new_snapshot_message}");
126        }
127    }
128
129    #[cfg(feature = "svg")]
130    #[cfg_attr(
131        feature = "tracing",
132        tracing::instrument(level = "debug", skip(self, inputs))
133    )]
134    fn create_and_write_new_snapshot(
135        &mut self,
136        path: &Path,
137        inputs: impl Iterator<Item = UserInput>,
138    ) -> String {
139        let reproduced =
140            Transcript::from_inputs(&mut self.shell_options, inputs).unwrap_or_else(|err| {
141                panic!("Cannot create a snapshot `{path:?}`: {err}");
142            });
143        self.write_new_snapshot(path, &reproduced)
144    }
145
146    /// Returns a message to be appended to the panic message.
147    #[cfg(feature = "svg")]
148    #[cfg_attr(
149        feature = "tracing",
150        tracing::instrument(level = "debug", skip(self, transcript), ret)
151    )]
152    fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
153        if !self.update_mode.should_create_snapshot() {
154            return format!("Skipped writing new snapshot `{path:?}` per test config");
155        }
156
157        let mut new_path = path.to_owned();
158        new_path.set_extension("new.svg");
159        let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
160            panic!("Cannot create file for new snapshot `{new_path:?}`: {err}");
161        });
162        self.template
163            .render(transcript, &mut io::BufWriter::new(new_snapshot))
164            .unwrap_or_else(|err| {
165                panic!("Cannot render snapshot `{new_path:?}`: {err}");
166            });
167        format!("A new snapshot was saved to `{new_path:?}`")
168    }
169
170    #[cfg(not(feature = "svg"))]
171    #[allow(clippy::unused_self)] // necessary for uniformity
172    fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
173        format!(
174            "Not writing a new snapshot since `{}/svg` feature is not enabled",
175            env!("CARGO_PKG_NAME")
176        )
177    }
178
179    #[cfg(not(feature = "svg"))]
180    #[allow(clippy::unused_self)] // necessary for uniformity
181    fn create_and_write_new_snapshot(
182        &mut self,
183        _: &Path,
184        _: impl Iterator<Item = UserInput>,
185    ) -> String {
186        format!(
187            "Not writing a new snapshot since `{}/svg` feature is not enabled",
188            env!("CARGO_PKG_NAME")
189        )
190    }
191
192    /// Tests the `transcript`. This is a lower-level alternative to [`Self::test()`].
193    ///
194    /// # Panics
195    ///
196    /// - Panics if an error occurs during reproducing the transcript or processing
197    ///   its output.
198    /// - Panics if there are mismatches between outputs in the original and reproduced
199    ///   transcripts.
200    pub fn test_transcript(&mut self, transcript: &Transcript<Parsed>) {
201        let (stats, _) = self
202            .test_transcript_for_stats(transcript)
203            .unwrap_or_else(|err| panic!("{err}"));
204        stats.assert_no_errors(self.match_kind);
205    }
206
207    /// Tests the `transcript` and returns testing stats together with
208    /// the reproduced [`Transcript`]. This is a lower-level alternative to [`Self::test()`].
209    ///
210    /// # Errors
211    ///
212    /// - Returns an error if an error occurs during reproducing the transcript or processing
213    ///   its output.
214    #[cfg_attr(feature = "tracing", tracing::instrument(skip(transcript), err))]
215    pub fn test_transcript_for_stats(
216        &mut self,
217        transcript: &Transcript<Parsed>,
218    ) -> io::Result<(TestStats, Transcript)> {
219        if self.output == TestOutputConfig::Quiet {
220            let mut out = NoColor::new(io::sink());
221            self.test_transcript_inner(&mut out, transcript)
222        } else {
223            let mut out = ColorPrintlnWriter::new(self.color_choice);
224            self.test_transcript_inner(&mut out, transcript)
225        }
226    }
227
228    pub(super) fn test_transcript_inner(
229        &mut self,
230        out: &mut impl WriteColor,
231        transcript: &Transcript<Parsed>,
232    ) -> io::Result<(TestStats, Transcript)> {
233        let inputs = transcript
234            .interactions()
235            .iter()
236            .map(|interaction| interaction.input().clone());
237        let reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?;
238
239        let stats = self.compare_transcripts(out, transcript, &reproduced)?;
240        Ok((stats, reproduced))
241    }
242
243    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, ret, err))]
244    pub(super) fn compare_transcripts(
245        &self,
246        out: &mut impl WriteColor,
247        parsed: &Transcript<Parsed>,
248        reproduced: &Transcript,
249    ) -> io::Result<TestStats> {
250        let it = parsed
251            .interactions()
252            .iter()
253            .zip(reproduced.interactions().iter().map(Interaction::output));
254
255        let mut stats = TestStats {
256            matches: Vec::with_capacity(parsed.interactions().len()),
257        };
258        for (original, reproduced) in it {
259            #[cfg(feature = "tracing")]
260            let _entered =
261                tracing::debug_span!("compare_interaction", input = ?original.input).entered();
262
263            write!(out, "  ")?;
264            out.set_color(ColorSpec::new().set_intense(true))?;
265            write!(out, "[")?;
266
267            // First, process text only.
268            let original_text = original.output().plaintext();
269            let reproduced_text = reproduced
270                .to_plaintext()
271                .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
272            let mut actual_match = if original_text == reproduced_text {
273                Some(MatchKind::TextOnly)
274            } else {
275                None
276            };
277            #[cfg(feature = "tracing")]
278            tracing::debug!(?actual_match, "compared output texts");
279
280            // If we do precise matching, check it as well.
281            let color_diff = if self.match_kind == MatchKind::Precise && actual_match.is_some() {
282                let original_spans = &original.output().color_spans;
283                let reproduced_spans =
284                    ColorSpan::parse(reproduced.as_ref()).map_err(|err| match err {
285                        TermError::Io(err) => err,
286                        other => io::Error::new(io::ErrorKind::InvalidInput, other),
287                    })?;
288
289                let diff = ColorDiff::new(original_spans, &reproduced_spans);
290                #[cfg(feature = "tracing")]
291                tracing::debug!(?diff, "compared output coloring");
292
293                if diff.is_empty() {
294                    actual_match = Some(MatchKind::Precise);
295                    None
296                } else {
297                    Some(diff)
298                }
299            } else {
300                None
301            };
302
303            stats.matches.push(actual_match);
304            if actual_match >= Some(self.match_kind) {
305                out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Green)))?;
306                write!(out, "+")?;
307            } else {
308                out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Red)))?;
309                if color_diff.is_some() {
310                    write!(out, "#")?;
311                } else {
312                    write!(out, "-")?;
313                }
314            }
315            out.set_color(ColorSpec::new().set_intense(true))?;
316            write!(out, "]")?;
317            out.reset()?;
318            writeln!(out, " Input: {}", original.input().as_ref())?;
319
320            if let Some(diff) = color_diff {
321                let original_spans = &original.output().color_spans;
322                diff.highlight_text(out, original_text, original_spans)?;
323                diff.write_as_table(out)?;
324            } else if actual_match.is_none() {
325                Self::write_diff(out, original_text, &reproduced_text)?;
326            } else if self.output == TestOutputConfig::Verbose {
327                out.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(244))))?;
328                let mut out_with_indents = IndentingWriter::new(&mut *out, b"    ");
329                writeln!(out_with_indents, "{}", original.output().plaintext())?;
330                out.reset()?;
331            }
332        }
333
334        Ok(stats)
335    }
336
337    #[cfg(feature = "pretty_assertions")]
338    fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
339        use pretty_assertions::Comparison;
340
341        // Since `Comparison` uses `fmt::Debug`, we define this simple wrapper
342        // to switch to `fmt::Display`.
343        struct DebugStr<'a>(&'a str);
344
345        impl fmt::Debug for DebugStr<'_> {
346            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347                // Align output with verbose term output. Since `Comparison` adds one space,
348                // we need to add 3 spaces instead of 4.
349                for line in self.0.lines() {
350                    writeln!(formatter, "   {line}")?;
351                }
352                Ok(())
353            }
354        }
355
356        write!(
357            out,
358            "    {}",
359            Comparison::new(&DebugStr(original), &DebugStr(reproduced))
360        )
361    }
362
363    #[cfg(not(feature = "pretty_assertions"))]
364    fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
365        writeln!(out, "  Original:")?;
366        for line in original.lines() {
367            writeln!(out, "    {line}")?;
368        }
369        writeln!(out, "  Reproduced:")?;
370        for line in reproduced.lines() {
371            writeln!(out, "    {line}")?;
372        }
373        Ok(())
374    }
375}