use std::path::Path;
use quick_junit::NonSuccessKind;
use quick_junit::Property;
use quick_junit::Report;
use quick_junit::TestCase;
use quick_junit::TestCaseStatus;
use quick_junit::TestSuite;
use crate::report::result::Failure;
use crate::report::result::Outcome;
use crate::report::result::TestResult;
use crate::report::result::events_json_link;
use crate::report::result::log_link;
use relux_core::diagnostics::IrSpan;
use relux_core::table::SourceTable;
pub fn generate_junit(
run_dir: &Path,
suite_name: &str,
results: &[TestResult],
source_table: &SourceTable,
) {
let xml = render_junit(suite_name, results, run_dir, source_table);
std::fs::write(run_dir.join("junit.xml"), xml).expect("failed to write junit.xml");
}
fn render_junit(
suite_name: &str,
results: &[TestResult],
run_dir: &Path,
source_table: &SourceTable,
) -> String {
let mut report = Report::new(suite_name);
let mut suite = TestSuite::new(suite_name);
for result in results {
let classname = Path::new(&result.test_path)
.with_extension("")
.display()
.to_string();
let mut case = TestCase::new(&result.test_name, TestCaseStatus::success());
case.set_classname(&classname);
case.set_time(result.duration);
match &result.outcome {
Outcome::Pass => {} Outcome::Fail(failure) => {
let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
status.set_message(failure.summary());
status.set_type(failure.failure_type());
status.set_description(format_failure_detail(failure, source_table));
case.status = status;
}
Outcome::Cancelled(c) => {
let mut status = TestCaseStatus::non_success(NonSuccessKind::Error);
status.set_message(format!("cancelled: {}", c.reason_tag()));
status.set_type("cancelled");
case.status = status;
}
Outcome::Skipped(reason) => {
let mut status = TestCaseStatus::skipped();
status.set_message(reason.as_str());
case.status = status;
}
Outcome::Invalid(reason) => {
let mut status = TestCaseStatus::non_success(NonSuccessKind::Error);
status.set_message(reason.as_str());
status.set_type("Invalid");
case.status = status;
}
}
if let Some(link) = log_link(run_dir, result) {
let json_link = events_json_link(run_dir, result);
let system_out = match &json_link {
Some(json) => format!("[[ATTACHMENT|{link}]]\n[[ATTACHMENT|{json}]]"),
None => format!("[[ATTACHMENT|{link}]]"),
};
case.set_system_out(system_out);
case.add_property(Property::new("log", &link));
if let Some(json) = json_link {
case.add_property(Property::new("events_json", &json));
}
}
suite.add_test_case(case);
}
report.add_test_suite(suite);
report.to_string().expect("JUnit XML serialization failed")
}
fn format_failure_detail(failure: &Failure, source_table: &SourceTable) -> String {
match failure {
Failure::MatchTimeout {
pattern,
span,
shell,
..
} => {
let loc = source_location(span, source_table);
format!("shell: {shell}\npattern: {pattern}\n{loc}")
}
Failure::FailPatternMatched {
pattern,
matched_line,
span,
shell,
..
} => {
let loc = source_location(span, source_table);
format!("shell: {shell}\npattern: {pattern}\nmatched: {matched_line}\n{loc}")
}
Failure::ShellExited {
shell,
exit_code,
span,
..
} => {
let loc = source_location(span, source_table);
let code_str = match exit_code {
Some(code) => code.to_string(),
None => "unknown".to_string(),
};
format!("shell: {shell}\nexit_code: {code_str}\n{loc}")
}
Failure::Runtime {
message,
span,
shell,
..
} => {
let shell_line = match shell {
Some(s) => format!("shell: {s}\n"),
None => String::new(),
};
let loc_line = match span {
Some(s) => format!("\n{}", source_location(s, source_table)),
None => String::new(),
};
format!("{shell_line}message: {message}{loc_line}")
}
}
}
fn source_location(span: &IrSpan, source_table: &SourceTable) -> String {
if let Some(sf) = source_table.get(span.file()) {
let line = line_number(&sf.source, span.span().start());
format!("file: {}\nline: {line}", sf.path.display())
} else {
format!("file: {}\nline: ?", span.file().path().display())
}
}
fn line_number(source: &str, offset: usize) -> usize {
source[..offset.min(source.len())]
.bytes()
.filter(|&b| b == b'\n')
.count()
+ 1
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::result::Failure;
use crate::report::result::FailureContext;
use crate::report::result::Outcome;
use crate::report::result::TestResult;
use relux_core::diagnostics::IrSpan;
use relux_core::table::FileId;
use relux_core::table::SharedTable;
use relux_core::table::SourceFile;
use std::path::PathBuf;
use std::time::Duration;
fn test_source_table() -> SourceTable {
let table: SourceTable = SharedTable::new();
table.insert(
FileId::new(PathBuf::from("tests/auth/login.relux")),
SourceFile::new(
PathBuf::from("tests/auth/login.relux"),
"line1\nline2\nline3\n".to_string(),
),
);
table
}
fn test_span(offset_start: usize, offset_end: usize) -> IrSpan {
IrSpan::new(
FileId::new(PathBuf::from("tests/auth/login.relux")),
relux_core::Span::new(offset_start, offset_end),
)
}
fn make_result(
name: &str,
path: &str,
outcome: Outcome,
duration: Duration,
log_dir: Option<PathBuf>,
) -> TestResult {
TestResult {
test_name: name.to_string(),
test_path: path.to_string(),
outcome,
duration,
progress: String::new(),
log_dir,
warnings: Vec::new(),
flaky_retries: 0,
}
}
#[test]
fn passed_test_with_log() {
let source_table = test_source_table();
let run_dir = Path::new("/tmp/runs/run-001");
let results = vec![make_result(
"login test",
"tests/auth/login.relux",
Outcome::Pass,
Duration::from_millis(1230),
Some(PathBuf::from("/tmp/runs/run-001/login-test")),
)];
let xml = render_junit("my-project", &results, run_dir, &source_table);
assert!(xml.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(xml.contains("name=\"login test\""));
assert!(xml.contains("classname=\"tests/auth/login\""));
assert!(xml.contains("[[ATTACHMENT|login-test/event.html]]"));
assert!(xml.contains("[[ATTACHMENT|login-test/events.json]]"));
assert!(xml.contains("<property name=\"log\" value=\"login-test/event.html\""));
assert!(xml.contains("<property name=\"events_json\" value=\"login-test/events.json\""));
assert!(!xml.contains("<failure"));
assert!(!xml.contains("<skipped"));
}
#[test]
fn failed_test_with_diagnostics() {
let source_table = test_source_table();
let run_dir = Path::new("/tmp/runs/run-001");
let failure = Failure::MatchTimeout {
pattern: "/ready/".to_string(),
span: test_span(12, 17),
shell: "default".to_string(),
effective: Box::new(relux_ir::IrTimeout::tolerance(Duration::from_secs(5))),
context: FailureContext::pre_vm(),
};
let results = vec![make_result(
"timeout test",
"tests/auth/timeout.relux",
Outcome::Fail(failure),
Duration::from_secs(5),
Some(PathBuf::from("/tmp/runs/run-001/timeout-test")),
)];
let xml = render_junit("my-project", &results, run_dir, &source_table);
assert!(xml.contains("name=\"timeout test\""));
assert!(xml.contains("classname=\"tests/auth/timeout\""));
assert!(xml.contains("type=\"MatchTimeout\""));
assert!(xml.contains("shell: default"));
assert!(xml.contains("pattern: /ready/"));
assert!(xml.contains("file: tests/auth/login.relux"));
assert!(xml.contains("line: 3"));
assert!(xml.contains("[[ATTACHMENT|timeout-test/event.html]]"));
assert!(xml.contains("[[ATTACHMENT|timeout-test/events.json]]"));
}
#[test]
fn skipped_test() {
let source_table = test_source_table();
let run_dir = Path::new("/tmp/runs/run-001");
let results = vec![make_result(
"setup test",
"tests/platform/setup.relux",
Outcome::Skipped("os:linux".to_string()),
Duration::ZERO,
None,
)];
let xml = render_junit("my-project", &results, run_dir, &source_table);
assert!(xml.contains("name=\"setup test\""));
assert!(xml.contains("classname=\"tests/platform/setup\""));
assert!(xml.contains("<skipped"));
assert!(xml.contains("os:linux"));
assert!(!xml.contains("ATTACHMENT"));
assert!(!xml.contains("<property"));
}
#[test]
fn suite_name_appears_in_output() {
let source_table = test_source_table();
let run_dir = Path::new("/tmp/runs/run-001");
let results = vec![];
let xml = render_junit("my-cool-project", &results, run_dir, &source_table);
assert!(xml.contains("name=\"my-cool-project\""));
}
#[test]
fn classname_strips_extension() {
let source_table = test_source_table();
let run_dir = Path::new("/tmp/runs/run-001");
let results = vec![make_result(
"a test",
"tests/deep/nested/file.relux",
Outcome::Pass,
Duration::from_millis(100),
None,
)];
let xml = render_junit("proj", &results, run_dir, &source_table);
assert!(xml.contains("classname=\"tests/deep/nested/file\""));
}
#[test]
fn line_number_calculation() {
assert_eq!(line_number("hello", 0), 1);
assert_eq!(line_number("hello\nworld", 6), 2);
assert_eq!(line_number("a\nb\nc\n", 4), 3);
assert_eq!(line_number("ab", 100), 1);
}
}