1use 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)), ..Style::NONE
36};
37
38#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, ret, err))]
39#[doc(hidden)] pub 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 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 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 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()?; 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 struct DebugStr<'a>(&'a str);
145
146 impl fmt::Debug for DebugStr<'_> {
147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148 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 #[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 #[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)] 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)] 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 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 #[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 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}