Add test runner dashboard service (test.rose-ash.com)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Public Quart microservice that runs pytest against shared/tests/ and
shared/sexp/tests/, serving an HTMX-powered sexp-rendered dashboard
with pass/fail/running status, auto-refresh polling, and re-run button.
No database — results stored in memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 20:08:10 +00:00
parent 00efbc2a35
commit 1559c5c931
17 changed files with 588 additions and 3 deletions

0
test/sexp/__init__.py Normal file
View File

88
test/sexp/dashboard.sexpr Normal file
View File

@@ -0,0 +1,88 @@
;; Test dashboard components
(defcomp ~test-status-badge (&key status)
(span :class (str "inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium "
(if (= status "running") "border-amber-300 bg-amber-50 text-amber-700 animate-pulse"
(if (= status "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
(if (= status "failed") "border-rose-300 bg-rose-50 text-rose-700"
"border-stone-300 bg-stone-50 text-stone-700"))))
status))
(defcomp ~test-run-button (&key running csrf)
(form :method "POST" :action "/run" :class "inline"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit"
:class (str "rounded bg-stone-800 px-4 py-2 text-sm font-medium text-white hover:bg-stone-700 "
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors")
: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)
(div :class "space-y-4"
(div :class "flex items-center justify-between flex-wrap gap-3"
(div :class "flex items-center gap-3"
(h2 :class "text-lg font-semibold text-stone-800" "Test Results")
(when status (~test-status-badge :status status)))
(~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-2xl font-bold text-stone-800" total)
(div :class "text-xs text-stone-500" "Total"))
(div :class "rounded border border-emerald-200 bg-emerald-50 p-3 text-center"
(div :class "text-2xl font-bold text-emerald-700" passed)
(div :class "text-xs text-emerald-600" "Passed"))
(div :class "rounded border border-rose-200 bg-rose-50 p-3 text-center"
(div :class "text-2xl font-bold text-rose-700" failed)
(div :class "text-xs text-rose-600" "Failed"))
(div :class "rounded border border-orange-200 bg-orange-50 p-3 text-center"
(div :class "text-2xl font-bold text-orange-700" errors)
(div :class "text-xs text-orange-600" "Errors"))
(div :class "rounded border border-sky-200 bg-sky-50 p-3 text-center"
(div :class "text-2xl font-bold text-sky-700" skipped)
(div :class "text-xs text-sky-600" "Skipped"))
(div :class "rounded border border-stone-200 bg-white p-3 text-center"
(div :class "text-2xl font-bold text-stone-800" (str duration "s"))
(div :class "text-xs text-stone-500" "Duration")))
(div :class "text-xs text-stone-400" (str "Last run: " last-run)))))
(defcomp ~test-row (&key nodeid outcome duration longrepr)
(tr :class (str "border-b border-stone-100 "
(if (= outcome "passed") "bg-white"
(if (= outcome "failed") "bg-rose-50"
(if (= outcome "skipped") "bg-sky-50"
"bg-orange-50"))))
(td :class "px-3 py-2 text-xs font-mono text-stone-700 max-w-0 truncate" :title nodeid nodeid)
(td :class "px-3 py-2 text-center"
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium "
(if (= outcome "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
(if (= outcome "failed") "border-rose-300 bg-rose-50 text-rose-700"
(if (= outcome "skipped") "border-sky-300 bg-sky-50 text-sky-700"
"border-orange-300 bg-orange-50 text-orange-700"))))
outcome))
(td :class "px-3 py-2 text-right text-xs text-stone-500 tabular-nums" (str duration "s"))
(td :class "px-3 py-2 text-xs text-rose-600 font-mono max-w-xs truncate" :title longrepr
(when longrepr longrepr))))
(defcomp ~test-results-table (&key rows-html has-failures)
(div :class "overflow-x-auto rounded border border-stone-200 bg-white"
(table :class "w-full text-left"
(thead
(tr :class "border-b border-stone-200 bg-stone-50"
(th :class "px-3 py-2 text-xs font-medium text-stone-600" "Test")
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-center w-24" "Status")
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-right w-20" "Time")
(th :class "px-3 py-2 text-xs font-medium text-stone-600 w-48" "Error")))
(tbody rows-html))))
(defcomp ~test-running-indicator ()
(div :class "flex items-center justify-center py-12 text-stone-500"
(div :class "flex items-center gap-3"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full")
(span :class "text-sm" "Running tests..."))))
(defcomp ~test-no-results ()
(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."))))

View File

@@ -0,0 +1,105 @@
"""Test service s-expression page components."""
from __future__ import annotations
import os
from datetime import datetime
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import root_header_html, full_page
# Load test-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
def _format_time(ts: float | None) -> str:
"""Format a unix timestamp for display."""
if not ts:
return "never"
return datetime.fromtimestamp(ts).strftime("%-d %b %Y, %H:%M:%S")
def _test_rows_html(tests: list[dict]) -> str:
"""Render all test result rows."""
parts = []
for t in tests:
parts.append(render(
"test-row",
nodeid=t["nodeid"],
outcome=t["outcome"],
duration=str(t["duration"]),
longrepr=t.get("longrepr", ""),
))
return "".join(parts)
def _results_partial_html(result: dict | None, running: bool, csrf: str) -> str:
"""Render the results section (summary + table or running indicator)."""
if running and not result:
summary = render(
"test-summary",
status="running", passed="0", failed="0", errors="0",
skipped="0", total="0", duration="...",
last_run="in progress", running=True, csrf=csrf,
)
return summary + render("test-running-indicator")
if not result:
summary = render(
"test-summary",
status=None, passed="0", failed="0", errors="0",
skipped="0", total="0", duration="0",
last_run="never", running=running, csrf=csrf,
)
return summary + render("test-no-results")
status = "running" if running else result["status"]
summary = render(
"test-summary",
status=status,
passed=str(result["passed"]),
failed=str(result["failed"]),
errors=str(result["errors"]),
skipped=str(result.get("skipped", 0)),
total=str(result["total"]),
duration=str(result["duration"]),
last_run=_format_time(result["finished_at"]) if not running else "in progress",
running=running,
csrf=csrf,
)
if running:
return summary + render("test-running-indicator")
tests = result.get("tests", [])
if not tests:
return summary + render("test-no-results")
has_failures = result["failed"] > 0 or result["errors"] > 0
rows = _test_rows_html(tests)
table = render("test-results-table", rows_html=rows,
has_failures=str(has_failures).lower())
return summary + table
def _wrap_results_div(inner_html: str, running: bool) -> str:
"""Wrap results in a div with HTMX polling when running."""
attrs = 'id="test-results" class="space-y-6 p-4"'
if running:
attrs += ' hx-get="/results" hx-trigger="every 2s" hx-swap="outerHTML"'
return f'<div {attrs}>{inner_html}</div>'
async def render_dashboard_page(ctx: dict, result: dict | None,
running: bool, csrf: str) -> str:
"""Full page: test dashboard."""
hdr = root_header_html(ctx)
inner = _results_partial_html(result, running, csrf)
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:
"""HTMX partial: just the results section (wrapped in polling div)."""
inner = _results_partial_html(result, running, csrf)
return _wrap_results_div(inner, running)