#!/usr/bin/env python3 """Convert Surefire/JUnit XML (eunit_surefire) into a simple HTML report.""" from __future__ import annotations import argparse import html import sys import xml.etree.ElementTree as ET from pathlib import Path def load_suites(paths: list[Path]) -> list[ET.Element]: suites: list[ET.Element] = [] for path in paths: try: root = ET.parse(path).getroot() except ET.ParseError as exc: print(f"skip {path}: {exc}", file=sys.stderr) continue if root.tag == "testsuite": suites.append(root) elif root.tag == "testsuites": suites.extend(list(root.findall("testsuite"))) return suites def render(suites: list[ET.Element], title: str) -> str: total = failures = errors = skipped = 0 rows: list[str] = [] for suite in suites: s_name = suite.attrib.get("name", "suite") total += int(suite.attrib.get("tests", "0") or 0) failures += int(suite.attrib.get("failures", "0") or 0) errors += int(suite.attrib.get("errors", "0") or 0) skipped += int(suite.attrib.get("skipped", "0") or 0) for case in suite.findall("testcase"): name = case.attrib.get("name", "?") classname = case.attrib.get("classname", s_name) time_s = case.attrib.get("time", "") fail = case.find("failure") err = case.find("error") skip = case.find("skipped") if fail is not None: status, detail = "FAIL", fail.attrib.get("message") or (fail.text or "") elif err is not None: status, detail = "ERROR", err.attrib.get("message") or (err.text or "") elif skip is not None: status, detail = "SKIP", skip.attrib.get("message") or "" else: status, detail = "PASS", "" cls = status.lower() rows.append( "{status}{suite}{case}" "{time}
{detail}
".format( cls=html.escape(cls), status=html.escape(status), suite=html.escape(classname), case=html.escape(name), time=html.escape(time_s), detail=html.escape(detail.strip()[:4000]), ) ) ok = total - failures - errors - skipped return f""" {html.escape(title)}

{html.escape(title)}

tests: {total} pass: {ok} fail: {failures} error: {errors} skip: {skipped}

{''.join(rows) if rows else ''}
StatusSuiteCaseTimeDetail
No testcases
""" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("xml_dir", type=Path, help="Directory with TEST-*.xml") ap.add_argument("-o", "--out", type=Path, required=True, help="Output HTML file or dir") ap.add_argument("--title", default="EUnit report") args = ap.parse_args() xml_files = sorted(args.xml_dir.glob("TEST-*.xml")) + sorted(args.xml_dir.glob("*.xml")) # de-dupe seen = set() unique: list[Path] = [] for p in xml_files: if p.resolve() in seen: continue seen.add(p.resolve()) unique.append(p) suites = load_suites(unique) out = args.out if out.suffix.lower() != ".html": out.mkdir(parents=True, exist_ok=True) out = out / "index.html" else: out.parent.mkdir(parents=True, exist_ok=True) out.write_text(render(suites, args.title), encoding="utf-8") print(f"wrote {out} ({len(suites)} suites, {len(unique)} xml files)") return 0 if __name__ == "__main__": raise SystemExit(main())