use std::{
fmt,
fs::File,
io::{self, BufReader, Write},
path::Path,
};
use anstream::{AutoStream, ColorChoice};
use anstyle::{Ansi256Color, AnsiColor, Color, Style};
use styled_str::{StyleDiff, TextDiff};
use super::{
MatchKind, TestCommand, TestConfig, TestOutputConfig, TestStats,
utils::{ChoiceWriter, IndentingWriter, PrintlnWriter},
};
use crate::{Interaction, ShellOptions, Transcript, UserInput, traits::SpawnShell};
const SUCCESS: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)));
const ERROR: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightRed)));
const VERBOSE_OUTPUT: Style = Style::new().fg_color(Some(Color::Ansi256(Ansi256Color(244))));
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, ret, err))]
#[doc(hidden)] pub fn compare_transcripts(
out: &mut impl Write,
parsed: &Transcript,
reproduced: &Transcript,
match_kind: MatchKind,
verbose: bool,
) -> io::Result<TestStats> {
let it = parsed
.interactions()
.iter()
.zip(reproduced.interactions().iter().map(Interaction::output));
let mut stats = TestStats {
matches: Vec::with_capacity(parsed.interactions().len()),
};
for (original, reproduced) in it {
#[cfg(feature = "tracing")]
let _entered =
tracing::debug_span!("compare_interaction", input = ?original.input()).entered();
write!(out, " [")?;
let original_text = original.output().text();
let reproduced_text = reproduced.text();
let mut actual_match = if original_text == reproduced_text {
Some(MatchKind::TextOnly)
} else {
None
};
#[cfg(feature = "tracing")]
tracing::debug!(?actual_match, "compared output texts");
let color_diff = if match_kind == MatchKind::Precise && actual_match.is_some() {
let diff = StyleDiff::new(original.output().as_str(), reproduced.as_str());
#[cfg(feature = "tracing")]
tracing::debug!(?diff, "compared output coloring");
if diff.is_empty() {
actual_match = Some(MatchKind::Precise);
None
} else {
Some(diff)
}
} else {
None
};
stats.matches.push(actual_match);
if actual_match >= Some(match_kind) {
write!(out, "{SUCCESS}+{SUCCESS:#}")?;
} else if color_diff.is_some() {
write!(out, "{ERROR}#{ERROR:#}")?;
} else {
write!(out, "{ERROR}-{ERROR:#}")?;
}
writeln!(out, "] Input: {}", original.input().as_ref())?;
if let Some(diff) = color_diff {
write!(out, "{diff:>4}{diff:#}")?;
} else if actual_match.is_none() {
write!(out, "{:>4}", TextDiff::new(original_text, reproduced_text))?;
} else if verbose {
write!(out, "{VERBOSE_OUTPUT}")?;
let mut out_with_indents = IndentingWriter::new(&mut *out, " ");
writeln!(out_with_indents, "{}", original.output().text())?;
write!(out, "{VERBOSE_OUTPUT:#}")?;
}
}
out.flush()?; Ok(stats)
}
impl<Cmd: TestCommand, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
#[cfg_attr(feature = "tracing", tracing::instrument(name = "test", skip(self)))]
fn test_inner(&mut self, snapshot_path: &Path, input: Cmd::Inputs) {
if snapshot_path.is_file() {
#[cfg(feature = "tracing")]
tracing::debug!(snapshot_path.is_file = true);
let snapshot = File::open(snapshot_path).unwrap_or_else(|err| {
panic!("Cannot open `{}`: {err}", snapshot_path.display());
});
let snapshot = BufReader::new(snapshot);
let transcript = Transcript::from_svg(snapshot).unwrap_or_else(|err| {
panic!(
"Cannot parse snapshot from `{}`: {err}",
snapshot_path.display()
);
});
self.compare_and_test_transcript(snapshot_path, &transcript, input);
} else if snapshot_path.exists() {
panic!(
"Snapshot path `{}` exists, but is not a file",
snapshot_path.display()
);
} else {
#[cfg(feature = "tracing")]
tracing::debug!(snapshot_path.is_file = false);
let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
panic!(
"Snapshot `{}` is missing\n{new_snapshot_message}",
snapshot_path.display()
);
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip(self, parsed))
)]
fn compare_and_test_transcript(
&mut self,
snapshot_path: &Path,
parsed: &Transcript,
input: Cmd::Inputs,
) {
let actual_inputs: Vec<_> = parsed
.interactions()
.iter()
.map(Interaction::input)
.collect();
let expected_inputs = Cmd::extract_inputs(&input);
if !actual_inputs.iter().copied().eq(expected_inputs.as_ref()) {
let expected_inputs = expected_inputs.into_owned();
let new_snapshot_message = self.create_and_write_new_snapshot(snapshot_path, input);
panic!(
"Unexpected user inputs in parsed snapshot: expected {expected_inputs:?}, \
got {actual_inputs:?}\n{new_snapshot_message}"
);
}
let (stats, reproduced) = self
.test_transcript_with_input(parsed, input)
.unwrap_or_else(|err| panic!("{err}"));
if stats.errors(self.match_kind) > 0 {
let new_snapshot_message = self.write_new_snapshot(snapshot_path, &reproduced);
panic!("There were test failures\n{new_snapshot_message}");
}
}
fn test_transcript_with_input(
&mut self,
parsed: &Transcript,
input: Cmd::Inputs,
) -> io::Result<(TestStats, Transcript)> {
if self.output == TestOutputConfig::Quiet {
self.test_transcript_inner(&mut io::sink(), parsed, input)
} else {
let choice = if self.color_choice == ColorChoice::Auto {
AutoStream::choice(&io::stdout())
} else {
self.color_choice
};
let mut out = ChoiceWriter::new(PrintlnWriter::default(), choice);
self.test_transcript_inner(&mut out, parsed, input)
}
}
pub(super) fn test_transcript_inner(
&mut self,
out: &mut impl Write,
parsed: &Transcript,
input: Cmd::Inputs,
) -> io::Result<(TestStats, Transcript)> {
let mut reproduced = self.command.reproduce(input)?;
(self.transform)(&mut reproduced);
let stats = compare_transcripts(
out,
parsed,
&reproduced,
self.match_kind,
self.output == TestOutputConfig::Verbose,
)?;
Ok((stats, reproduced))
}
#[cfg(feature = "svg")]
#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self)))]
fn create_and_write_new_snapshot(&mut self, path: &Path, inputs: Cmd::Inputs) -> String {
let mut reproduced = self.command.reproduce(inputs).unwrap_or_else(|err| {
panic!("Cannot create a snapshot `{}`: {err}", path.display());
});
(self.transform)(&mut reproduced);
self.write_new_snapshot(path, &reproduced)
}
#[cfg(feature = "svg")]
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip(self, transcript), ret)
)]
fn write_new_snapshot(&self, path: &Path, transcript: &Transcript) -> String {
if !self.update_mode.should_create_snapshot() {
return format!(
"Skipped writing new snapshot `{}` per test config",
path.display()
);
}
let mut new_path = path.to_owned();
new_path.set_extension("new.svg");
let new_snapshot = File::create(&new_path).unwrap_or_else(|err| {
panic!(
"Cannot create file for new snapshot `{}`: {err}",
new_path.display()
);
});
self.template
.render(transcript, &mut io::BufWriter::new(new_snapshot))
.unwrap_or_else(|err| {
panic!("Cannot render snapshot `{}`: {err}", new_path.display());
});
format!("A new snapshot was saved to `{}`", new_path.display())
}
#[cfg(not(feature = "svg"))]
#[allow(clippy::unused_self)] fn write_new_snapshot(&self, _: &Path, _: &Transcript) -> String {
format!(
"Not writing a new snapshot since `{}/svg` feature is not enabled",
env!("CARGO_PKG_NAME")
)
}
#[cfg(not(feature = "svg"))]
#[allow(clippy::unused_self)] fn create_and_write_new_snapshot(&mut self, _: &Path, _: Cmd::Inputs) -> String {
format!(
"Not writing a new snapshot since `{}/svg` feature is not enabled",
env!("CARGO_PKG_NAME")
)
}
}
impl<F: FnMut(&mut Transcript)> TestConfig<(), F> {
pub fn test_captured(&mut self, snapshot_path: impl AsRef<Path>, captured: Transcript) {
self.test_inner(snapshot_path.as_ref(), captured);
}
}
impl<Cmd: SpawnShell + fmt::Debug, F: FnMut(&mut Transcript)> TestConfig<ShellOptions<Cmd>, F> {
pub fn test<I: Into<UserInput>>(
&mut self,
snapshot_path: impl AsRef<Path>,
inputs: impl IntoIterator<Item = I>,
) {
let input: Vec<_> = inputs.into_iter().map(Into::into).collect();
let snapshot_path = snapshot_path.as_ref();
self.test_inner(snapshot_path, input);
}
pub fn test_transcript(&mut self, transcript: &Transcript) {
let (stats, _) = self
.test_transcript_for_stats(transcript)
.unwrap_or_else(|err| panic!("{err}"));
stats.assert_no_errors(self.match_kind);
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
pub fn test_transcript_for_stats(
&mut self,
transcript: &Transcript,
) -> io::Result<(TestStats, Transcript)> {
let inputs = transcript
.interactions()
.iter()
.map(|interaction| interaction.input().clone())
.collect();
self.test_transcript_with_input(transcript, inputs)
}
}