"""Golden HTML rendering tests against the OCaml SX kernel. Loads curated test cases from golden_data.json and verifies the OCaml kernel produces identical HTML output. Also tests aser and aser-slot modes. Usage: pytest shared/sx/tests/test_ocaml_render.py -v """ import asyncio import json import os import sys 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 _GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "golden_data.json") def _load_golden() -> list[dict]: """Load golden test data.""" if not os.path.isfile(_GOLDEN_PATH): return [] with open(_GOLDEN_PATH) as f: return json.load(f) class TestOcamlGoldenRender(unittest.IsolatedAsyncioTestCase): """Golden HTML tests — compare OCaml render output to Python-generated HTML.""" @classmethod def setUpClass(cls): 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" ) cls.golden = _load_golden() if not cls.golden: raise unittest.SkipTest( f"No golden data at {_GOLDEN_PATH}. " f"Generate with: python3 shared/sx/tests/generate_golden.py" ) async def asyncSetUp(self): self.bridge = OcamlBridge() await self.bridge.start() async def asyncTearDown(self): await self.bridge.stop() # Cases with known issues (spec-only functions, attribute order, etc.) _RENDER_SKIP = {"filter_even", "void_input", "do_block"} async def test_golden_render(self): """Each golden case: OCaml render matches Python HTML.""" passed = 0 failed = [] for case in self.golden: name = case["name"] if name in self._RENDER_SKIP: continue sx_input = case["sx_input"] expected = case["expected_html"] try: actual = await asyncio.wait_for( self.bridge.render(sx_input), timeout=5.0 ) if actual.strip() == expected.strip(): passed += 1 else: failed.append((name, expected, actual)) except asyncio.TimeoutError: failed.append((name, expected, "TIMEOUT")) # Bridge may be desynced — stop and restart await self.bridge.stop() self.bridge = OcamlBridge() await self.bridge.start() except OcamlBridgeError as e: failed.append((name, expected, f"ERROR: {e}")) if failed: msg_parts = [f"\n{len(failed)} golden render mismatches:\n"] for name, expected, actual in failed[:10]: msg_parts.append(f" {name}:") msg_parts.append(f" expected: {expected[:120]}") msg_parts.append(f" actual: {actual[:120]}") self.fail("\n".join(msg_parts)) # Cases that use spec-only functions or macros with &rest that don't # round-trip through aser cleanly (render still works fine). # Cases that use spec-only functions, macros with &rest, or trigger # known parity issues in aser expansion (render still works fine). _ASER_SKIP = {"filter_even", "macro_unless"} _ASER_SLOT_SKIP = {"filter_even", "macro_unless", "defcomp_no_optional"} async def test_golden_aser(self): """Each golden case: OCaml aser produces valid SX wire format.""" passed = 0 errors = [] for case in self.golden: name = case["name"] if name in self._ASER_SKIP: continue sx_input = case["sx_input"] try: result = await self.bridge.aser(sx_input) # aser should produce some output (string, not empty) if result is not None: passed += 1 else: errors.append((name, "returned None")) except OcamlBridgeError as e: errors.append((name, str(e))) if errors: msg_parts = [f"\n{len(errors)} aser errors:\n"] for name, err in errors[:10]: msg_parts.append(f" {name}: {err[:120]}") self.fail("\n".join(msg_parts)) async def test_golden_aser_slot(self): """Each golden case: OCaml aser-slot produces valid SX wire format.""" passed = 0 errors = [] for case in self.golden: name = case["name"] if name in self._ASER_SLOT_SKIP: continue sx_input = case["sx_input"] try: result = await self.bridge.aser_slot(sx_input) if result is not None: passed += 1 else: errors.append((name, "returned None")) except OcamlBridgeError as e: errors.append((name, str(e))) if errors: msg_parts = [f"\n{len(errors)} aser-slot errors:\n"] for name, err in errors[:10]: msg_parts.append(f" {name}: {err[:120]}") self.fail("\n".join(msg_parts)) async def test_aser_slot_expands_components(self): """aser-slot expands component calls while aser does not.""" await self.bridge.load_source( '(defcomp ~golden-test (&key label) (span :class "tag" label))' ) # aser should preserve the component call aser_result = await self.bridge.aser('(~golden-test :label "Hi")') self.assertTrue( aser_result.startswith("(~golden-test"), f"aser should preserve component call, got: {aser_result}", ) # aser-slot should expand the component slot_result = await self.bridge.aser_slot('(~golden-test :label "Hi")') self.assertTrue( slot_result.startswith("(span"), f"aser-slot should expand component, got: {slot_result}", ) async def test_aser_does_not_crash_on_component_call(self): """Regression: aser with a component call must not crash. This catches the bug where adapter-sx.sx called expand-components? without guarding env-has?, causing 'Undefined symbol' on kernels that don't bind it or when aser (not aser-slot) is used. """ await self.bridge.load_source( '(defcomp ~regress-comp (&key title &rest children) ' '(div :class "box" (h2 title) children))' ) # aser must succeed (serialize the component call, not expand it) result = await self.bridge.aser( '(~regress-comp :title "Hello" (p "world"))' ) self.assertIn("~regress-comp", result) self.assertIn('"Hello"', result) async def test_render_raw_html(self): """Regression: raw! must inject HTML without escaping.""" html = await self.bridge.render('(raw! "bold")') self.assertEqual(html, "bold") async def test_render_component_with_raw(self): """Regression: component using raw! (like ~shared:misc/rich-text).""" await self.bridge.load_source( '(defcomp ~rich-text (&key html) (raw! html))' ) html = await self.bridge.render('(~rich-text :html "
CMS content
")') self.assertEqual(html, "CMS content
") async def test_aser_nested_components_no_crash(self): """Regression: aser with nested component calls must not crash.""" await self.bridge.load_source( '(defcomp ~outer-reg (&key title &rest children) ' '(section (h1 title) children))' ) await self.bridge.load_source( '(defcomp ~inner-reg (&key text) (span text))' ) result = await self.bridge.aser( '(~outer-reg :title "Outer" (~inner-reg :text "Inner"))' ) self.assertIn("~outer-reg", result) self.assertIn("~inner-reg", result) async def test_render_shell_with_raw(self): """Integration: shell component with raw! renders full HTML page. The page shell uses raw! extensively for script content, CSS, pre-rendered HTML, etc. This catches missing raw! in the renderer. """ await self.bridge.load_source( '(defcomp ~test-shell (&key title page-sx css) ' '(<> (raw! "") ' '(html (head (title title) (style (raw! (or css "")))) ' '(body (script :type "text/sx" (raw! (or page-sx "")))))))' ) html = await self.bridge.render( '(~test-shell :title "Test" ' ':page-sx "(div :class \\"card\\" \\"hello\\")" ' ':css "body{margin:0}")' ) self.assertIn("", html) self.assertIn("hello
a
b
") def test_cli_render_void(self): html = self._run_cli("render", "(br)") self.assertEqual(html, "yes
") def test_cli_aser_with_defcomp(self): """CLI --aser with component def + call must not crash.""" sx = ('(do (defcomp ~cli-test (&key title) (div title)) ' '(~cli-test :title "Hi"))') result = self._run_cli("aser", sx) self.assertIn("~cli-test", result) # Same skip list as the bridge golden tests _CLI_RENDER_SKIP = {"filter_even", "void_input", "do_block"} def test_cli_golden_render(self): """Run all golden cases through CLI --render.""" golden = _load_golden() if not golden: self.skipTest("No golden data") failed = [] for case in golden: if case["name"] in self._CLI_RENDER_SKIP: continue try: actual = self._run_cli("render", case["sx_input"]) if actual.strip() != case["expected_html"].strip(): failed.append((case["name"], case["expected_html"], actual)) except Exception as e: failed.append((case["name"], case["expected_html"], str(e))) if failed: msg_parts = [f"\n{len(failed)} CLI golden render mismatches:\n"] for name, expected, actual in failed[:10]: msg_parts.append(f" {name}:") msg_parts.append(f" expected: {expected[:120]}") msg_parts.append(f" actual: {actual[:120]}") self.fail("\n".join(msg_parts)) if __name__ == "__main__": unittest.main()