use std::{collections::BTreeMap, time::{Duration, UNIX_EPOCH}};
use crate::{
automation,
error::HenError,
request::{AssertionOutcome, AssertionStatus, ExecutionRecord, RequestFailure, RequestFailureKind},
};
#[derive(Debug, Clone)]
struct JUnitCase {
class_name: String,
name: String,
time_secs: f64,
status: JUnitCaseStatus,
}
#[derive(Debug, Clone)]
enum JUnitCaseStatus {
Passed,
Failure {
kind: String,
message: String,
details: String,
},
Skipped {
message: String,
},
Error {
kind: String,
message: String,
details: String,
},
}
pub fn run_outcome_junit(outcome: &automation::RunOutcome) -> String {
let suite_name = report_suite_name(
&outcome.collection.name,
&outcome.collection.path.display().to_string(),
);
let mut records_by_index: BTreeMap<usize, &ExecutionRecord> = outcome
.records
.iter()
.map(|record| (record.index, record))
.collect();
let mut failures_by_index: BTreeMap<usize, &RequestFailure> = outcome
.failures
.iter()
.filter_map(|failure| failure.index().map(|index| (index, failure)))
.collect();
let mut cases = Vec::new();
let request_summaries: BTreeMap<usize, &automation::RequestSummary> = outcome
.collection
.requests
.iter()
.map(|request| (request.index, request))
.collect();
for index in &outcome.plan {
if let Some(record) = records_by_index.remove(index) {
cases.extend(record_cases(
&suite_name,
request_summaries.get(index).copied(),
record,
));
continue;
}
if let Some(failure) = failures_by_index.remove(index) {
cases.extend(failure_cases(
&suite_name,
request_summaries.get(index).copied(),
failure,
));
}
}
cases.extend(records_by_index.into_values().flat_map(|record| {
record_cases(
&suite_name,
request_summaries.get(&record.index).copied(),
record,
)
}));
cases.extend(failures_by_index.into_values().flat_map(|failure| {
failure_cases(
&suite_name,
failure
.index()
.and_then(|index| request_summaries.get(&index).copied()),
failure,
)
}));
cases.extend(
outcome
.failures
.iter()
.filter(|failure| failure.index().is_none())
.flat_map(|failure| failure_cases(&suite_name, None, failure)),
);
if let Some(signal) = outcome.interrupted {
cases.push(JUnitCase {
class_name: suite_name.clone(),
name: format!("run interrupted by {}", signal.as_str()),
time_secs: 0.0,
status: JUnitCaseStatus::Error {
kind: "interrupted".to_string(),
message: format!("Execution interrupted by {}", signal.as_str()),
details: format!(
"Execution interrupted by {} before completing all planned requests",
signal.as_str()
),
},
});
}
render_junit_suite(
&suite_name,
total_elapsed(&outcome.records)
.unwrap_or_default()
.as_secs_f64(),
&[
("path", outcome.collection.path.display().to_string()),
(
"selectedRequests",
outcome
.selected_requests
.iter()
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join(","),
),
(
"plan",
outcome
.plan
.iter()
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join(","),
),
("executionFailed", outcome.execution_failed.to_string()),
("interrupted", outcome.interrupted.is_some().to_string()),
(
"interruptSignal",
outcome
.interrupted
.map(|signal| signal.as_str().to_string())
.unwrap_or_default(),
),
],
&cases,
)
}
pub fn verification_result_junit(result: &automation::VerificationResult) -> String {
let fallback = result
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "hen verify".to_string());
let suite_name = report_suite_name(&result.summary.name, &fallback);
render_junit_suite(
&suite_name,
0.0,
&[("path", fallback.clone())],
&[JUnitCase {
class_name: suite_name.clone(),
name: "syntax validation".to_string(),
time_secs: 0.0,
status: JUnitCaseStatus::Passed,
}],
)
}
pub fn hen_error_junit(suite_name: &str, case_name: &str, error: &HenError) -> String {
render_junit_suite(
suite_name,
0.0,
&[],
&[JUnitCase {
class_name: suite_name.to_string(),
name: case_name.to_string(),
time_secs: 0.0,
status: JUnitCaseStatus::Error {
kind: error.kind().label().to_string(),
message: error.summary().to_string(),
details: error.to_string(),
},
}],
)
}
fn success_case(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
record: &ExecutionRecord,
) -> JUnitCase {
JUnitCase {
class_name: suite_name.to_string(),
name: request_case_name(
record.index,
request_summary,
record.method.as_str(),
&record.url,
&record.description,
),
time_secs: record.duration.as_secs_f64(),
status: JUnitCaseStatus::Passed,
}
}
fn record_cases(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
record: &ExecutionRecord,
) -> Vec<JUnitCase> {
let request_name = request_case_name(
record.index,
request_summary,
record.method.as_str(),
&record.url,
&record.description,
);
if record.execution.assertions.is_empty() {
return vec![success_case(suite_name, request_summary, record)];
}
let case_time_secs = record.duration.as_secs_f64() / record.execution.assertions.len() as f64;
assertion_cases(
suite_name,
&request_name,
&record.execution.assertions,
case_time_secs,
"assertion",
)
}
fn failure_case(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
failure: &RequestFailure,
) -> JUnitCase {
let name = match failure.index() {
Some(index) => request_case_name(
index,
request_summary,
request_summary
.map(|summary| summary.method.as_str())
.unwrap_or("request"),
request_summary
.map(|summary| summary.url.as_str())
.unwrap_or(""),
failure.request(),
),
None => failure.request().to_string(),
};
let status = match failure.kind() {
RequestFailureKind::Execution { message, .. } => JUnitCaseStatus::Failure {
kind: "execution".to_string(),
message: message.clone(),
details: failure.to_string(),
},
RequestFailureKind::Dependency { dependency } => JUnitCaseStatus::Skipped {
message: format!("dependency '{}' failed", dependency),
},
RequestFailureKind::MissingDependency { dependency } => JUnitCaseStatus::Skipped {
message: format!("missing dependency '{}'", dependency),
},
RequestFailureKind::MapAborted { group, cause } => JUnitCaseStatus::Skipped {
message: format!("'{}' aborted after failure in '{}'", group, cause),
},
RequestFailureKind::Join { message } => JUnitCaseStatus::Error {
kind: "join".to_string(),
message: message.clone(),
details: failure.to_string(),
},
};
JUnitCase {
class_name: suite_name.to_string(),
name,
time_secs: 0.0,
status,
}
}
fn failure_cases(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
failure: &RequestFailure,
) -> Vec<JUnitCase> {
match failure.kind() {
RequestFailureKind::Execution { assertions, .. } if !assertions.is_empty() => {
let request_name = match failure.index() {
Some(index) => request_case_name(
index,
request_summary,
request_summary
.map(|summary| summary.method.as_str())
.unwrap_or("request"),
request_summary
.map(|summary| summary.url.as_str())
.unwrap_or(""),
failure.request(),
),
None => failure.request().to_string(),
};
assertion_cases(suite_name, &request_name, assertions, 0.0, "assertion")
}
_ => vec![failure_case(suite_name, request_summary, failure)],
}
}
fn assertion_cases(
suite_name: &str,
request_name: &str,
assertions: &[AssertionOutcome],
time_secs: f64,
failure_kind: &str,
) -> Vec<JUnitCase> {
assertions
.iter()
.map(|assertion| JUnitCase {
class_name: suite_name.to_string(),
name: assertion_case_name(request_name, &assertion.assertion),
time_secs,
status: assertion_status_to_junit(
&assertion.status,
&assertion.message,
&assertion.assertion,
failure_kind,
),
})
.collect()
}
fn assertion_status_to_junit(
status: &AssertionStatus,
message: &Option<String>,
assertion: &str,
failure_kind: &str,
) -> JUnitCaseStatus {
match status {
AssertionStatus::Passed => JUnitCaseStatus::Passed,
AssertionStatus::Skipped => JUnitCaseStatus::Skipped {
message: message
.clone()
.unwrap_or_else(|| "assertion skipped".to_string()),
},
AssertionStatus::Failed => {
let details = message.clone().unwrap_or_else(|| assertion.to_string());
JUnitCaseStatus::Failure {
kind: failure_kind.to_string(),
message: details.clone(),
details,
}
}
}
}
fn assertion_case_name(request_name: &str, assertion: &str) -> String {
format!("{} :: {}", request_name, assertion)
}
fn request_case_name(
index: usize,
request_summary: Option<&automation::RequestSummary>,
method: &str,
url: &str,
fallback: &str,
) -> String {
match request_summary {
Some(summary) => format!("#{} {} {}", summary.index, summary.method, summary.url),
None if !url.is_empty() => format!("#{} {} {}", index, method, url),
None => format!("#{} {}", index, fallback),
}
}
fn render_junit_suite(
suite_name: &str,
suite_time_secs: f64,
properties: &[(&str, String)],
cases: &[JUnitCase],
) -> String {
let failure_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Failure { .. }))
.count();
let skipped_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Skipped { .. }))
.count();
let error_count = cases
.iter()
.filter(|case| matches!(case.status, JUnitCaseStatus::Error { .. }))
.count();
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">",
escape_xml(suite_name),
cases.len(),
failure_count,
error_count,
skipped_count,
format_junit_time(suite_time_secs),
));
if !properties.is_empty() {
xml.push_str("\n <properties>");
for (name, value) in properties {
xml.push_str(&format!(
"\n <property name=\"{}\" value=\"{}\"/>",
escape_xml(name),
escape_xml(value),
));
}
xml.push_str("\n </properties>");
}
for case in cases {
xml.push_str(&format!(
"\n <testcase classname=\"{}\" name=\"{}\" time=\"{}\">",
escape_xml(&case.class_name),
escape_xml(&case.name),
format_junit_time(case.time_secs),
));
match &case.status {
JUnitCaseStatus::Passed => {}
JUnitCaseStatus::Failure {
kind,
message,
details,
} => {
xml.push_str(&format!(
"\n <failure type=\"{}\" message=\"{}\">{}</failure>",
escape_xml(kind),
escape_xml(message),
escape_xml(details),
));
}
JUnitCaseStatus::Skipped { message } => {
xml.push_str(&format!(
"\n <skipped message=\"{}\"/>",
escape_xml(message),
));
}
JUnitCaseStatus::Error {
kind,
message,
details,
} => {
xml.push_str(&format!(
"\n <error type=\"{}\" message=\"{}\">{}</error>",
escape_xml(kind),
escape_xml(message),
escape_xml(details),
));
}
}
xml.push_str("\n </testcase>");
}
xml.push_str("\n</testsuite>\n");
xml
}
fn report_suite_name(name: &str, fallback: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
fn format_junit_time(time_secs: f64) -> String {
if time_secs == 0.0 {
"0".to_string()
} else {
format!("{:.3}", time_secs)
}
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn total_elapsed(records: &[ExecutionRecord]) -> Option<Duration> {
if records.is_empty() {
return None;
}
let mut earliest: Option<Duration> = None;
let mut latest: Option<Duration> = None;
for record in records {
let start = match record.started_at.duration_since(UNIX_EPOCH) {
Ok(duration) => duration,
Err(_) => continue,
};
let finish = match record
.started_at
.checked_add(record.duration)
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
{
Some(duration) => duration,
None => continue,
};
earliest = Some(match earliest {
Some(current) => current.min(start),
None => start,
});
latest = Some(match latest {
Some(current) => current.max(finish),
None => finish,
});
}
match (earliest, latest) {
(Some(start), Some(end)) if end >= start => Some(end - start),
_ => None,
}
}