feat(3 U2-scaffold): summary card + level/status SVG badge renderers (offline; pure)
harness/card.py: render_badge_svg/level_badge_svg (shields-style SVG, colour-by-level, R6) + render_card_html (recipe+version, level badge, per-stage/per-test ✔/✘ table, embedded screenshot, invariant flags — REPORTS results.json verbatim, never recomputes; cardinal no-inflation guardrail) + render_card_png (best-effort Playwright HTML->PNG, R7). 8 pure unit tests. Orchestrator wiring + stable-URL serving + live PNG demo come after U0 PASSes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
runner/harness/card.py
Normal file
184
runner/harness/card.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""Phase 3 — summary card + level/status badge rendering (plan-phase3-results-ux.md §4.2, R3/R6/U2).
|
||||
|
||||
Two render layers, both PURE string builders (unit-testable, deterministic) plus a thin best-effort
|
||||
Playwright PNG step:
|
||||
|
||||
- `render_badge_svg(...)` → shields-style SVG: "cc-ci | level N" (or a status word), colour by level.
|
||||
- `render_card_html(data)` → an HTML results card (recipe+version, the level badge, a per-stage /
|
||||
per-test ✔/✘ table, and the embedded app screenshot) from a results.json
|
||||
dict. Deterministic inline CSS + a relative screenshot.png ref so it
|
||||
renders offline (file://) with no external assets.
|
||||
- `render_card_png(...)` → screenshot the HTML card to PNG via the harness Playwright browser.
|
||||
Best-effort: returns None on any failure (cosmetics never block, R7).
|
||||
|
||||
The card REPORTS results.json verbatim — it must never present a run greener than its tests
|
||||
(cardinal guardrail, plan §6). The level + ✔/✘ shown are read straight from the data this module is
|
||||
handed; it computes nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import os
|
||||
|
||||
# Level → colour ramp (YunoHost-ish): red at the floor, climbing to green at the top.
|
||||
LEVEL_COLOR = {
|
||||
0: "#e5534b", # red — install failed
|
||||
1: "#e0823d", # orange
|
||||
2: "#e0823d",
|
||||
3: "#d9b343", # amber
|
||||
4: "#a0b93f", # yellow-green
|
||||
5: "#57ab5a", # green
|
||||
6: "#3fb950", # bright green — full climb
|
||||
}
|
||||
STATUS_MARK = {"pass": "✔", "fail": "✘", "skip": "–", "error": "✘", "na": "–"}
|
||||
STATUS_COLOR = {
|
||||
"pass": "#3fb950",
|
||||
"fail": "#f85149",
|
||||
"error": "#f85149",
|
||||
"skip": "#8b949e",
|
||||
"na": "#8b949e",
|
||||
}
|
||||
|
||||
|
||||
def level_color(level: int) -> str:
|
||||
return LEVEL_COLOR.get(int(level), "#8b949e")
|
||||
|
||||
|
||||
def _text_width(s: str) -> int:
|
||||
"""Rough px width for a Verdana-11 label (badge sizing); good enough for shields-style boxes."""
|
||||
return 7 * len(s) + 10
|
||||
|
||||
|
||||
def render_badge_svg(label: str, message: str, color: str) -> str:
|
||||
"""A two-box shields-style SVG badge (left grey label, right coloured message)."""
|
||||
lw = _text_width(label)
|
||||
mw = _text_width(message)
|
||||
w = lw + mw
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" '
|
||||
f'aria-label="{html.escape(label)}: {html.escape(message)}">'
|
||||
f'<rect width="{lw}" height="20" fill="#555"/>'
|
||||
f'<rect x="{lw}" width="{mw}" height="20" fill="{color}"/>'
|
||||
f'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">'
|
||||
f'<text x="6" y="14">{html.escape(label)}</text>'
|
||||
f'<text x="{lw + 6}" y="14">{html.escape(message)}</text></g></svg>'
|
||||
)
|
||||
|
||||
|
||||
def level_badge_svg(level: int, cap_reason: str = "") -> str:
|
||||
"""Per-recipe/-run LEVEL badge: 'cc-ci | level N'. Colour by level (R6)."""
|
||||
msg = f"level {int(level)}"
|
||||
return render_badge_svg("cc-ci", msg, level_color(level))
|
||||
|
||||
|
||||
def _stage_rows(stages: list[dict]) -> str:
|
||||
rows = []
|
||||
for st in stages:
|
||||
smark = STATUS_MARK.get(st.get("status", ""), "?")
|
||||
scolor = STATUS_COLOR.get(st.get("status", ""), "#8b949e")
|
||||
rows.append(
|
||||
f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{scolor}">{smark}</span>'
|
||||
f'<b>{html.escape(st.get("name", "?"))}</b></td>'
|
||||
f'<td class="st" style="color:{scolor}">{html.escape(st.get("status", ""))}</td></tr>'
|
||||
)
|
||||
for t in st.get("tests", []):
|
||||
tmark = STATUS_MARK.get(t.get("status", ""), "?")
|
||||
tcolor = STATUS_COLOR.get(t.get("status", ""), "#8b949e")
|
||||
ms = t.get("ms", 0)
|
||||
rows.append(
|
||||
f'<tr class="test"><td class="tmark" style="color:{tcolor}">{tmark}</td>'
|
||||
f'<td class="tname">{html.escape(t.get("name", "?"))}</td>'
|
||||
f'<td class="tms">{ms} ms</td></tr>'
|
||||
)
|
||||
return "\n".join(rows) or '<tr><td colspan="3">no stages</td></tr>'
|
||||
|
||||
|
||||
def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") -> str:
|
||||
"""Build the summary-card HTML from a results.json dict. `screenshot_rel` is the relative path to
|
||||
the screenshot PNG (same dir as the card) — omitted from the card if None / absent.
|
||||
|
||||
The card shows exactly what the data says: recipe + version, the level badge + cap reason, the
|
||||
per-stage/per-test ✔/✘ table, the invariant flags, and the app screenshot. No computation here."""
|
||||
recipe = html.escape(str(data.get("recipe", "?")))
|
||||
version = html.escape(str(data.get("version") or data.get("ref") or ""))
|
||||
level = int(data.get("level", 0))
|
||||
cap = html.escape(str(data.get("level_cap_reason") or ""))
|
||||
color = level_color(level)
|
||||
flags = data.get("flags", {}) or {}
|
||||
flag_bits = []
|
||||
for key, lbl in (("clean_teardown", "clean teardown"), ("no_secret_leak", "no secret leak")):
|
||||
ok = bool(flags.get(key))
|
||||
flag_bits.append(
|
||||
f'<span class="flag" style="border-color:{"#3fb950" if ok else "#f85149"}">'
|
||||
f'{STATUS_MARK["pass"] if ok else STATUS_MARK["fail"]} {lbl}</span>'
|
||||
)
|
||||
show_shot = bool(screenshot_rel) and bool(data.get("screenshot"))
|
||||
shot_html = (
|
||||
f'<div class="shot"><img src="{html.escape(screenshot_rel)}" alt="app screenshot"/></div>'
|
||||
if show_shot
|
||||
else '<div class="shot noshot">no screenshot</div>'
|
||||
)
|
||||
rows = _stage_rows(data.get("stages", []))
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0d1117;color:#c9d1d9}}
|
||||
.card{{width:900px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden}}
|
||||
.hd{{display:flex;align-items:center;gap:1rem;padding:1.1rem 1.3rem;border-bottom:1px solid #30363d}}
|
||||
.flower{{font-size:1.8rem}}
|
||||
.title{{flex:1}}
|
||||
.title h1{{margin:0;font-size:1.4rem}}
|
||||
.title .ver{{color:#8b949e;font-size:.9rem}}
|
||||
.lvl{{text-align:center}}
|
||||
.lvl .num{{display:inline-block;min-width:64px;padding:.3rem .7rem;border-radius:10px;
|
||||
font-size:1.6rem;font-weight:700;color:#0d1117;background:{color}}}
|
||||
.lvl .lbl{{display:block;color:#8b949e;font-size:.72rem;text-transform:uppercase;margin-top:.2rem}}
|
||||
.cap{{padding:.4rem 1.3rem;color:#8b949e;font-size:.82rem;border-bottom:1px solid #21262d}}
|
||||
.body{{display:flex;gap:1rem;padding:1rem 1.3rem}}
|
||||
.tbl{{flex:1}}
|
||||
table{{border-collapse:collapse;width:100%;font-size:.85rem}}
|
||||
td{{padding:.18rem .4rem;border-bottom:1px solid #21262d}}
|
||||
tr.stage td{{padding-top:.5rem;border-bottom:1px solid #30363d}}
|
||||
.mark{{font-weight:700;margin-right:.4rem}}
|
||||
.st{{text-align:right;text-transform:uppercase;font-size:.74rem}}
|
||||
.test .tmark{{width:1.4rem;text-align:center}}
|
||||
.test .tname{{color:#c9d1d9;font-family:ui-monospace,monospace;font-size:.8rem}}
|
||||
.test .tms{{text-align:right;color:#8b949e;font-size:.74rem;width:5rem}}
|
||||
.shot{{width:360px;flex:none;border:1px solid #30363d;border-radius:8px;overflow:hidden;background:#0d1117}}
|
||||
.shot img{{width:100%;display:block}}
|
||||
.shot.noshot{{display:flex;align-items:center;justify-content:center;height:225px;color:#8b949e;font-size:.85rem}}
|
||||
.flags{{display:flex;gap:.6rem;padding:.6rem 1.3rem 1rem}}
|
||||
.flag{{border:1px solid;border-radius:6px;padding:.15rem .5rem;font-size:.78rem;color:#c9d1d9}}
|
||||
</style></head><body><div class="card">
|
||||
<div class="hd"><span class="flower">🌻</span>
|
||||
<div class="title"><h1>{recipe}</h1><span class="ver">{version}</span></div>
|
||||
<div class="lvl"><span class="num">{level}</span><span class="lbl">level</span></div></div>
|
||||
<div class="cap">{("⚑ " + cap) if cap else "🏆 full clean climb (level 6)"}</div>
|
||||
<div class="body"><div class="tbl"><table>{rows}</table></div>{shot_html}</div>
|
||||
<div class="flags">{"".join(flag_bits)}</div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def render_card_png(html_path: str, out_png: str) -> str | None:
|
||||
"""Render an HTML card file to PNG via Playwright (screenshot the .card element). Best-effort:
|
||||
returns out_png on success, None on any failure (cosmetics never block the pipeline, R7)."""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
try:
|
||||
page = browser.new_context(
|
||||
viewport={"width": 980, "height": 700}, device_scale_factor=2
|
||||
).new_page()
|
||||
page.goto(f"file://{os.path.abspath(html_path)}", wait_until="networkidle")
|
||||
el = page.query_selector(".card")
|
||||
(el or page).screenshot(path=out_png)
|
||||
finally:
|
||||
browser.close()
|
||||
return out_png if os.path.exists(out_png) and os.path.getsize(out_png) > 0 else None
|
||||
except Exception as e: # noqa: BLE001 — cosmetic; never fail a run (R7)
|
||||
print(f" card: PNG render failed (non-fatal): {e}", flush=True)
|
||||
return None
|
||||
90
tests/unit/test_card.py
Normal file
90
tests/unit/test_card.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Unit tests for the pure card/badge renderers (harness.card), Phase 3 U2 (R3/R6).
|
||||
|
||||
Covers the deterministic HTML + SVG string builders (the PNG step needs Playwright + is exercised in
|
||||
the U2 live demo). The cardinal check: the card REPORTS the data verbatim — level/marks come straight
|
||||
from the dict, never recomputed. Run cold: cc-ci-run -m pytest tests/unit/test_card.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import card as C # noqa: E402
|
||||
|
||||
|
||||
def _data(level=4, cap="L5 integration (SSO/OIDC + cross-app) N/A"):
|
||||
return {
|
||||
"recipe": "uptime-kuma",
|
||||
"version": "1.23.0",
|
||||
"level": level,
|
||||
"level_cap_reason": cap,
|
||||
"flags": {"clean_teardown": True, "no_secret_leak": True},
|
||||
"screenshot": "screenshot.png",
|
||||
"stages": [
|
||||
{
|
||||
"name": "install",
|
||||
"status": "pass",
|
||||
"tests": [{"name": "test_serving", "status": "pass", "ms": 168}],
|
||||
},
|
||||
{
|
||||
"name": "custom",
|
||||
"status": "fail",
|
||||
"tests": [
|
||||
{"name": "test_health", "status": "pass", "ms": 17},
|
||||
{"name": "test_broken", "status": "fail", "ms": 5},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_level_color_ramp():
|
||||
assert C.level_color(0) != C.level_color(6)
|
||||
assert C.level_color(6) == "#3fb950"
|
||||
assert C.level_color(99) == "#8b949e" # unknown → grey
|
||||
|
||||
|
||||
def test_badge_svg_wellformed():
|
||||
svg = C.level_badge_svg(4)
|
||||
assert svg.startswith("<svg") and svg.endswith("</svg>")
|
||||
assert "level 4" in svg
|
||||
assert C.level_color(4) in svg
|
||||
|
||||
|
||||
def test_card_html_reports_level_verbatim():
|
||||
html = C.render_card_html(_data(level=2, cap="L3 backup/restore (data integrity) N/A"))
|
||||
assert "uptime-kuma" in html
|
||||
assert "1.23.0" in html
|
||||
# the level shown is exactly what was passed (no recompute)
|
||||
assert ">2<" in html
|
||||
assert "L3 backup/restore" in html
|
||||
assert C.level_color(2) in html
|
||||
|
||||
|
||||
def test_card_html_shows_stage_and_test_marks():
|
||||
html = C.render_card_html(_data())
|
||||
assert "install" in html and "custom" in html
|
||||
assert "test_serving" in html and "test_broken" in html
|
||||
assert C.STATUS_MARK["pass"] in html and C.STATUS_MARK["fail"] in html
|
||||
|
||||
|
||||
def test_card_html_flags_rendered():
|
||||
html = C.render_card_html(_data())
|
||||
assert "clean teardown" in html and "no secret leak" in html
|
||||
|
||||
|
||||
def test_card_html_no_screenshot_placeholder():
|
||||
d = _data()
|
||||
d["screenshot"] = None
|
||||
html = C.render_card_html(d)
|
||||
assert "no screenshot" in html
|
||||
|
||||
|
||||
def test_card_html_escapes_recipe_name():
|
||||
d = _data()
|
||||
d["recipe"] = "<script>x</script>"
|
||||
html = C.render_card_html(d)
|
||||
assert "<script>x" not in html
|
||||
assert "<script>" in html
|
||||
Reference in New Issue
Block a user