feat(3 U5.1+U5.2): per-recipe latest-level badge endpoint /badge/<recipe>.svg (R6, level-coloured, status fallback) + complete docs/results-ux.md §3-5 (card/screenshot/PR-comment/badge-embedding, R8); +2 badge unit tests
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@ -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"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img">
|
||||
<rect width="{lw}" height="20" fill="#555"/><rect x="{lw}" width="{mw}" height="20" fill="{color}"/>
|
||||
<g fill="#fff" font-family="Verdana,sans-serif" font-size="11">
|
||||
<text x="6" y="14">{html.escape(label)}</text>
|
||||
<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>"""
|
||||
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)}">'
|
||||
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(msg)}</text></g></svg>'
|
||||
)
|
||||
|
||||
|
||||
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: <recipe> | level N', coloured by level —
|
||||
embeddable in a recipe README (`/badge/<recipe>.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/<run_id>/<file> — stable URL for a run's results.json / summary.png / screenshot /
|
||||
# badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file.
|
||||
|
||||
@ -97,20 +97,64 @@ run's exit code (cosmetics never block the pipeline, R7).
|
||||
|
||||
## 3. Summary card + app screenshot (R3/R4)
|
||||
|
||||
<!-- TODO(U2/U1): finalize once wired — the card renderer (harness/card.py) builds an HTML results
|
||||
card (recipe+version, level badge, per-stage/per-test ✔/✘ table, embedded app screenshot) and renders
|
||||
it to PNG via the harness Playwright browser; the screenshot (harness/screenshot.py) is captured from
|
||||
the live app before teardown, secret-safe (landing page by default; recipes needing a post-login view
|
||||
opt into a SCREENSHOT hook that avoids credential pages). Document the stable serving URL
|
||||
(/runs/<run_id>/summary.png) once the dashboard serves the artifact dir. -->
|
||||
**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>/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/<run_id>/summary.png # the card
|
||||
https://ci.commoninternet.net/runs/<run_id>/screenshot.png # the app screenshot
|
||||
https://ci.commoninternet.net/runs/<run_id>/badge.svg # the per-run level badge
|
||||
https://ci.commoninternet.net/runs/<run_id>/results.json # the raw data
|
||||
```
|
||||
|
||||
`<run_id>` 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)
|
||||
|
||||
<!-- TODO(U3): document the YunoHost-shaped comment — 🌻 marker + level/status badge + summary card
|
||||
image, both linking to the run/dashboard; one comment per PR, updated in place; re-`!testme` refreshes
|
||||
it; falls back to a text comment if image rendering fails. -->
|
||||
On a `!testme` run the comment-bridge (`bridge/bridge.py`) maintains **one comment per PR, updated in
|
||||
place** (it carries a hidden `<!-- cc-ci:testme -->` marker so re-`!testme` finds and refreshes the
|
||||
same comment rather than stacking new ones):
|
||||
|
||||
1. **On start** — a 🌻 + ⏳ placeholder: `testing <recipe> @ <sha>` + 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
|
||||
|
||||
<!-- TODO(U2/U5): document the per-recipe level/status SVG badge endpoint and the markdown snippet to
|
||||
embed it in a recipe README. -->
|
||||
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/<recipe>.svg`
|
||||
→ `cc-ci: <recipe> | 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/<run_id>/badge.svg`.
|
||||
|
||||
Embed the per-recipe badge in a recipe README (Markdown), linking to the cc-ci dashboard:
|
||||
|
||||
```markdown
|
||||
[](https://ci.commoninternet.net/recipe/<recipe>)
|
||||
```
|
||||
|
||||
The link target `…/recipe/<recipe>` is that recipe's run-history page (level/version/status per run,
|
||||
with a link to each run's summary card).
|
||||
|
||||
@ -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("<svg") and "image" not in svg # plain SVG
|
||||
# A higher displayed level than earned would be inflation — badge shows exactly the given level.
|
||||
assert "level 5" not in svg and "level 6" not in svg
|
||||
|
||||
|
||||
def test_status_badge_fallback_when_no_level():
|
||||
# Recipe with no results.json level → status badge, not a fabricated level.
|
||||
svg = dashboard.render_badge("ghost", "failure")
|
||||
assert "failure" in svg and "level" not in svg
|
||||
assert dashboard._COLORS["failure"] in svg
|
||||
|
||||
|
||||
def test_results_for_traversal_guarded():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.makedirs(os.path.join(d, "5"))
|
||||
|
||||
Reference in New Issue
Block a user