Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s

- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -21,11 +21,11 @@ async def test_context() -> dict:
blog_url_str = blog_url("")
else:
blog_url_str = str(blog_url or "")
ctx["cart_mini_html"] = render(
ctx["cart_mini"] = render(
"cart-mini", cart_count=0, blog_url=blog_url_str, cart_url="",
)
ctx["auth_menu_html"] = ""
ctx["nav_tree_html"] = ""
ctx["auth_menu"] = ""
ctx["nav_tree"] = ""
return ctx

View File

@@ -14,7 +14,7 @@ def register(url_prefix: str = "/") -> Blueprint:
"""Full page dashboard with last results."""
from shared.sexp.page import get_template_context
from shared.browser.app.csrf import generate_csrf_token
from sexp.sexp_components import render_dashboard_page
from sexp.sexp_components import render_dashboard_page_sexp
import runner
ctx = await get_template_context()
@@ -24,7 +24,7 @@ def register(url_prefix: str = "/") -> Blueprint:
active_filter = request.args.get("filter")
active_service = request.args.get("service")
html = await render_dashboard_page(
html = await render_dashboard_page_sexp(
ctx, result, running, csrf,
active_filter=active_filter,
active_service=active_service,
@@ -40,7 +40,7 @@ def register(url_prefix: str = "/") -> Blueprint:
asyncio.create_task(runner.run_tests())
# HX-Redirect for HTMX, regular redirect for non-HTMX
if request.headers.get("HX-Request"):
if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
resp = Response("", status=200)
resp.headers["HX-Redirect"] = "/"
return resp
@@ -58,7 +58,7 @@ def register(url_prefix: str = "/") -> Blueprint:
from quart import abort
abort(404)
is_htmx = bool(request.headers.get("HX-Request"))
is_htmx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
if is_htmx:
# S-expression wire format — sexp.js renders client-side
@@ -68,17 +68,17 @@ def register(url_prefix: str = "/") -> Blueprint:
# Full page render (direct navigation / refresh)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_test_detail_page
from sexp.sexp_components import render_test_detail_page_sexp
ctx = await get_template_context()
html = await render_test_detail_page(ctx, test)
html = await render_test_detail_page_sexp(ctx, test)
return await make_response(html, 200)
@bp.get("/results")
async def results():
"""HTMX partial — poll target for results table."""
from shared.browser.app.csrf import generate_csrf_token
from sexp.sexp_components import render_results_partial
from sexp.sexp_components import render_results_partial_sexp
import runner
result = runner.get_results()
@@ -87,7 +87,7 @@ def register(url_prefix: str = "/") -> Blueprint:
active_filter = request.args.get("filter")
active_service = request.args.get("service")
html = await render_results_partial(
html = await render_results_partial_sexp(
result, running, csrf,
active_filter=active_filter,
active_service=active_service,

View File

@@ -19,11 +19,11 @@
(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"
:sx-get href
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-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 " ""))
@@ -79,11 +79,11 @@
"bg-orange-50"))))
(td :class "px-3 py-2 text-sm font-mono text-stone-700 max-w-0 truncate" :title nodeid
(a :href (str "/test/" nodeid)
:hx-get (str "/test/" nodeid)
:hx-target "#main-panel"
:hx-select "#main-panel"
:hx-swap "outerHTML"
:hx-push-url "true"
:sx-get (str "/test/" nodeid)
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class "hover:underline hover:text-sky-600"
nodeid))
(td :class "px-3 py-2 text-center"
@@ -97,7 +97,7 @@
(td :class "px-3 py-2 text-sm text-rose-600 font-mono max-w-xs truncate" :title longrepr
(when longrepr longrepr))))
(defcomp ~test-results-table (&key rows-html has-failures)
(defcomp ~test-results-table (&key rows has-failures)
(div :class "overflow-x-auto rounded border border-stone-200 bg-white"
(table :class "w-full text-left"
(thead
@@ -106,7 +106,7 @@
(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 (raw! rows-html)))))
(tbody (when rows rows)))))
(defcomp ~test-running-indicator ()
(div :class "flex items-center justify-center py-12 text-stone-500"
@@ -124,11 +124,11 @@
(div :class "space-y-6 p-4"
(div :class "flex items-center gap-3"
(a :href "/"
:hx-get "/"
:hx-target "#main-panel"
:hx-select "#main-panel"
:hx-swap "outerHTML"
:hx-push-url "true"
:sx-get "/"
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class "text-sky-600 hover:text-sky-800 text-sm"
"← Back to results")
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium "

View File

@@ -4,8 +4,11 @@ from __future__ import annotations
import os
from datetime import datetime
from shared.sexp.jinja_bridge import render, load_service_components, client_components_tag
from shared.sexp.helpers import root_header_html, full_page
from shared.sexp.jinja_bridge import load_service_components
from shared.sexp.helpers import (
sexp_call, SexpExpr,
root_header_sexp, full_page_sexp, header_child_sexp,
)
# Load test-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -30,84 +33,6 @@ _FILTER_MAP = {
}
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 = []
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 _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."""
@@ -125,33 +50,116 @@ def _filter_tests(tests: list[dict], active_filter: str | None,
# 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)."""
def test_detail_sexp(test: dict) -> str:
"""Return s-expression wire format for a test detail view."""
inner = sexp_call(
"test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],
duration=str(test["duration"]),
longrepr=test.get("longrepr", ""),
)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
# ---------------------------------------------------------------------------
# Sexp-native versions — return sexp source (not HTML)
# ---------------------------------------------------------------------------
def _test_header_sexp(ctx: dict, active_service: str | None = None) -> str:
"""Build the Tests menu-row as sexp call."""
nav = _service_nav_sexp(ctx, active_service)
return sexp_call("menu-row-sx",
id="test-row", level=1, colour="sky",
link_href="/", link_label="Tests", icon="fa fa-flask",
nav=SexpExpr(nav),
child_id="test-header-child",
)
def _service_nav_sexp(ctx: dict, active_service: str | None = None) -> str:
"""Service filter nav as sexp."""
from runner import _SERVICE_ORDER
parts = []
parts.append(sexp_call("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(sexp_call("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_sexp(ctx: dict, active_service: str | None = None) -> str:
"""Full header stack as sexp."""
hdr = root_header_sexp(ctx)
inner = _test_header_sexp(ctx, active_service)
child = header_child_sexp(inner)
return "(<> " + hdr + " " + child + ")"
def _test_rows_sexp(tests: list[dict]) -> str:
"""Render all test result rows as sexp."""
parts = []
for t in tests:
parts.append(sexp_call("test-row",
nodeid=t["nodeid"],
outcome=t["outcome"],
duration=str(t["duration"]),
longrepr=t.get("longrepr", ""),
))
return "(<> " + " ".join(parts) + ")"
def _grouped_rows_sexp(tests: list[dict]) -> str:
"""Test rows grouped by service as sexp."""
from runner import group_tests_by_service
sections = group_tests_by_service(tests)
parts = []
for sec in sections:
parts.append(sexp_call("test-service-header",
service=sec["service"],
total=str(sec["total"]),
passed=str(sec["passed"]),
failed=str(sec["failed"]),
))
parts.append(_test_rows_sexp(sec["tests"]))
return "(<> " + " ".join(parts) + ")"
def _results_partial_sexp(result: dict | None, running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Results section as sexp."""
if running and not result:
summary = render(
"test-summary",
summary = sexp_call("test-summary",
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")
return "(<> " + summary + " " + sexp_call("test-running-indicator") + ")"
if not result:
summary = render(
"test-summary",
summary = sexp_call("test-summary",
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")
return "(<> " + summary + " " + sexp_call("test-no-results") + ")"
status = "running" if running else result["status"]
summary = render(
"test-summary",
summary = sexp_call("test-summary",
status=status,
passed=str(result["passed"]),
failed=str(result["failed"]),
@@ -166,88 +174,65 @@ def _results_partial_html(result: dict | None, running: bool, csrf: str,
)
if running:
return summary + render("test-running-indicator")
return "(<> " + summary + " " + sexp_call("test-running-indicator") + ")"
tests = result.get("tests", [])
tests = _filter_tests(tests, active_filter, active_service)
if not tests:
return summary + render("test-no-results")
return "(<> " + summary + " " + sexp_call("test-no-results") + ")"
has_failures = result["failed"] > 0 or result["errors"] > 0
rows = _grouped_rows_html(tests)
table = render("test-results-table", rows_html=rows,
has_failures=str(has_failures).lower())
return summary + table
rows = _grouped_rows_sexp(tests)
table = sexp_call("test-results-table",
rows=SexpExpr(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"'
def _wrap_results_div_sexp(inner: str, running: bool) -> str:
"""Wrap results in a div with HTMX polling (sexp)."""
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>'
attrs += ' :sx-get "/results" :sx-trigger "every 2s" :sx-swap "outerHTML"'
return f'(div {attrs} {inner})'
async def render_dashboard_page(ctx: dict, result: dict | None,
running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Full page: test dashboard."""
hdr = _header_stack_html(ctx, active_service)
inner = _results_partial_html(result, running, csrf, active_filter, active_service)
content = _wrap_results_div(inner, running)
body_end = client_components_tag()
return full_page(ctx, header_rows_html=hdr, content_html=content,
body_end_html=body_end)
async def render_dashboard_page_sexp(ctx: dict, result: dict | None,
running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Full page: test dashboard (sexp wire format)."""
hdr = _header_stack_sexp(ctx, active_service)
inner = _results_partial_sexp(result, running, csrf, active_filter, active_service)
content = _wrap_results_div_sexp(inner, running)
return full_page_sexp(ctx, header_rows=hdr, content=content)
async def render_results_partial(result: dict | None, running: bool,
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, active_filter, active_service)
return _wrap_results_div(inner, running)
async def render_results_partial_sexp(result: dict | None, running: bool,
csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""HTMX partial: results section (sexp wire format)."""
inner = _results_partial_sexp(result, running, csrf, active_filter, active_service)
return _wrap_results_div_sexp(inner, running)
async def render_test_detail_page(ctx: dict, test: dict) -> str:
"""Full page: test detail view."""
hdr = _header_stack_html(ctx)
detail_row = render(
"menu-row",
async def render_test_detail_page_sexp(ctx: dict, test: dict) -> str:
"""Full page: test detail (sexp wire format)."""
root_hdr = root_header_sexp(ctx)
test_row = _test_header_sexp(ctx)
detail_row = sexp_call("menu-row-sx",
id="test-detail-row", level=2, colour="sky",
link_href=f"/test/{test['nodeid']}",
link_label=test["nodeid"].rsplit("::", 1)[-1],
)
hdr += render("header-child", id="test-header-child",
inner_html=detail_row)
content = render(
"test-detail",
inner = "(<> " + test_row + " " + header_child_sexp(detail_row, id="test-header-child") + ")"
hdr = "(<> " + root_hdr + " " + header_child_sexp(inner) + ")"
content = sexp_call("test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],
duration=str(test["duration"]),
longrepr=test.get("longrepr", ""),
)
body_end = client_components_tag()
return full_page(ctx, header_rows_html=hdr, content_html=content,
body_end_html=body_end)
def test_detail_sexp(test: dict) -> str:
"""Return s-expression wire format for a test detail view.
When a client has sexp.js loaded (HTMX navigation from the dashboard),
we can send the raw s-expression instead of pre-rendered HTML.
sexp.js will render it client-side.
"""
from shared.sexp.parser import serialize
nodeid = serialize(test["nodeid"])
outcome = serialize(test["outcome"])
duration = serialize(str(test["duration"]))
longrepr = serialize(test.get("longrepr", ""))
return (
f"(section :id \"main-panel\""
f" :class \"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\""
f" (~test-detail :nodeid {nodeid} :outcome {outcome}"
f" :duration {duration} :longrepr {longrepr}))"
)
return full_page_sexp(ctx, header_rows=hdr, content=content)