diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 7f18587..511b10a 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-19T11:12:01Z"; + var SX_VERSION = "2026-03-19T12:40:33Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2679,7 +2679,7 @@ PRIMITIVES["aser"] = aser; return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy((name == "raw!")) ? aserCall("raw!", args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : NIL); var expandAll = (isSxTruthy(envHas(env, "expand-components?")) ? expandComponents_p() : false); - return (isSxTruthy((isSxTruthy(comp) && isMacro(comp))) ? aser(expandMacro(comp, args, env), env) : (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && isSxTruthy(sxOr(expandAll, (componentAffinity(comp) == "server"))) && !isSxTruthy((componentAffinity(comp) == "client")))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env))); + return (isSxTruthy((isSxTruthy(comp) && isMacro(comp))) ? aser(expandMacro(comp, args, env), env) : (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && isSxTruthy(!isSxTruthy(isIsland(comp))) && isSxTruthy(sxOr(expandAll, (componentAffinity(comp) == "server"))) && !isSxTruthy((componentAffinity(comp) == "client")))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env))); })() : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { var f = trampoline(evalExpr(head, env)); var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); diff --git a/shared/sx/tests/test_ocaml_helpers.py b/shared/sx/tests/test_ocaml_helpers.py new file mode 100644 index 0000000..987f37f --- /dev/null +++ b/shared/sx/tests/test_ocaml_helpers.py @@ -0,0 +1,355 @@ +"""Tests for OCaml kernel ↔ Python page helper IO bridge. + +Verifies that: +1. Helper injection registers functions in the OCaml kernel +2. The kernel can call helpers via (io-request "helper" ...) +3. aser_slot expands components that use helpers +4. Caching eliminates redundant IO round-trips + +Usage: + pytest shared/sx/tests/test_ocaml_helpers.py -v +""" + +import asyncio +import os +import sys +import time +import unittest + +_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +from shared.sx.ocaml_bridge import OcamlBridge, OcamlBridgeError, _DEFAULT_BIN, _escape + + +def _skip_if_no_binary(): + bin_path = os.path.abspath(_DEFAULT_BIN) + if not os.path.isfile(bin_path): + raise unittest.SkipTest( + f"OCaml binary not found at {bin_path}. " + f"Build with: cd hosts/ocaml && eval $(opam env) && dune build" + ) + + +class TestHelperInjection(unittest.IsolatedAsyncioTestCase): + """Test that page helpers can be injected into the OCaml kernel.""" + + @classmethod + def setUpClass(cls): + _skip_if_no_binary() + + async def asyncSetUp(self): + self.bridge = OcamlBridge() + await self.bridge.start() + # Load spec + adapter (needed for aser) + spec_dir = os.path.join(_project_root, "spec") + web_dir = os.path.join(_project_root, "web") + for f in ["parser.sx", "render.sx"]: + path = os.path.join(spec_dir, f) + if os.path.isfile(path): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(path)}")') + await self.bridge._read_until_ok(ctx=None) + adapter = os.path.join(web_dir, "adapter-sx.sx") + if os.path.isfile(adapter): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(adapter)}")') + await self.bridge._read_until_ok(ctx=None) + + async def asyncTearDown(self): + await self.bridge.stop() + + async def _inject_test_helper(self, name: str, nargs: int): + """Inject a single helper proxy into the kernel.""" + param_names = " ".join(chr(97 + i) for i in range(nargs)) + arg_list = " ".join(chr(97 + i) for i in range(nargs)) + sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))' + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(sx_def)}")') + await self.bridge._read_until_ok(ctx=None) + + async def test_helper_call_returns_value(self): + """Injected helper can be called and returns IO result.""" + # The "helper" native binding is already in the kernel. + # Define a test helper that calls (helper "json-encode" value) + await self._inject_test_helper("json-encode", 1) + + # Call it via eval — should yield io-request, Python dispatches + result = await self.bridge.eval( + '(json-encode "hello")', + ctx={"_helper_service": "sx"} + ) + self.assertIn("hello", result) + + async def test_helper_with_two_args(self): + """Helper with 2 args works (e.g. highlight pattern).""" + # Define a 2-arg test helper via the generic helper binding + sx_def = '(define test-two-args (fn (a b) (helper "json-encode" (str a ":" b))))' + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(sx_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.eval( + '(test-two-args "hello" "world")', + ctx={"_helper_service": "sx"} + ) + self.assertIn("hello:world", result) + + async def test_undefined_helper_errors(self): + """Calling an uninjected helper raises an error.""" + with self.assertRaises(OcamlBridgeError) as cm: + await self.bridge.eval('(nonexistent-helper "arg")') + self.assertIn("Undefined symbol", str(cm.exception)) + + async def test_helper_in_aser(self): + """Helper works inside aser — result inlined in SX output.""" + await self._inject_test_helper("json-encode", 1) + + # aser a component-like expression that calls the helper + result = await self.bridge.aser( + '(div :class "test" (json-encode "hello"))', + ctx={"_helper_service": "sx"} + ) + # The aser should evaluate json-encode and inline the result + self.assertIn("div", result) + self.assertIn("hello", result) + + async def test_helper_in_aser_slot_component(self): + """aser_slot expands component containing helper call.""" + await self._inject_test_helper("json-encode", 1) + + # Define a component that calls the helper + async with self.bridge._lock: + self.bridge._send( + '(load-source "(defcomp ~test/code-display (&key code) ' + '(pre (code code)))")' + ) + await self.bridge._read_until_ok(ctx=None) + + # aser_slot should expand the component, evaluating the body + result = await self.bridge.aser_slot( + '(~test/code-display :code (json-encode "test-value"))', + ctx={"_helper_service": "sx"} + ) + # Should contain expanded HTML tags, not component call + self.assertIn("pre", result) + self.assertIn("test-value", result) + # Should NOT contain the component call + self.assertNotIn("~test/code-display", result) + + +class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase): + """Test that helper IO round-trips are fast enough for production.""" + + @classmethod + def setUpClass(cls): + _skip_if_no_binary() + + async def asyncSetUp(self): + self.bridge = OcamlBridge() + await self.bridge.start() + spec_dir = os.path.join(_project_root, "spec") + web_dir = os.path.join(_project_root, "web") + for f in ["parser.sx", "render.sx"]: + path = os.path.join(spec_dir, f) + if os.path.isfile(path): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(path)}")') + await self.bridge._read_until_ok(ctx=None) + adapter = os.path.join(web_dir, "adapter-sx.sx") + if os.path.isfile(adapter): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(adapter)}")') + await self.bridge._read_until_ok(ctx=None) + + async def asyncTearDown(self): + await self.bridge.stop() + + async def test_sequential_helper_calls_timing(self): + """Measure round-trip time for sequential helper calls.""" + # Inject json-encode as a fast helper + param_names = "a" + sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))' + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(sx_def)}")') + await self.bridge._read_until_ok(ctx=None) + + # Time 20 sequential calls (simulating a page with 20 highlight calls) + n_calls = 20 + calls = " ".join(f'(json-encode "{i}")' for i in range(n_calls)) + expr = f'(list {calls})' + + start = time.monotonic() + result = await self.bridge.eval(expr, ctx={"_helper_service": "sx"}) + elapsed = time.monotonic() - start + + # Should complete in under 5 seconds (generous for 20 IO round-trips) + self.assertLess(elapsed, 5.0, + f"20 helper IO round-trips took {elapsed:.1f}s (target: <5s)") + + async def test_aser_slot_with_many_helper_calls(self): + """aser_slot with multiple helper calls completes in reasonable time.""" + sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))' + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(sx_def)}")') + await self.bridge._read_until_ok(ctx=None) + + # Define a component with multiple helper calls + comp_def = ( + '(defcomp ~test/multi-helper (&key)' + ' (div' + ' (p (json-encode "a"))' + ' (p (json-encode "b"))' + ' (p (json-encode "c"))' + ' (p (json-encode "d"))' + ' (p (json-encode "e"))))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(comp_def)}")') + await self.bridge._read_until_ok(ctx=None) + + start = time.monotonic() + result = await self.bridge.aser_slot( + '(~test/multi-helper)', + ctx={"_helper_service": "sx"} + ) + elapsed = time.monotonic() - start + + self.assertIn("div", result) + self.assertLess(elapsed, 3.0, + f"aser_slot with 5 helpers took {elapsed:.1f}s (target: <3s)") + + +class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase): + """Test that :affinity :client components are NOT expanded by aser_slot.""" + + @classmethod + def setUpClass(cls): + _skip_if_no_binary() + + async def asyncSetUp(self): + self.bridge = OcamlBridge() + await self.bridge.start() + spec_dir = os.path.join(_project_root, "spec") + web_dir = os.path.join(_project_root, "web") + for f in ["parser.sx", "render.sx"]: + path = os.path.join(spec_dir, f) + if os.path.isfile(path): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(path)}")') + await self.bridge._read_until_ok(ctx=None) + adapter = os.path.join(web_dir, "adapter-sx.sx") + if os.path.isfile(adapter): + async with self.bridge._lock: + self.bridge._send(f'(load "{_escape(adapter)}")') + await self.bridge._read_until_ok(ctx=None) + + async def asyncTearDown(self): + await self.bridge.stop() + + async def test_client_affinity_not_expanded(self): + """Components with :affinity :client stay as calls in aser_slot.""" + comp_def = ( + '(defcomp ~test/client-only () :affinity :client' + ' (div "browser-only-content"))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(comp_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser_slot('(~test/client-only)') + # Should remain as a component call, NOT expanded + self.assertIn("~test/client-only", result) + self.assertNotIn("browser-only-content", result) + + async def test_server_affinity_expanded(self): + """Components with :affinity :server are expanded by regular aser.""" + comp_def = ( + '(defcomp ~test/server-only (&key label) :affinity :server' + ' (div :class "server" label))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(comp_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser( + '(~test/server-only :label "hello")') + # Should be expanded — div with class, not component call + self.assertIn("div", result) + self.assertIn("server", result) + self.assertNotIn("~test/server-only", result) + + async def test_auto_affinity_not_expanded_by_aser(self): + """Default affinity components are NOT expanded by regular aser.""" + comp_def = ( + '(defcomp ~test/auto-comp (&key label)' + ' (div "auto-content" label))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(comp_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser( + '(~test/auto-comp :label "hi")') + # Should remain as component call + self.assertIn("~test/auto-comp", result) + + async def test_island_not_expanded_by_aser_slot(self): + """Islands are NEVER expanded server-side, even with expand-all.""" + island_def = ( + '(defisland ~test/reactive-isle (&key label)' + ' (div (deref (signal 0)) label))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(island_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser_slot( + '(~test/reactive-isle :label "hello")') + # Island should be serialized as a call, NOT expanded + self.assertIn("~test/reactive-isle", result) + # Body content (deref, signal) should NOT appear + self.assertNotIn("deref", result) + self.assertNotIn("signal", result) + + async def test_island_preserved_inside_expanded_component(self): + """Island calls survive inside aser_slot-expanded components.""" + src = ( + '(defisland ~test/inner-isle (&key v) (span (deref (signal v))))' + '(defcomp ~test/outer-comp (&key title)' + ' (div (h1 title) (~test/inner-isle :v 42)))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(src)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser_slot( + '(~test/outer-comp :title "Test")') + # Outer component expanded + self.assertNotIn("~test/outer-comp", result) + self.assertIn("div", result) + self.assertIn("Test", result) + # Inner island preserved as call + self.assertIn("~test/inner-isle", result) + + async def test_auto_affinity_expanded_by_aser_slot(self): + """Default affinity components ARE expanded by aser_slot.""" + comp_def = ( + '(defcomp ~test/auto-comp2 (&key label)' + ' (div "expanded" label))' + ) + async with self.bridge._lock: + self.bridge._send(f'(load-source "{_escape(comp_def)}")') + await self.bridge._read_until_ok(ctx=None) + + result = await self.bridge.aser_slot( + '(~test/auto-comp2 :label "hi")') + # Should be expanded + self.assertIn("div", result) + self.assertIn("expanded", result) + self.assertNotIn("~test/auto-comp2", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/adapter-sx.sx b/web/adapter-sx.sx index e8278ad..cb01454 100644 --- a/web/adapter-sx.sx +++ b/web/adapter-sx.sx @@ -87,6 +87,7 @@ (and comp (macro? comp)) (aser (expand-macro comp args env) env) (and comp (component? comp) + (not (island? comp)) (or expand-all (= (component-affinity comp) "server")) ;; :affinity :client components are never expanded