test: добавить полное покрытие Molecule + HTML report генератор
Molecule тесты для всех аддонов и кластерный topology тест: Аддоны (Helm lint + template + assertions): - addons/technitium-dns/role/molecule/ — Primary/Secondary DNS, CronJob, kube-vip - addons/authelia/role/molecule/ — OIDC clients, access_control, manifests - addons/ingress-proxypass/role/molecule/ — proxies, Service/Endpoints/Ingress - addons/ingress-add-domains/role/molecule/ — entries, Ingress per namespace - addons/yandex-dns-controller/role/molecule/ — CronJob, ConfigMap, RBAC Кластер: - molecule/cluster/ — 3 master (embedded etcd HA) + 2 worker topology тест Инфраструктура: - scripts/molecule-report.py — генератор HTML отчётов из JUnit XML (читает /tmp/molecule-junit/*.xml → /tmp/molecule-report.html) - requirements-python.txt — комментарий к отчётному блоку - docker/entrypoint.sh — добавлены команды molecule-addon, molecule-cluster, molecule-report с автоматическим включением junit callback - Makefile — targets: molecule-cluster, molecule-addon-*, molecule-addon-all, molecule-report; molecule-all генерирует HTML отчёт - docs/molecule-testing.md — полная документация всех сценариев - docs/addons.md — добавлены technitium-dns и authelia в таблицу аддонов
This commit is contained in:
415
scripts/molecule-report.py
Executable file
415
scripts/molecule-report.py
Executable file
@@ -0,0 +1,415 @@
|
||||
#!/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 <testsuites> wrapper and bare <testsuite>
|
||||
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 = ["<!DOCTYPE html><html lang='ru'><head>"]
|
||||
parts.append("<meta charset='UTF-8'>")
|
||||
parts.append("<meta name='viewport' content='width=device-width, initial-scale=1'>")
|
||||
parts.append("<title>Molecule Test Report</title>")
|
||||
parts.append(f"<style>{_CSS}</style>")
|
||||
parts.append("</head><body>")
|
||||
|
||||
# Header
|
||||
parts.append(
|
||||
f"<header>"
|
||||
f"<h1>🧪 Molecule Test Report "
|
||||
f"<span class='badge {overall_badge}'>{overall_label}</span></h1>"
|
||||
f"<span class='ts'>Generated: {generated_at.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f" | Duration: {_fmt_time(total_time)}</span>"
|
||||
f"</header>"
|
||||
)
|
||||
|
||||
# Summary bar
|
||||
parts.append("<div class='summary'>")
|
||||
parts.append(f"<div class='stat total'><div class='num'>{total_tests}</div><div class='label'>Total</div></div>")
|
||||
parts.append(f"<div class='stat pass'><div class='num'>{total_passed}</div><div class='label'>Passed</div></div>")
|
||||
parts.append(f"<div class='stat fail'><div class='num'>{total_failed}</div><div class='label'>Failed</div></div>")
|
||||
parts.append(f"<div class='stat skip'><div class='num'>{total_skipped}</div><div class='label'>Skipped</div></div>")
|
||||
parts.append("</div>")
|
||||
|
||||
parts.append("<div class='container'>")
|
||||
|
||||
if not suites:
|
||||
parts.append("<div class='no-xml'>No JUnit XML files found. Run molecule tests first.</div>")
|
||||
else:
|
||||
parts.append(
|
||||
"<div class='toggle-all'>"
|
||||
"<button onclick='expandAll()'>Expand all</button>"
|
||||
"<button onclick='collapseAll()'>Collapse all</button>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
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"<div class='scenario'>")
|
||||
parts.append(
|
||||
f"<div class='scenario-header' onclick=\"toggleScenario('{body_id}')\">"
|
||||
f"<span class='scenario-name'>{html.escape(suite.name)}</span>"
|
||||
f"<span class='badge {badge_cls}'>{badge_txt}</span>"
|
||||
f"<span class='scenario-time'>{_fmt_time(suite.time)} "
|
||||
f"{suite.passed}/{suite.total} tasks passed</span>"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
parts.append(f"<div class='scenario-body' id='{body_id}'>")
|
||||
parts.append("<table><thead><tr>")
|
||||
parts.append("<th>Status</th><th>Task</th><th>Time</th>")
|
||||
parts.append("</tr></thead><tbody>")
|
||||
|
||||
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"<div class='error-msg'>{html.escape(detail)}</div>"
|
||||
parts.append(
|
||||
f"<tr>"
|
||||
f"<td class='status-icon'>{icon}</td>"
|
||||
f"<td><div class='task-name'>{html.escape(tc.name)}</div>"
|
||||
f"<div class='task-class'>{html.escape(tc.classname)}</div>"
|
||||
f"{error_block}</td>"
|
||||
f"<td class='task-time'>{_fmt_time(tc.time)}</td>"
|
||||
f"</tr>"
|
||||
)
|
||||
|
||||
parts.append("</tbody></table></div></div>")
|
||||
|
||||
parts.append("</div>")
|
||||
parts.append(f"<script>{_JS}</script>")
|
||||
parts.append("</body></html>")
|
||||
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())
|
||||
Reference in New Issue
Block a user