use std::io::{self, Write};
use owo_colors::OwoColorize;
use serde::Serialize;
use crate::pool::PoolEvent;
#[derive(clap::ValueEnum, serde::Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum ReporterKind {
Pretty,
Tap,
Json,
Junit,
}
pub enum Reporter {
Pretty(PrettyReporter),
Tap(TapReporter),
Json(JsonReporter),
Junit(JunitReporter),
Silent,
}
impl Reporter {
pub fn new(kind: ReporterKind) -> Self {
match kind {
ReporterKind::Pretty => Reporter::Pretty(PrettyReporter::default()),
ReporterKind::Tap => Reporter::Tap(TapReporter::default()),
ReporterKind::Json => Reporter::Json(JsonReporter::default()),
ReporterKind::Junit => Reporter::Junit(JunitReporter::default()),
}
}
pub fn set_total(&mut self, n: u32) {
if let Reporter::Pretty(r) = self {
r.set_total(n);
}
}
pub fn on_event(&mut self, ev: &PoolEvent) {
match self {
Reporter::Pretty(r) => r.on_event(ev),
Reporter::Tap(r) => r.on_event(ev),
Reporter::Json(r) => r.on_event(ev),
Reporter::Junit(r) => r.on_event(ev),
Reporter::Silent => {}
}
}
pub fn finish(&mut self, summary: &Summary) {
match self {
Reporter::Pretty(r) => r.finish(summary),
Reporter::Tap(r) => r.finish(summary),
Reporter::Json(r) => r.finish(summary),
Reporter::Junit(r) => r.finish(summary),
Reporter::Silent => {}
}
}
pub fn tick(&mut self) {
if let Reporter::Pretty(r) = self {
r.tick();
}
}
}
#[derive(Default, Debug, Clone, Copy)]
pub struct Summary {
pub pass: u32,
pub fail: u32,
pub error: u32,
pub pending: u32,
pub skipped: u32,
pub worker_crashes: u32,
}
impl Summary {
pub fn apply(&mut self, ev: &PoolEvent) {
match ev {
PoolEvent::TestPass { .. } => self.pass += 1,
PoolEvent::TestFail { .. } => self.fail += 1,
PoolEvent::TestError { .. } => self.error += 1,
PoolEvent::TestPending { .. } => self.pending += 1,
PoolEvent::TestSkip { .. } => self.skipped += 1,
PoolEvent::WorkerCrashed { .. } => self.worker_crashes += 1,
_ => {}
}
}
pub fn exit_code(&self) -> i32 {
if self.fail == 0 && self.error == 0 && self.worker_crashes == 0 {
0
} else {
1
}
}
}
const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct PrettyReporter {
total: u32,
completed: u32,
pass: u32,
fail: u32,
error: u32,
is_terminal: bool,
spinner_frame: usize,
}
impl Default for PrettyReporter {
fn default() -> Self {
Self {
total: 0,
completed: 0,
pass: 0,
fail: 0,
error: 0,
is_terminal: std::io::IsTerminal::is_terminal(&std::io::stdout()),
spinner_frame: 0,
}
}
}
impl PrettyReporter {
pub fn set_total(&mut self, n: u32) {
self.total = n;
}
fn erase_progress(&self, stdout: &mut impl Write) {
if self.is_terminal && self.total > 0 {
let _ = write!(stdout, "\r\x1b[2K");
}
}
fn draw_progress(&self, stdout: &mut impl Write) {
if !self.is_terminal || self.total == 0 {
return;
}
let spinner = SPINNER[self.spinner_frame % SPINNER.len()];
let _ = write!(
stdout,
"\r{} {}/{} ",
spinner.dimmed(),
self.completed,
self.total,
);
if self.pass > 0 {
let _ = write!(stdout, "{} ", format!("{} ✓", self.pass).green());
}
if self.fail > 0 {
let _ = write!(stdout, "{} ", format!("{} ✗", self.fail).red());
}
if self.error > 0 {
let _ = write!(stdout, "{} ", format!("{} !", self.error).yellow());
}
let _ = stdout.flush();
}
pub fn tick(&mut self) {
if !self.is_terminal || self.total == 0 {
return;
}
self.spinner_frame = self.spinner_frame.wrapping_add(1);
let mut stdout = io::stdout().lock();
self.draw_progress(&mut stdout);
}
fn on_event(&mut self, ev: &PoolEvent) {
let mut stdout = io::stdout().lock();
self.erase_progress(&mut stdout);
match ev {
PoolEvent::TestPass { file, name, us, .. } => {
self.completed += 1;
self.pass += 1;
let _ = writeln!(
stdout,
"{} {} :: {name} ({})",
"✓".green(),
file.dimmed(),
fmt_us(*us),
);
}
PoolEvent::TestFail {
file,
name,
us,
failures,
..
} => {
self.completed += 1;
self.fail += 1;
let _ = writeln!(
stdout,
"{} {} :: {name} ({})",
"✗".red(),
file.dimmed(),
fmt_us(*us),
);
for f in failures {
let mut lines = f.lines();
if let Some(first) = lines.next() {
let _ = writeln!(stdout, " {} {first}", "→".red());
for rest in lines {
let _ = writeln!(stdout, " {rest}");
}
}
}
}
PoolEvent::TestError {
file,
name,
message,
..
} => {
self.completed += 1;
self.error += 1;
let _ = writeln!(
stdout,
"{} {} :: {name}: {message}",
"!".yellow(),
file.dimmed(),
);
}
PoolEvent::TestPending {
file,
name,
message,
..
} => {
self.completed += 1;
let _ = writeln!(
stdout,
"{} {} :: {name} (pending: {message})",
"?".cyan(),
file.dimmed(),
);
}
PoolEvent::TestSkip { file, name, .. } => {
self.completed += 1;
let _ = writeln!(stdout, "{} {} :: {name}", "s".dimmed(), file.dimmed(),);
}
PoolEvent::WorkerCrashed {
worker,
current_file,
message,
} => {
let file = current_file.as_deref().unwrap_or("<none>");
let _ = writeln!(
stdout,
"{} worker {worker} crashed while running {file}: {message}",
"✗".red()
);
}
_ => {}
}
self.draw_progress(&mut stdout);
}
fn finish(&mut self, s: &Summary) {
let mut stdout = io::stdout().lock();
self.erase_progress(&mut stdout);
drop(stdout);
println!();
let total = s.pass + s.fail + s.error + s.pending + s.skipped;
print!("{} tests ", total.bold());
if s.pass > 0 {
print!("{} ", format!("{} passed", s.pass).green());
}
if s.fail > 0 {
print!("{} ", format!("{} failed", s.fail).red());
}
if s.error > 0 {
print!("{} ", format!("{} errored", s.error).yellow());
}
if s.pending > 0 {
print!("{} ", format!("{} pending", s.pending).cyan());
}
if s.skipped > 0 {
print!("{} ", format!("{} skipped", s.skipped).dimmed());
}
if s.worker_crashes > 0 {
print!("{} ", format!("{} worker crashes", s.worker_crashes).red());
}
println!();
}
}
fn fmt_us(us: u64) -> String {
if us < 1_000 {
format!("{us} µs")
} else if us < 1_000_000 {
format!("{:.2} ms", us as f64 / 1_000.0)
} else {
format!("{:.2} s", us as f64 / 1_000_000.0)
}
}
#[derive(Default)]
pub struct TapReporter {
header_emitted: bool,
buffered: Vec<PoolEvent>,
}
impl TapReporter {
fn on_event(&mut self, ev: &PoolEvent) {
self.buffered.push(ev.clone());
}
fn finish(&mut self, s: &Summary) {
let total = s.pass + s.fail + s.error;
if !self.header_emitted {
println!("TAP version 14");
println!("1..{}", total);
self.header_emitted = true;
}
let events = std::mem::take(&mut self.buffered);
let mut n: u32 = 0;
for ev in &events {
match ev {
PoolEvent::TestPass { name, us, file, .. } => {
n += 1;
println!("ok {n} - {file} :: {name} # {} µs", us);
}
PoolEvent::TestFail {
name,
us,
failures,
file,
..
} => {
n += 1;
println!("not ok {n} - {file} :: {name} # {} µs", us);
println!(" ---");
println!(" failures:");
for f in failures {
println!(" - {}", tap_escape(f));
}
println!(" ...");
}
PoolEvent::TestError {
name,
message,
file,
..
} => {
n += 1;
println!("not ok {n} - {file} :: {name} # ERROR");
println!(" ---");
println!(" error: {}", tap_escape(message));
println!(" ...");
}
PoolEvent::TestPending {
name,
message,
file,
..
} => {
n += 1;
println!(
"ok {n} - {file} :: {name} # SKIP pending: {}",
tap_escape(message)
);
}
PoolEvent::TestSkip { name, file, .. } => {
n += 1;
println!("ok {n} - {file} :: {name} # SKIP");
}
PoolEvent::WorkerCrashed {
worker,
current_file,
message,
} => {
let file = current_file.as_deref().unwrap_or("<none>");
println!(
"# worker {worker} crashed in {file}: {}",
tap_escape(message)
);
}
_ => {}
}
}
println!(
"# pass={} fail={} error={} pending={} skipped={} worker_crashes={}",
s.pass, s.fail, s.error, s.pending, s.skipped, s.worker_crashes
);
}
}
fn tap_escape(s: &str) -> String {
s.replace('\n', "\\n")
}
#[derive(Default)]
pub struct JsonReporter {}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum JsonEvent<'a> {
TestPass {
worker: usize,
file: &'a str,
name: &'a str,
us: u64,
},
TestFail {
worker: usize,
file: &'a str,
name: &'a str,
us: u64,
failures: &'a [String],
},
TestError {
worker: usize,
file: &'a str,
name: &'a str,
message: &'a str,
},
TestPending {
worker: usize,
file: &'a str,
name: &'a str,
message: &'a str,
},
TestSkip {
worker: usize,
file: &'a str,
name: &'a str,
message: &'a str,
},
WorkerCrashed {
worker: usize,
current_file: Option<&'a str>,
message: &'a str,
},
StderrNoise {
worker: usize,
line: &'a str,
},
}
#[derive(Serialize)]
struct JsonSummary {
kind: &'static str,
pass: u32,
fail: u32,
error: u32,
pending: u32,
skipped: u32,
worker_crashes: u32,
}
impl JsonReporter {
fn on_event(&mut self, ev: &PoolEvent) {
let mapped = match ev {
PoolEvent::TestPass {
worker,
file,
name,
us,
} => Some(JsonEvent::TestPass {
worker: *worker,
file,
name,
us: *us,
}),
PoolEvent::TestFail {
worker,
file,
name,
us,
failures,
} => Some(JsonEvent::TestFail {
worker: *worker,
file,
name,
us: *us,
failures,
}),
PoolEvent::TestError {
worker,
file,
name,
message,
} => Some(JsonEvent::TestError {
worker: *worker,
file,
name,
message,
}),
PoolEvent::TestPending {
worker,
file,
name,
message,
} => Some(JsonEvent::TestPending {
worker: *worker,
file,
name,
message,
}),
PoolEvent::TestSkip {
worker,
file,
name,
message,
} => Some(JsonEvent::TestSkip {
worker: *worker,
file,
name,
message,
}),
PoolEvent::WorkerCrashed {
worker,
current_file,
message,
} => Some(JsonEvent::WorkerCrashed {
worker: *worker,
current_file: current_file.as_deref(),
message,
}),
PoolEvent::StderrNoise { worker, line } => Some(JsonEvent::StderrNoise {
worker: *worker,
line,
}),
};
if let Some(e) = mapped {
if let Ok(line) = serde_json::to_string(&e) {
let mut stdout = io::stdout().lock();
let _ = writeln!(stdout, "{line}");
}
}
}
fn finish(&mut self, s: &Summary) {
let summary = JsonSummary {
kind: "summary",
pass: s.pass,
fail: s.fail,
error: s.error,
pending: s.pending,
skipped: s.skipped,
worker_crashes: s.worker_crashes,
};
if let Ok(line) = serde_json::to_string(&summary) {
let mut stdout = io::stdout().lock();
let _ = writeln!(stdout, "{line}");
}
}
}
struct JunitCase {
classname: String,
name: String,
time_s: f64,
failure: Option<String>,
}
#[derive(Default)]
pub struct JunitReporter {
cases: Vec<JunitCase>,
total_time_s: f64,
}
impl JunitReporter {
fn on_event(&mut self, ev: &PoolEvent) {
match ev {
PoolEvent::TestPass { file, name, us, .. } => {
let time_s = *us as f64 / 1_000_000.0;
self.total_time_s += time_s;
self.cases.push(JunitCase {
classname: file.clone(),
name: name.clone(),
time_s,
failure: None,
});
}
PoolEvent::TestFail {
file,
name,
us,
failures,
..
} => {
let time_s = *us as f64 / 1_000_000.0;
self.total_time_s += time_s;
self.cases.push(JunitCase {
classname: file.clone(),
name: name.clone(),
time_s,
failure: Some(failures.join("\n")),
});
}
PoolEvent::TestError {
file,
name,
message,
..
} => {
self.cases.push(JunitCase {
classname: file.clone(),
name: name.clone(),
time_s: 0.0,
failure: Some(format!("error: {message}")),
});
}
PoolEvent::TestPending {
file,
name,
message,
..
} => {
self.cases.push(JunitCase {
classname: file.clone(),
name: name.clone(),
time_s: 0.0,
failure: Some(format!("pending: {message}")),
});
}
PoolEvent::TestSkip { file, name, .. } => {
self.cases.push(JunitCase {
classname: file.clone(),
name: name.clone(),
time_s: 0.0,
failure: None,
});
}
_ => {}
}
}
fn finish(&mut self, s: &Summary) {
let mut out = io::stdout().lock();
let total = s.pass + s.fail + s.error;
let _ = writeln!(out, r#"<?xml version="1.0" encoding="UTF-8"?>"#);
let _ = writeln!(out, r#"<testsuites>"#);
let _ = writeln!(
out,
r#" <testsuite name="gdtest" tests="{}" failures="{}" errors="{}" time="{:.3}">"#,
total, s.fail, s.error, self.total_time_s,
);
for c in &self.cases {
let classname = xml_escape(&c.classname);
let name = xml_escape(&c.name);
if let Some(msg) = &c.failure {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}" time="{:.6}">"#,
c.time_s,
);
let _ = writeln!(out, r#" <failure message="{}"/>"#, xml_escape(msg));
let _ = writeln!(out, r#" </testcase>"#);
} else {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}" time="{:.6}"/>"#,
c.time_s,
);
}
}
let _ = writeln!(out, r#" </testsuite>"#);
let _ = writeln!(out, r#"</testsuites>"#);
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}