221 lines
9.1 KiB
Python
221 lines
9.1 KiB
Python
"""HTML report for pulumi hybrid lab suite (pulumi-vsphere + REST + SOAP)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
def _esc(value: Any) -> str:
|
||
return html.escape(str(value), quote=True)
|
||
|
||
|
||
def _section_rest(summary: dict[str, Any]) -> str:
|
||
rest = summary.get("rest") or {}
|
||
majors = rest.get("majors") or []
|
||
by_verb = rest.get("by_verb") or {}
|
||
failures = rest.get("failures") or []
|
||
coverage = rest.get("coverage_line") or f"{(rest.get('probed') or 0) - (rest.get('critical') or 0)}/{rest.get('declared') or rest.get('total') or 0}"
|
||
critical = rest.get("critical", rest.get("failed", 0))
|
||
|
||
major_rows = []
|
||
for m in majors:
|
||
buckets = m.get("buckets") or {}
|
||
deep = m.get("deep_vs_stub") or {}
|
||
major_rows.append(
|
||
"<tr>"
|
||
f"<td>{_esc(m.get('major'))}</td>"
|
||
f"<td>{_esc(m.get('version'))}</td>"
|
||
f"<td>{_esc(m.get('declared'))}</td>"
|
||
f"<td>{_esc(m.get('probed'))}</td>"
|
||
f"<td>{_esc(m.get('coverage_line') or '—')}</td>"
|
||
f"<td>{_esc(m.get('critical', m.get('failed')))}</td>"
|
||
f"<td>{_esc(buckets.get('success_2xx', 0))}</td>"
|
||
f"<td>{_esc(buckets.get('client_4xx', 0))}</td>"
|
||
f"<td>{_esc(buckets.get('server_5xx', 0))}</td>"
|
||
f"<td>{_esc(deep.get('deep', 0))}/{_esc(deep.get('stub', 0))}</td>"
|
||
"</tr>"
|
||
)
|
||
|
||
verb_bits = " · ".join(f"{_esc(k)}={_esc(v)}" for k, v in sorted(by_verb.items()))
|
||
fail_rows = []
|
||
for f in failures[:40]:
|
||
fail_rows.append(
|
||
"<tr>"
|
||
f"<td>{_esc(f.get('major'))}</td>"
|
||
f"<td>{_esc(f.get('verb'))}</td>"
|
||
f"<td><code>{_esc(f.get('path'))}</code></td>"
|
||
f"<td>{_esc(f.get('kind'))}</td>"
|
||
f"<td>{_esc(f.get('status'))}</td>"
|
||
f"<td class='err'>{_esc((f.get('body') or f.get('expected') or '')[:180])}</td>"
|
||
"</tr>"
|
||
)
|
||
|
||
return f"""
|
||
<h2>REST HTTP contract matrix (Layer A)</h2>
|
||
<p class="meta"><strong>coverage {_esc(coverage)}</strong>
|
||
· critical={_esc(critical)}
|
||
· probed={_esc(rest.get("probed") or rest.get("total"))}
|
||
· declared={_esc(rest.get("declared"))}
|
||
· verbs: {verb_bits or "—"}
|
||
<br/>100% = HTTP matrix (IMPLEMENTED × majors + HEAD), not pulumi-vsphere resource count.</p>
|
||
<table>
|
||
<thead><tr><th>Major</th><th>Version</th><th>Declared</th><th>Probed</th><th>Coverage</th>
|
||
<th>Critical</th><th>2xx</th><th>4xx</th><th>5xx</th><th>deep/stub</th></tr></thead>
|
||
<tbody>{"".join(major_rows) or '<tr><td colspan="10">—</td></tr>'}</tbody>
|
||
</table>
|
||
<h3>REST critical failures (sample)</h3>
|
||
<table>
|
||
<thead><tr><th>Major</th><th>Verb</th><th>Path</th><th>Kind</th><th>Status</th><th>Detail</th></tr></thead>
|
||
<tbody>{"".join(fail_rows) or '<tr><td colspan="6">none</td></tr>'}</tbody>
|
||
</table>
|
||
"""
|
||
|
||
def _section_crud(summary: dict[str, Any]) -> str:
|
||
crud = summary.get("crud") or {}
|
||
flows = crud.get("flows") or []
|
||
rows = []
|
||
for f in flows:
|
||
rows.append(
|
||
f"<tr class='{_esc(f.get('status'))}'>"
|
||
f"<td>{_esc(f.get('flow'))}</td>"
|
||
f"<td>{_esc(f.get('step'))}</td>"
|
||
f"<td>{_esc(f.get('status'))}</td>"
|
||
f"<td class='err'>{_esc(f.get('error') or '—')}</td>"
|
||
f"</tr>"
|
||
)
|
||
return f"""
|
||
<h2>Deep REST CRUD</h2>
|
||
<p class="meta">total={_esc(crud.get("total"))} · failed={_esc(crud.get("failed"))}</p>
|
||
<table>
|
||
<thead><tr><th>Flow</th><th>Step</th><th>Status</th><th>Detail</th></tr></thead>
|
||
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips CRUD)</td></tr>'}</tbody>
|
||
</table>
|
||
"""
|
||
|
||
|
||
def _section_soap(summary: dict[str, Any]) -> str:
|
||
soap = summary.get("soap") or {}
|
||
ops = soap.get("ops") or []
|
||
rows = []
|
||
for op in ops:
|
||
status = "passed" if op.get("ok") else "failed"
|
||
rows.append(
|
||
f"<tr class='{_esc(status)}'>"
|
||
f"<td><code>{_esc(op.get('op'))}</code></td>"
|
||
f"<td>{_esc(op.get('status'))}</td>"
|
||
f"<td>{_esc(status)}</td>"
|
||
f"<td class='err'>{_esc(op.get('error') or '—')}</td>"
|
||
f"</tr>"
|
||
)
|
||
return f"""
|
||
<h2>SOAP WSDL ops</h2>
|
||
<p class="meta">total={_esc(soap.get("total"))} · failed={_esc(soap.get("failed"))}</p>
|
||
<table>
|
||
<thead><tr><th>Operation</th><th>HTTP</th><th>Status</th><th>Detail</th></tr></thead>
|
||
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips SOAP)</td></tr>'}</tbody>
|
||
</table>
|
||
"""
|
||
|
||
|
||
def render_html(summary: dict[str, Any]) -> str:
|
||
generated = summary.get("generated_at") or datetime.now(UTC).isoformat()
|
||
cases = summary.get("cases") or []
|
||
total = len(cases)
|
||
passed = sum(1 for c in cases if c.get("status") == "passed")
|
||
failed = sum(1 for c in cases if c.get("status") == "failed")
|
||
skipped = sum(1 for c in cases if c.get("status") == "skipped")
|
||
rest = summary.get("rest") or {}
|
||
crud = summary.get("crud") or {}
|
||
soap = summary.get("soap") or {}
|
||
|
||
rows = []
|
||
for case in cases:
|
||
status = case.get("status") or ""
|
||
outs = case.get("outputs") or {}
|
||
out_html = "<br/>".join(f"<code>{_esc(k)}</code>={_esc(v)}" for k, v in outs.items())
|
||
err = _esc(case.get("error") or "")
|
||
rows.append(
|
||
f"<tr class='{_esc(status)}'>"
|
||
f"<td>{_esc(case.get('id'))}</td>"
|
||
f"<td>{_esc(case.get('title'))}</td>"
|
||
f"<td>{_esc(status)}</td>"
|
||
f"<td class='outs'>{out_html or '—'}</td>"
|
||
f"<td class='err'>{err or '—'}</td>"
|
||
f"</tr>"
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>pulumi hybrid lab report</title>
|
||
<style>
|
||
:root {{
|
||
--bg:#0f1419; --panel:#1a222c; --text:#e7eef7; --muted:#9aa8b8;
|
||
--ok:#3dd68c; --fail:#ff6b6b; --skip:#f0c14a; --line:#2a3542; --accent:#5b9fd4;
|
||
}}
|
||
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",sans-serif; background:var(--bg); color:var(--text); }}
|
||
header, main {{ max-width:1200px; margin:0 auto; padding:1.5rem; }}
|
||
h1 {{ margin:0 0 .4rem; }}
|
||
h2 {{ margin:1.75rem 0 .6rem; color:var(--accent); }}
|
||
h3 {{ margin:1rem 0 .4rem; font-size:1rem; color:var(--muted); }}
|
||
.meta {{ color:var(--muted); }}
|
||
.summary {{ display:grid; grid-template-columns:repeat(4,1fr); gap:.75rem; margin:1rem 0 1.5rem; }}
|
||
.stat {{ background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1rem; }}
|
||
.stat strong {{ display:block; font-size:1.5rem; margin-top:.25rem; }}
|
||
.ok strong {{ color:var(--ok); }} .fail strong {{ color:var(--fail); }} .skip strong {{ color:var(--skip); }}
|
||
table {{ width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line);
|
||
border-radius:12px; overflow:hidden; margin-bottom:1rem; }}
|
||
th, td {{ padding:.55rem .65rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:.9rem; }}
|
||
th {{ color:var(--accent); }}
|
||
tr.failed td:nth-child(3) {{ color:var(--fail); font-weight:600; }}
|
||
tr.passed td:nth-child(3) {{ color:var(--ok); }}
|
||
tr.skipped td:nth-child(3) {{ color:var(--skip); }}
|
||
code {{ font-family:ui-monospace,Menlo,monospace; font-size:.8rem; }}
|
||
.outs, .err {{ word-break:break-word; }}
|
||
.metrics {{ display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; margin-bottom:1rem; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>pulumi hybrid lab suite</h1>
|
||
<div class="meta">Generated {_esc(generated)} · server {_esc(summary.get("vsphere_server"))}
|
||
· base {_esc(summary.get("vsphere_base"))}
|
||
· smoke={_esc(summary.get("smoke"))}</div>
|
||
</header>
|
||
<main>
|
||
<section class="summary">
|
||
<div class="stat"><span>Cases</span><strong>{total}</strong></div>
|
||
<div class="stat ok"><span>Passed</span><strong>{passed}</strong></div>
|
||
<div class="stat fail"><span>Failed</span><strong>{failed}</strong></div>
|
||
<div class="stat skip"><span>Skipped</span><strong>{skipped}</strong></div>
|
||
</section>
|
||
<section class="metrics">
|
||
<div class="stat"><span>REST failed</span><strong>{_esc(rest.get("failed", 0))}</strong>
|
||
<span class="meta"> / {_esc(rest.get("total", 0))} probes</span></div>
|
||
<div class="stat"><span>CRUD failed</span><strong>{_esc(crud.get("failed", 0))}</strong>
|
||
<span class="meta"> / {_esc(crud.get("total", 0))} flows</span></div>
|
||
<div class="stat"><span>SOAP failed</span><strong>{_esc(soap.get("failed", 0))}</strong>
|
||
<span class="meta"> / {_esc(soap.get("total", 0))} ops</span></div>
|
||
</section>
|
||
<h2>Suite cases</h2>
|
||
<table>
|
||
<thead><tr><th>ID</th><th>Title</th><th>Status</th><th>Outputs</th><th>Error</th></tr></thead>
|
||
<tbody>{"".join(rows)}</tbody>
|
||
</table>
|
||
{_section_rest(summary)}
|
||
{_section_crud(summary)}
|
||
{_section_soap(summary)}
|
||
</main>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def write_report(summary: dict[str, Any], path: Path) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(render_html(summary), encoding="utf-8")
|