- 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>
354 lines
14 KiB
Python
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()
|