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:
Sergey Antropoff
2026-04-26 18:57:11 +03:00
parent 225f77598a
commit 91299fcc1b
25 changed files with 2376 additions and 72 deletions

415
scripts/molecule-report.py Executable file
View 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 &nbsp;"
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"&nbsp;|&nbsp; 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)} &nbsp; "
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())