135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
#!/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(
|
|
"<tr class=\"{cls}\"><td>{status}</td><td>{suite}</td><td>{case}</td>"
|
|
"<td>{time}</td><td><pre>{detail}</pre></td></tr>".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"""<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>{html.escape(title)}</title>
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; margin: 1.5rem; }}
|
|
.summary span {{ margin-right: 1rem; }}
|
|
.pass {{ color: #0a7; }}
|
|
.fail, .error {{ color: #c22; }}
|
|
.skip {{ color: #a80; }}
|
|
table {{ border-collapse: collapse; width: 100%; margin-top: 1rem; }}
|
|
th, td {{ border: 1px solid #ddd; padding: .4rem .6rem; vertical-align: top; text-align: left; }}
|
|
th {{ background: #f4f4f4; }}
|
|
pre {{ white-space: pre-wrap; margin: 0; font-size: 12px; }}
|
|
tr.fail, tr.error {{ background: #fff5f5; }}
|
|
tr.pass {{ background: #f5fff8; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{html.escape(title)}</h1>
|
|
<p class="summary">
|
|
<span>tests: <b>{total}</b></span>
|
|
<span class="pass">pass: <b>{ok}</b></span>
|
|
<span class="fail">fail: <b>{failures}</b></span>
|
|
<span class="error">error: <b>{errors}</b></span>
|
|
<span class="skip">skip: <b>{skipped}</b></span>
|
|
</p>
|
|
<table>
|
|
<thead><tr><th>Status</th><th>Suite</th><th>Case</th><th>Time</th><th>Detail</th></tr></thead>
|
|
<tbody>
|
|
{''.join(rows) if rows else '<tr><td colspan="5">No testcases</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
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())
|