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 в таблицу аддонов
416 lines
13 KiB
Python
Executable File
416 lines
13 KiB
Python
Executable File
#!/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())
|