Files
cc-ci/runner/harness/card.py
autonomic-bot b3ab68a9dd
Some checks failed
continuous-integration/drone/push Build is failing
refactor: simplify to a list of intentionally-skipped rungs
Per operator: drop the gap-sensitivity / cap-intent-clause / stale-detection
machinery. Model is now dead simple — recipe_meta.EXPECTED_NA = {rung: reason}
lists the rungs a recipe intentionally skips; ANY rung skipped (N/A) and not in
that list is unintentional.

results.json: replace the 'na' block + level_cap_intent with
  skips: { intentional: {rung: reason}, unintentional: [rung] }
plus level_cap_rung (which rung capped). Badge/card derive intentional-vs-
unintentional from whether the capping rung is in the intentional list. Skips
still cap the level (never inflate). custom-html-tiny lists all three rungs it
intentionally skips (backup_restore, integration, recipe_local).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:36:53 +00:00

242 lines
12 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>'
)
# Third-segment colours for the level badge: amber = an UNINTENTIONAL skip (a rung skipped but not
# in the recipe's intentional list — likely missing coverage) capped the climb; muted = an
# INTENTIONAL skip (declared in recipe_meta.EXPECTED_NA — nothing to fix). Font-safe text labels
# (no emoji) so the SVG renders anywhere.
GAP_COLOR = "#d29922"
EXPECT_COLOR = "#6e7681"
def level_badge_svg(level: int, cap_reason: str = "", cap_skip: str = "") -> str:
"""Per-recipe/-run LEVEL badge: 'cc-ci | level N' coloured by level (R6), with a THIRD segment
that differentiates *why* the climb stopped when a SKIP capped it (`cap_skip`):
- "unintentional" (a rung skipped but not in the recipe's intentional list): amber 'gap?'.
- "intentional" (a skip declared in recipe_meta.EXPECTED_NA): muted 'expected'.
- "" (clean cap / full climb / a real failure): no third segment (the level + card carry it).
The badge never inflates — it only annotates the cap the level already reflects."""
label, msg = "cc-ci", f"level {int(level)}"
lw, mw = _text_width(label), _text_width(msg)
third: tuple[str, str] | None = None
if cap_skip == "unintentional":
third = ("gap?", GAP_COLOR)
elif cap_skip == "intentional":
third = ("expected", EXPECT_COLOR)
if third is None:
return render_badge_svg(label, msg, level_color(level))
txt, tcolor = third
tw = _text_width(txt)
w = lw + mw + tw
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" '
f'aria-label="{html.escape(label)}: {html.escape(msg)} ({html.escape(txt)})">'
f'<rect width="{lw}" height="20" fill="#555"/>'
f'<rect x="{lw}" width="{mw}" height="20" fill="{level_color(level)}"/>'
f'<rect x="{lw + mw}" width="{tw}" height="20" fill="{tcolor}"/>'
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(msg)}</text>'
f'<text x="{lw + mw + 6}" y="14">{html.escape(txt)}</text></g></svg>'
)
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_reason = str(data.get("level_cap_reason") or "")
# Annotate the cap line by whether the capping rung was an intentional skip (declared, with its
# reason) or an unintentional one (skipped but not declared).
capped = data.get("level_cap_rung")
sk = data.get("skips", {}) or {}
if capped and capped in (sk.get("intentional") or {}):
cap_reason += f" · intentional: {sk['intentional'][capped]}"
elif capped and capped in (sk.get("unintentional") or []):
cap_reason += " · unintentional skip (no EXPECTED_NA — add a test or declare it)"
cap = html.escape(cap_reason)
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