Skip to main content

cu_profiler_report/
junit.rs

1//! JUnit XML output for CI test dashboards.
2//!
3//! Each scenario maps to a `<testcase>`; a budget/regression failure or an
4//! unexpected simulation outcome maps to a `<failure>`, so existing CI tooling
5//! can surface compute regressions as test failures.
6
7use cu_profiler_core::model::{Report, ScenarioReport, Status};
8
9/// Render `report` as a JUnit `<testsuite>` document.
10#[must_use]
11pub fn render(report: &Report) -> String {
12    let sum = &report.summary;
13    let mut out = String::new();
14    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
15    out.push_str(&format!(
16        "<testsuite name=\"cu-profiler\" tests=\"{}\" failures=\"{}\">\n",
17        sum.total_scenarios, sum.failed,
18    ));
19    for s in &report.scenarios {
20        push_case(&mut out, s);
21    }
22    out.push_str("</testsuite>\n");
23    out
24}
25
26fn push_case(out: &mut String, s: &ScenarioReport) {
27    out.push_str(&format!(
28        "  <testcase name=\"{}\" classname=\"cu-profiler\">\n",
29        escape(&s.name),
30    ));
31    match s.status {
32        Status::Fail | Status::Unknown => {
33            let message = failure_message(s);
34            out.push_str(&format!(
35                "    <failure message=\"{}\">{}</failure>\n",
36                escape(&message),
37                escape(&detail(s)),
38            ));
39        }
40        Status::Warn => {
41            out.push_str(&format!(
42                "    <system-out>WARN: {}</system-out>\n",
43                escape(&detail(s)),
44            ));
45        }
46        Status::Pass => {}
47    }
48    out.push_str("  </testcase>\n");
49}
50
51fn failure_message(s: &ScenarioReport) -> String {
52    s.policy_results
53        .iter()
54        .find(|p| matches!(p.status, cu_profiler_core::budget::PolicyStatus::Fail))
55        .map_or_else(|| format!("{} did not pass", s.name), |p| p.message.clone())
56}
57
58fn detail(s: &ScenarioReport) -> String {
59    let mut parts = vec![format!("status={}", s.status.label())];
60    parts.push(format!("total_cu={}", s.measurement.total_cu));
61    for d in &s.diagnostics {
62        parts.push(format!("{}: {}", d.id, d.recommendation));
63    }
64    parts.join("\n")
65}
66
67fn escape(s: &str) -> String {
68    s.replace('&', "&amp;")
69        .replace('<', "&lt;")
70        .replace('>', "&gt;")
71        .replace('"', "&quot;")
72        .replace('\'', "&apos;")
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use cu_profiler_core::Profiler;
79    use cu_profiler_core::backend::RecordedLogsBackend;
80    use cu_profiler_core::budget::BudgetPolicy;
81    use cu_profiler_core::metadata::RunMetadata;
82    use cu_profiler_core::scenario::Scenario;
83
84    #[test]
85    fn failing_scenario_becomes_failure_case() {
86        let mut backend = RecordedLogsBackend::new();
87        backend.insert_blob(
88            "swap",
89            "Program P invoke [1]\nProgram P consumed 120000 of 200000 compute units\nProgram P success",
90            true,
91        );
92        let mut scenario = Scenario::new("swap");
93        scenario.budget = BudgetPolicy {
94            absolute_max_cu: Some(100_000),
95            ..Default::default()
96        };
97        let report =
98            Profiler::new().run(&backend, &[scenario], None, RunMetadata::recorded("0.1.0"));
99        let xml = render(&report);
100        assert!(xml.contains("<testsuite"));
101        assert!(xml.contains("failures=\"1\""));
102        assert!(xml.contains("<failure"));
103    }
104
105    #[test]
106    fn escapes_special_characters() {
107        assert_eq!(escape("a<b>&\"'"), "a&lt;b&gt;&amp;&quot;&apos;");
108    }
109}