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
21impl<Cmd: SpawnShell + fmt::Debug, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
22 #[cfg_attr(
56 feature = "tracing",
57 tracing::instrument(skip_all, fields(snapshot_path, inputs))
58 )]
59 pub fn test<I: Into<UserInput>>(
60 &mut self,
61 snapshot_path: impl AsRef<Path>,
62 inputs: impl IntoIterator<Item = I>,
63 ) {
64 let inputs: Vec<_> = inputs.into_iter().map(Into::into).collect();
65 let snapshot_path = snapshot_path.as_ref();
66 #[cfg(feature = "tracing")]
67 tracing::Span::current()
68 .record("snapshot_path", tracing::field::debug(snapshot_path))
69 .record("inputs", tracing::field::debug(&inputs));
70
71 if snapshot_path.is_file() {
72 #[cfg(feature = "tracing")]
73 tracing::debug!(snapshot_path.is_file = true);
74
75 let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
76 panic!("Cannot open `{}`: {err}", snapshot_path.display());
77 });
78 let snapshot = BufReader::new(snapshot);
79 let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
80 panic!(
81 "Cannot parse snapshot from `{}`: {err}",
82 snapshot_path.display()
83 );
84 });
85 self.compare_and_test_transcript(snapshot_path, &transcript, &inputs);
86 } else if snapshot_path.exists() {
87 panic!(
88 "Snapshot path `{}` exists, but is not a file",
89 snapshot_path.display()
90 );
91 } else {
92 #[cfg(feature = "tracing")]
93 tracing::debug!(snapshot_path.is_file = false);
94
95 let new_snapshot_message =
96 self.create_and_write_new_snapshot(snapshot_path, inputs.into_iter());
97 panic!(
98 "Snapshot `{}` is missing\n{new_snapshot_message}",
99 snapshot_path.display()
100 );
101 }
102 }
103
104 #[cfg_attr(
105 feature = "tracing",
106 tracing::instrument(level = "debug", skip(self, transcript))
107 )]
108 fn compare_and_test_transcript(
109 &mut self,
110 snapshot_path: &Path,
111 transcript: &Transcript<Parsed>,
112 expected_inputs: &[UserInput],
113 ) {
114 let actual_inputs: Vec<_> = transcript
115 .interactions()
116 .iter()
117 .map(Interaction::input)
118 .collect();
119
120 if !actual_inputs.iter().copied().eq(expected_inputs) {
121 let new_snapshot_message =
122 self.create_and_write_new_snapshot(snapshot_path, expected_inputs.iter().cloned());
123 panic!(
124 "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
125 got {actual_inputs:?}\n{new_snapshot_message}"
126 );
127 }
128
129 let (stats, reproduced) = self
130 .test_transcript_for_stats(transcript)
131 .unwrap_or_else(|err| panic!("{err}"));
132 if stats.errors(self.match_kind) > 0 {
133 let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
134 panic!("There were test failures\n{new_snapshot_message}");
135 }
136 }
137
138 #[cfg(feature = "svg")]
139 #[cfg_attr(
140 feature = "tracing",
141 tracing::instrument(level = "debug", skip(self, inputs))
142 )]
143 fn create_and_write_new_snapshot(
144 &mut self,
145 path: &Path,
146 inputs: impl Iterator<Item = UserInput>,
147 ) -> String {
148 let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)
149 .unwrap_or_else(|err| {
150 panic!("Cannot create a snapshot `{}`: {err}", path.display());
151 });
152 (self.transform)(&mut reproduced);
153 self.write_new_snapshot(path, &reproduced)
154 }
155
156 #[cfg(feature = "svg")]
158 #[cfg_attr(
159 feature = "tracing",
160 tracing::instrument(level = "debug", skip(self, transcript), ret)
161 )]
162 fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
163 if !self.update_mode.should_create_snapshot() {
164 return format!(
165 "Skipped writing new snapshot `{}` per test config",
166 path.display()
167 );
168 }
169
170 let mut new_path = path.to_owned();
171 new_path.set_extension("new.svg");
172 let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
173 panic!(
174 "Cannot create file for new snapshot `{}`: {err}",
175 new_path.display()
176 );
177 });
178 self.template
179 .render(transcript, &mut io::BufWriter::new(new_snapshot))
180 .unwrap_or_else(|err| {
181 panic!("Cannot render snapshot `{}`: {err}", new_path.display());
182 });
183 format!("A new snapshot was saved to `{}`", new_path.display())
184 }
185
186 #[cfg(not(feature = "svg"))]
187 #[allow(clippy::unused_self)] fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
189 format!(
190 "Not writing a new snapshot since `{}/svg` feature is not enabled",
191 env!("CARGO_PKG_NAME")
192 )
193 }
194
195 #[cfg(not(feature = "svg"))]
196 #[allow(clippy::unused_self)] fn create_and_write_new_snapshot(
198 &mut self,
199 _: &Path,
200 _: impl Iterator<Item = UserInput>,
201 ) -> String {
202 format!(
203 "Not writing a new snapshot since `{}/svg` feature is not enabled",
204 env!("CARGO_PKG_NAME")
205 )
206 }
207
208 pub fn test_transcript(&mut self, transcript: &Transcript<Parsed>) {
217 let (stats, _) = self
218 .test_transcript_for_stats(transcript)
219 .unwrap_or_else(|err| panic!("{err}"));
220 stats.assert_no_errors(self.match_kind);
221 }
222
223 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
231 pub fn test_transcript_for_stats(
232 &mut self,
233 transcript: &Transcript<Parsed>,
234 ) -> io::Result<(TestStats, Transcript)> {
235 if self.output == TestOutputConfig::Quiet {
236 let mut out = NoColor::new(io::sink());
237 self.test_transcript_inner(&mut out, transcript)
238 } else {
239 let mut out = ColorPrintlnWriter::new(self.color_choice);
240 self.test_transcript_inner(&mut out, transcript)
241 }
242 }
243
244 pub(super) fn test_transcript_inner(
245 &mut self,
246 out: &mut impl WriteColor,
247 transcript: &Transcript<Parsed>,
248 ) -> io::Result<(TestStats, Transcript)> {
249 let inputs = transcript
250 .interactions()
251 .iter()
252 .map(|interaction| interaction.input().clone());
253 let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?;
254 (self.transform)(&mut reproduced);
255
256 let stats = self.compare_transcripts(out, transcript, &reproduced)?;
257 Ok((stats, reproduced))
258 }
259
260 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, ret, err))]
261 pub(super) fn compare_transcripts(
262 &self,
263 out: &mut impl WriteColor,
264 parsed: &Transcript<Parsed>,
265 reproduced: &Transcript,
266 ) -> io::Result<TestStats> {
267 let it = parsed
268 .interactions()
269 .iter()
270 .zip(reproduced.interactions().iter().map(Interaction::output));
271
272 let mut stats = TestStats {
273 matches: Vec::with_capacity(parsed.interactions().len()),
274 };
275 for (original, reproduced) in it {
276 #[cfg(feature = "tracing")]
277 let _entered =
278 tracing::debug_span!("compare_interaction", input = ?original.input).entered();
279
280 write!(out, " ")?;
281 out.set_color(ColorSpec::new().set_intense(true))?;
282 write!(out, "[")?;
283
284 let original_text = original.output().plaintext();
286 let reproduced_text = reproduced
287 .to_plaintext()
288 .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
289 let mut actual_match = if original_text == reproduced_text {
290 Some(MatchKind::TextOnly)
291 } else {
292 None
293 };
294 #[cfg(feature = "tracing")]
295 tracing::debug!(?actual_match, "compared output texts");
296
297 let color_diff = if self.match_kind == MatchKind::Precise && actual_match.is_some() {
299 let original_spans = &original.output().color_spans;
300 let reproduced_spans =
301 ColorSpan::parse(reproduced.as_ref()).map_err(|err| match err {
302 TermError::Io(err) => err,
303 other => io::Error::new(io::ErrorKind::InvalidInput, other),
304 })?;
305
306 let diff = ColorDiff::new(original_spans, &reproduced_spans);
307 #[cfg(feature = "tracing")]
308 tracing::debug!(?diff, "compared output coloring");
309
310 if diff.is_empty() {
311 actual_match = Some(MatchKind::Precise);
312 None
313 } else {
314 Some(diff)
315 }
316 } else {
317 None
318 };
319
320 stats.matches.push(actual_match);
321 if actual_match >= Some(self.match_kind) {
322 out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Green)))?;
323 write!(out, "+")?;
324 } else {
325 out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Red)))?;
326 if color_diff.is_some() {
327 write!(out, "#")?;
328 } else {
329 write!(out, "-")?;
330 }
331 }
332 out.set_color(ColorSpec::new().set_intense(true))?;
333 write!(out, "]")?;
334 out.reset()?;
335 writeln!(out, " Input: {}", original.input().as_ref())?;
336
337 if let Some(diff) = color_diff {
338 let original_spans = &original.output().color_spans;
339 diff.highlight_text(out, original_text, original_spans)?;
340 diff.write_as_table(out)?;
341 } else if actual_match.is_none() {
342 Self::write_diff(out, original_text, &reproduced_text)?;
343 } else if self.output == TestOutputConfig::Verbose {
344 out.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(244))))?;
345 let mut out_with_indents = IndentingWriter::new(&mut *out, b" ");
346 writeln!(out_with_indents, "{}", original.output().plaintext())?;
347 out.reset()?;
348 }
349 }
350
351 Ok(stats)
352 }
353
354 #[cfg(feature = "pretty_assertions")]
355 fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
356 use pretty_assertions::Comparison;
357
358 struct DebugStr<'a>(&'a str);
361
362 impl fmt::Debug for DebugStr<'_> {
363 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
364 for line in self.0.lines() {
367 writeln!(formatter, " {line}")?;
368 }
369 Ok(())
370 }
371 }
372
373 write!(
374 out,
375 " {}",
376 Comparison::new(&DebugStr(original), &DebugStr(reproduced))
377 )
378 }
379
380 #[cfg(not(feature = "pretty_assertions"))]
381 fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
382 writeln!(out, " Original:")?;
383 for line in original.lines() {
384 writeln!(out, " {line}")?;
385 }
386 writeln!(out, " Reproduced:")?;
387 for line in reproduced.lines() {
388 writeln!(out, " {line}")?;
389 }
390 Ok(())
391 }
392}