OCaml raw! in HTML renderer + SX_USE_OCAML env promotion + golden tests
- sx_render.ml: add raw! handler to HTML renderer (inject pre-rendered content without HTML escaping) - docker-compose.yml: move SX_USE_OCAML/SX_OCAML_BIN to shared env (available to all services, not just sx_docs) - hosts/ocaml/Dockerfile: OCaml kernel build stage - shared/sx/tests/: golden test data + generator for OCaml render tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
353
shared/sx/tests/test_ocaml_render.py
Normal file
353
shared/sx/tests/test_ocaml_render.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""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! "<b>bold</b>")')
|
||||
self.assertEqual(html, "<b>bold</b>")
|
||||
|
||||
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 "<p>CMS content</p>")')
|
||||
self.assertEqual(html, "<p>CMS content</p>")
|
||||
|
||||
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! "<!doctype html>") '
|
||||
'(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("<!doctype html>", html)
|
||||
self.assertIn("<title>Test</title>", html)
|
||||
self.assertIn('(div :class "card" "hello")', html)
|
||||
self.assertIn("body{margin:0}", html)
|
||||
|
||||
async def test_render_never_returns_raw_sx(self):
|
||||
"""The render command must never return raw SX as the response.
|
||||
|
||||
Even if the shell component fails, the bridge.render() should
|
||||
either return HTML or raise — never return SX wire format.
|
||||
"""
|
||||
# Component that produces HTML, not SX
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~test-page (&key content) '
|
||||
'(<> (raw! "<!doctype html>") (html (body (raw! content)))))'
|
||||
)
|
||||
html = await self.bridge.render(
|
||||
'(~test-page :content "(div \\"hello\\")")'
|
||||
)
|
||||
# Must start with <!doctype, not with (
|
||||
self.assertTrue(
|
||||
html.startswith("<!doctype"),
|
||||
f"render returned SX instead of HTML: {html[:100]}",
|
||||
)
|
||||
# Must not contain bare SX component calls as visible text
|
||||
self.assertNotIn("(~test-page", html)
|
||||
|
||||
async def test_aser_slot_server_affinity_always_expands(self):
|
||||
"""Server-affinity components expand in both aser and aser-slot."""
|
||||
await self.bridge.load_source(
|
||||
'(defcomp ~golden-server (&key x) :affinity :server (div x))'
|
||||
)
|
||||
# Both modes should expand server-affinity components
|
||||
aser_result = await self.bridge.aser('(~golden-server :x "test")')
|
||||
self.assertTrue(
|
||||
"(div" in aser_result,
|
||||
f"aser should expand server-affinity, got: {aser_result}",
|
||||
)
|
||||
slot_result = await self.bridge.aser_slot('(~golden-server :x "test")')
|
||||
self.assertTrue(
|
||||
"(div" in slot_result,
|
||||
f"aser-slot should expand server-affinity, got: {slot_result}",
|
||||
)
|
||||
|
||||
|
||||
class TestOcamlCLI(unittest.TestCase):
|
||||
"""Test the --render and --aser CLI modes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.bin_path = os.path.abspath(_DEFAULT_BIN)
|
||||
if not os.path.isfile(cls.bin_path):
|
||||
raise unittest.SkipTest("OCaml binary not found")
|
||||
|
||||
def _run_cli(self, mode: str, sx_input: str) -> str:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[self.bin_path, f"--{mode}"],
|
||||
input=sx_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"CLI {mode} failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
def test_cli_render_simple(self):
|
||||
html = self._run_cli("render", '(div :class "card" (p "hello"))')
|
||||
self.assertEqual(html, '<div class="card"><p>hello</p></div>')
|
||||
|
||||
def test_cli_render_fragment(self):
|
||||
html = self._run_cli("render", '(<> (p "a") (p "b"))')
|
||||
self.assertEqual(html, "<p>a</p><p>b</p>")
|
||||
|
||||
def test_cli_render_void(self):
|
||||
html = self._run_cli("render", "(br)")
|
||||
self.assertEqual(html, "<br />")
|
||||
|
||||
def test_cli_render_conditional(self):
|
||||
html = self._run_cli("render", '(if true (p "yes") (p "no"))')
|
||||
self.assertEqual(html, "<p>yes</p>")
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user