diff --git a/runner/harness/card.py b/runner/harness/card.py new file mode 100644 index 0000000..334c2b6 --- /dev/null +++ b/runner/harness/card.py @@ -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'' + 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"""
+
🌻 +

{recipe}

{version}
+
{level}level
+
{("⚑ " + cap) if cap else "🏆 full clean climb (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 diff --git a/tests/unit/test_card.py b/tests/unit/test_card.py new file mode 100644 index 0000000..cdafc02 --- /dev/null +++ b/tests/unit/test_card.py @@ -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("") + 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"] = "" + html = C.render_card_html(d) + assert "