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