1use 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)] pub 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 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 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 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 struct DebugStr<'a>(&'a str);
136
137 impl fmt::Debug for DebugStr<'_> {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 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 #[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 #[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)] 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)] 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 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 #[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}