Test dashboard: full menu system, all-service tests, filtering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s

- Run tests for all 10 services via per-service pytest subprocesses
- Group results by service with section headers
- Clickable summary cards filter by outcome (passed/failed/errors/skipped)
- Service filter nav using ~nav-link buttons in menu bar
- Full menu integration: ~header-row + ~header-child + ~menu-row
- Show logo image via cart-mini rendering
- Mount full service directories in docker-compose for test access
- Add 24 unit test files across 9 services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 22:54:25 +00:00
parent 81e51ae7bc
commit 3809affcab
41 changed files with 2484 additions and 110 deletions

View File

@@ -17,7 +17,20 @@
:disabled (if running "true" nil)
(if running "Running..." "Run Tests"))))
(defcomp ~test-summary (&key status passed failed errors skipped total duration last-run running csrf)
(defcomp ~test-filter-card (&key href label count colour-border colour-bg colour-text active)
(a :href href
:hx-get href
:hx-target "#main-panel"
:hx-select "#main-panel"
:hx-swap "outerHTML"
:hx-push-url "true"
:class (str "block rounded border p-3 text-center transition-colors no-underline hover:opacity-80 "
colour-border " " colour-bg " "
(if active "ring-2 ring-offset-1 ring-stone-500 " ""))
(div :class (str "text-3xl font-bold " colour-text) count)
(div :class (str "text-sm " colour-text) label)))
(defcomp ~test-summary (&key status passed failed errors skipped total duration last-run running csrf active-filter)
(div :class "space-y-4"
(div :class "flex items-center justify-between flex-wrap gap-3"
(div :class "flex items-center gap-3"
@@ -26,26 +39,38 @@
(~test-run-button :running running :csrf csrf))
(when status
(div :class "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"
(div :class "rounded border border-stone-200 bg-white p-3 text-center"
(div :class "text-3xl font-bold text-stone-800" total)
(div :class "text-sm text-stone-500" "Total"))
(div :class "rounded border border-emerald-200 bg-emerald-50 p-3 text-center"
(div :class "text-3xl font-bold text-emerald-700" passed)
(div :class "text-sm text-emerald-600" "Passed"))
(div :class "rounded border border-rose-200 bg-rose-50 p-3 text-center"
(div :class "text-3xl font-bold text-rose-700" failed)
(div :class "text-sm text-rose-600" "Failed"))
(div :class "rounded border border-orange-200 bg-orange-50 p-3 text-center"
(div :class "text-3xl font-bold text-orange-700" errors)
(div :class "text-sm text-orange-600" "Errors"))
(div :class "rounded border border-sky-200 bg-sky-50 p-3 text-center"
(div :class "text-3xl font-bold text-sky-700" skipped)
(div :class "text-sm text-sky-600" "Skipped"))
(div :class "rounded border border-stone-200 bg-white p-3 text-center"
(div :class "text-3xl font-bold text-stone-800" (str duration "s"))
(div :class "text-sm text-stone-500" "Duration")))
(~test-filter-card :href "/" :label "Total" :count total
:colour-border "border-stone-200" :colour-bg "bg-white"
:colour-text "text-stone-800"
:active (if (= active-filter nil) "true" nil))
(~test-filter-card :href "/?filter=passed" :label "Passed" :count passed
:colour-border "border-emerald-200" :colour-bg "bg-emerald-50"
:colour-text "text-emerald-700"
:active (if (= active-filter "passed") "true" nil))
(~test-filter-card :href "/?filter=failed" :label "Failed" :count failed
:colour-border "border-rose-200" :colour-bg "bg-rose-50"
:colour-text "text-rose-700"
:active (if (= active-filter "failed") "true" nil))
(~test-filter-card :href "/?filter=errors" :label "Errors" :count errors
:colour-border "border-orange-200" :colour-bg "bg-orange-50"
:colour-text "text-orange-700"
:active (if (= active-filter "errors") "true" nil))
(~test-filter-card :href "/?filter=skipped" :label "Skipped" :count skipped
:colour-border "border-sky-200" :colour-bg "bg-sky-50"
:colour-text "text-sky-700"
:active (if (= active-filter "skipped") "true" nil))
(~test-filter-card :href "/" :label "Duration" :count (str duration "s")
:colour-border "border-stone-200" :colour-bg "bg-white"
:colour-text "text-stone-800" :active nil))
(div :class "text-sm text-stone-400" (str "Last run: " last-run)))))
(defcomp ~test-service-header (&key service total passed failed)
(tr :class "border-b-2 border-stone-300 bg-stone-100"
(td :class "px-3 py-2 text-sm font-bold text-stone-700" :colspan "4"
(span service)
(span :class "ml-2 text-xs font-normal text-stone-500"
(str total " tests, " passed " passed, " failed " failed")))))
(defcomp ~test-row (&key nodeid outcome duration longrepr)
(tr :class (str "border-b border-stone-100 "
(if (= outcome "passed") "bg-white"
@@ -85,4 +110,4 @@
(div :class "flex items-center justify-center py-12 text-stone-400"
(div :class "text-center"
(div :class "text-4xl mb-2" "?")
(div :class "text-sm" "No test results yet. Click \"Run Tests\" to start."))))
(div :class "text-sm" "No test results yet. Click Run Tests to start."))))

View File

@@ -18,6 +18,65 @@ def _format_time(ts: float | None) -> str:
return datetime.fromtimestamp(ts).strftime("%-d %b %Y, %H:%M:%S")
# ---------------------------------------------------------------------------
# Menu / header
# ---------------------------------------------------------------------------
_FILTER_MAP = {
"passed": "passed",
"failed": "failed",
"errors": "error",
"skipped": "skipped",
}
def _test_header_html(ctx: dict, active_service: str | None = None) -> str:
"""Build the Tests menu-row (level 1) with service nav links."""
nav = _service_nav_html(ctx, active_service)
return render(
"menu-row",
id="test-row", level=1, colour="sky",
link_href="/", link_label="Tests", icon="fa fa-flask",
nav_html=nav,
child_id="test-header-child",
)
def _service_nav_html(ctx: dict, active_service: str | None = None) -> str:
"""Render service filter nav links using ~nav-link component."""
from runner import _SERVICE_ORDER
parts = []
# "All" link
parts.append(render(
"nav-link",
href="/",
label="all",
is_selected="true" if not active_service else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
))
for svc in _SERVICE_ORDER:
parts.append(render(
"nav-link",
href=f"/?service={svc}",
label=svc,
is_selected="true" if active_service == svc else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
))
return "".join(parts)
def _header_stack_html(ctx: dict, active_service: str | None = None) -> str:
"""Full header stack: root row + tests child row."""
hdr = root_header_html(ctx)
inner = _test_header_html(ctx, active_service)
hdr += render("header-child", inner_html=inner)
return hdr
# ---------------------------------------------------------------------------
# Test rows / grouping
# ---------------------------------------------------------------------------
def _test_rows_html(tests: list[dict]) -> str:
"""Render all test result rows."""
parts = []
@@ -32,7 +91,43 @@ def _test_rows_html(tests: list[dict]) -> str:
return "".join(parts)
def _results_partial_html(result: dict | None, running: bool, csrf: str) -> str:
def _grouped_rows_html(tests: list[dict]) -> str:
"""Render test rows grouped by service with section headers."""
from runner import group_tests_by_service
sections = group_tests_by_service(tests)
parts = []
for sec in sections:
parts.append(render(
"test-service-header",
service=sec["service"],
total=str(sec["total"]),
passed=str(sec["passed"]),
failed=str(sec["failed"]),
))
parts.append(_test_rows_html(sec["tests"]))
return "".join(parts)
def _filter_tests(tests: list[dict], active_filter: str | None,
active_service: str | None) -> list[dict]:
"""Filter tests by outcome and/or service."""
from runner import _service_from_nodeid
filtered = tests
if active_filter and active_filter in _FILTER_MAP:
outcome = _FILTER_MAP[active_filter]
filtered = [t for t in filtered if t["outcome"] == outcome]
if active_service:
filtered = [t for t in filtered if _service_from_nodeid(t["nodeid"]) == active_service]
return filtered
# ---------------------------------------------------------------------------
# Results partial
# ---------------------------------------------------------------------------
def _results_partial_html(result: dict | None, running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Render the results section (summary + table or running indicator)."""
if running and not result:
summary = render(
@@ -40,6 +135,7 @@ def _results_partial_html(result: dict | None, running: bool, csrf: str) -> str:
status="running", passed="0", failed="0", errors="0",
skipped="0", total="0", duration="...",
last_run="in progress", running=True, csrf=csrf,
active_filter=active_filter,
)
return summary + render("test-running-indicator")
@@ -49,6 +145,7 @@ def _results_partial_html(result: dict | None, running: bool, csrf: str) -> str:
status=None, passed="0", failed="0", errors="0",
skipped="0", total="0", duration="0",
last_run="never", running=running, csrf=csrf,
active_filter=active_filter,
)
return summary + render("test-no-results")
@@ -65,17 +162,19 @@ def _results_partial_html(result: dict | None, running: bool, csrf: str) -> str:
last_run=_format_time(result["finished_at"]) if not running else "in progress",
running=running,
csrf=csrf,
active_filter=active_filter,
)
if running:
return summary + render("test-running-indicator")
tests = result.get("tests", [])
tests = _filter_tests(tests, active_filter, active_service)
if not tests:
return summary + render("test-no-results")
has_failures = result["failed"] > 0 or result["errors"] > 0
rows = _test_rows_html(tests)
rows = _grouped_rows_html(tests)
table = render("test-results-table", rows_html=rows,
has_failures=str(has_failures).lower())
return summary + table
@@ -90,16 +189,20 @@ def _wrap_results_div(inner_html: str, running: bool) -> str:
async def render_dashboard_page(ctx: dict, result: dict | None,
running: bool, csrf: str) -> str:
running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Full page: test dashboard."""
hdr = root_header_html(ctx)
inner = _results_partial_html(result, running, csrf)
hdr = _header_stack_html(ctx, active_service)
inner = _results_partial_html(result, running, csrf, active_filter, active_service)
content = _wrap_results_div(inner, running)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_results_partial(result: dict | None, running: bool,
csrf: str) -> str:
csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""HTMX partial: just the results section (wrapped in polling div)."""
inner = _results_partial_html(result, running, csrf)
inner = _results_partial_html(result, running, csrf, active_filter, active_service)
return _wrap_results_div(inner, running)