#!/usr/bin/env python3 """ Molecule HTML Report Generator Reads JUnit XML files produced by Ansible's junit callback and generates a single HTML report with per-scenario summaries and task-level details. Usage: python3 scripts/molecule-report.py [--xml-dir DIR] [--output FILE] Default: --xml-dir /tmp/molecule-junit --output /tmp/molecule-report.html """ import argparse import html import os import sys from datetime import datetime from pathlib import Path from xml.etree import ElementTree # ── ANSI colour stripping ──────────────────────────────────────────────────── import re _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def strip_ansi(text: str) -> str: return _ANSI_RE.sub("", text or "") # ── JUnit XML parsing ──────────────────────────────────────────────────────── class TestCase: def __init__(self, classname: str, name: str, time: float): self.classname = classname self.name = name self.time = time self.status = "passed" # passed | failed | skipped | error self.message = "" self.text = "" class TestSuite: def __init__(self, name: str, time: float): self.name = name self.time = time self.tests: list[TestCase] = [] @property def passed(self) -> int: return sum(1 for t in self.tests if t.status == "passed") @property def failed(self) -> int: return sum(1 for t in self.tests if t.status in ("failed", "error")) @property def skipped(self) -> int: return sum(1 for t in self.tests if t.status == "skipped") @property def total(self) -> int: return len(self.tests) def parse_junit_file(path: Path) -> list[TestSuite]: tree = ElementTree.parse(path) root = tree.getroot() # Support both wrapper and bare suites_el = [] if root.tag == "testsuites": suites_el = list(root) elif root.tag == "testsuite": suites_el = [root] result = [] for suite_el in suites_el: if suite_el.tag != "testsuite": continue suite = TestSuite( name=suite_el.get("name", path.stem), time=float(suite_el.get("time", 0) or 0), ) for tc_el in suite_el.iter("testcase"): tc = TestCase( classname=tc_el.get("classname", ""), name=tc_el.get("name", ""), time=float(tc_el.get("time", 0) or 0), ) failure = tc_el.find("failure") error = tc_el.find("error") skipped = tc_el.find("skipped") if failure is not None: tc.status = "failed" tc.message = strip_ansi(failure.get("message", "")) tc.text = strip_ansi(failure.text or "") elif error is not None: tc.status = "error" tc.message = strip_ansi(error.get("message", "")) tc.text = strip_ansi(error.text or "") elif skipped is not None: tc.status = "skipped" tc.message = strip_ansi(skipped.get("message", "")) suite.tests.append(tc) result.append(suite) return result def load_all_suites(xml_dir: Path) -> list[TestSuite]: suites = [] for xml_file in sorted(xml_dir.glob("*.xml")): try: suites.extend(parse_junit_file(xml_file)) except Exception as exc: print(f" [warn] Cannot parse {xml_file.name}: {exc}", file=sys.stderr) return suites # ── HTML generation ────────────────────────────────────────────────────────── _CSS = """ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background: #f4f6f9; color: #24292e; font-size: 14px; line-height: 1.5; } header { background: #1a1f36; color: #fff; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; } header h1 { font-size: 20px; font-weight: 600; } header .ts { font-size: 12px; opacity: 0.6; } .summary { display: flex; gap: 16px; padding: 20px 32px; flex-wrap: wrap; } .stat { background: #fff; border: 1px solid #e1e4e8; border-radius: 8px; padding: 16px 24px; text-align: center; min-width: 120px; } .stat .num { font-size: 32px; font-weight: 700; } .stat .label { font-size: 12px; color: #6a737d; margin-top: 4px; } .stat.pass .num { color: #28a745; } .stat.fail .num { color: #d73a49; } .stat.skip .num { color: #6f42c1; } .stat.total .num { color: #0366d6; } .container { padding: 0 32px 40px; } .scenario { background: #fff; border: 1px solid #e1e4e8; border-radius: 8px; margin-bottom: 20px; overflow: hidden; } .scenario-header { display: flex; align-items: center; padding: 14px 20px; cursor: pointer; user-select: none; background: #f6f8fa; border-bottom: 1px solid #e1e4e8; gap: 12px; } .scenario-header:hover { background: #eef0f3; } .scenario-name { font-weight: 600; font-size: 15px; flex: 1; } .badge { padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; } .badge.pass { background: #dcffe4; color: #22863a; } .badge.fail { background: #ffeef0; color: #d73a49; } .badge.mixed { background: #fff3cd; color: #856404; } .badge.skip { background: #ede3ff; color: #6f42c1; } .scenario-time { font-size: 12px; color: #6a737d; } .scenario-body { display: none; } .scenario-body.open { display: block; } table { width: 100%; border-collapse: collapse; } thead th { background: #f6f8fa; border-bottom: 1px solid #e1e4e8; padding: 8px 16px; text-align: left; font-size: 12px; color: #6a737d; text-transform: uppercase; letter-spacing: 0.04em; } tbody tr { border-bottom: 1px solid #f0f0f0; } tbody tr:last-child { border-bottom: none; } td { padding: 10px 16px; vertical-align: top; } .status-icon { font-size: 16px; width: 24px; } .task-name { font-weight: 500; } .task-class { font-size: 11px; color: #6a737d; } .task-time { color: #6a737d; font-size: 12px; white-space: nowrap; } .error-msg { background: #fff5f5; border-left: 3px solid #d73a49; padding: 8px 12px; margin-top: 6px; font-size: 12px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; white-space: pre-wrap; word-break: break-word; color: #a0192a; max-height: 300px; overflow-y: auto; } .toggle-all { margin-bottom: 16px; } .toggle-all button { background: #fff; border: 1px solid #e1e4e8; border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: 13px; margin-right: 8px; } .toggle-all button:hover { background: #f3f4f6; } .no-xml { text-align: center; padding: 60px; color: #6a737d; font-size: 16px; } """ _JS = """ function toggleScenario(id) { var body = document.getElementById(id); body.classList.toggle('open'); } function expandAll() { document.querySelectorAll('.scenario-body').forEach(function(el) { el.classList.add('open'); }); } function collapseAll() { document.querySelectorAll('.scenario-body').forEach(function(el) { el.classList.remove('open'); }); } // Auto-expand scenarios with failures document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.scenario').forEach(function(sc) { if (sc.querySelector('.badge.fail') || sc.querySelector('.badge.mixed')) { sc.querySelector('.scenario-body').classList.add('open'); } }); }); """ def _status_icon(status: str) -> str: return {"passed": "✅", "failed": "❌", "error": "🔴", "skipped": "⏭️"}.get(status, "❓") def _fmt_time(seconds: float) -> str: if seconds < 1: return f"{seconds * 1000:.0f}ms" if seconds < 60: return f"{seconds:.1f}s" return f"{seconds / 60:.1f}m" def render_html(suites: list[TestSuite], generated_at: datetime) -> str: total_tests = sum(s.total for s in suites) total_passed = sum(s.passed for s in suites) total_failed = sum(s.failed for s in suites) total_skipped = sum(s.skipped for s in suites) total_time = sum(s.time for s in suites) overall_badge = "pass" if total_failed == 0 else "fail" overall_label = "PASSED" if total_failed == 0 else "FAILED" parts = [""] parts.append("") parts.append("") parts.append("Molecule Test Report") parts.append(f"") parts.append("") # Header parts.append( f"
" f"

