diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 0e4eed5c..7cbec7d4 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -2140,6 +2140,31 @@ let http_mode port = let bind name fn = ignore (env_bind env name (NativeFn (name, fn))) in bind "effect" (fun _args -> Nil); bind "register-in-scope" (fun _args -> Nil); + (* Re-bind component-source — data/helpers.sx overrides the native version + with an SX version that calls env-get with wrong arity. Native version + uses env_get directly and handles pretty-printing in OCaml. *) + bind "component-source" (fun args -> + match args with + | [String name] -> + let lookup = if String.length name > 0 && name.[0] = '~' + then name + else "~" ^ name in + (try + let comp = env_get env lookup in + match comp with + | Component c -> + let params = List (List.map (fun s -> Symbol s) c.c_params) in + let form = List [Symbol "defcomp"; Symbol ("~" ^ c.c_name); + params; c.c_body] in + String (pretty_print_value form) + | Island c -> + let params = List (List.map (fun s -> Symbol s) c.i_params) in + let form = List [Symbol "defisland"; Symbol ("~" ^ c.i_name); + params; c.i_body] in + String (pretty_print_value form) + | _ -> String (";; " ^ name ^ ": not a component") + with _ -> String (";; component " ^ name ^ " not found")) + | _ -> raise (Eval_error "component-source: expected (name)")); let jt0 = Unix.gettimeofday () in let count = ref 0 in let compiler_names = [ diff --git a/spec/tests/test-reactive-islands.sx b/spec/tests/test-reactive-islands.sx new file mode 100644 index 00000000..607cd770 --- /dev/null +++ b/spec/tests/test-reactive-islands.sx @@ -0,0 +1,225 @@ +(define _island-tests (list)) + +(define + register-test + (fn + (name test-fn) + (append! _island-tests (dict "name" name "fn" test-fn)))) + +(register-test + "counter-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-counter :initial 0)))) + (assert (contains? html "−") "has minus button") + (assert (contains? html "+") "has plus button") + (assert (contains? html ">0<") "shows initial 0") + (assert (contains? html "doubled") "has doubled label")))) + +(register-test + "counter-initial-42" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-counter :initial 42)))) + (assert (contains? html ">42<") "shows 42") + (assert (contains? html ">84<") "doubled shows 84")))) + +(register-test + "counter-signals" + (fn + () + (let + ((count (signal 0)) + (doubled (computed (fn () (* 2 (deref count)))))) + (assert= (deref count) 0 "initial count") + (assert= (deref doubled) 0 "initial doubled") + (swap! count inc) + (assert= (deref count) 1 "after inc") + (assert= (deref doubled) 2 "doubled reacts") + (swap! count dec) + (assert= (deref count) 0 "after dec")))) + +(register-test + "temperature-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-temperature :celsius 20)))) + (assert (contains? html "°C") "shows celsius") + (assert (contains? html "°F") "shows fahrenheit") + (assert (contains? html "20") "shows 20") + (assert (contains? html "68") "shows 68F")))) + +(register-test + "temperature-conversion" + (fn + () + (let + ((c (signal 100)) + (f (computed (fn () (+ (* (deref c) 9/5) 32))))) + (assert= (deref f) 212 "100C = 212F") + (reset! c 0) + (assert= (deref f) 32 "0C = 32F")))) + +(register-test + "stopwatch-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-stopwatch)))) + (assert (contains? html "0") "shows 0")))) + +(register-test + "input-binding-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-input-binding)))) + (assert (contains? html " (len html) 10) "renders content")))) + +(register-test + "resource-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-resource)))) + (assert (> (len html) 10) "renders content")))) + +(register-test + "transition-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-transition)))) + (assert (> (len html) 10) "renders content")))) + +(register-test + "event-bridge-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-event-bridge)))) + (assert (contains? html "Send") "has send button")))) + +(register-test + "imperative-renders" + (fn + () + (let + ((html (render-to-html (~reactive-islands/index/demo-imperative)))) + (assert (contains? html "button") "has button")))) + +(register-test + "counter-dom" + (fn + () + (let + ((container (dom-create-element "div" nil)) + (rendered + (render-to-dom + (~reactive-islands/index/demo-counter :initial 5) + nil + nil))) + (dom-append container rendered) + (assert= (len (dom-query-all container "button")) 2 "2 buttons") + (assert (contains? (dom-text-content container) "5") "shows 5") + (assert + (contains? (dom-text-content container) "10") + "doubled shows 10")))) + +(register-test + "temperature-dom" + (fn + () + (let + ((container (dom-create-element "div" nil)) + (rendered + (render-to-dom + (~reactive-islands/index/demo-temperature :celsius 25) + nil + nil))) + (dom-append container rendered) + (assert (contains? (dom-text-content container) "25") "shows 25") + (assert (contains? (dom-text-content container) "77") "shows 77F")))) diff --git a/tests/node/run-sx-tests.js b/tests/node/run-sx-tests.js new file mode 100644 index 00000000..93c10bb6 --- /dev/null +++ b/tests/node/run-sx-tests.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * run-sx-tests.js — Run SX deftest forms in the Node WASM harness. + * + * Loads island definitions + test file, runs all registered tests, + * reports results. + * + * Usage: + * node tests/node/run-sx-tests.js [test-file.sx] + */ +const { createSxEnv } = require('./sx-harness'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_TEST_FILE = path.resolve(__dirname, '../../spec/tests/test-reactive-islands.sx'); +const ISLAND_SRC = path.resolve(__dirname, '../../sx/sx/reactive-islands/index.sx'); + +async function main() { + const testFile = process.argv[2] || DEFAULT_TEST_FILE; + const origLog = console.log; + const origErr = console.error; + + origLog(`=== SX Test Runner (Node+WASM) ===`); + origLog(`Test file: ${path.basename(testFile)}\n`); + + const t0 = Date.now(); + + const env = await createSxEnv({ + html: '
', + }); + + // Load island definitions + const islandSrc = fs.readFileSync(ISLAND_SRC, 'utf8'); + env.load(islandSrc); + + // Load test file + const testSrc = fs.readFileSync(testFile, 'utf8'); + env.load(testSrc); + + // Get registered tests + const tests = env.eval('_island-tests'); + const testList = tests?.items || []; + + if (testList.length === 0) { + origLog('No tests found (check _island-tests registry)'); + env.close(); + process.exit(1); + } + + origLog(`Running ${testList.length} tests...\n`); + + let passed = 0, failed = 0; + const failures = []; + + for (const test of testList) { + const name = test.name; + const fn = test.fn; + try { + env.K.callFn(fn, []); + passed++; + origLog(` \u2713 ${name}`); + } catch (e) { + failed++; + const msg = e.message || String(e); + // Extract the assertion message from the error + const clean = msg.replace(/^Error:\s*/, '').replace(/\s*\(in .*$/, ''); + origErr(` \u2717 ${name}: ${clean}`); + failures.push({ name, error: clean }); + } + } + + env.close(); + + const dt = Date.now() - t0; + origLog(`\n=== ${passed} passed, ${failed} failed (${dt}ms) ===`); + if (failures.length > 0) { + origLog('\nFailures:'); + for (const f of failures) origLog(` ${f.name}: ${f.error}`); + } + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/node/test-reactive-islands.js b/tests/node/test-reactive-islands.js new file mode 100644 index 00000000..5764d4ea --- /dev/null +++ b/tests/node/test-reactive-islands.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * test-reactive-islands.js — Node tests for all reactive island demos. + * + * Loads the actual island definitions from sx/sx/reactive-islands/index.sx + * and tests hydration + interactions using the Node SX harness. + */ +const { createSxEnv } = require('./sx-harness'); +const fs = require('fs'); +const path = require('path'); + +const ISLAND_SRC = path.resolve(__dirname, '../../sx/sx/reactive-islands/index.sx'); + +let passed = 0, failed = 0, skipped = 0; +const origLog = console.log; +const origErr = console.error; + +function assert(name, cond, detail) { + if (cond) { passed++; } + else { failed++; origErr(` FAIL: ${name}${detail ? ' — ' + detail : ''}`); } +} + +function skip(name, reason) { + skipped++; + origLog(` SKIP: ${name} (${reason})`); +} + +/** Create an env with the island source loaded and a given island span in the DOM. */ +async function withIsland(islandName, stateSx, fn) { + const stateAttr = stateSx ? ` data-sx-state="${stateSx.replace(/"/g, '"')}"` : ''; + const env = await createSxEnv({ + html: `