Files
cc-ci/tests/unit/test_dashboard.py
autonomic-bot e219a7891d
All checks were successful
continuous-integration/drone/push Build is passing
feat(lvl5): P1 — 5-rung ladder (L5=abra recipe lint) + de-capped level semantics
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.
2026-06-11 07:42:30 +00:00

195 lines
7.1 KiB
Python

"""Phase 3 U4 — dashboard YunoHost-style grid + per-recipe history (pure-render + helpers).
The dashboard reads a Drone admin token at import; point DRONE_TOKEN_FILE at a temp file so the
module imports without the real secret. All tests here are pure (no network): they exercise the
rendering + results.json projection, asserting the grid/history mirror the artifact and never present
a run greener than its data (R5 / cardinal guardrail)."""
from __future__ import annotations
import json
import os
import sys
import tempfile
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok") as _tok:
_tok.write("test-token")
os.environ["DRONE_TOKEN_FILE"] = _tok.name
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "dashboard"))
import dashboard # noqa: E402
def _row(**kw):
base = {
"recipe": "custom-html",
"status": "success",
"number": 4,
"ref": "db9a9502",
"version": "db9a95024e9d",
"level": 4,
"has_screenshot": True,
"flags": {"clean_teardown": True, "no_secret_leak": True},
"finished": 0,
"url": "https://drone.x/cc-ci/4",
}
base.update(kw)
return base
def test_level_color_ramp_and_fallback():
assert dashboard.level_color(0) == "#e5534b"
assert dashboard.level_color(5) == "#3fb950" # full 5-rung climb (phase lvl5)
assert dashboard.level_color(4) == "#a0b93f"
assert dashboard.level_color(99) == "#8b949e"
assert dashboard.level_color(None) == "#8b949e"
def test_overview_grid_mirrors_results():
out = dashboard.render_overview([_row()])
assert "custom-html" in out
assert "level 4" in out # the corner level pill
assert dashboard.level_color(4) in out # coloured by level
assert "db9a95024e9d" in out # version from results.json
assert "/runs/4/screenshot.png" in out # thumbnail
assert "/runs/4/summary.png" in out # links to full card
assert "/recipe/custom-html" in out # history link
assert "✔ teardown" in out and "✔ no-leak" in out
def test_overview_never_greener_than_data():
# A failed run at level 0 must show level 0 + the failure pill — never a green/high level.
out = dashboard.render_overview(
[_row(status="failure", level=0, has_screenshot=False, flags={})]
)
assert "level 0" in out
assert dashboard.level_color(0) in out # red
assert dashboard._COLORS["failure"] in out
assert "level 4" not in out and "level 5" not in out
assert "no screenshot" in out # placeholder, no broken image
def test_level_pill_unknown_when_no_results():
assert "level —" in dashboard._level_pill(None)
assert "#8b949e" in dashboard._level_pill(None)
def test_history_table_lists_runs():
out = dashboard.render_history("custom-html", [_row(number=4), _row(number=3, level=2)])
assert "custom-html — run history" in out
assert "#4" in out and "#3" in out
assert "L4" in out and "L2" in out
assert "← all recipes" in out
assert "/runs/4/summary.png" in out # per-run card link
def test_history_empty():
out = dashboard.render_history("hedgedoc", [])
assert "no runs for this recipe yet" in out
def test_build_row_projects_results(monkeypatch):
monkeypatch.setattr(
dashboard,
"_results_for",
lambda n: {
"version": "1.2.3",
"level": 2,
"screenshot": "screenshot.png",
"flags": {"clean_teardown": True},
},
)
b = {
"number": 7,
"status": "success",
"event": "custom",
"params": {"RECIPE": "n8n", "REF": "abcdef1234567890"},
"finished": 10,
}
r = dashboard._build_row(b)
assert r["recipe"] == "n8n" and r["number"] == 7
assert r["level"] == 2 and r["version"] == "1.2.3"
assert r["has_screenshot"] is True
assert r["url"].endswith("/cc-ci/7")
def test_build_row_old_schema1_artifact_renders(monkeypatch):
# History compatibility (phase lvl5): pre-lvl5 results.json still carries cap fields and a
# 4-rung ladder — it must project + render without KeyError, level shown VERBATIM (no
# retroactive relabeling), and the old cap text simply isn't resurfaced anywhere.
monkeypatch.setattr(
dashboard,
"_results_for",
lambda n: {
"schema": 1,
"version": "0.9.1",
"level": 2,
"level_cap_reason": "L3 backup/restore (data integrity) N/A",
"level_cap_rung": "backup_restore",
"screenshot": "screenshot.png",
"flags": {"clean_teardown": True, "no_secret_leak": True},
},
)
b = {
"number": 11,
"status": "success",
"event": "custom",
"params": {"RECIPE": "legacy", "REF": "abc123"},
"finished": 5,
}
r = dashboard._build_row(b)
out = dashboard.render_overview([r])
assert "level 2" in out and dashboard.level_color(2) in out
assert "N/A" not in out and "capped" not in out # cap language gone from the surface
hist = dashboard.render_history("legacy", [r])
assert "L2" in hist
def test_build_row_degrades_without_results(monkeypatch):
# No results.json (e.g. an old run): grid still renders from Drone fields, level absent.
monkeypatch.setattr(dashboard, "_results_for", lambda n: {})
b = {
"number": 9,
"status": "running",
"event": "custom",
"params": {"RECIPE": "ghost", "REF": "deadbeefcafe1234567890"},
"finished": 0,
}
r = dashboard._build_row(b)
assert r["level"] is None and r["has_screenshot"] is False
assert r["version"] == "deadbeefcafe" # ref[:12] fallback
# render must not crash or claim a level
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"))
with open(os.path.join(d, "5", "results.json"), "w") as f:
json.dump({"level": 3}, f)
orig = dashboard.CCCI_RUNS_DIR
dashboard.CCCI_RUNS_DIR = d
try:
assert dashboard._results_for("5") == {"level": 3}
assert dashboard._results_for("../../etc") == {} # traversal rejected
assert dashboard._results_for("nonexist") == {} # missing → {}
assert dashboard._results_for("") == {}
finally:
dashboard.CCCI_RUNS_DIR = orig