"""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 (L5 = full # clean climb incl. lint — phase lvl5). LEVEL_COLOR = { 0: "#e5534b", # red — install failed 1: "#e0823d", # orange 2: "#e0823d", 3: "#d9b343", # amber 4: "#a0b93f", # yellow-green — above functional, lint not earned 5: "#3fb950", # bright green — full climb (lint passed) } STATUS_MARK = {"pass": "✔", "fail": "✘", "skip": "–", "error": "✘", "na": "–", "unver": "⊘"} STATUS_COLOR = { "pass": "#3fb950", "fail": "#f85149", "error": "#f85149", "skip": "#8b949e", "na": "#8b949e", "unver": "#d29922", # amber — exercised? no: should have run and wasn't verified } # 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)}' ) # Amber for UNVERIFIED rung rows in the table (a rung that should have run and wasn't checked). GAP_COLOR = "#d29922" def level_badge_svg(level: int) -> str: """Per-recipe/-run LEVEL badge: 'cc-ci | level N' coloured by level — NUMBER + COLOUR ONLY (operator-specified, phase lvl5). 'Why isn't it higher' lives in the card's per-rung table, never on the badge.""" return render_badge_svg("cc-ci", f"level {int(level)}", 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' # Friendly rung labels for the skip/unverified rows (the five essential rungs). RUNG_LABEL = { "install": "install", "upgrade": "upgrade", "backup_restore": "backup/restore", "functional": "functional", "lint": "lint", } SKIP_GREEN = ( "#57ab5a" # muted green — an intentional skip reads like a pass (but labelled, never inflating) ) def _skip_rows(skips: dict) -> str: """Render the non-run rungs as stage-like rows (phase lvl5 semantics). An INTENTIONAL skip (declared/structural — the rung does not apply, the climb continues past it) is muted green with its reason on the line below; an UNVERIFIED rung (should have run, wasn't checked — the level cannot rise above it) is amber 'unverified'.""" rows = [] for rung, reason in (skips.get("intentional") or {}).items(): rows.append( f'' f"{html.escape(RUNG_LABEL.get(rung, rung))}" f'intentional skip' ) rows.append( f'{html.escape(reason)}' ) for rung in skips.get("unintentional") or []: rows.append( f'' f"{html.escape(RUNG_LABEL.get(rung, rung))}" f'unverified' ) rows.append( 'rung did not run / could not be ' "checked — the level cannot rise above an unverified rung" ) return "\n".join(rows) 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, the per-stage/per-test ✔/✘ table (+ skip/unverified rung rows — the SOLE carrier of "why isn't the level higher"), the invariant flags, and the app screenshot. No computation here. Tolerates old (schema-1) artifacts: the ladder height is read off the rungs the artifact actually has.""" 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)) # Old (pre-lvl5) artifacts have a 4-rung ladder — render their "of N" honestly. ladder_top = 5 if "lint" in (data.get("rungs") or {}) else 4 sk = data.get("skips", {}) 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", [])) + "\n" + _skip_rows(sk) return f"""
{FLOWER_SVG}

{recipe}

{version}
{level}level
level {level} of {ladder_top}
{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