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