from __future__ import annotations import argparse import datetime as _dt import html import sys import xml.etree.ElementTree as ET from pathlib import Path _CSS = r""" :root { --bg: #0f1115; --panel: #151923; --panel-2: #10141d; --text: #e7eaf0; --muted: rgba(231,234,240,0.68); --border: rgba(231,234,240,0.10); --accent: #5fb3ff; --ok: #39d98a; --fail: #ff5c5c; --skip: #f6c177; --shadow: 0 18px 45px rgba(0,0,0,0.35); --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; font-family: var(--sans); background: radial-gradient(1200px 600px at 20% -20%, rgba(95,179,255,0.20), transparent 60%), radial-gradient(900px 550px at 100% 0%, rgba(57,217,138,0.14), transparent 55%), var(--bg); color: var(--text); } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } .wrap { max-width: 1180px; margin: 0 auto; padding: 22px 18px 60px; } .top { display: flex; gap: 14px; align-items: baseline; justify-content: space-between; padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); box-shadow: var(--shadow); } .title { font-size: 18px; font-weight: 700; letter-spacing: 0.2px; } .meta { color: var(--muted); font-size: 12px; } .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; justify-content: flex-end; } .chip { display: inline-flex; gap: 8px; align-items: center; padding: 7px 10px; border-radius: 999px; border: 1px solid var(--border); background: rgba(255,255,255,0.03); font-size: 12px; color: var(--muted); } .dot { width: 9px; height: 9px; border-radius: 999px; background: var(--muted); box-shadow: 0 0 0 3px rgba(255,255,255,0.04); } .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(57,217,138,0.14); } .dot.fail { background: var(--fail); box-shadow: 0 0 0 3px rgba(255,92,92,0.14); } .dot.skip { background: var(--skip); box-shadow: 0 0 0 3px rgba(246,193,119,0.14); } .grid { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 14px; } .panel { border: 1px solid var(--border); border-radius: 14px; background: rgba(21,25,35,0.92); box-shadow: var(--shadow); overflow: hidden; } .panel-h { display: flex; gap: 10px; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); } .panel-h .left { display: flex; gap: 10px; align-items: center; min-width: 0; } .suite { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .small { color: var(--muted); font-size: 12px; } .panel-b { padding: 10px 12px 12px; } table { width: 100%; border-collapse: collapse; } th, td { padding: 9px 10px; vertical-align: top; } th { text-align: left; font-size: 12px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); } tr + tr td { border-top: 1px solid rgba(231,234,240,0.07); } .name { font-weight: 600; color: var(--text); } .cls { font-family: var(--mono); font-size: 12px; color: rgba(231,234,240,0.78); } .time { font-family: var(--mono); font-size: 12px; color: rgba(231,234,240,0.72); text-align: right; white-space: nowrap; } .status { font-size: 12px; font-weight: 700; letter-spacing: 0.2px; } .status.ok { color: var(--ok); } .status.fail { color: var(--fail); } .status.skip { color: var(--skip); } details { margin-top: 8px; } summary { cursor: pointer; color: var(--muted); font-size: 12px; user-select: none; } .pre { margin-top: 8px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(231,234,240,0.10); background: rgba(16,20,29,0.85); font-family: var(--mono); font-size: 12px; line-height: 1.4; white-space: pre-wrap; overflow-wrap: anywhere; } .footer { margin-top: 14px; color: var(--muted); font-size: 12px; text-align: center; } """ def _as_int(value: str | None, default: int = 0) -> int: try: return int(float(value or 0)) except Exception: return default def _as_float(value: str | None, default: float = 0.0) -> float: try: return float(value or 0.0) except Exception: return default def _now_local_str() -> str: try: return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") except Exception: return "" def _parse_junit(xml_path: Path) -> dict: tree = ET.parse(xml_path) root = tree.getroot() # JUnit can be either or . if root.tag == "testsuites": suites = list(root.findall("testsuite")) root_totals = { "tests": _as_int(root.get("tests")), "failures": _as_int(root.get("failures")), "errors": _as_int(root.get("errors")), "skipped": _as_int(root.get("skipped")), "time": _as_float(root.get("time")), } else: suites = [root] if root.tag == "testsuite" else list(root.findall(".//testsuite")) root_totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0} # Some generators (including pytest) may omit totals on . # Always compute totals from child suites; only trust root totals if they look valid. computed = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0} for s in suites: computed["tests"] += _as_int(s.get("tests")) computed["failures"] += _as_int(s.get("failures")) computed["errors"] += _as_int(s.get("errors")) computed["skipped"] += _as_int(s.get("skipped")) computed["time"] += _as_float(s.get("time")) # If root totals are all zeros but suites have real counts, use computed. if sum(int(root_totals.get(k, 0) or 0) for k in ("tests", "failures", "errors", "skipped")) == 0 and computed["tests"] > 0: totals = computed else: totals = root_totals parsed_suites: list[dict] = [] for suite in suites: suite_name = suite.get("name") or "testsuite" suite_time = _as_float(suite.get("time")) suite_tests = _as_int(suite.get("tests")) suite_failures = _as_int(suite.get("failures")) suite_errors = _as_int(suite.get("errors")) suite_skipped = _as_int(suite.get("skipped")) cases: list[dict] = [] for tc in suite.findall(".//testcase"): name = tc.get("name") or "" classname = tc.get("classname") or "" t = _as_float(tc.get("time")) status = "ok" detail = "" failure = tc.find("failure") error = tc.find("error") skipped = tc.find("skipped") if skipped is not None: status = "skip" detail = skipped.get("message") or (skipped.text or "") elif failure is not None: status = "fail" detail = (failure.get("message") or "") + ("\n" if (failure.text or "").strip() else "") + (failure.text or "") elif error is not None: status = "fail" detail = (error.get("message") or "") + ("\n" if (error.text or "").strip() else "") + (error.text or "") cases.append( { "name": name, "classname": classname, "time": t, "status": status, "detail": detail.strip(), } ) parsed_suites.append( { "name": suite_name, "tests": suite_tests, "failures": suite_failures, "errors": suite_errors, "skipped": suite_skipped, "time": suite_time, "cases": cases, } ) return {"totals": totals, "suites": parsed_suites} def _escape(s: str) -> str: return html.escape(s or "", quote=False) def _render_html(title: str, xml_path: Path, data: dict) -> str: totals = data.get("totals") or {} suites = data.get("suites") or [] failures_total = int(totals.get("failures", 0) or 0) + int(totals.get("errors", 0) or 0) status = "ok" if failures_total == 0 else "fail" if status == "ok" and int(totals.get("skipped", 0) or 0) > 0: status = "skip" def chip(label: str, value: str, kind: str) -> str: return f'{_escape(label)}:{_escape(value)}' overall_label = "PASSED" if status == "ok" else ("SKIPPED" if status == "skip" else "FAILED") chips = "\n".join( [ chip("Overall", overall_label, status), chip("Tests", str(int(totals.get("tests", 0) or 0)), "ok" if status != "fail" else "fail"), chip("Failures", str(int(failures_total)), "fail" if failures_total else "ok"), chip("Skipped", str(int(totals.get("skipped", 0) or 0)), "skip" if int(totals.get("skipped", 0) or 0) else "ok"), chip("Time", f"{float(totals.get('time', 0.0) or 0.0):.2f}s", "ok" if status != "fail" else "fail"), ] ) rendered_suites: list[str] = [] for suite in suites: suite_name = suite.get("name") or "testsuite" cases = suite.get("cases") or [] suite_fail = int(suite.get("failures", 0) or 0) + int(suite.get("errors", 0) or 0) suite_skip = int(suite.get("skipped", 0) or 0) suite_status = "fail" if suite_fail else ("skip" if suite_skip else "ok") rows: list[str] = [] for c in cases: st = c.get("status") or "ok" status_label = "PASSED" if st == "ok" else ("SKIPPED" if st == "skip" else "FAILED") detail = c.get("detail") or "" detail_html = "" if detail: detail_html = f"""
Details
{_escape(detail)}
""" rows.append( f"""
{_escape(c.get("name") or "")}
{_escape(c.get("classname") or "")}
{detail_html} {float(c.get("time") or 0.0):.3f}s {status_label} """ ) rendered_suites.append( f"""
{_escape(suite_name)}
{int(suite.get("tests", 0) or 0)} tests • {suite_fail} failing • {suite_skip} skipped • {float(suite.get("time", 0.0) or 0.0):.2f}s
{''.join(rows)}
Test Case Time Status
""" ) xml_name = xml_path.name xml_mtime = "" try: xml_mtime = _dt.datetime.fromtimestamp(xml_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") except Exception: pass return f""" {_escape(title)}
{_escape(title)}
Source: {_escape(xml_name)} • Generated: {_escape(_now_local_str())} • XML mtime: {_escape(xml_mtime)}
{chips}
{''.join(rendered_suites) if rendered_suites else '
No suites found.
'}
""" def _write_index(index_dir: Path) -> None: try: index_dir.mkdir(parents=True, exist_ok=True) except Exception: return items: list[tuple[float, Path]] = [] try: for p in index_dir.glob("*.html"): if p.name.lower() == "index.html": continue try: items.append((p.stat().st_mtime, p)) except Exception: items.append((0.0, p)) except Exception: return items.sort(key=lambda t: t[0], reverse=True) rows: list[str] = [] for mtime, p in items[:200]: ts = "" try: ts = _dt.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") except Exception: pass rows.append( f'{html.escape(p.name)}{html.escape(ts)}' ) page = f""" Pytest Reports
Pytest Reports
Folder: {html.escape(str(index_dir))}
Count:{len(items)}
Latest HTML reports
{''.join(rows) if rows else ''}
ReportModified
No reports found.
""" try: (index_dir / "index.html").write_text(page, encoding="utf-8") except Exception: return def main(argv: list[str]) -> int: p = argparse.ArgumentParser(description="Convert a JUnit XML report to a styled HTML report.") p.add_argument("xml", type=str, help="Input JUnit XML path") p.add_argument("html", type=str, help="Output HTML path") p.add_argument("--title", type=str, default="Pytest Report", help="HTML title") p.add_argument("--index-dir", type=str, default="", help="If set, also write index.html in this folder") args = p.parse_args(argv) xml_path = Path(args.xml) html_path = Path(args.html) if not xml_path.exists(): print(f"[junit_to_html] Input XML not found: {xml_path}", file=sys.stderr) return 2 try: data = _parse_junit(xml_path) except Exception as exc: print(f"[junit_to_html] Failed to parse XML: {exc}", file=sys.stderr) return 3 try: html_path.parent.mkdir(parents=True, exist_ok=True) except Exception: pass page = _render_html(args.title, xml_path, data) try: html_path.write_text(page, encoding="utf-8") except Exception as exc: print(f"[junit_to_html] Failed to write HTML: {exc}", file=sys.stderr) return 4 if args.index_dir: try: _write_index(Path(args.index_dir)) except Exception: pass return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))