use termcolor::{Color, ColorChoice, ColorSpec, NoColor, StandardStream, WriteColor};
use std::{
io::{self, Write},
str,
};
use crate::{Interaction, ShellOptions, Transcript};
mod parser;
pub use self::parser::{ParseError, Parsed};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TestOutputConfig {
Quiet,
Normal,
Verbose,
}
impl Default for TestOutputConfig {
fn default() -> Self {
Self::Normal
}
}
#[derive(Debug)]
pub struct TestConfig {
shell_options: ShellOptions,
match_kind: MatchKind,
output: TestOutputConfig,
color_choice: ColorChoice,
}
impl TestConfig {
pub fn new<Ext>(shell_options: ShellOptions<Ext>) -> Self {
Self {
shell_options: shell_options.drop_extensions(),
match_kind: MatchKind::TextOnly,
output: TestOutputConfig::Normal,
color_choice: ColorChoice::Auto,
}
}
pub fn with_match_kind(&mut self, kind: MatchKind) -> &mut Self {
self.match_kind = kind;
self
}
pub fn with_color_choice(&mut self, color_choice: ColorChoice) -> &mut Self {
self.color_choice = color_choice;
self
}
pub fn with_output(&mut self, output: TestOutputConfig) -> &mut Self {
self.output = output;
self
}
pub fn test_transcript(&mut self, transcript: &Transcript<Parsed>) {
self.test_transcript_for_stats(transcript)
.unwrap_or_else(|err| panic!("{}", err))
.assert_no_errors(self.match_kind);
}
pub fn test_transcript_for_stats(
&mut self,
transcript: &Transcript<Parsed>,
) -> io::Result<TestStats> {
if self.output == TestOutputConfig::Quiet {
let mut out = NoColor::new(io::sink());
self.test_transcript_inner(&mut out, transcript)
} else {
let out = StandardStream::stdout(self.color_choice);
let mut out = out.lock();
self.test_transcript_inner(&mut out, transcript)
}
}
fn test_transcript_inner(
&mut self,
out: &mut impl WriteColor,
transcript: &Transcript<Parsed>,
) -> io::Result<TestStats> {
let inputs = transcript
.interactions()
.iter()
.map(|interaction| interaction.input().clone());
let reproduced = Transcript::from_inputs(&mut self.shell_options, inputs)?;
self.compare_transcripts(out, &transcript, &reproduced)
}
fn compare_transcripts(
&self,
out: &mut impl WriteColor,
parsed: &Transcript<Parsed>,
reproduced: &Transcript,
) -> 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 {
let (original_text, reproduced_text) = match self.match_kind {
MatchKind::Precise => {
let reproduced_html = reproduced
.to_html()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
(original.output().html(), reproduced_html)
}
MatchKind::TextOnly => {
let reproduced_plaintext = reproduced
.to_plaintext()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
(original.output().plaintext(), reproduced_plaintext)
}
};
write!(out, " ")?;
out.set_color(ColorSpec::new().set_intense(true))?;
write!(out, "[")?;
if original_text == reproduced_text {
stats.matches.push(Some(self.match_kind));
out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Green)))?;
write!(out, "+")?;
} else {
stats.matches.push(None);
out.set_color(ColorSpec::new().set_reset(false).set_fg(Some(Color::Red)))?;
write!(out, "-")?;
}
out.set_color(ColorSpec::new().set_intense(true))?;
write!(out, "]")?;
out.reset()?;
writeln!(out, " Input: {}", original.input().as_ref())?;
if original_text != reproduced_text {
Self::write_diff(out, original_text, &reproduced_text)?;
} else if self.output == TestOutputConfig::Verbose {
out.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(244))))?;
let mut out_with_indents = IndentingWriter::new(&mut *out, b" ");
writeln!(out_with_indents, "{}", original.output().plaintext())?;
out.reset()?;
}
}
Ok(stats)
}
#[cfg(feature = "pretty_assertions")]
fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
use pretty_assertions::Comparison;
use std::fmt;
struct DebugStr<'a>(&'a str);
impl fmt::Debug for DebugStr<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for line in self.0.lines() {
writeln!(formatter, " {}", line)?;
}
Ok(())
}
}
write!(
out,
" {}",
Comparison::new(&DebugStr(original), &DebugStr(reproduced))
)
}
#[cfg(not(feature = "pretty_assertions"))]
fn write_diff(out: &mut impl Write, original: &str, reproduced: &str) -> io::Result<()> {
writeln!(out, " Original:")?;
for line in original.lines() {
writeln!(out, " {}", line)?;
}
writeln!(out, " Reproduced:")?;
for line in reproduced.lines() {
writeln!(out, " {}", line)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum MatchKind {
TextOnly,
Precise,
}
#[derive(Debug, Clone)]
pub struct TestStats {
matches: Vec<Option<MatchKind>>,
}
impl TestStats {
pub fn passed(&self, match_level: MatchKind) -> usize {
self.matches
.iter()
.filter(|&&kind| kind >= Some(match_level))
.count()
}
pub fn errors(&self, match_level: MatchKind) -> usize {
self.matches.len() - self.passed(match_level)
}
pub fn matches(&self) -> &[Option<MatchKind>] {
&self.matches
}
#[allow(clippy::missing_panics_doc)]
pub fn assert_no_errors(&self, match_level: MatchKind) {
assert_eq!(self.errors(match_level), 0, "There were test errors");
}
}
#[derive(Debug)]
struct IndentingWriter<W> {
inner: W,
padding: &'static [u8],
new_line: bool,
}
impl<W: Write> IndentingWriter<W> {
pub fn new(writer: W, padding: &'static [u8]) -> Self {
Self {
inner: writer,
padding,
new_line: true,
}
}
}
impl<W: Write> Write for IndentingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
for (i, line) in buf.split(|&c| c == b'\n').enumerate() {
if i > 0 {
self.inner.write_all(b"\n")?;
}
if !line.is_empty() && (i > 0 || self.new_line) {
self.inner.write_all(self.padding)?;
}
self.inner.write_all(line)?;
}
self.new_line = buf.ends_with(b"\n");
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
svg::{Template, TemplateOptions},
UserInput,
};
#[test]
fn indenting_writer_basics() -> io::Result<()> {
let mut buffer = vec![];
let mut writer = IndentingWriter::new(&mut buffer, b" ");
write!(writer, "Hello, ")?;
writeln!(writer, "world!")?;
writeln!(writer, "many\n lines!")?;
assert_eq!(buffer, b" Hello, world!\n many\n lines!\n" as &[u8]);
Ok(())
}
fn test_snapshot_testing(test_config: &mut TestConfig) -> anyhow::Result<()> {
let transcript = Transcript::from_inputs(
&mut ShellOptions::default(),
vec![UserInput::command("echo \"Hello, world!\"")],
)?;
let mut svg_buffer = vec![];
Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;
let parsed = Transcript::from_svg(svg_buffer.as_slice())?;
test_config.test_transcript(&parsed);
Ok(())
}
#[test]
fn snapshot_testing_with_default_params() -> anyhow::Result<()> {
let mut test_config = TestConfig::new(ShellOptions::default());
test_snapshot_testing(&mut test_config)
}
#[test]
fn snapshot_testing_with_exact_match() -> anyhow::Result<()> {
let mut test_config = TestConfig::new(ShellOptions::default());
test_snapshot_testing(&mut test_config.with_match_kind(MatchKind::Precise))
}
fn test_negative_snapshot_testing(
out: &mut Vec<u8>,
test_config: &mut TestConfig,
) -> anyhow::Result<()> {
let mut transcript = Transcript::from_inputs(
&mut ShellOptions::default(),
vec![UserInput::command("echo \"Hello, world!\"")],
)?;
transcript.add_interaction(UserInput::command("echo \"Sup?\""), "Nah");
let mut svg_buffer = vec![];
Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;
let parsed = Transcript::from_svg(svg_buffer.as_slice())?;
let stats = test_config.test_transcript_inner(&mut NoColor::new(out), &parsed)?;
assert_eq!(stats.errors(MatchKind::TextOnly), 1);
Ok(())
}
#[test]
fn negative_snapshot_testing_with_default_output() {
let mut out = vec![];
let mut test_config = TestConfig::new(ShellOptions::default());
test_config.with_color_choice(ColorChoice::Never);
test_negative_snapshot_testing(&mut out, &mut test_config).unwrap();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("[+] Input: echo \"Hello, world!\""), "{}", out);
assert_eq!(out.matches("Hello, world!").count(), 1, "{}", out);
assert!(out.contains("[-] Input: echo \"Sup?\""), "{}", out);
assert!(out.contains("Nah"), "{}", out);
}
#[test]
fn negative_snapshot_testing_with_verbose_output() {
let mut out = vec![];
let mut test_config = TestConfig::new(ShellOptions::default());
test_config
.with_output(TestOutputConfig::Verbose)
.with_color_choice(ColorChoice::Never);
test_negative_snapshot_testing(&mut out, &mut test_config).unwrap();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("[+] Input: echo \"Hello, world!\""), "{}", out);
assert_eq!(out.matches("Hello, world!").count(), 2, "{}", out);
assert!(out.contains("[-] Input: echo \"Sup?\""), "{}", out);
assert!(out.contains("Nah"), "{}", out);
}
}