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, TestCommand, TestConfig, TestOutputConfig, TestStats,
16 utils::{ChoiceWriter, IndentingWriter, PrintlnWriter},
17};
18use crate::{Interaction, ShellOptions, 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: TestCommand, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
104 #[cfg_attr(feature = "tracing", tracing::instrument(name = "test", skip(self)))]
105 fn test_inner(&mut self, snapshot_path: &Path, input: Cmd::Inputs) {
106 if snapshot_path.is_file() {
107 #[cfg(feature = "tracing")]
108 tracing::debug!(snapshot_path.is_file = true);
109
110 let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
111 panic!("Cannot open `{}`: {err}", snapshot_path.display());
112 });
113 let snapshot = BufReader::new(snapshot);
114 let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
115 panic!(
116 "Cannot parse snapshot from `{}`: {err}",
117 snapshot_path.display()
118 );
119 });
120 self.compare_and_test_transcript(snapshot_path, &transcript, input);
121 } else if snapshot_path.exists() {
122 panic!(
123 "Snapshot path `{}` exists, but is not a file",
124 snapshot_path.display()
125 );
126 } else {
127 #[cfg(feature = "tracing")]
128 tracing::debug!(snapshot_path.is_file = false);
129
130 let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
131 panic!(
132 "Snapshot `{}` is missing\n{new_snapshot_message}",
133 snapshot_path.display()
134 );
135 }
136 }
137
138 #[cfg_attr(
139 feature = "tracing",
140 tracing::instrument(level = "debug", skip(self, parsed))
141 )]
142 fn compare_and_test_transcript(
143 &mut self,
144 snapshot_path: &Path,
145 parsed: &Transcript,
146 input: Cmd::Inputs,
147 ) {
148 let actual_inputs: Vec<_> = parsed
149 .interactions()
150 .iter()
151 .map(Interaction::input)
152 .collect();
153 let expected_inputs = Cmd::extract_inputs(&input);
154
155 if !actual_inputs.iter().copied().eq(expected_inputs.as_ref()) {
156 let expected_inputs = expected_inputs.into_owned();
157 let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
158 panic!(
159 "Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
160 got {actual_inputs:?}\n{new_snapshot_message}"
161 );
162 }
163
164 let (stats, reproduced) = self
165 .test_transcript_with_input(parsed, input)
166 .unwrap_or_else(|err| panic!("{err}"));
167 if stats.errors(self.match_kind) > 0 {
168 let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
169 panic!("There were test failures\n{new_snapshot_message}");
170 }
171 }
172
173 fn test_transcript_with_input(
174 &mut self,
175 parsed: &Transcript,
176 input: Cmd::Inputs,
177 ) -> io::Result<(TestStats, Transcript)> {
178 if self.output == TestOutputConfig::Quiet {
179 self.test_transcript_inner(&mut io::sink(), parsed, input)
180 } else {
181 let choice = if self.color_choice == ColorChoice::Auto {
182 AutoStream::choice(&io::stdout())
183 } else {
184 self.color_choice
185 };
186 let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice);
189 self.test_transcript_inner(&mut out, parsed, input)
190 }
191 }
192
193 pub(super) fn test_transcript_inner(
194 &mut self,
195 out: &mut impl Write,
196 parsed: &Transcript,
197 input: Cmd::Inputs,
198 ) -> io::Result<(TestStats, Transcript)> {
199 let mut reproduced = self.command.reproduce(input)?;
200 (self.transform)(&mut reproduced);
201
202 let stats = compare_transcripts(
203 out,
204 parsed,
205 &reproduced,
206 self.match_kind,
207 self.output == TestOutputConfig::Verbose,
208 )?;
209 Ok((stats, reproduced))
210 }
211
212 #[cfg(feature = "svg")]
213 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self)))]
214 fn create_and_write_new_snapshot(&mut self, path: &Path, inputs: Cmd::Inputs) -> String {
215 let mut reproduced = self.command.reproduce(inputs).unwrap_or_else(|err| {
216 panic!("Cannot create a snapshot `{}`: {err}", path.display());
217 });
218 (self.transform)(&mut reproduced);
219 self.write_new_snapshot(path, &reproduced)
220 }
221
222 #[cfg(feature = "svg")]
224 #[cfg_attr(
225 feature = "tracing",
226 tracing::instrument(level = "debug", skip(self, transcript), ret)
227 )]
228 fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
229 if !self.update_mode.should_create_snapshot() {
230 return format!(
231 "Skipped writing new snapshot `{}` per test config",
232 path.display()
233 );
234 }
235
236 let mut new_path = path.to_owned();
237 new_path.set_extension("new.svg");
238 let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
239 panic!(
240 "Cannot create file for new snapshot `{}`: {err}",
241 new_path.display()
242 );
243 });
244 self.template
245 .render(transcript, &mut io::BufWriter::new(new_snapshot))
246 .unwrap_or_else(|err| {
247 panic!("Cannot render snapshot `{}`: {err}", new_path.display());
248 });
249 format!("A new snapshot was saved to `{}`", new_path.display())
250 }
251
252 #[cfg(not(feature = "svg"))]
253 #[allow(clippy::unused_self)] fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
255 format!(
256 "Not writing a new snapshot since `{}/svg` feature is not enabled",
257 env!("CARGO_PKG_NAME")
258 )
259 }
260
261 #[cfg(not(feature = "svg"))]
262 #[allow(clippy::unused_self)] fn create_and_write_new_snapshot(&mut self, _: &Path, _: Cmd::Inputs) -> String {
264 format!(
265 "Not writing a new snapshot since `{}/svg` feature is not enabled",
266 env!("CARGO_PKG_NAME")
267 )
268 }
269}
270
271impl<F: FnMut(&mut Transcript)> TestConfig<(), F> {
272 pub fn test_captured(&mut self, snapshot_path: impl AsRef<Path>, captured: Transcript) {
277 self.test_inner(snapshot_path.as_ref(), captured);
278 }
279}
280
281impl<Cmd: SpawnShell + fmt::Debug, F: FnMut(&mut Transcript)> TestConfig<ShellOptions<Cmd>, F> {
282 pub fn test<I: Into<UserInput>>(
316 &mut self,
317 snapshot_path: impl AsRef<Path>,
318 inputs: impl IntoIterator<Item = I>,
319 ) {
320 let input: Vec<_> = inputs.into_iter().map(Into::into).collect();
321 let snapshot_path = snapshot_path.as_ref();
322 self.test_inner(snapshot_path, input);
323 }
324
325 pub fn test_transcript(&mut self, transcript: &Transcript) {
334 let (stats, _) = self
335 .test_transcript_for_stats(transcript)
336 .unwrap_or_else(|err| panic!("{err}"));
337 stats.assert_no_errors(self.match_kind);
338 }
339
340 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
348 pub fn test_transcript_for_stats(
349 &mut self,
350 transcript: &Transcript,
351 ) -> io::Result<(TestStats, Transcript)> {
352 let inputs = transcript
353 .interactions()
354 .iter()
355 .map(|interaction| interaction.input().clone())
356 .collect();
357 self.test_transcript_with_input(transcript, inputs)
358 }
359}