Files
rose-ash/shared/sx/tests/test_ocaml_render.py
giles bb34b4948b 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>
2026-03-22 22:21:04 +00:00

354 lines
14 KiB
Python

"""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()