Push builds have been RED on the lint step since ~build 209 from accumulated formatting drift. This is the mechanical cleanup: ruff format + ruff --fix (UP038 isinstance unions, SIM105 contextlib.suppress, UP031 f-strings, SIM115 tempfile context manager), shfmt -i 2 -ci, nixpkgs-fmt/statix/deadnix (merged attrsets, dropped unused lib args), yamllint, and shell quoting fixes in tests/lasuite-docs/setup_custom_tests.sh. No behaviour changes intended; lint: PASS, unit tests: 138 passed.
275 lines
13 KiB
Python
275 lines
13 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.
|
||
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>'
|
||
|
||
|
||
# Friendly rung labels for the skip rows (the four essential rungs).
|
||
RUNG_LABEL = {
|
||
"install": "install",
|
||
"upgrade": "upgrade",
|
||
"backup_restore": "backup/restore",
|
||
"functional": "functional",
|
||
}
|
||
SKIP_GREEN = (
|
||
"#57ab5a" # muted green — an intentional skip reads like a pass (but labelled, never inflating)
|
||
)
|
||
|
||
|
||
def _skip_rows(skips: dict) -> str:
|
||
"""Render SKIPPED rungs as stage-like rows. An intentional (declared) skip looks like a pass row
|
||
but its status says 'INTENTIONAL SKIP' (muted green) with the declared reason on the line below;
|
||
an unintentional skip is amber 'UNINTENTIONAL SKIP' with a prompt to add a test or declare it."""
|
||
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}">unintentional skip</td></tr>'
|
||
)
|
||
rows.append(
|
||
'<tr class="skipreason"><td></td><td colspan="2">not declared in EXPECTED_NA — add the '
|
||
"missing test/label, or declare the skip with a reason</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 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 "")
|
||
cap = html.escape(cap_reason)
|
||
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}}
|
||
.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}}
|
||
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}}
|
||
.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 (4)"}</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
|