use std::{collections::BTreeMap, time::{Duration, UNIX_EPOCH}};
use crate::{
automation,
error::HenError,
request::{
AssertionOutcome, AssertionStatus, ExecutionFailureClass, ExecutionRecord,
ExecutionReliabilityMetadata, RequestFailure, RequestFailureKind,
},
};
use super::redaction::OutputRedactor;
#[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()
),
},
});
}
let properties = vec![
("path", outcome.collection.path.display().to_string()),
(
"availableEnvironments",
outcome.collection.available_environments.join(","),
),
(
"selectedEnvironment",
outcome.collection.selected_environment.clone().unwrap_or_default(),
),
(
"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(),
),
];
render_junit_suite(
&suite_name,
total_elapsed(&outcome.records)
.unwrap_or_default()
.as_secs_f64(),
&properties,
&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);
let properties = vec![
("path", fallback.clone()),
(
"availableEnvironments",
result.summary.available_environments.join(","),
),
];
render_junit_suite(
&suite_name,
0.0,
&properties,
&[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 {
let redactor = OutputRedactor::with_header_names(
&record.sensitive_values,
&record.sensitive_header_names,
);
JUnitCase {
class_name: suite_name.to_string(),
name: request_case_name(
record.index,
request_summary,
record.method.as_str(),
&redactor.redact_text(&record.url),
&redactor.redact_text(&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 redactor = OutputRedactor::with_header_names(
&record.sensitive_values,
&record.sensitive_header_names,
);
let request_name = request_case_name(
record.index,
request_summary,
record.method.as_str(),
&redactor.redact_text(&record.url),
&redactor.redact_text(&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",
None,
&redactor,
)
}
fn failure_case(
suite_name: &str,
request_summary: Option<&automation::RequestSummary>,
failure: &RequestFailure,
) -> JUnitCase {
let redactor = OutputRedactor::with_header_names(
failure.sensitive_values(),
failure.sensitive_header_names(),
);
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(""),
&redactor.redact_text(failure.request()),
),
None => redactor.redact_text(failure.request()),
};
let status = match failure.kind() {
RequestFailureKind::Execution { message, .. } => {
execution_failure_status(failure, message, &redactor)
}
RequestFailureKind::Dependency { dependency } => JUnitCaseStatus::Skipped {
message: redactor.redact_text(&format!("dependency '{}' failed", dependency)),
},
RequestFailureKind::MissingDependency { dependency } => JUnitCaseStatus::Skipped {
message: redactor.redact_text(&format!("missing dependency '{}'", dependency)),
},
RequestFailureKind::MapAborted { group, cause } => JUnitCaseStatus::Skipped {
message: redactor.redact_text(&format!("'{}' aborted after failure in '{}'", group, cause)),
},
RequestFailureKind::Join { message } => JUnitCaseStatus::Error {
kind: "join".to_string(),
message: redactor.redact_text(message),
details: redactor.redact_text(&failure.to_string()),
},
};
JUnitCase {
class_name: suite_name.to_string(),
name,
time_secs: failure.duration().map(|duration| duration.as_secs_f64()).unwrap_or(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 redactor = OutputRedactor::with_header_names(
failure.sensitive_values(),
failure.sensitive_header_names(),
);
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(""),
&redactor.redact_text(failure.request()),
),
None => redactor.redact_text(failure.request()),
};
let case_time_secs = failure
.duration()
.map(|duration| duration.as_secs_f64() / assertions.len() as f64)
.unwrap_or(0.0);
let detail_suffix = execution_reliability_detail_suffix(failure.reliability());
assertion_cases(
suite_name,
&request_name,
assertions,
case_time_secs,
"assertion",
detail_suffix.as_deref(),
&redactor,
)
}
_ => 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,
detail_suffix: Option<&str>,
redactor: &OutputRedactor,
) -> 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,
detail_suffix,
redactor,
),
})
.collect()
}
fn assertion_status_to_junit(
status: &AssertionStatus,
message: &Option<String>,
assertion: &str,
failure_kind: &str,
detail_suffix: Option<&str>,
redactor: &OutputRedactor,
) -> JUnitCaseStatus {
match status {
AssertionStatus::Passed => JUnitCaseStatus::Passed,
AssertionStatus::Skipped => JUnitCaseStatus::Skipped {
message: redactor.redact_text(
message.as_deref().unwrap_or("assertion skipped"),
),
},
AssertionStatus::Failed => {
let mut details = redactor.redact_text(message.as_deref().unwrap_or(assertion));
if let Some(suffix) = detail_suffix {
details.push_str(suffix);
}
JUnitCaseStatus::Failure {
kind: failure_kind.to_string(),
message: details.clone(),
details,
}
}
}
}
fn execution_failure_status(
failure: &RequestFailure,
message: &str,
redactor: &OutputRedactor,
) -> JUnitCaseStatus {
let message = redactor.redact_text(&execution_failure_message(message, failure.reliability()));
let details = redactor.redact_text(&execution_failure_details(failure));
match failure.failure_class().unwrap_or(ExecutionFailureClass::Assertion) {
ExecutionFailureClass::Assertion => JUnitCaseStatus::Failure {
kind: "assertion".to_string(),
message,
details,
},
ExecutionFailureClass::Timeout => JUnitCaseStatus::Error {
kind: "timeout".to_string(),
message,
details,
},
ExecutionFailureClass::Transport => JUnitCaseStatus::Error {
kind: "transport".to_string(),
message,
details,
},
}
}
fn execution_failure_message(
message: &str,
reliability: Option<&ExecutionReliabilityMetadata>,
) -> String {
match reliability {
Some(reliability) if reliability.attempts > 1 => {
format!("{} (after {} attempts)", message, reliability.attempts)
}
_ => message.to_string(),
}
}
fn execution_failure_details(failure: &RequestFailure) -> String {
let mut details = failure.to_string();
if let Some(suffix) = execution_reliability_detail_suffix(failure.reliability()) {
details.push_str(&suffix);
}
details
}
fn execution_reliability_detail_suffix(
reliability: Option<&ExecutionReliabilityMetadata>,
) -> Option<String> {
let reliability = reliability?;
let mut lines = vec![
String::new(),
format!("attempts: {}", reliability.attempts),
format!("polled: {}", reliability.attempts > 1),
format!("timeout: {}", reliability.timeout),
];
if let Some(poll_until) = &reliability.poll_until {
lines.push(format!("poll_until: {}", poll_until));
}
if let Some(poll_every) = &reliability.poll_every {
lines.push(format!("poll_every: {}", poll_every));
}
if let Some(class) = reliability.failure_class {
lines.push(format!("failure_class: {}", class.as_str()));
}
Some(lines.join("\n"))
}
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,
}
}