"""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'' for a in range(0, 360, 45) ) FLOWER_SVG = ( '' f'{_PETALS}' ) 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'' f'' f'' f'' f'{html.escape(label)}' f'{html.escape(message)}' ) 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'{smark}' f'{html.escape(st.get("name", "?"))}' f'{html.escape(st.get("status", ""))}' ) 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'{tmark}' f'{html.escape(t.get("name", "?"))}' f'{ms} ms' ) return "\n".join(rows) or 'no stages' 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'' f'{STATUS_MARK["pass"] if ok else STATUS_MARK["fail"]} {lbl}' ) show_shot = bool(screenshot_rel) and bool(data.get("screenshot")) shot_html = ( f'
app screenshot
' if show_shot else '
no screenshot
' ) rows = _stage_rows(data.get("stages", [])) return f"""
{FLOWER_SVG}

{recipe}

{version}
{level}level
{("capped: " + cap) if cap else "full clean climb — top level (6)"}
{rows}
{shot_html}
{"".join(flag_bits)}
""" 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