Test dashboard: full menu system, all-service tests, filtering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
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:
11
test/app.py
11
test/app.py
@@ -3,6 +3,7 @@ import path_setup # noqa: F401
|
||||
import sexp.sexp_components as sexp_components # noqa: F401
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
from shared.sexp.jinja_bridge import render
|
||||
|
||||
from bp import register_dashboard
|
||||
from services import register_domain_services
|
||||
@@ -14,7 +15,15 @@ async def test_context() -> dict:
|
||||
|
||||
ctx = await base_context()
|
||||
ctx["menu_items"] = []
|
||||
ctx["cart_mini_html"] = ""
|
||||
# Render cart-mini with cart_count=0 to show the logo image
|
||||
blog_url = ctx.get("blog_url", "")
|
||||
if callable(blog_url):
|
||||
blog_url_str = blog_url("")
|
||||
else:
|
||||
blog_url_str = str(blog_url or "")
|
||||
ctx["cart_mini_html"] = render(
|
||||
"cart-mini", cart_count=0, blog_url=blog_url_str, cart_url="",
|
||||
)
|
||||
ctx["auth_menu_html"] = ""
|
||||
ctx["nav_tree_html"] = ""
|
||||
return ctx
|
||||
|
||||
@@ -21,8 +21,14 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
result = runner.get_results()
|
||||
running = runner.is_running()
|
||||
csrf = generate_csrf_token()
|
||||
active_filter = request.args.get("filter")
|
||||
active_service = request.args.get("service")
|
||||
|
||||
html = await render_dashboard_page(ctx, result, running, csrf)
|
||||
html = await render_dashboard_page(
|
||||
ctx, result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
active_service=active_service,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/run")
|
||||
@@ -52,8 +58,14 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
result = runner.get_results()
|
||||
running = runner.is_running()
|
||||
csrf = generate_csrf_token()
|
||||
active_filter = request.args.get("filter")
|
||||
active_service = request.args.get("service")
|
||||
|
||||
html = await render_results_partial(result, running, csrf)
|
||||
html = await render_results_partial(
|
||||
result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
active_service=active_service,
|
||||
)
|
||||
|
||||
resp = Response(html, status=200, content_type="text/html")
|
||||
# If still running, tell HTMX to keep polling
|
||||
|
||||
209
test/runner.py
209
test/runner.py
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -13,13 +15,81 @@ log = logging.getLogger(__name__)
|
||||
_last_result: dict | None = None
|
||||
_running: bool = False
|
||||
|
||||
# Paths to test directories (relative to /app in Docker)
|
||||
_TEST_DIRS = [
|
||||
"shared/tests/",
|
||||
"shared/sexp/tests/",
|
||||
# Each service group runs in its own pytest subprocess with its own PYTHONPATH
|
||||
_SERVICE_GROUPS: list[dict] = [
|
||||
{"name": "shared", "dirs": ["shared/tests/", "shared/sexp/tests/"],
|
||||
"pythonpath": None},
|
||||
{"name": "blog", "dirs": ["blog/tests/"], "pythonpath": "/app/blog"},
|
||||
{"name": "market", "dirs": ["market/tests/"], "pythonpath": "/app/market"},
|
||||
{"name": "cart", "dirs": ["cart/tests/"], "pythonpath": "/app/cart"},
|
||||
{"name": "events", "dirs": ["events/tests/"], "pythonpath": "/app/events"},
|
||||
{"name": "account", "dirs": ["account/tests/"], "pythonpath": "/app/account"},
|
||||
{"name": "orders", "dirs": ["orders/tests/"], "pythonpath": "/app/orders"},
|
||||
{"name": "federation", "dirs": ["federation/tests/"],
|
||||
"pythonpath": "/app/federation"},
|
||||
{"name": "relations", "dirs": ["relations/tests/"],
|
||||
"pythonpath": "/app/relations"},
|
||||
{"name": "likes", "dirs": ["likes/tests/"], "pythonpath": "/app/likes"},
|
||||
]
|
||||
|
||||
_REPORT_PATH = "/tmp/test-report.json"
|
||||
_SERVICE_ORDER = [g["name"] for g in _SERVICE_GROUPS]
|
||||
_REPORT_PATH = "/tmp/test-report-{}.json"
|
||||
|
||||
|
||||
def _parse_report(path: str) -> tuple[list[dict], dict]:
|
||||
"""Parse a pytest-json-report file."""
|
||||
rp = Path(path)
|
||||
if not rp.exists():
|
||||
return [], {}
|
||||
try:
|
||||
report = json.loads(rp.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return [], {}
|
||||
|
||||
summary = report.get("summary", {})
|
||||
tests_raw = report.get("tests", [])
|
||||
|
||||
tests = []
|
||||
for t in tests_raw:
|
||||
tests.append({
|
||||
"nodeid": t.get("nodeid", ""),
|
||||
"outcome": t.get("outcome", "unknown"),
|
||||
"duration": round(t.get("duration", 0), 4),
|
||||
"longrepr": (t.get("call", {}) or {}).get("longrepr", ""),
|
||||
})
|
||||
return tests, summary
|
||||
|
||||
|
||||
async def _run_group(group: dict) -> tuple[list[dict], dict, str]:
|
||||
"""Run pytest for a single service group."""
|
||||
existing = [d for d in group["dirs"] if Path(f"/app/{d}").is_dir()]
|
||||
if not existing:
|
||||
return [], {}, ""
|
||||
|
||||
report_file = _REPORT_PATH.format(group["name"])
|
||||
cmd = [
|
||||
"python3", "-m", "pytest",
|
||||
*existing,
|
||||
"--json-report",
|
||||
f"--json-report-file={report_file}",
|
||||
"-q",
|
||||
"--tb=short",
|
||||
]
|
||||
env = {**os.environ}
|
||||
if group["pythonpath"]:
|
||||
env["PYTHONPATH"] = group["pythonpath"] + ":" + env.get("PYTHONPATH", "")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd="/app",
|
||||
env=env,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
stdout_str = (stdout or b"").decode("utf-8", errors="replace")
|
||||
tests, summary = _parse_report(report_file)
|
||||
return tests, summary, stdout_str
|
||||
|
||||
|
||||
async def run_tests() -> dict:
|
||||
@@ -33,74 +103,48 @@ async def run_tests() -> dict:
|
||||
started_at = time.time()
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
"python3", "-m", "pytest",
|
||||
*_TEST_DIRS,
|
||||
"--json-report",
|
||||
f"--json-report-file={_REPORT_PATH}",
|
||||
"-q",
|
||||
"--tb=short",
|
||||
]
|
||||
tasks = [_run_group(g) for g in _SERVICE_GROUPS]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
all_tests: list[dict] = []
|
||||
total_passed = total_failed = total_errors = total_skipped = total_count = 0
|
||||
all_stdout: list[str] = []
|
||||
|
||||
for i, res in enumerate(results):
|
||||
if isinstance(res, Exception):
|
||||
log.error("Group %s failed: %s", _SERVICE_GROUPS[i]["name"], res)
|
||||
continue
|
||||
tests, summary, stdout_str = res
|
||||
all_tests.extend(tests)
|
||||
total_passed += summary.get("passed", 0)
|
||||
total_failed += summary.get("failed", 0)
|
||||
total_errors += summary.get("error", 0)
|
||||
total_skipped += summary.get("skipped", 0)
|
||||
total_count += summary.get("total", len(tests))
|
||||
if stdout_str.strip():
|
||||
all_stdout.append(stdout_str)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd="/app",
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
finished_at = time.time()
|
||||
|
||||
# Parse JSON report
|
||||
report_path = Path(_REPORT_PATH)
|
||||
if report_path.exists():
|
||||
try:
|
||||
report = json.loads(report_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
report = {}
|
||||
else:
|
||||
report = {}
|
||||
|
||||
summary = report.get("summary", {})
|
||||
tests_raw = report.get("tests", [])
|
||||
|
||||
tests = []
|
||||
for t in tests_raw:
|
||||
tests.append({
|
||||
"nodeid": t.get("nodeid", ""),
|
||||
"outcome": t.get("outcome", "unknown"),
|
||||
"duration": round(t.get("duration", 0), 4),
|
||||
"longrepr": (t.get("call", {}) or {}).get("longrepr", ""),
|
||||
})
|
||||
|
||||
passed = summary.get("passed", 0)
|
||||
failed = summary.get("failed", 0)
|
||||
errors = summary.get("error", 0)
|
||||
skipped = summary.get("skipped", 0)
|
||||
total = summary.get("total", len(tests))
|
||||
|
||||
if failed > 0 or errors > 0:
|
||||
status = "failed"
|
||||
else:
|
||||
status = "passed"
|
||||
status = "failed" if total_failed > 0 or total_errors > 0 else "passed"
|
||||
|
||||
_last_result = {
|
||||
"status": status,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": round(finished_at - started_at, 2),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"errors": errors,
|
||||
"skipped": skipped,
|
||||
"total": total,
|
||||
"tests": tests,
|
||||
"stdout": (stdout or b"").decode("utf-8", errors="replace")[-5000:],
|
||||
"passed": total_passed,
|
||||
"failed": total_failed,
|
||||
"errors": total_errors,
|
||||
"skipped": total_skipped,
|
||||
"total": total_count,
|
||||
"tests": all_tests,
|
||||
"stdout": "\n".join(all_stdout)[-5000:],
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Test run complete: %s (%d passed, %d failed, %d errors, %.1fs)",
|
||||
status, passed, failed, errors, _last_result["duration"],
|
||||
status, total_passed, total_failed, total_errors,
|
||||
_last_result["duration"],
|
||||
)
|
||||
return _last_result
|
||||
|
||||
@@ -130,6 +174,49 @@ def get_results() -> dict | None:
|
||||
return _last_result
|
||||
|
||||
|
||||
def get_test(nodeid: str) -> dict | None:
|
||||
"""Look up a single test by nodeid."""
|
||||
if not _last_result:
|
||||
return None
|
||||
for t in _last_result["tests"]:
|
||||
if t["nodeid"] == nodeid:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
"""Check if tests are currently running."""
|
||||
return _running
|
||||
|
||||
|
||||
def _service_from_nodeid(nodeid: str) -> str:
|
||||
"""Extract service name from a test nodeid."""
|
||||
parts = nodeid.split("/")
|
||||
return parts[0] if len(parts) >= 2 else "other"
|
||||
|
||||
|
||||
def group_tests_by_service(tests: list[dict]) -> list[dict]:
|
||||
"""Group tests into ordered sections by service."""
|
||||
buckets: dict[str, list[dict]] = OrderedDict()
|
||||
for svc in _SERVICE_ORDER:
|
||||
buckets[svc] = []
|
||||
for t in tests:
|
||||
svc = _service_from_nodeid(t["nodeid"])
|
||||
if svc not in buckets:
|
||||
buckets[svc] = []
|
||||
buckets[svc].append(t)
|
||||
|
||||
sections = []
|
||||
for svc, svc_tests in buckets.items():
|
||||
if not svc_tests:
|
||||
continue
|
||||
sections.append({
|
||||
"service": svc,
|
||||
"tests": svc_tests,
|
||||
"total": len(svc_tests),
|
||||
"passed": sum(1 for t in svc_tests if t["outcome"] == "passed"),
|
||||
"failed": sum(1 for t in svc_tests if t["outcome"] == "failed"),
|
||||
"errors": sum(1 for t in svc_tests if t["outcome"] == "error"),
|
||||
"skipped": sum(1 for t in svc_tests if t["outcome"] == "skipped"),
|
||||
})
|
||||
return sections
|
||||
|
||||
@@ -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."))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user