From 9742d0236e1e60b42c1568b123a13e38488ca1cc Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 31 Mar 2026 17:37:12 +0000 Subject: [PATCH] Convert WASM browser tests to SX deftest forms Tests moved from inline JS assertions to web/tests/test-wasm-browser.sx using the standard deftest/defsuite/assert-equal framework. The JS driver (test_wasm_native.js) now just boots the kernel, loads modules, and runs the SX test file. 15/15 source, 15/15 bytecode. Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/ocaml/browser/test_wasm_native.js | 137 ++++-------------- web/tests/test-wasm-browser.sx | 184 ++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 109 deletions(-) create mode 100644 web/tests/test-wasm-browser.sx diff --git a/hosts/ocaml/browser/test_wasm_native.js b/hosts/ocaml/browser/test_wasm_native.js index b92911cb..4b934117 100644 --- a/hosts/ocaml/browser/test_wasm_native.js +++ b/hosts/ocaml/browser/test_wasm_native.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // test_wasm_native.js — Run WASM kernel tests in Node.js using the actual -// WASM binary (not js_of_ocaml JS fallback). This tests the exact same -// kernel that runs in the browser. +// WASM binary (not js_of_ocaml JS fallback). Tests are SX deftest forms +// in web/tests/test-wasm-browser.sx. // // Usage: node hosts/ocaml/browser/test_wasm_native.js // SX_TEST_BYTECODE=1 node hosts/ocaml/browser/test_wasm_native.js @@ -149,118 +149,37 @@ async function main() { } if (K.endModuleLoad) K.endModuleLoad(); - // --- Test runner --- + // --- Register test framework hooks --- let pass = 0, fail = 0; - function assert(name, got, expected) { - if (got === expected) { pass++; } - else { fail++; console.error(`FAIL: ${name}\n got: ${JSON.stringify(got)}\n expected: ${JSON.stringify(expected)}`); } - } - function assertIncludes(name, got, substr) { - if (typeof got === 'string' && got.includes(substr)) { pass++; } - else { fail++; console.error(`FAIL: ${name}\n got: ${JSON.stringify(got)}\n expected to include: ${JSON.stringify(substr)}`); } - } + const suiteStack = []; - // --- Tests --- + K.registerNative('report-pass', args => { + pass++; + return null; + }); + K.registerNative('report-fail', args => { + fail++; + const suitePath = suiteStack.join(' > '); + console.error(`FAIL: ${suitePath ? suitePath + ' > ' : ''}${args[0]}\n ${args[1]}`); + return null; + }); + K.registerNative('push-suite', args => { + suiteStack.push(args[0]); + return null; + }); + K.registerNative('pop-suite', args => { + suiteStack.pop(); + return null; + }); + // try-call must return {"ok": bool, "error": string|nil} for the test framework + K.eval('(define try-call (fn (thunk) (let ((result (cek-try thunk (fn (err) err)))) (if (and (= (type-of result) "string") (starts-with? result "Error")) {"ok" false "error" result} {"ok" true "error" nil}))))'); - const SCOPED_TEST = '(dom-get-attr (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class "scoped" "text") (global-env) nil)))) "class")'; - // Basic - assert('arithmetic', K.eval('(+ 1 2)'), 3); - assert('div preserves keywords', K.eval('(inspect (div :class "test" "hello"))'), '(div :class "test" "hello")'); - assert('render div+class', K.eval('(render-to-html (div :class "card" "content"))'), '
content
'); + // --- Load test framework + SX test file --- + K.load(fs.readFileSync(path.join(PROJECT_ROOT, 'spec/tests/test-framework.sx'), 'utf8')); + K.load(fs.readFileSync(path.join(PROJECT_ROOT, 'web/tests/test-wasm-browser.sx'), 'utf8')); - // DOM rendering - assert('dom class attr', - K.eval('(dom-get-attr (render-to-dom (div :class "test" "hello") (global-env) nil) "class")'), - 'test'); - - // Reactive: scoped static class - assert('scoped static class', - K.eval(SCOPED_TEST), 'scoped'); - - // Reactive: signal deref initial value in scope - assert('signal attr initial value', - K.eval('(dom-get-attr (let ((s (signal "active")) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class (deref s) "content") (global-env) nil)))) "class")'), - 'active'); - - // Reactive: signal text in scope - assertIncludes('signal text in scope', - K.eval('(host-get (let ((s (signal 42)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div (deref s)) (global-env) nil)))) "outerHTML")'), - '42'); - - // CRITICAL: define vs let closure with host objects + effect - // This is the root cause of the hydration rendering bug. - // A function defined with `define` that takes a host object (DOM element) - // and uses `effect` to modify it — the effect body doesn't see the element. - assert('define+effect+host-obj (same eval)', - K.eval('(do (define test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-define") (dom-get-attr el "class")))'), - 'from-define'); - - // Verify the effect body ACTUALLY EXECUTES (not just returning a value). - // In browser WASM, the effect body silently doesn't run. - assert('define+effect body executes', - K.eval('(do (define test-fx-log (fn (el log) (effect (fn () (append! log "ran") (dom-set-attr el "class" "fx"))))) (let ((el (dom-create-element "div" nil)) (log (list))) (test-fx-log el log) (str (len log) ":" (first log))))'), - '1:ran'); - - // Same thing with let works (proves it's define-specific) - assert('let+effect+host-obj', - K.eval('(let ((test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val)))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-let") (dom-get-attr el "class")))'), - 'from-let'); - - // CRITICAL: define in separate eval (matches real module-load pattern). - // This is how reactive-spread/reactive-attr work: defined at module load, - // called later during hydration. The effect closure must capture host objects. - K.eval('(define test-set-attr-sep (fn (el name val) (effect (fn () (dom-set-attr el name val)))))'); - assert('define+effect+host-obj (separate eval)', - K.eval('(let ((el (dom-create-element "div" nil))) (test-set-attr-sep el "class" "from-sep-define") (dom-get-attr el "class"))'), - 'from-sep-define'); - - // Module-loaded define (via K.load, same as real module loading). - // reactive-spread is loaded this way — test that effect fires. - K.load('(define test-set-attr-mod (fn (el name val) (effect (fn () (dom-set-attr el name val)))))'); - assert('define+effect+host-obj (module-loaded)', - K.eval('(let ((el (dom-create-element "div" nil))) (test-set-attr-mod el "class" "from-mod") (dom-get-attr el "class"))'), - 'from-mod'); - - // The actual reactive-spread pattern: module-loaded function creates effect - // that calls cek-call on a render-fn returning a spread, then applies attrs. - assert('reactive-spread from module', - K.eval('(let ((el (dom-create-element "div" nil)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (reactive-spread el (fn () (~cssx/tw :tokens "text-center"))))) (dom-get-attr el "class"))'), - 'sx-text-center'); - - // Full hydration pattern: render-to-dom with CSSX inside island scope - assertIncludes('render-to-dom CSSX in island scope', - K.eval("(host-get (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom '(div (~cssx/tw :tokens \"text-center font-bold\") \"hello\") (global-env) nil)))) \"outerHTML\")"), - 'sx-text-center'); - - // Reactive: signal update propagation - // Note: render-to-dom needs the UNEVALUATED expression (as in real browser boot - // where expressions come from parsing). Use quote to prevent eager eval of (deref s). - K.eval('(define test-reactive-sig (signal "before"))'); - assert('reactive attr update', - K.eval("(let ((d (list))) (let ((el (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom '(div :class (deref test-reactive-sig) \"content\") (global-env) nil))))) (reset! test-reactive-sig \"after\") (dom-get-attr el \"class\")))"), - 'after'); - - // ===================================================================== - // Section: Boot step bisection - // Simulate boot steps to find which one breaks scoped rendering - // ===================================================================== - if (process.env.SX_TEST_BOOT_BISECT === '1') { - console.log('\n=== Boot step bisection ==='); - const bootSteps = [ - ['init-css-tracking', '(init-css-tracking)'], - ['process-page-scripts', '(process-page-scripts)'], - // process-sx-scripts needs