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