From 7eb158c79f900d6721e3c0f0a55606e9089415a9 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 11:00:37 +0000 Subject: [PATCH] Add live browser test runner to /specs/testing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sx-browser.js evaluates test.sx directly in the browser — click "Run 81 tests" to see SX test itself. Uses the same Sx global that rendered the page. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-test-runner.js | 96 +++++++++++++++++++++++++ sx/sx/testing.sx | 31 +++++++- 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 shared/static/scripts/sx-test-runner.js diff --git a/shared/static/scripts/sx-test-runner.js b/shared/static/scripts/sx-test-runner.js new file mode 100644 index 0000000..89ce30c --- /dev/null +++ b/shared/static/scripts/sx-test-runner.js @@ -0,0 +1,96 @@ +// sx-test-runner.js — Run test.sx in the browser using sx-browser.js. +// Loaded on the /specs/testing page. Uses the Sx global. +(function() { + var NIL = Sx.NIL; + function isNil(x) { return x === NIL || x === null || x === undefined; } + function deepEqual(a, b) { + if (a === b) return true; + if (isNil(a) && isNil(b)) return true; + if (typeof a !== typeof b) return false; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false; + return true; + } + if (a && typeof a === "object" && b && typeof b === "object") { + var ka = Object.keys(a), kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false; + return true; + } + return false; + } + + window.sxRunTests = function(srcId, outId, btnId) { + var src = document.getElementById(srcId).textContent; + var out = document.getElementById(outId); + var btn = document.getElementById(btnId); + + var stack = [], passed = 0, failed = 0, num = 0, lines = []; + + var env = { + "try-call": function(thunk) { + try { + Sx.eval([thunk], env); + return { ok: true }; + } catch(e) { + return { ok: false, error: e.message || String(e) }; + } + }, + "report-pass": function(name) { + num++; passed++; + lines.push("ok " + num + " - " + stack.concat([name]).join(" > ")); + }, + "report-fail": function(name, error) { + num++; failed++; + lines.push("not ok " + num + " - " + stack.concat([name]).join(" > ")); + lines.push(" # " + error); + }, + "push-suite": function(name) { stack.push(name); }, + "pop-suite": function() { stack.pop(); }, + + "equal?": function(a, b) { return deepEqual(a, b); }, + "eq?": function(a, b) { return a === b; }, + "boolean?": function(x) { return typeof x === "boolean"; }, + "string-length": function(s) { return String(s).length; }, + "substring": function(s, start, end) { return String(s).slice(start, end); }, + "string-contains?": function(s, n) { return String(s).indexOf(n) !== -1; }, + "upcase": function(s) { return String(s).toUpperCase(); }, + "downcase": function(s) { return String(s).toLowerCase(); }, + "reverse": function(c) { return c ? c.slice().reverse() : []; }, + "flatten": function(c) { + var r = []; + for (var i = 0; i < (c||[]).length; i++) { + if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]); + else r.push(c[i]); + } + return r; + }, + "has-key?": function(d, k) { return d && typeof d === "object" && k in d; }, + "append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); }, + }; + + try { + var t0 = performance.now(); + var exprs = Sx.parseAll(src); + for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + var elapsed = Math.round(performance.now() - t0); + lines.push(""); + lines.push("1.." + num); + lines.push("# tests " + (passed + failed)); + lines.push("# pass " + passed); + if (failed > 0) lines.push("# fail " + failed); + lines.push("# time " + elapsed + "ms"); + } catch(e) { + lines.push(""); + lines.push("FATAL: " + (e.message || String(e))); + } + + out.textContent = lines.join("\n"); + out.style.display = "block"; + btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)"); + btn.className = failed > 0 + ? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default" + : "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default"; + }; +})(); diff --git a/sx/sx/testing.sx b/sx/sx/testing.sx index 0a56288..7ed3e4d 100644 --- a/sx/sx/testing.sx +++ b/sx/sx/testing.sx @@ -22,6 +22,29 @@ (code :class "text-violet-700 text-sm" "if") " works. No code generation, no intermediate files — the evaluator runs the spec.")) + ;; Live test runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Run in browser") + (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:") + (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" + :onclick "sxRunTests('test-sx-source','test-output','test-btn')" + "Run 81 tests")) + (pre :id "test-output" + :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + :style "display:none" + "") + ;; Hidden: raw test.sx source for the browser runner + (textarea :id "test-sx-source" :style "display:none" spec-source) + ;; Load the test runner script + (script :src (asset-url "/scripts/sx-test-runner.js"))) + ;; How it works (div :class "space-y-3" (h2 :class "text-2xl font-semibold text-stone-800" "Architecture") @@ -30,6 +53,8 @@ (div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700" "test.sx Self-executing: macros + helpers + 81 tests + | + |--- browser sx-browser.js evaluates test.sx in this page | |--- run.js Injects 5 platform fns, evaluates test.sx | | @@ -39,7 +64,7 @@ | +-> evaluator.py Python evaluator -Platform functions: +Platform functions (5 total — everything else is pure SX): try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"} report-pass (name) -> output pass report-fail (name error) -> output fail @@ -121,11 +146,11 @@ Platform functions: (h2 :class "text-lg font-semibold text-blue-900" "What this proves") (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" (li "The test spec is " (strong "written in SX") " and " (strong "executed by SX") " — no code generation") - (li "The same 81 tests run on " (strong "both Python and JavaScript") " from the same file") + (li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file") (li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX") (li "Adding a new host means implementing 5 functions, not rewriting tests") (li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden")) - (li "The spec is " (strong "executable") " — it doesn't just describe behavior, it verifies it"))) + (li "The spec is " (strong "executable") " — click the button above to prove it"))) ;; Test suites (div :class "space-y-3"