1use 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, TestConfig, TestOutputConfig, TestStats,
16 utils::{ChoiceWriter, IndentingWriter, PrintlnWriter},
17};
18use crate::{Interaction, 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)));
22const 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)] pub 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 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 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()?; Ok(stats)
101}
102
103impl<Cmd: SpawnShell + fmt::Debug, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
104 #[cfg_attr(
138 feature = "tracing",
139 tracing::instrument(skip_all, fields(snapshot_path, inputs))
140 )]
141 pub fn test<I: Into<UserInput>>(
142 &mut self,
143 snapshot_path: impl AsRef<Path>,
144 inputs: impl IntoIterator<Item = I>,
145 ) {
146 let inputs: Vec<_> = inputs.into_iter().map(Into::into).collect();
147 let snapshot_path = snapshot_path.as_ref();
148 #[cfg(feature = "tracing")]
149 tracing::Span::current()
150 .record("snapshot_path", tracing::field::debug(snapshot_path))
151 .record("inputs", tracing::field::debug(&inputs));
152
153 if snapshot_path.is_file() {
154 #[cfg(feature = "tracing")]
155 tracing::debug!(snapshot_path.is_file = true);
156
157 let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
158 panic!("Cannot open `{}`: {err}", snapshot_path.display());
159 });
160 let snapshot = BufReader::new(snapshot);
161 let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
162 panic!(
163 "Cannot parse snapshot from `{}`: {err}",
164 snapshot_path.display()
165 );
166 });
167 self.compare_and_test_transcript(snapshot_path, &transcript, &inputs);
168 } else if snapshot_path.exists() {
169 panic!(
170 "Snapshot path `{}` exists, but is not a file",
171 snapshot_path.display()
172 );
173 } else {
174 #[cfg(feature = "tracing")]
175 tracing::debug!(snapshot_path.is_file = false);
176
177 let new_snapshot_message =
178 self.create_and_write_new_snapshot(snapshot_path, inputs.into_iter());
179 panic!(
180 "Snapshot `{}` is missing\n{new_snapshot_message}",
181 snapshot_path.display()
182 );
183 }
184 }
185
186 #[cfg_attr(
187 feature = "tracing",
188 tracing::instrument(level = "debug", skip(self, transcript))
189 )]
190 fn compare_and_test_transcript(
191 &mut self,
192 snapshot_path: &Path,
193 transcript: &Transcript,
194 expected_inputs: &[UserInput],
195 ) {
196 let actual_inputs: Vec<_> = transcript
197 .interactions()
198 .iter()
199 .map(Interaction::input)
200 .collect();
201
202 if !actual_inputs.iter().copied().eq(expected_inputs) {
203 let new_snapshot_message =
204 self.create_and_write_new_snapshot(snapshot_path, expected_inputs.iter().cloned());
205 panic!(
206 "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
207 got {actual_inputs:?}\n{new_snapshot_message}"
208 );
209 }
210
211 let (stats, reproduced) = self
212 .test_transcript_for_stats(transcript)
213 .unwrap_or_else(|err| panic!("{err}"));
214 if stats.errors(self.match_kind) > 0 {
215 let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
216 panic!("There were test failures\n{new_snapshot_message}");
217 }
218 }
219
220 #[cfg(feature = "svg")]
221 #[cfg_attr(
222 feature = "tracing",
223 tracing::instrument(level = "debug", skip(self, inputs))
224 )]
225 fn create_and_write_new_snapshot(
226 &mut self,
227 path: &Path,
228 inputs: impl Iterator<Item = UserInput>,
229 ) -> String {
230 let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)
231 .unwrap_or_else(|err| {
232 panic!("Cannot create a snapshot `{}`: {err}", path.display());
233 });
234 (self.transform)(&mut reproduced);
235 self.write_new_snapshot(path, &reproduced)
236 }
237
238 #[cfg(feature = "svg")]
240 #[cfg_attr(
241 feature = "tracing",
242 tracing::instrument(level = "debug", skip(self, transcript), ret)
243 )]
244 fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
245 if !self.update_mode.should_create_snapshot() {
246 return format!(
247 "Skipped writing new snapshot `{}` per test config",
248 path.display()
249 );
250 }
251
252 let mut new_path = path.to_owned();
253 new_path.set_extension("new.svg");
254 let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
255 panic!(
256 "Cannot create file for new snapshot `{}`: {err}",
257 new_path.display()
258 );
259 });
260 self.template
261 .render(transcript, &mut io::BufWriter::new(new_snapshot))
262 .unwrap_or_else(|err| {
263 panic!("Cannot render snapshot `{}`: {err}", new_path.display());
264 });
265 format!("A new snapshot was saved to `{}`", new_path.display())
266 }
267
268 #[cfg(not(feature = "svg"))]
269 #[allow(clippy::unused_self)] fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
271 format!(
272 "Not writing a new snapshot since `{}/svg` feature is not enabled",
273 env!("CARGO_PKG_NAME")
274 )
275 }
276
277 #[cfg(not(feature = "svg"))]
278 #[allow(clippy::unused_self)] fn create_and_write_new_snapshot(
280 &mut self,
281 _: &Path,
282 _: impl Iterator<Item = UserInput>,
283 ) -> String {
284 format!(
285 "Not writing a new snapshot since `{}/svg` feature is not enabled",
286 env!("CARGO_PKG_NAME")
287 )
288 }
289
290 pub fn test_transcript(&mut self, transcript: &Transcript) {
299 let (stats, _) = self
300 .test_transcript_for_stats(transcript)
301 .unwrap_or_else(|err| panic!("{err}"));
302 stats.assert_no_errors(self.match_kind);
303 }
304
305 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
313 pub fn test_transcript_for_stats(
314 &mut self,
315 transcript: &Transcript,
316 ) -> io::Result<(TestStats, Transcript)> {
317 if self.output == TestOutputConfig::Quiet {
318 self.test_transcript_inner(&mut io::sink(), transcript)
319 } else {
320 let choice = if self.color_choice == ColorChoice::Auto {
321 AutoStream::choice(&io::stdout())
322 } else {
323 self.color_choice
324 };
325 let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice);
328 self.test_transcript_inner(&mut out, transcript)
329 }
330 }
331
332 pub(super) fn test_transcript_inner(
333 &mut self,
334 out: &mut impl Write,
335 transcript: &Transcript,
336 ) -> io::Result<(TestStats, Transcript)> {
337 let inputs = transcript
338 .interactions()
339 .iter()
340 .map(|interaction| interaction.input().clone());
341 let mut reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?;
342 (self.transform)(&mut reproduced);
343
344 let stats = compare_transcripts(
345 out,
346 transcript,
347 &reproduced,
348 self.match_kind,
349 self.output == TestOutputConfig::Verbose,
350 )?;
351 Ok((stats, reproduced))
352 }
353}