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, TestCommand, TestConfig, TestOutputConfig, TestStats,
16    utils::{ChoiceWriter, IndentingWriter, PrintlnWriter},
17};
18use crate::{Interaction, ShellOptions, 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: TestCommand, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
104    #[cfg_attr(feature = "tracing", tracing::instrument(name = "test", skip(self)))]
105    fn test_inner(&mut self, snapshot_path: &Path, input: Cmd::Inputs) {
106        if snapshot_path.is_file() {
107            #[cfg(feature = "tracing")]
108            tracing::debug!(snapshot_path.is_file = true);
109
110            let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
111                panic!("Cannot open `{}`: {err}", snapshot_path.display());
112            });
113            let snapshot = BufReader::new(snapshot);
114            let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
115                panic!(
116                    "Cannot parse snapshot from `{}`: {err}",
117                    snapshot_path.display()
118                );
119            });
120            self.compare_and_test_transcript(snapshot_path, &transcript, input);
121        } else if snapshot_path.exists() {
122            panic!(
123                "Snapshot path `{}` exists, but is not a file",
124                snapshot_path.display()
125            );
126        } else {
127            #[cfg(feature = "tracing")]
128            tracing::debug!(snapshot_path.is_file = false);
129
130            let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
131            panic!(
132                "Snapshot `{}` is missing\n{new_snapshot_message}",
133                snapshot_path.display()
134            );
135        }
136    }
137
138    #[cfg_attr(
139        feature = "tracing",
140        tracing::instrument(level = "debug", skip(self, parsed))
141    )]
142    fn compare_and_test_transcript(
143        &mut self,
144        snapshot_path: &Path,
145        parsed: &Transcript,
146        input: Cmd::Inputs,
147    ) {
148        let actual_inputs: Vec<_> = parsed
149            .interactions()
150            .iter()
151            .map(Interaction::input)
152            .collect();
153        let expected_inputs = Cmd::extract_inputs(&input);
154
155        if !actual_inputs.iter().copied().eq(expected_inputs.as_ref()) {
156            let expected_inputs = expected_inputs.into_owned();
157            let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
158            panic!(
159                "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
160                 got {actual_inputs:?}\n{new_snapshot_message}"
161            );
162        }
163
164        let (stats, reproduced) = self
165            .test_transcript_with_input(parsed, input)
166            .unwrap_or_else(|err| panic!("{err}"));
167        if stats.errors(self.match_kind) > 0 {
168            let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
169            panic!("There were test failures\n{new_snapshot_message}");
170        }
171    }
172
173    fn test_transcript_with_input(
174        &mut self,
175        parsed: &Transcript,
176        input: Cmd::Inputs,
177    ) -> io::Result<(TestStats, Transcript)> {
178        if self.output == TestOutputConfig::Quiet {
179            self.test_transcript_inner(&mut io::sink(), parsed, input)
180        } else {
181            let choice = if self.color_choice == ColorChoice::Auto {
182                AutoStream::choice(&io::stdout())
183            } else {
184                self.color_choice
185            };
186            // We cannot create an `AutoStream` here because it would require `PrintlnWriter` to implement `anstream::RawStream`,
187            // which is a sealed trait.
188            let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice);
189            self.test_transcript_inner(&mut out, parsed, input)
190        }
191    }
192
193    pub(super) fn test_transcript_inner(
194        &mut self,
195        out: &mut impl Write,
196        parsed: &Transcript,
197        input: Cmd::Inputs,
198    ) -> io::Result<(TestStats, Transcript)> {
199        let mut reproduced = self.command.reproduce(input)?;
200        (self.transform)(&mut reproduced);
201
202        let stats = compare_transcripts(
203            out,
204            parsed,
205            &reproduced,
206            self.match_kind,
207            self.output == TestOutputConfig::Verbose,
208        )?;
209        Ok((stats, reproduced))
210    }
211
212    #[cfg(feature = "svg")]
213    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self)))]
214    fn create_and_write_new_snapshot(&mut self, path: &Path, inputs: Cmd::Inputs) -> String {
215        let mut reproduced = self.command.reproduce(inputs).unwrap_or_else(|err| {
216            panic!("Cannot create a snapshot `{}`: {err}", path.display());
217        });
218        (self.transform)(&mut reproduced);
219        self.write_new_snapshot(path, &reproduced)
220    }
221
222    /// Returns a message to be appended to the panic message.
223    #[cfg(feature = "svg")]
224    #[cfg_attr(
225        feature = "tracing",
226        tracing::instrument(level = "debug", skip(self, transcript), ret)
227    )]
228    fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
229        if !self.update_mode.should_create_snapshot() {
230            return format!(
231                "Skipped writing new snapshot `{}` per test config",
232                path.display()
233            );
234        }
235
236        let mut new_path = path.to_owned();
237        new_path.set_extension("new.svg");
238        let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
239            panic!(
240                "Cannot create file for new snapshot `{}`: {err}",
241                new_path.display()
242            );
243        });
244        self.template
245            .render(transcript, &mut io::BufWriter::new(new_snapshot))
246            .unwrap_or_else(|err| {
247                panic!("Cannot render snapshot `{}`: {err}", new_path.display());
248            });
249        format!("A new snapshot was saved to `{}`", new_path.display())
250    }
251
252    #[cfg(not(feature = "svg"))]
253    #[allow(clippy::unused_self)] // necessary for uniformity
254    fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
255        format!(
256            "Not writing a new snapshot since `{}/svg` feature is not enabled",
257            env!("CARGO_PKG_NAME")
258        )
259    }
260
261    #[cfg(not(feature = "svg"))]
262    #[allow(clippy::unused_self)] // necessary for uniformity
263    fn create_and_write_new_snapshot(&mut self, _: &Path, _: Cmd::Inputs) -> String {
264        format!(
265            "Not writing a new snapshot since `{}/svg` feature is not enabled",
266            env!("CARGO_PKG_NAME")
267        )
268    }
269}
270
271impl<F: FnMut(&mut Transcript)> TestConfig<(), F> {
272    /// Tests a snapshot at the specified path against the provided captured snapshot.
273    ///
274    /// This method is similar to [`TestConfig::test()`], but it allows to fully customize to how the snapshot
275    /// is reproduced for the test.
276    pub fn test_captured(&mut self, snapshot_path: impl AsRef<Path>, captured: Transcript) {
277        self.test_inner(snapshot_path.as_ref(), captured);
278    }
279}
280
281impl<Cmd: SpawnShell + fmt::Debug, F: FnMut(&mut Transcript)> TestConfig<ShellOptions<Cmd>, F> {
282    /// Tests a snapshot at the specified path with the provided inputs.
283    ///
284    /// If the path is relative, it is resolved relative to the current working dir,
285    /// which in the case of tests is the root directory of the including crate (i.e., the dir
286    /// where the crate manifest is located). You may specify an absolute path
287    /// using env vars that Cargo sets during build, such as [`env!("CARGO_MANIFEST_DIR")`].
288    ///
289    /// Similar to other kinds of snapshot testing, a new snapshot will be generated if
290    /// there is no existing snapshot or there are mismatches between inputs or outputs
291    /// in the original and reproduced transcripts. This new snapshot will have the same path
292    /// as the original snapshot, but with the `.new.svg` extension. As an example,
293    /// if the snapshot at `snapshots/help.svg` is tested, the new snapshot will be saved at
294    /// `snapshots/help.new.svg`.
295    ///
296    /// Generation of new snapshots will only happen if the `svg` crate feature is enabled
297    /// (which it is by default), and if the [update mode](Self::with_update_mode())
298    /// is not [`UpdateMode::Never`], either because it was set explicitly or
299    /// [inferred] from the execution environment.
300    ///
301    /// The snapshot template can be customized via [`Self::with_template()`].
302    ///
303    /// # Panics
304    ///
305    /// - Panics if there is no snapshot at the specified path, or if the path points
306    ///   to a directory.
307    /// - Panics if an error occurs during reproducing the transcript or processing
308    ///   its output.
309    /// - Panics if there are mismatches between inputs or outputs in the original and reproduced
310    ///   transcripts.
311    ///
312    /// [`env!("CARGO_MANIFEST_DIR")`]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
313    /// [`UpdateMode::Never`]: crate::test::UpdateMode::Never
314    /// [inferred]: crate::test::UpdateMode::from_env()
315    pub fn test<I: Into<UserInput>>(
316        &mut self,
317        snapshot_path: impl AsRef<Path>,
318        inputs: impl IntoIterator<Item = I>,
319    ) {
320        let input: Vec<_> = inputs.into_iter().map(Into::into).collect();
321        let snapshot_path = snapshot_path.as_ref();
322        self.test_inner(snapshot_path, input);
323    }
324
325    /// Tests the `transcript`. This is a lower-level alternative to [`Self::test()`].
326    ///
327    /// # Panics
328    ///
329    /// - Panics if an error occurs during reproducing the transcript or processing
330    ///   its output.
331    /// - Panics if there are mismatches between outputs in the original and reproduced
332    ///   transcripts.
333    pub fn test_transcript(&mut self, transcript: &Transcript) {
334        let (stats, _) = self
335            .test_transcript_for_stats(transcript)
336            .unwrap_or_else(|err| panic!("{err}"));
337        stats.assert_no_errors(self.match_kind);
338    }
339
340    /// Tests the `transcript` and returns testing stats together with
341    /// the reproduced [`Transcript`]. This is a lower-level alternative to [`Self::test()`].
342    ///
343    /// # Errors
344    ///
345    /// - Returns an error if an error occurs during reproducing the transcript or processing
346    ///   its output.
347    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
348    pub fn test_transcript_for_stats(
349        &mut self,
350        transcript: &Transcript,
351    ) -> io::Result<(TestStats, Transcript)> {
352        let inputs = transcript
353            .interactions()
354            .iter()
355            .map(|interaction| interaction.input().clone())
356            .collect();
357        self.test_transcript_with_input(transcript, inputs)
358    }
359}