Files
cc-ci/runner/harness/card.py
autonomic-bot 8179d3f3f9 fix(3 U2): inline-SVG sunflower + font-safe cap line for headless card render
Headless chromium has no colour-emoji font, so 🌻/🏆/⚑ rendered as tofu boxes in the PNG card.
Replace with a self-contained inline-SVG sunflower + plain-text 'capped:'/'full clean climb' markers.
The U3 PR comment keeps the real 🌻 emoji (Gitea markdown renders it). Pure render change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 06:23:13 +00:00

199 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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",
}
# Inline-SVG sunflower (🌻) for the card header. Self-contained so it renders deterministically in
# headless chromium, which has no colour-emoji font (the PR comment in U3 keeps the real 🌻 emoji —
# Gitea markdown renders it). 8 petals around a seed disc.
_PETALS = "".join(
f'<ellipse cx="14" cy="5.5" rx="2.6" ry="5.5" transform="rotate({a} 14 14)"/>'
for a in range(0, 360, 45)
)
FLOWER_SVG = (
'<svg class="flower" width="30" height="30" viewBox="0 0 28 28" aria-label="cc-ci">'
f'<g fill="#f0b429">{_PETALS}</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
)
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{{flex:none}}
.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}}
.cap b{{color:#c9d1d9}}
</style></head><body><div class="card">
<div class="hd">{FLOWER_SVG}
<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">{("<b>capped:</b> " + cap) if cap else "<b>full clean climb</b> — top 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