use crate::event::{suite, test, Record};
use chrono::Utc;
use std::{
fmt::{Debug, Display, Formatter},
io::Write,
time::Duration,
};
pub trait Addon<W>: Debug
where
W: Write,
{
fn render(&self, write: &mut W) -> anyhow::Result<()>;
}
#[derive(Debug)]
pub struct ProcessOptions<W> {
pub disable_front_matter: bool,
pub addons: Vec<Box<dyn Addon<W>>>,
}
pub struct Processor<W>
where
W: Write,
{
write: W,
options: ProcessOptions<W>,
tests: Vec<test::Event>,
test_count: Option<u64>,
summary: Option<Summary>,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
enum Outcome {
Ok,
Failed,
}
impl Display for Outcome {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ok => f.write_str("✅"),
Self::Failed => f.write_str("❌"),
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
struct Summary {
outcome: Outcome,
passed: u64,
failed: u64,
ignored: u64,
filtered_out: u64,
exec_time: Duration,
}
impl<W> Processor<W>
where
W: Write,
{
pub fn new(write: W, options: ProcessOptions<W>) -> Self {
Self {
write,
options,
tests: Vec::new(),
test_count: None,
summary: None,
}
}
fn write_header(&mut self, summary: &Summary) -> anyhow::Result<()> {
let run_id = std::env::var("GITHUB_RUN_ID").ok();
let repo = std::env::var("GITHUB_REPOSITORY").ok();
let link = match (&repo, &run_id) {
(Some(repo), Some(id)) => Some(format!(
"https://github.com/{repo}/actions/runs/{id}",
repo = repo,
id = id
)),
_ => None,
};
let date = Utc::now();
if !self.options.disable_front_matter {
let title = format!(
"{} Test Result {}",
summary.outcome,
date.format("%Y-%m-%d %H:%M UTC")
);
writeln!(self.write, "---")?;
writeln!(self.write, "title: \"{}\"", title)?;
writeln!(self.write, "date: {}", date.to_rfc3339())?;
writeln!(self.write, "categories: test-report")?;
writeln!(self.write, "excerpt_separator: <!--more-->")?;
writeln!(self.write, "---")?;
writeln!(self.write)?;
}
let total = self
.test_count
.map(|total| total.to_string())
.unwrap_or_else(|| "*unknown*".into());
writeln!(
self.write,
r#"
| | Total | Passed | Failed | Ignored | Filtered | Duration |
| --- | ----- | -------| ------ | ------- | -------- | -------- |
| {} | {} | {} | {} | {} | {} | {:?} |
"#,
summary.outcome,
total,
summary.passed,
summary.failed,
summary.ignored,
summary.filtered_out,
summary.exec_time
)?;
writeln!(self.write)?;
for addon in &self.options.addons {
addon.render(&mut self.write)?;
writeln!(self.write)?;
}
if let Some(link) = link {
writeln!(self.write, "**Job:** [{link}]({link})", link = link)?;
writeln!(self.write)?;
}
writeln!(self.write, "<!-- more -->")?;
Ok(())
}
pub fn line(&mut self, line: &str) -> anyhow::Result<()> {
match serde_json::from_str(line) {
Ok(record) => self.record(record)?,
Err(err) => log::debug!("Ignoring line: {:?} -> {}", err, line),
}
Ok(())
}
fn record(&mut self, record: Record) -> anyhow::Result<()> {
log::debug!("Record: {:?}", record);
match record {
Record::Test(test) => {
self.tests.push(test);
}
Record::Suite(suite::Event::Started { test_count }) => {
self.test_count = Some(test_count);
}
Record::Suite(suite::Event::Ok {
passed,
failed,
ignored,
filtered_out,
exec_time,
..
}) => {
self.summary = Some(Summary {
outcome: Outcome::Ok,
passed,
failed,
ignored,
filtered_out,
exec_time,
});
}
Record::Suite(suite::Event::Failed {
passed,
failed,
ignored,
filtered_out,
exec_time,
..
}) => {
self.summary = Some(Summary {
outcome: Outcome::Failed,
passed,
failed,
ignored,
filtered_out,
exec_time,
});
}
}
Ok(())
}
fn make_name(&self, name: &str, outcome: &str) -> String {
format!(
"[{}](#{})",
name,
make_anchor(&self.make_heading(name, outcome))
)
}
fn make_heading(&self, name: &str, outcome: &str) -> String {
format!("{} {}", outcome, name)
}
fn render_index(&mut self) -> anyhow::Result<()> {
writeln!(self.write)?;
writeln!(self.write, "# Index")?;
writeln!(self.write)?;
writeln!(self.write, "| Name | Result | Duration |")?;
writeln!(self.write, "| ---- | ------ | -------- |")?;
for test in &self.tests {
match test {
test::Event::Started { .. } => {}
test::Event::Ok { name, exec_time } => {
writeln!(
self.write,
"| {} | ✅ | {:?} | ",
self.make_name(&name, "✅"),
exec_time
)?;
}
test::Event::Failed {
name, exec_time, ..
} => {
writeln!(
self.write,
"| {} | ❌ | {:?} | ",
self.make_name(&name, "❌"),
exec_time
)?;
}
}
}
Ok(())
}
fn render_details(&mut self) -> anyhow::Result<()> {
writeln!(self.write)?;
writeln!(self.write)?;
writeln!(self.write, "# Details")?;
for test in &self.tests {
match test {
test::Event::Started { .. } => {}
test::Event::Ok { name, exec_time } => {
writeln!(self.write)?;
writeln!(self.write, "## {}", self.make_heading(name, "✅"))?;
writeln!(self.write)?;
writeln!(self.write, "**Duration**: {:?}", exec_time)?;
}
test::Event::Failed {
name,
exec_time,
stdout,
} => {
writeln!(self.write)?;
writeln!(self.write, "## {}", self.make_heading(name, "❌"))?;
writeln!(self.write)?;
writeln!(self.write, "**Duration**: {:?}", exec_time)?;
if !stdout.is_empty() {
writeln!(self.write)?;
writeln!(self.write, "<details>")?;
writeln!(self.write)?;
writeln!(self.write, "<summary>Test output</summary>")?;
writeln!(self.write)?;
writeln!(self.write, "<pre>")?;
writeln!(self.write, "{}", stdout)?;
writeln!(self.write, "</pre>")?;
writeln!(self.write)?;
writeln!(self.write, "</details>")?;
}
}
}
}
Ok(())
}
}
impl<W> Drop for Processor<W>
where
W: Write,
{
fn drop(&mut self) {
if let Some(summary) = self.summary.clone() {
self.write_header(&summary).expect("Render header");
}
self.render_index().expect("Render index");
self.render_details().expect("Render details");
}
}
fn make_anchor(link: &str) -> String {
let mut s = String::with_capacity(link.len());
let mut was_dash = false;
for c in link.chars() {
if c == '_' {
s.push(c);
was_dash = false;
} else if c == ' ' || c == '-' {
if !was_dash {
was_dash = true;
s.push('-');
}
} else if c.is_alphanumeric() {
s.push(c);
was_dash = false;
}
}
s
}