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