JUnit XML reports
JUnit XML test reports from Surefire, pytest, Jest and the rest read and written: nested suites, failures, errors and skips, Surefire's flaky and rerun elements, times with commas in them, totals that do not match the file noted, the slowest tests, shards merged, and several runs compared to find what is flaky.
Version 1.0.0 · 6 functions · 2 crates
Functions
What use junit_xml.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use junit_read, junit_read_file from junit_xml (Quillo 0.6).
junit_read(text: str) → anyor an errorReads a report, <testsuites> or one <testsuite>, nested ones too: { totals, suites } with each suite's cases, statuses, failures and captured output.
junit_read_file(path: str) → anyor an errorReads a report from a file, as junit_read does.
junit_summary(text: str, slowest: int) → anyor an errorThe totals alone, the tests that failed, those that were flaky, and the slowest n: what a CI summary prints.
junit_compare_runs(reports: list) → anyor an errorSeveral runs of the same tests: a test that passed in one and failed in another is flaky, one that failed in all is failing.
junit_merge(reports: list) → stror an errorJoins reports into one, with each suite named as it was: shards of a parallel run.
junit_write(suites: list) → stror an errorWrites suites { name, cases: [{ name, class, time, status, failures, skipped, stdout, stderr }] } as junit_read gives them; a line of { status: "failed", message } is enough for a case.
Get it
$ quillo install junit_xml # junit_xml.qwer into this folder$ quillo install junit_xml --example # and its example, junit_xml.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/junit_xml.qwer$ curl -O https://quillolang.org/plugins/files/junit_xml.qwe$ curl -O https://quillolang.org/plugins/files/junit_xml_sample.xmlThen, in your program, all of it or only what you call:
use junit_xml.qweruse junit_read, junit_read_file from junit_xml // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
roxmltree = "0.20"serde_json = { version = "1", features = ["preserve_order"] }Example
// A build's test report: totals, what failed, what was flaky and what took longest, three runs// compared to find the flaky ones, and a report written by a program that runs its own checks.//// quillo run junit_xml.qwe then open http://localhost:8080/report, /summary, /runs, /write, /merge and /brokenuse "junit_xml.qwer"let TEXT = read_file("junit_xml_sample.xml")route GET /report { let r = junit_read_file("junit_xml_sample.xml")? let cart = r.suites[0] { totals: r.totals, suites: r.suites | map((s) => [s.name, s.tests, s.time]), statuses: cart.cases | map((c) => c.status), failure: cart.cases[1].failures[0].message, trace: split(cart.cases[1].failures[0].text, "\n")[1], place: [cart.cases[1].file, cart.cases[1].line], out: cart.cases[1].stdout, skipped: cart.cases[2].skipped, property: cart.properties, escaped: r.suites[1].cases[2].name, comma: r.suites[1].cases[2].time, thousands: r.suites[1].cases[0].time, wrong: r.suites[2].problems, }}route GET /summary = junit_summary(TEXT, 2)?fn run(a, b) { `<testsuite name="s"><testcase classname="T" name="always"/><testcase classname="T" name="sometimes">${a}</testcase><testcase classname="T" name="never">${b}</testcase></testsuite>`}route GET /runs { let bad = `<failure message="no"/>` junit_compare_runs([run("", bad), run(bad, bad), run("", bad)])?}let SUITES = [{ name: "checks <live>", time: 3.5, timestamp: "2026-11-01T10:00:00", properties: { branch: "main" }, cases: [ { name: "ping", class: "smoke", time: 0.5 }, { name: "login \"form\"", class: "smoke", time: 1.5, status: "failed", failures: [{ message: "got <500> & more", type: "Assert", text: "line one\nline <two>" }], stdout: "colour \u{1b}[31mred" }, { name: "pay", class: "smoke", status: "skipped", skipped: "sandbox down" }, { name: "boom", class: "smoke", status: "error", message: "panic" }, ],}]route GET /write { let xml = junit_write(SUITES)? let back = junit_read(xml)? { head: split(xml, "\n")[1], suite: split(xml, "\n")[2], totals: back.totals, name: back.suites[0].name, again: back.suites[0].cases | map((c) => [c.name, c.status]), failure: back.suites[0].cases[1].failures[0], out: back.suites[0].cases[1].stdout, stable: junit_write(back.suites)? == junit_write(junit_read(junit_write(back.suites)?)?.suites)?, unnamed: junit_write([{ name: "s", cases: [{ time: 1 }] }]).error, }}route GET /merge { let two = junit_merge([TEXT, run("", "")])? junit_read(two)?.totals}route GET /broken = { not_xml: junit_read("<testsuite").error != null, wrong_root: junit_read("<project/>").error, entity: junit_read("<!DOCTYPE a [<!ENTITY x 'y'>]><testsuite name='a'/>").error != null, no_file: junit_read_file("nothing.xml").error, second_bad: junit_compare_runs([TEXT, "<html/>"]).error, none: junit_compare_runs([]).error,}$ quillo run junit_xml.qwe$ curl 'localhost:8080/report'$ curl 'localhost:8080/summary'$ curl 'localhost:8080/runs'$ curl 'localhost:8080/write'$ curl 'localhost:8080/merge'$ curl 'localhost:8080/broken'Source
//! JUnit XML test reports read and written: suites and cases, failures, errors and skips, flaky reruns from Surefire and across runs, totals and the slowest tests.//! version: 1.0.0//!//! [dependencies]//! roxmltree = "0.20"//! serde_json = { version = "1", features = ["preserve_order"] }use roxmltree::{Document, Node, ParsingOptions};use serde_json::{json, Map, Value};use std::collections::BTreeMap;const MAX: usize = 256 * 1024 * 1024;/// Seconds as tools write them: "1.5", "1,234.5" (Java's locale), "0,25" (a comma as the point), "" or junk as null.fn seconds(t: Option<&str>) -> Option<f64> { let t = t?.trim(); if t.is_empty() { return None; } let plain = if t.contains('.') { t.replace(',', "") } else if let Some((a, b)) = t.rsplit_once(',') { if b.len() == 3 && !a.is_empty() && t.matches(',').count() >= 1 && a.len() <= 3 && !a.starts_with('0') { t.replace(',', "") } else { format!("{}.{}", a.replace(',', ""), b) } } else { t.to_string() }; plain.parse::<f64>().ok().filter(|f| f.is_finite() && *f >= 0.0)}fn round(f: f64) -> f64 { (f * 1e6).round() / 1e6 + 0.0}fn kids<'a, 'i>(n: Node<'a, 'i>, name: &'static str) -> impl Iterator<Item = Node<'a, 'i>> { n.children().filter(move |c| c.is_element() && c.tag_name().name() == name)}fn all_text(n: Node) -> String { n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect::<String>()}fn problem(n: Node) -> Value { json!({ "kind": n.tag_name().name(), "message": n.attribute("message"), "type": n.attribute("type"), "text": Some(all_text(n).trim().to_string()).filter(|t| !t.is_empty()), })}fn read_case(c: Node, suite: &str) -> Value { let class = c.attribute("classname").or(c.attribute("class")); let name = c.attribute("name").unwrap_or(""); let first = |names: &[&'static str]| names.iter().flat_map(|n| kids(c, n)).map(problem).collect::<Vec<_>>(); let failures = first(&["failure", "error"]); let skipped: Option<Node> = kids(c, "skipped").next(); let reruns = first(&["rerunFailure", "rerunError"]); let flaky = first(&["flakyFailure", "flakyError"]); let status = if !failures.is_empty() { if failures.iter().any(|f| f["kind"] == "error") { "error" } else { "failed" } } else if skipped.is_some() { "skipped" } else if !flaky.is_empty() { "flaky" } else { "passed" }; let text = |tag: &'static str| kids(c, tag).next().map(all_text).filter(|t| !t.trim().is_empty()); let mut props = Map::new(); for p in kids(c, "properties").flat_map(|x| kids(x, "property")) { if let Some(k) = p.attribute("name") { props.insert(k.to_string(), json!(p.attribute("value").map(str::to_string).unwrap_or_else(|| all_text(p)))); } } json!({ "suite": suite, "class": class, "name": name, "id": format!("{}.{}", class.unwrap_or(suite), name), "status": status, "time": seconds(c.attribute("time")), "file": c.attribute("file"), "line": c.attribute("line").and_then(|l| l.parse::<i64>().ok()), "failures": failures, "skipped": skipped.map(|s| s.attribute("message").map(str::to_string).unwrap_or_else(|| all_text(s).trim().to_string())), "reruns": reruns, "flaky": flaky, "stdout": text("system-out"), "stderr": text("system-err"), "properties": props, })}fn read_suite(s: Node, out: &mut Vec<Value>, prefix: &str) { let own = s.attribute("name").unwrap_or(""); let name = if prefix.is_empty() { own.to_string() } else { format!("{}/{}", prefix, own) }; let cases: Vec<Value> = kids(s, "testcase").map(|c| read_case(c, &name)).collect(); let n = |st: &str| cases.iter().filter(|c| c["status"] == st).count(); let time_sum: f64 = cases.iter().filter_map(|c| c["time"].as_f64()).sum(); let attr = |k: &str| s.attribute(k).and_then(|x| x.parse::<i64>().ok()); let mut props = Map::new(); for p in kids(s, "properties").flat_map(|x| kids(x, "property")) { if let Some(k) = p.attribute("name") { props.insert(k.to_string(), json!(p.attribute("value").map(str::to_string).unwrap_or_else(|| all_text(p)))); } } // A wrapping suite with no cases of its own is not a suite of its own: only what it holds. if !cases.is_empty() || kids(s, "testsuite").next().is_none() { let mut problems = vec![]; if let Some(t) = attr("tests").filter(|t| *t != cases.len() as i64) { problems.push(format!("says {} tests and holds {}", t, cases.len())); } if let Some(t) = attr("failures").filter(|t| *t != n("failed") as i64) { problems.push(format!("says {} failures and holds {}", t, n("failed"))); } out.push(json!({ "name": name, "package": s.attribute("package"), "hostname": s.attribute("hostname"), "timestamp": s.attribute("timestamp"), "tests": cases.len(), "passed": n("passed"), "failed": n("failed"), "errors": n("error"), "skipped": n("skipped"), "flaky": n("flaky"), "time": seconds(s.attribute("time")).unwrap_or(round(time_sum)), "properties": props, "cases": cases, "problems": problems, })); } for k in kids(s, "testsuite") { read_suite(k, out, &name); }}fn suites_of(text: &str) -> Result<Vec<Value>, String> { if text.len() > MAX { return Err("the report is over 256 MB".to_string()); } let text = text.trim_start_matches('\u{feff}'); let doc = Document::parse_with_options(text, ParsingOptions { allow_dtd: false, ..Default::default() }).map_err(|e| format!("not XML: {}", e))?; let root = doc.root_element(); let mut out = vec![]; match root.tag_name().name() { "testsuites" => { for s in kids(root, "testsuite") { read_suite(s, &mut out, ""); } } "testsuite" => read_suite(root, &mut out, ""), other => return Err(format!("not a JUnit report: the root element is <{}>", other)), } Ok(out)}fn totals(suites: &[Value]) -> Value { let sum = |k: &str| suites.iter().filter_map(|s| s[k].as_i64()).sum::<i64>(); let time: f64 = suites.iter().filter_map(|s| s["time"].as_f64()).sum(); let bad = sum("failed") + sum("errors"); json!({ "suites": suites.len(), "tests": sum("tests"), "passed": sum("passed"), "failed": sum("failed"), "errors": sum("errors"), "skipped": sum("skipped"), "flaky": sum("flaky"), "time": round(time), "ok": bad == 0, })}fn cases<'a>(suites: &'a [Value]) -> impl Iterator<Item = &'a Value> { suites.iter().flat_map(|s| s["cases"].as_array().into_iter().flatten())}/// Reads a report, `<testsuites>` or one `<testsuite>`, nested ones too: `{ totals, suites }` with each suite's cases, statuses, failures and captured output.pub fn junit_read(text: &str) -> Result<Value, String> { let suites = suites_of(text)?; Ok(json!({ "totals": totals(&suites), "suites": suites }))}/// Reads a report from a file, as `junit_read` does.pub fn junit_read_file(path: &str) -> Result<Value, String> { let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {}", path, e))?; junit_read(&String::from_utf8_lossy(&bytes))}/// The totals alone, the tests that failed, those that were flaky, and the `slowest` n: what a CI summary prints.pub fn junit_summary(text: &str, slowest: i64) -> Result<Value, String> { let suites = suites_of(text)?; let mut timed: Vec<&Value> = cases(&suites).filter(|c| c["time"].is_number() && c["status"] != "skipped").collect(); timed.sort_by(|a, b| b["time"].as_f64().partial_cmp(&a["time"].as_f64()).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a["id"].as_str().cmp(&b["id"].as_str()))); let top: Vec<Value> = timed.iter().take(slowest.max(0) as usize).map(|c| json!({ "id": c["id"], "time": c["time"] })).collect(); let failing: Vec<Value> = cases(&suites).filter(|c| c["status"] == "failed" || c["status"] == "error").map(|c| { let f = &c["failures"][0]; json!({ "id": c["id"], "status": c["status"], "message": f["message"], "type": f["type"], "file": c["file"], "line": c["line"] }) }).collect(); let flaky: Vec<Value> = cases(&suites).filter(|c| c["status"] == "flaky").map(|c| json!({ "id": c["id"], "tries": c["flaky"].as_array().map_or(0, Vec::len) + 1 })).collect(); Ok(json!({ "totals": totals(&suites), "failing": failing, "flaky": flaky, "slowest": top }))}/// Several runs of the same tests: a test that passed in one and failed in another is flaky, one that failed in all is failing.pub fn junit_compare_runs(reports: Vec<String>) -> Result<Value, String> { if reports.is_empty() { return Err("no reports to compare".to_string()); } let mut seen: BTreeMap<String, (i64, i64)> = BTreeMap::new(); for (i, r) in reports.iter().enumerate() { let suites = suites_of(r).map_err(|e| format!("report {}: {}", i, e))?; for c in cases(&suites) { let e = seen.entry(c["id"].as_str().unwrap_or("").to_string()).or_default(); match c["status"].as_str() { Some("passed") => e.0 += 1, Some("flaky") => { e.0 += 1; e.1 += 1; } Some("failed") | Some("error") => e.1 += 1, _ => {} } } } let mut flaky = vec![]; let mut failing = vec![]; for (id, (p, f)) in seen.iter() { if *p > 0 && *f > 0 { flaky.push(json!({ "id": id, "passed": p, "failed": f, "rate": round(*f as f64 / (*p + *f) as f64) })); } else if *f > 0 { failing.push(json!({ "id": id, "failed": f })); } } Ok(json!({ "runs": reports.len(), "tests": seen.len(), "flaky": flaky, "failing": failing }))}/// Joins reports into one, with each suite named as it was: shards of a parallel run.pub fn junit_merge(reports: Vec<String>) -> Result<String, String> { let mut all = vec![]; for (i, r) in reports.iter().enumerate() { all.extend(suites_of(r).map_err(|e| format!("report {}: {}", i, e))?); } write_suites(&all)}fn esc(s: &str, out: &mut String, attr: bool) { for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' if attr => out.push_str("""), '\n' if attr => out.push_str(" "), '\t' if attr => out.push_str("	"), '\r' => out.push_str(" "), // XML 1.0 has no way to say these, and a test's output is full of them (ANSI colours). c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("[0x{:02X}]", c as u32)), '\u{FFFE}' | '\u{FFFF}' => out.push('\u{FFFD}'), c => out.push(c), } }}fn attr(out: &mut String, k: &str, v: &Value) { let text = match v { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), _ => return, }; out.push(' '); out.push_str(k); out.push_str("=\""); esc(&text, out, true); out.push('"');}fn write_problem(out: &mut String, tag: &str, p: &Value, pad: &str) { out.push_str(pad); out.push('<'); out.push_str(tag); attr(out, "message", &p["message"]); attr(out, "type", &p["type"]); match p["text"].as_str() { Some(t) => { out.push('>'); esc(t, out, false); out.push_str(&format!("</{}>\n", tag)); } None => out.push_str("/>\n"), }}fn write_output(out: &mut String, tag: &str, v: &Value, pad: &str) { if let Some(t) = v.as_str() { out.push_str(&format!("{}<{}>", pad, tag)); esc(t, out, false); out.push_str(&format!("</{}>\n", tag)); }}fn write_case(out: &mut String, c: &Value, i: usize) -> Result<(), String> { let name = c["name"].as_str().filter(|n| !n.is_empty()).ok_or(format!("case {}: a case needs a `name`", i))?; let status = c["status"].as_str().unwrap_or(""); let fails = c["failures"].as_array().cloned().unwrap_or_default(); let reruns = c["reruns"].as_array().cloned().unwrap_or_default(); let flaky = c["flaky"].as_array().cloned().unwrap_or_default(); out.push_str(" <testcase"); attr(out, "name", &json!(name)); attr(out, "classname", &c["class"]); attr(out, "time", &c["time"]); attr(out, "file", &c["file"]); attr(out, "line", &c["line"]); let plain = status != "failed" && status != "error" && status != "skipped" && fails.is_empty() && reruns.is_empty() && flaky.is_empty() && c["stdout"].is_null() && c["stderr"].is_null() && c["skipped"].is_null(); if plain { out.push_str("/>\n"); return Ok(()); } out.push_str(">\n"); if fails.is_empty() && (status == "failed" || status == "error") { write_problem(out, if status == "error" { "error" } else { "failure" }, &json!({ "message": c.get("message").cloned().unwrap_or(Value::Null) }), " "); } for f in &fails { let tag = if f["kind"] == "error" || (status == "error" && f["kind"].is_null()) { "error" } else { "failure" }; write_problem(out, tag, f, " "); } if status == "skipped" || !c["skipped"].is_null() { out.push_str(" <skipped"); attr(out, "message", &c["skipped"]); out.push_str("/>\n"); } for f in &reruns { write_problem(out, if f["kind"] == "rerunError" { "rerunError" } else { "rerunFailure" }, f, " "); } for f in &flaky { write_problem(out, if f["kind"] == "flakyError" { "flakyError" } else { "flakyFailure" }, f, " "); } write_output(out, "system-out", &c["stdout"], " "); write_output(out, "system-err", &c["stderr"], " "); out.push_str(" </testcase>\n"); Ok(())}fn write_suites(suites: &[Value]) -> Result<String, String> { let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); let mut body = String::new(); let (mut tests, mut failures, mut errors, mut skipped) = (0, 0, 0, 0); let mut total = 0.0; for s in suites { let name = s["name"].as_str().filter(|n| !n.is_empty()).ok_or("a suite needs a `name`")?; let list = s["cases"].as_array().cloned().unwrap_or_default(); let is = |c: &Value, st: &str| c["status"] == st || (c["status"].is_null() && st == "failed" && !c["failures"].as_array().map_or(true, Vec::is_empty)); let (f, e, sk) = (list.iter().filter(|c| is(c, "failed")).count(), list.iter().filter(|c| is(c, "error")).count(), list.iter().filter(|c| is(c, "skipped") || !c["skipped"].is_null()).count()); let time: f64 = s["time"].as_f64().unwrap_or_else(|| list.iter().filter_map(|c| c["time"].as_f64()).sum()); body.push_str(" <testsuite"); attr(&mut body, "name", &json!(name)); attr(&mut body, "tests", &json!(list.len())); attr(&mut body, "failures", &json!(f)); attr(&mut body, "errors", &json!(e)); attr(&mut body, "skipped", &json!(sk)); attr(&mut body, "time", &json!(round(time))); attr(&mut body, "timestamp", &s["timestamp"]); attr(&mut body, "hostname", &s["hostname"]); attr(&mut body, "package", &s["package"]); body.push_str(">\n"); if let Some(p) = s["properties"].as_object().filter(|p| !p.is_empty()) { body.push_str(" <properties>\n"); for (k, v) in p { body.push_str(" <property"); attr(&mut body, "name", &json!(k)); attr(&mut body, "value", v); body.push_str("/>\n"); } body.push_str(" </properties>\n"); } for (i, c) in list.iter().enumerate() { write_case(&mut body, c, i).map_err(|e| format!("suite {}: {}", name, e))?; } body.push_str(" </testsuite>\n"); tests += list.len(); failures += f; errors += e; skipped += sk; total += time; } out.push_str(&format!("<testsuites tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">\n", tests, failures, errors, skipped, round(total))); out.push_str(&body); out.push_str("</testsuites>\n"); Ok(out)}/// Writes suites `{ name, cases: [{ name, class, time, status, failures, skipped, stdout, stderr }] }` as `junit_read` gives them; a line of `{ status: "failed", message }` is enough for a case.pub fn junit_write(suites: Vec<serde_json::Value>) -> Result<String, String> { write_suites(&suites)}Related
tap_results.qwer · 1.0.0TAP streams read and written, version 14 and older ones: plans first or last, SKIP and TODO in the spellings producers use, indented and buffered subtests, YAML diagnostics, Bail out!, escaped hashes. A summary across subtests that passes or fails by TAP's own rules: a failing TODO does not count, a wrong plan does.3 views · 8 downloadsSARIF reports sarif.qwer · 1.0.0SARIF 2.1 reports from code scanners read into flat results, with rules found by index or id, messages filled in, severities defaulted as the spec says and places followed through artifacts. Reports built and merged by tool with their indexes redone, a summary by rule and file, and what is new since a base report.3 views · 8 downloadsDMARC reports dmarc_report.qwer · 1.0.0Read DMARC aggregate reports from their XML or their gzip and zip attachments, with a limit on what they unpack to, into records and summaries by source and alignment that show who sends as your domain and what fails.2 views · 6 downloadsFake data fake_data.qwer · 1.0.0Realistic fake people, addresses, companies and whole tables by locale and seed, the same for the same seed: demo data, seed scripts and tests without real customers.7 views · 9 downloadsjq jq.qwer · 1.0.0jq filters over JSON with jaq: pick, reshape, filter and sum API answers, webhook bodies and JSON lines in the language people already write in jq, with variables and a check for typed-in filters.7 views · 8 downloadsSQL parsing sql_parse.qwer · 1.0.0SQL read as a database would: whether it parses in a dialect, its statement kind, the tables it reads and writes, and a fingerprint for grouping logs.9 views · 12 downloadsThread 0 posts
On JUnit XML reports: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.