term_transcript/test/
config_impl.rs

1//! Implementation details for `TestConfig`.
2
3use std::{
4    fmt,
5    fs::File,
6    io::{self, BufReader, Write},
7    path::Path,
8    str,
9};
10
11use termcolor::{Color, ColorSpec, NoColor, WriteColor};
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, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
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_all, 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 `{}`: {err}", snapshot_path.display());
77            });
78            let snapshot = BufReader::new(snapshot);
79            let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
80                panic!(
81                    "Cannot parse snapshot from `{}`: {err}",
82                    snapshot_path.display()
83                );
84            });
85            self.compare_and_test_transcript(snapshot_path, &transcript, &inputs);
86        } else if snapshot_path.exists() {
87            panic!(
88                "Snapshot path `{}` exists, but is not a file",
89                snapshot_path.display()
90            );
91        } else {
92            #[cfg(feature = "tracing")]
93            tracing::debug!(snapshot_path.is_file = false);
94
95            let new_snapshot_message =
96                self.create_and_write_new_snapshot(snapshot_path, inputs.into_iter());
97            panic!(
98                "Snapshot `{}` is missing\n{new_snapshot_message}",
99                snapshot_path.display()
100            );
101        }
102    }
103
104    #[cfg_attr(
105        feature = "tracing",
106        tracing::instrument(level = "debug", skip(self, transcript))
107    )]
108    fn compare_and_test_transcript(
109        &mut self,
110        snapshot_path: &Path,
111        transcript: &Transcript<Parsed>,
112        expected_inputs: &[UserInput],
113    ) {
114        let actual_inputs: Vec<_> = transcript
115            .interactions()
116            .iter()
117            .map(Interaction::input)
118            .collect();
119
120        if !actual_inputs.iter().copied().eq(expected_inputs) {
121            let new_snapshot_message =
122                self.create_and_write_new_snapshot(snapshot_path, expected_inputs.iter().cloned());
123            panic!(
124                "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
125                 got {actual_inputs:?}\n{new_snapshot_message}"
126            );
127        }
128
129        let (stats, reproduced) = self
130            .test_transcript_for_stats(transcript)
131            .unwrap_or_else(|err| panic!("{err}"));
132        if stats.errors(self.match_kind) > 0 {
133            let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
134            panic!("There were test failures\n{new_snapshot_message}");
135        }
136    }
137
138    #[cfg(feature = "svg")]
139    #[cfg_attr(
140        feature = "tracing",
141        tracing::instrument(level = "debug", skip(self, inputs))
142    )]
143    fn create_and_write_new_snapshot(
144        &mut self,
145        path: &Path,
146        inputs: impl Iterator<Item = UserInput>,
147    ) -> String {
148        let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)
149            .unwrap_or_else(|err| {
150                panic!("Cannot create a snapshot `{}`: {err}", path.display());
151            });
152        (self.transform)(&mut reproduced);
153        self.write_new_snapshot(path, &reproduced)
154    }
155
156    /// Returns a message to be appended to the panic message.
157    #[cfg(feature = "svg")]
158    #[cfg_attr(
159        feature = "tracing",
160        tracing::instrument(level = "debug", skip(self, transcript), ret)
161    )]
162    fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
163        if !self.update_mode.should_create_snapshot() {
164            return format!(
165                "Skipped writing new snapshot `{}` per test config",
166                path.display()
167            );
168        }
169
170        let mut new_path = path.to_owned();
171        new_path.set_extension("new.svg");
172        let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
173            panic!(
174                "Cannot create file for new snapshot `{}`: {err}",
175                new_path.display()
176            );
177        });
178        self.template
179            .render(transcript, &mut io::BufWriter::new(new_snapshot))
180            .unwrap_or_else(|err| {
181                panic!("Cannot render snapshot `{}`: {err}", new_path.display());
182            });
183        format!("A new snapshot was saved to `{}`", new_path.display())
184    }
185
186    #[cfg(not(feature = "svg"))]
187    #[allow(clippy::unused_self)] // necessary for uniformity
188    fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
189        format!(
190            "Not writing a new snapshot since `{}/svg` feature is not enabled",
191            env!("CARGO_PKG_NAME")
192        )
193    }
194
195    #[cfg(not(feature = "svg"))]
196    #[allow(clippy::unused_self)] // necessary for uniformity
197    fn create_and_write_new_snapshot(
198        &mut self,
199        _: &Path,
200        _: impl Iterator<Item = UserInput>,
201    ) -> String {
202        format!(
203            "Not writing a new snapshot since `{}/svg` feature is not enabled",
204            env!("CARGO_PKG_NAME")
205        )
206    }
207
208    /// Tests the `transcript`. This is a lower-level alternative to [`Self::test()`].
209    ///
210    /// # Panics
211    ///
212    /// - Panics if an error occurs during reproducing the transcript or processing
213    ///   its output.
214    /// - Panics if there are mismatches between outputs in the original and reproduced
215    ///   transcripts.
216    pub fn test_transcript(&mut self, transcript: &Transcript<Parsed>) {
217        let (stats, _) = self
218            .test_transcript_for_stats(transcript)
219            .unwrap_or_else(|err| panic!("{err}"));
220        stats.assert_no_errors(self.match_kind);
221    }
222
223    /// Tests the `transcript` and returns testing stats together with
224    /// the reproduced [`Transcript`]. This is a lower-level alternative to [`Self::test()`].
225    ///
226    /// # Errors
227    ///
228    /// - Returns an error if an error occurs during reproducing the transcript or processing
229    ///   its output.
230    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
231    pub fn test_transcript_for_stats(
232        &mut self,
233        transcript: &Transcript<Parsed>,
234    ) -> io::Result<(TestStats, Transcript)> {
235        if self.output == TestOutputConfig::Quiet {
236            let mut out = NoColor::new(io::sink());
237            self.test_transcript_inner(&mut out, transcript)
238        } else {
239            let mut out = ColorPrintlnWriter::new(self.color_choice);
240            self.test_transcript_inner(&mut out, transcript)
241        }
242    }
243
244    pub(super) fn test_transcript_inner(
245        &mut self,
246        out: &mut impl WriteColor,
247        transcript: &Transcript<Parsed>,
248    ) -> io::Result<(TestStats, Transcript)> {
249        let inputs = transcript
250            .interactions()
251            .iter()
252            .map(|interaction| interaction.input().clone());
253        let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?;
254        (self.transform)(&mut reproduced);
255
256        let stats = self.compare_transcripts(out, transcript, &reproduced)?;
257        Ok((stats, reproduced))
258    }
259
260    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, ret, err))]
261    pub(super) fn compare_transcripts(
262        &self,
263        out: &mut impl WriteColor,
264        parsed: &Transcript<Parsed>,
265        reproduced: &Transcript,
266    ) -> io::Result<TestStats> {
267        let it = parsed
268            .interactions()
269            .iter()
270            .zip(reproduced.interactions().iter().map(Interaction::output));
271
272        let mut stats = TestStats {
273            matches: Vec::with_capacity(parsed.interactions().len()),
274        };
275        for (original, reproduced) in it {
276            #[cfg(feature = "tracing")]
277            let _entered =
278                tracing::debug_span!("compare_interaction", input = ?original.input).entered();
279
280            write!(out, "  ")?;
281            out.set_color(ColorSpec::new().set_intense(true))?;
282            write!(out, "[")?;
283
284            // First, process text only.
285            let original_text = original.output().plaintext();
286            let reproduced_text = reproduced
287                .to_plaintext()
288                .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
289            let mut actual_match = if original_text == reproduced_text {
290                Some(MatchKind::TextOnly)
291            } else {
292                None
293            };
294            #[cfg(feature = "tracing")]
295            tracing::debug!(?actual_match, "compared output texts");
296
297            // If we do precise matching, check it as well.
298            let color_diff = if self.match_kind == MatchKind::Precise && actual_match.is_some() {
299                let original_spans = &original.output().color_spans;
300                let reproduced_spans =
301                    ColorSpan::parse(reproduced.as_ref()).map_err(|err| match err {
302                        TermError::Io(err) => err,
303                        other => io::Error::new(io::ErrorKind::InvalidInput, other),
304                    })?;
305
306                let diff = ColorDiff::new(original_spans, &reproduced_spans);
307                #[cfg(feature = "tracing")]
308                tracing::debug!(?diff, "compared output coloring");
309
310                if diff.is_empty() {
311                    actual_match = Some(MatchKind::Precise);
312                    None
313                } else {
314                    Some(diff)
315                }
316            } else {
317                None
318            };
319
320            stats.matches.push(actual_match);
321            if actual_match >= Some(self.match_kind) {
322                out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Green)))?;
323                write!(out, "+")?;
324            } else {
325                out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Red)))?;
326                if color_diff.is_some() {
327                    write!(out, "#")?;
328                } else {
329                    write!(out, "-")?;
330                }
331            }
332            out.set_color(ColorSpec::new().set_intense(true))?;
333            write!(out, "]")?;
334            out.reset()?;
335            writeln!(out, " Input: {}", original.input().as_ref())?;
336
337            if let Some(diff) = color_diff {
338                let original_spans = &original.output().color_spans;
339                diff.highlight_text(out, original_text, original_spans)?;
340                diff.write_as_table(out)?;
341            } else if actual_match.is_none() {
342                Self::write_diff(out, original_text, &reproduced_text)?;
343            } else if self.output == TestOutputConfig::Verbose {
344                out.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(244))))?;
345                let mut out_with_indents = IndentingWriter::new(&mut *out, b"    ");
346                writeln!(out_with_indents, "{}", original.output().plaintext())?;
347                out.reset()?;
348            }
349        }
350
351        Ok(stats)
352    }
353
354    #[cfg(feature = "pretty_assertions")]
355    fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
356        use pretty_assertions::Comparison;
357
358        // Since `Comparison` uses `fmt::Debug`, we define this simple wrapper
359        // to switch to `fmt::Display`.
360        struct DebugStr<'a>(&'a str);
361
362        impl fmt::Debug for DebugStr<'_> {
363            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
364                // Align output with verbose term output. Since `Comparison` adds one space,
365                // we need to add 3 spaces instead of 4.
366                for line in self.0.lines() {
367                    writeln!(formatter, "   {line}")?;
368                }
369                Ok(())
370            }
371        }
372
373        write!(
374            out,
375            "    {}",
376            Comparison::new(&DebugStr(original), &DebugStr(reproduced))
377        )
378    }
379
380    #[cfg(not(feature = "pretty_assertions"))]
381    fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
382        writeln!(out, "  Original:")?;
383        for line in original.lines() {
384            writeln!(out, "    {line}")?;
385        }
386        writeln!(out, "  Reproduced:")?;
387        for line in reproduced.lines() {
388            writeln!(out, "    {line}")?;
389        }
390        Ok(())
391    }
392}