🧪 Molecule Test Report  " f"{overall_label}

" f"Generated: {generated_at.strftime('%Y-%m-%d %H:%M:%S')} " f" |  Duration: {_fmt_time(total_time)}" f"
" ) # Summary bar parts.append("
") parts.append(f"
{total_tests}
Total
") parts.append(f"
{total_passed}
Passed
") parts.append(f"
{total_failed}
Failed
") parts.append(f"") parts.append("
") parts.append("
") if not suites: parts.append("
No JUnit XML files found. Run molecule tests first.
") else: parts.append( "
" "" "" "
" ) for idx, suite in enumerate(suites): body_id = f"suite-{idx}" if suite.failed > 0: badge_cls = "fail" badge_txt = f"✗ {suite.failed} failed" elif suite.total == 0: badge_cls = "skip" badge_txt = "no tests" else: badge_cls = "pass" badge_txt = "✓ passed" parts.append(f"
") parts.append( f"
" f"{html.escape(suite.name)}" f"{badge_txt}" f"{_fmt_time(suite.time)}   " f"{suite.passed}/{suite.total} tasks passed" f"
" ) parts.append(f"
") parts.append("") parts.append("") parts.append("") for tc in suite.tests: icon = _status_icon(tc.status) error_block = "" if tc.message or tc.text: detail = (tc.text or tc.message)[:4000] error_block = f"
{html.escape(detail)}
" parts.append( f"" f"" f"" f"" f"" ) parts.append("
StatusTaskTime
{icon}
{html.escape(tc.name)}
" f"
{html.escape(tc.classname)}
" f"{error_block}
{_fmt_time(tc.time)}
") parts.append("
") parts.append(f"") parts.append("") return "\n".join(parts) # ── CLI ────────────────────────────────────────────────────────────────────── def main() -> int: parser = argparse.ArgumentParser(description="Generate HTML report from Molecule JUnit XML files") parser.add_argument("--xml-dir", default="/tmp/molecule-junit", help="Directory with JUnit XML files") parser.add_argument("--output", default="/tmp/molecule-report.html", help="Output HTML file path") args = parser.parse_args() xml_dir = Path(args.xml_dir) output = Path(args.output) if not xml_dir.exists(): print(f"[warn] XML directory not found: {xml_dir}", file=sys.stderr) suites = [] else: print(f"Loading JUnit XML files from: {xml_dir}") suites = load_all_suites(xml_dir) print(f" Found {len(suites)} test suite(s)") html_content = render_html(suites, datetime.now()) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(html_content, encoding="utf-8") total_failed = sum(s.failed for s in suites) total_tests = sum(s.total for s in suites) print(f"\nReport: {output}") print(f"Result: {total_tests - total_failed}/{total_tests} passed") return 1 if total_failed > 0 else 0 if __name__ == "__main__": sys.exit(main())