All checks were successful
continuous-integration/drone/push Build is passing
level.py: RUNGS += lint; statuses {pass,fail,skip,unver}; compute_level = max passed
rung with all below pass-or-skip (fail/unver block); cap_reason/capped DELETED.
harness/lint.py: lint executor — pristine scratch clone of the per-run tree at the
exact tested ref (mirror-origin + untracked-overlay pollution solved by context, no
rule filtered), PTY via script -qec, 60s hard budget, lint.txt artifact, table-parse
classifier (rc only signals FATA), unver on any non-run (never silent pass).
results.py: derive_rungs classifies every N/A source (structural/declared → skip,
else unver), lint rung + synthetic lint stage + lint block in results.json, schema 2,
cap fields removed. run_recipe_ci.py: lint call before tiers (double-wrapped,
verdict-neutral), badge = level only. card/dashboard: 0-5 ramp, cap line → 'level N
of {4|5}', unverified rows, badge number+colour only, lint.txt servable, old schema-1
artifacts render untouched. Unit suite rewritten: 245 passed on cc-ci venv.
251 lines
12 KiB
Python
251 lines
12 KiB
Python
"""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'<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>'
|
||
)
|
||
|
||
|
||
# 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'<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>'
|
||
|
||
|
||
# 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'<tr class="stage"><td colspan="2"><span class="mark" style="color:{SKIP_GREEN}">⊘</span>'
|
||
f"<b>{html.escape(RUNG_LABEL.get(rung, rung))}</b></td>"
|
||
f'<td class="st" style="color:{SKIP_GREEN}">intentional skip</td></tr>'
|
||
)
|
||
rows.append(
|
||
f'<tr class="skipreason"><td></td><td colspan="2">{html.escape(reason)}</td></tr>'
|
||
)
|
||
for rung in skips.get("unintentional") or []:
|
||
rows.append(
|
||
f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{GAP_COLOR}">⊘</span>'
|
||
f"<b>{html.escape(RUNG_LABEL.get(rung, rung))}</b></td>"
|
||
f'<td class="st" style="color:{GAP_COLOR}">unverified</td></tr>'
|
||
)
|
||
rows.append(
|
||
'<tr class="skipreason"><td></td><td colspan="2">rung did not run / could not be '
|
||
"checked — the level cannot rise above an unverified rung</td></tr>"
|
||
)
|
||
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'<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", [])) + "\n" + _skip_rows(sk)
|
||
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}}
|
||
.ladder{{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}}
|
||
tr.skipreason td{{color:#8b949e;font-size:.78rem;font-style:italic;padding-top:0;padding-bottom:.45rem;border-bottom:1px solid #21262d}}
|
||
.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}}
|
||
.ladder 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="ladder"><b>level {level} of {ladder_top}</b></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
|