diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index 705bdc4..831f254 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -326,16 +326,31 @@ def render_history(recipe, rows): return _page(f"{recipe} — cc-ci history", inner) -def render_badge(recipe, status): - color = _COLORS.get(status, "#8b949e") - label, msg = "cc-ci", status - lw, mw = 44, max(40, 7 * len(msg) + 10) +def _badge_svg(label, msg, color): + """Two-box shields-style SVG (grey label | coloured message). Stdlib-only, deterministic sizing.""" + lw = max(44, 7 * len(label) + 12) + mw = max(40, 7 * len(msg) + 12) w = lw + mw - return f""" - - -{html.escape(label)} -{html.escape(msg)}""" + return ( + f'' + f'' + f'' + f'' + f'{html.escape(label)}' + f'{html.escape(msg)}' + ) + + +def render_badge(recipe, status): + """Status fallback badge (used when a recipe has no results.json level yet).""" + return _badge_svg("cc-ci", status, _COLORS.get(status, "#8b949e")) + + +def render_level_badge(recipe, level): + """Per-recipe latest-LEVEL badge (R6): 'cc-ci: | level N', coloured by level — + embeddable in a recipe README (`/badge/.svg`) and shown on the dashboard.""" + return _badge_svg(f"cc-ci: {recipe}", f"level {int(level)}", level_color(level)) def serve_run_file(run_id, fname): @@ -363,8 +378,11 @@ class Handler(BaseHTTPRequestHandler): if path.startswith("/badge/") and path.endswith(".svg"): recipe = path[len("/badge/") : -len(".svg")] row = next((r for r in recipes_cached() if r["recipe"] == recipe), None) - status = row["status"] if row else "unknown" - return 200, render_badge(recipe, status), "image/svg+xml" + # R6: per-recipe LATEST-LEVEL badge (from results.json). Fall back to a status badge when + # the recipe has no level yet (never ran / failed before emitting results.json). + if row and row.get("level") is not None: + return 200, render_level_badge(recipe, row["level"]), "image/svg+xml" + return 200, render_badge(recipe, row["status"] if row else "unknown"), "image/svg+xml" if path.startswith("/runs/"): # /runs// — stable URL for a run's results.json / summary.png / screenshot / # badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file. diff --git a/docs/results-ux.md b/docs/results-ux.md index 6feaf6a..270daba 100644 --- a/docs/results-ux.md +++ b/docs/results-ux.md @@ -97,20 +97,64 @@ run's exit code (cosmetics never block the pipeline, R7). ## 3. Summary card + app screenshot (R3/R4) - +**App screenshot** (`runner/harness/screenshot.py`). After the app deploys and passes health/readiness +and **before any tier mutates state or teardown runs**, the harness captures a real Playwright +screenshot of the live app and writes `screenshot.png` to the run dir. It is **secret-safe by +default**: it shoots the **landing page** (login/setup forms show input *fields*, not secret values), +viewport-only (`full_page=False`, no scroll into a secrets panel), and the harness never auto-fills an +install wizard. A recipe whose landing page is uninformative may opt into a post-login view via an +optional `SCREENSHOT` hook in `tests//recipe_meta.py` — **that hook owns the no-credential-page +guarantee**. Capture is **best-effort**: any error returns `None`, writes no file, and never blocks the +run (R7); `results.json.screenshot` is set only when a file was actually produced. + +**Summary card** (`runner/harness/card.py`). After `results.json` is written, the harness builds an +HTML results card — recipe + version, the level badge, a per-stage/per-test ✔/✘ table with timings, +the embedded app screenshot (base64 data-URI so the PNG is self-contained), and the invariant flags — +and screenshots that HTML to `summary.png` via the harness Playwright browser. The card **reports +`results.json` verbatim — it computes nothing**, so it can never show a run greener than its tests +(cardinal guardrail). Rendering is best-effort (returns `None` on failure → no card, run unaffected). + +**Stable URLs.** The dashboard serves the run artifact dir read-only at: + +``` +https://ci.commoninternet.net/runs//summary.png # the card +https://ci.commoninternet.net/runs//screenshot.png # the app screenshot +https://ci.commoninternet.net/runs//badge.svg # the per-run level badge +https://ci.commoninternet.net/runs//results.json # the raw data +``` + +`` is the Drone build number. The route is whitelist + traversal-guarded (filenames from a +fixed set; `run_id` charset-restricted; realpath must stay inside the runs dir) and read-only. ## 4. PR comment (R2) - +On a `!testme` run the comment-bridge (`bridge/bridge.py`) maintains **one comment per PR, updated in +place** (it carries a hidden `` marker so re-`!testme` finds and refreshes the +same comment rather than stacking new ones): + +1. **On start** — a 🌻 + ⏳ placeholder: `testing @ ` + a live-logs link, "level pending". +2. **On completion** — the same comment is edited to the YunoHost-shaped result: 🌻 + a **level badge** + image + the **summary card** image, **both linking to the run**, plus full-logs/dashboard links. + +If the rendered card isn't served (render failed, build didn't finish), the comment **falls back to a +compact text verdict** with the run link (the bridge checks artifact availability with a cheap HEAD +request) — R7: a cosmetics failure degrades to text, never a broken image, never affecting the verdict. ## 5. Badges (R6) + how to embed one - +Two SVG badge endpoints, both shields-style and coloured by level (`level_color`): + +- **Per-recipe latest-level** (for a recipe README): `https://ci.commoninternet.net/badge/.svg` + → `cc-ci: | level N` for that recipe's most recent run (falls back to a status badge if the + recipe has no level yet). Re-rendered live from the latest `results.json`. +- **Per-run** (pinned to one run, e.g. in the PR comment): + `https://ci.commoninternet.net/runs//badge.svg`. + +Embed the per-recipe badge in a recipe README (Markdown), linking to the cc-ci dashboard: + +```markdown +[![cc-ci level](https://ci.commoninternet.net/badge/.svg)](https://ci.commoninternet.net/recipe/) +``` + +The link target `…/recipe/` is that recipe's run-history page (level/version/status per run, +with a link to each run's summary card). diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 1c7ff49..8d50d73 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -108,6 +108,22 @@ def test_build_row_degrades_without_results(monkeypatch): assert "level —" in dashboard.render_overview([r]) +def test_level_badge_shows_level_coloured(monkeypatch): + svg = dashboard.render_level_badge("custom-html", 4) + assert "custom-html" in svg and "level 4" in svg + assert dashboard.level_color(4) in svg # coloured by level + assert svg.startswith("