From 6f403c0c2d847a426bfad43390a14cb0f8bbddd6 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 11:10:24 +0000 Subject: [PATCH] Add server-side test runner to /specs/testing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python evaluator runs test.sx at page load, results shown alongside the browser runner. Both hosts prove the same 81 tests from the same spec file — server on render, client on click. Co-Authored-By: Claude Opus 4.6 --- sx/sx/boundary.sx | 5 +++ sx/sx/testing.sx | 20 +++++++++--- sx/sxc/pages/docs.sx | 3 +- sx/sxc/pages/helpers.py | 71 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index 3f0f5e0..87d062b 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -59,3 +59,8 @@ :params () :returns "dict" :service "sx") + +(define-page-helper "run-spec-tests" + :params () + :returns "dict" + :service "sx") diff --git a/sx/sx/testing.sx b/sx/sx/testing.sx index 7ed3e4d..6c4ab4a 100644 --- a/sx/sx/testing.sx +++ b/sx/sx/testing.sx @@ -1,6 +1,6 @@ ;; Testing spec page — SX tests SX. -(defcomp ~spec-testing-content (&key spec-source) +(defcomp ~spec-testing-content (&key spec-source server-results) (~doc-page :title "Testing" (div :class "space-y-8" @@ -22,15 +22,27 @@ (code :class "text-violet-700 text-sm" "if") " works. No code generation, no intermediate files — the evaluator runs the spec.")) - ;; Live test runner + ;; Server-side results (ran when this page was rendered) (div :class "space-y-3" - (h2 :class "text-2xl font-semibold text-stone-800" "Run in browser") + (h2 :class "text-2xl font-semibold text-stone-800" "Server: Python evaluator") + (p :class "text-stone-600" + "The Python evaluator ran " + (code :class "text-violet-700 text-sm" "test.sx") + " when this page loaded — " + (strong (str (get server-results "passed") "/" (get server-results "total") " passed")) + " in " (str (get server-results "elapsed-ms")) "ms.") + (pre :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + (get server-results "output"))) + + ;; Client-side test runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Browser: JavaScript evaluator") (p :class "text-stone-600" "This page loaded " (code :class "text-violet-700 text-sm" "sx-browser.js") " to render itself. The same evaluator can run " (code :class "text-violet-700 text-sm" "test.sx") - " right here — SX testing SX, in your browser:") + " right here:") (div :class "flex items-center gap-4" (button :id "test-btn" :class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index dcac513..e90b708 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -342,7 +342,8 @@ :source (read-spec-file (get item "filename")))) extension-spec-items)) "testing" (~spec-testing-content - :spec-source (read-spec-file "test.sx")) + :spec-source (read-spec-file "test.sx") + :server-results (run-spec-tests)) :else (let ((spec (find-spec slug))) (if spec (~spec-detail-content diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 3c40544..150ca97 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -24,6 +24,7 @@ def _register_sx_helpers() -> None: "bundle-analyzer-data": _bundle_analyzer_data, "routing-analyzer-data": _routing_analyzer_data, "data-test-data": _data_test_data, + "run-spec-tests": _run_spec_tests, }) @@ -491,6 +492,76 @@ def _event_detail_data(slug: str) -> dict: } +def _run_spec_tests() -> dict: + """Run test.sx against the Python SX evaluator and return results.""" + import os + import time + from shared.sx.parser import parse_all + from shared.sx.evaluator import _eval, _trampoline + + ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") + if not os.path.isdir(ref_dir): + ref_dir = "/app/shared/sx/ref" + test_path = os.path.join(ref_dir, "test.sx") + with open(test_path, encoding="utf-8") as f: + src = f.read() + + suite_stack: list[str] = [] + passed = 0 + failed = 0 + test_num = 0 + lines: list[str] = [] + + def try_call(thunk): + try: + _trampoline(_eval([thunk], {})) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def report_pass(name): + nonlocal passed, test_num + test_num += 1 + passed += 1 + lines.append("ok " + str(test_num) + " - " + " > ".join(suite_stack + [name])) + + def report_fail(name, error): + nonlocal failed, test_num + test_num += 1 + failed += 1 + full = " > ".join(suite_stack + [name]) + lines.append("not ok " + str(test_num) + " - " + full) + lines.append(" # " + str(error)) + + def push_suite(name): + suite_stack.append(name) + + def pop_suite(): + suite_stack.pop() + + env = { + "try-call": try_call, + "report-pass": report_pass, + "report-fail": report_fail, + "push-suite": push_suite, + "pop-suite": pop_suite, + } + + t0 = time.monotonic() + exprs = parse_all(src) + for expr in exprs: + _trampoline(_eval(expr, env)) + elapsed = round((time.monotonic() - t0) * 1000) + + return { + "passed": passed, + "failed": failed, + "total": passed + failed, + "elapsed-ms": elapsed, + "output": "\n".join(lines), + } + + def _data_test_data() -> dict: """Return test data for the client-side data rendering test page.