use serde::Serialize;
#[derive(Debug, PartialEq, Serialize)]
pub struct TestRun {
pub passed: u32,
pub failed: u32,
#[serde(skip_serializing_if = "is_zero")]
pub skipped: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub suites: Option<Vec<Suite>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<TestError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<Coverage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Coverage {
pub covered: u32,
pub lines: u32,
pub percent: u32,
pub files: Vec<FileCoverage>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct FileCoverage {
pub path: String,
pub covered: u32,
pub lines: u32,
pub percent: u32,
pub uncovered: Vec<u32>,
pub covered_lines: Vec<u32>,
}
impl From<&crate::coverage::CoverageReport> for Coverage {
fn from(r: &crate::coverage::CoverageReport) -> Self {
Coverage {
covered: r.total_covered(),
lines: r.total_lines(),
percent: r.total_percent(),
files: r
.files
.iter()
.map(|f| FileCoverage {
path: f.path.clone(),
covered: f.covered,
lines: f.total,
percent: crate::coverage::percent(f.covered, f.total),
uncovered: f.uncovered.clone(),
covered_lines: f.covered_lines.clone(),
})
.collect(),
}
}
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Suite {
pub name: String,
pub kind: String,
pub cases: Vec<Case>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Case {
pub name: String,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<Location>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Location {
pub path: String,
pub line: u32,
pub col: u32,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct TestError {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr: Option<String>,
}
impl TestRun {
pub fn empty() -> Self {
TestRun {
passed: 0,
failed: 0,
skipped: 0,
suites: Some(Vec::new()),
error: None,
coverage: None,
transport: None,
}
}
pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
if !report.is_empty() {
self.coverage = Some(Coverage::from(report));
}
self
}
pub fn discovered(suites: Vec<Suite>) -> Self {
TestRun {
passed: 0,
failed: 0,
skipped: 0,
suites: Some(suites),
error: None,
coverage: None,
transport: None,
}
}
pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
TestRun {
passed: 0,
failed: 0,
skipped: 0,
suites: None,
error: Some(TestError {
kind: "runtime".to_string(),
message: Some(message.into()),
diagnostics: Vec::new(),
stderr: stderr.filter(|s| !s.trim().is_empty()),
}),
coverage: None,
transport: None,
}
}
pub fn compile_error(diagnostics: Vec<String>) -> Self {
TestRun {
passed: 0,
failed: 0,
skipped: 0,
suites: None,
error: Some(TestError {
kind: "compile".to_string(),
message: None,
diagnostics,
stderr: None,
}),
coverage: None,
transport: None,
}
}
pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
self.transport = Some(transport);
if skipped.is_empty() {
return self;
}
self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
let suites = self.suites.get_or_insert_with(Vec::new);
for (suite, case, reason) in skipped {
let case = Case {
name: case.clone(),
outcome: "skipped".to_string(),
message: Some(reason.clone()),
location: None,
retries: None,
};
match suites.iter_mut().find(|s| &s.name == suite) {
Some(s) => s.cases.push(case),
None => suites.push(Suite {
name: suite.clone(),
kind: "integration".to_string(),
cases: vec![case],
}),
}
}
self
}
pub fn render(&self) -> String {
let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
s.push('\n');
s
}
}
#[derive(Debug, Default, PartialEq)]
pub struct ParsedRun {
pub passed: u32,
pub failed: u32,
pub suites: Vec<Suite>,
pub complete: bool,
}
impl ParsedRun {
pub fn into_document(self, stderr: &str) -> TestRun {
if self.complete {
TestRun {
passed: self.passed,
failed: self.failed,
skipped: 0,
suites: Some(self.suites),
error: None,
coverage: None,
transport: None,
}
} else {
let trimmed = stderr.trim();
TestRun {
passed: self.passed,
failed: self.failed,
skipped: 0,
suites: Some(self.suites),
error: Some(TestError {
kind: "runtime".to_string(),
message: Some("the test runner exited before completing".to_string()),
diagnostics: Vec::new(),
stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
}),
coverage: None,
transport: None,
}
}
}
}
pub fn parse_ndjson(stdout: &str) -> ParsedRun {
let mut run = ParsedRun::default();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
match value.get("type").and_then(|t| t.as_str()) {
Some("suite-begin") => {
run.suites.push(Suite {
name: str_field(&value, "name"),
kind: str_field(&value, "kind"),
cases: Vec::new(),
});
}
Some("case") => {
let outcome = str_field(&value, "outcome");
if outcome == "pass" {
run.passed += 1;
} else {
run.failed += 1;
}
let message = value
.get("message")
.and_then(|m| m.as_str())
.map(str::to_string);
let location = value
.get("location")
.and_then(|l| l.as_str())
.and_then(parse_location);
let retries = value
.get("retries")
.and_then(serde_json::Value::as_u64)
.filter(|n| *n > 0)
.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
let case = Case {
name: str_field(&value, "name"),
outcome,
message,
location,
retries,
};
if let Some(suite) = run.suites.last_mut() {
suite.cases.push(case);
}
}
Some("run-end") => {
run.complete = true;
}
_ => {}
}
}
run
}
fn str_field(value: &serde_json::Value, key: &str) -> String {
value
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
fn parse_location(s: &str) -> Option<Location> {
let (rest, col) = s.rsplit_once(':')?;
let (path, line) = rest.rsplit_once(':')?;
let line: u32 = line.parse().ok()?;
let col: u32 = col.parse().ok()?;
if path.is_empty() {
return None;
}
Some(Location {
path: path.to_string(),
line,
col,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_coverage_carries_covered_and_uncovered_lines() {
let report = crate::coverage::CoverageReport {
files: vec![crate::coverage::FileCoverage {
path: "src/demo/m.bynk".into(),
covered: 2,
total: 5,
uncovered: vec![7, 8, 9],
covered_lines: vec![3, 4],
}],
};
assert_eq!(
serde_json::to_string(&Coverage::from(&report)).unwrap(),
r#"{"covered":2,"lines":5,"percent":40,"files":[{"path":"src/demo/m.bynk","covered":2,"lines":5,"percent":40,"uncovered":[7,8,9],"covered_lines":[3,4]}]}"#
);
}
}