Route all rendering through OCaml bridge — render_to_html no longer uses Python async_eval. Fix register_components to parse &key params and &rest children from defcomp forms. Remove all dead sx_ref.py imports. Epoch protocol (prevents pipe desync): - Every command prefixed with (epoch N), all responses tagged with epoch - Both sides discard stale-epoch messages — desync structurally impossible - OCaml main loop discards stale io-responses between commands Consolidate scope primitives into sx_scope.ml: - Single source of truth for scope-push!/pop!/peek, collect!/collected, emit!/emitted, context, and 12 other scope operations - Removes duplicate registrations from sx_server.ml (including bugs where scope-emit! and clear-collected! were registered twice with different impls) - Bind scope prims into env so JIT VM finds them via OP_GLOBAL_GET JIT VM fixes: - Trampoline thunks before passing args to CALL_PRIM - as_list resolves thunks via _sx_trampoline_fn - len handles all value types (Bool, Number, RawHTML, SxExpr, Spread, etc.) Other fixes: - ~cssx/tw signature: (tokens) → (&key tokens) to match callers - Minimal Python evaluator in html.py for sync sx() Jinja function - Python scope primitive stubs (thread-local) for non-OCaml paths - Reader macro resolution via OcamlSync instead of sx_ref.py Tests: 1114 OCaml, 1078 JS, 35 Python regression, 6/6 Playwright SSR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
559 lines
23 KiB
Python
559 lines
23 KiB
Python
"""Tests exposing bugs after sx_ref.py removal.
|
|
|
|
These tests document all known breakages from removing the Python SX evaluator.
|
|
Each test targets a specific codepath that was depending on sx_ref.py and is now
|
|
broken.
|
|
|
|
Usage:
|
|
pytest shared/sx/tests/test_post_removal_bugs.py -v
|
|
"""
|
|
|
|
import asyncio
|
|
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.parser import parse, parse_all, serialize
|
|
from shared.sx.types import Component, Symbol, Keyword, NIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: load shared components fresh (no cache)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_components_fresh():
|
|
"""Load shared components, clearing cache to force re-parse."""
|
|
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
|
_COMPONENT_ENV.clear()
|
|
from shared.sx.components import load_shared_components
|
|
load_shared_components()
|
|
return _COMPONENT_ENV
|
|
|
|
|
|
# ===========================================================================
|
|
# 1. register_components() loses all parameter information
|
|
# ===========================================================================
|
|
|
|
class TestComponentRegistration(unittest.TestCase):
|
|
"""register_components() hardcodes params=[] and has_children=False
|
|
for every component, losing all parameter metadata."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.env = _load_components_fresh()
|
|
|
|
def test_shell_component_should_have_params(self):
|
|
"""~shared:shell/sx-page-shell has 17+ &key params but gets params=[]."""
|
|
comp = self.env.get("~shared:shell/sx-page-shell")
|
|
self.assertIsNotNone(comp, "Shell component not found")
|
|
self.assertIsInstance(comp, Component)
|
|
# BUG: params is [] — should include title, meta-html, csrf, etc.
|
|
self.assertGreater(
|
|
len(comp.params), 0,
|
|
f"Shell component has params={comp.params} — expected 17+ keyword params"
|
|
)
|
|
|
|
def test_cssx_tw_should_have_tokens_param(self):
|
|
"""~cssx/tw needs a 'tokens' parameter."""
|
|
comp = self.env.get("~cssx/tw")
|
|
self.assertIsNotNone(comp, "~cssx/tw component not found")
|
|
self.assertIn(
|
|
"tokens", comp.params,
|
|
f"~cssx/tw has params={comp.params} — expected 'tokens'"
|
|
)
|
|
|
|
def test_cart_mini_should_have_params(self):
|
|
"""~shared:fragments/cart-mini has &key params."""
|
|
comp = self.env.get("~shared:fragments/cart-mini")
|
|
self.assertIsNotNone(comp, "cart-mini component not found")
|
|
self.assertGreater(
|
|
len(comp.params), 0,
|
|
f"cart-mini has params={comp.params} — expected keyword params"
|
|
)
|
|
|
|
def test_has_children_flag(self):
|
|
"""Components with &rest children should have has_children=True."""
|
|
comp = self.env.get("~shared:shell/sx-page-shell")
|
|
self.assertIsNotNone(comp)
|
|
# Many components accept children but has_children is always False
|
|
# Check any component that is known to accept &rest children
|
|
# e.g. a layout component
|
|
for name, val in self.env.items():
|
|
if isinstance(val, Component):
|
|
# Every component has has_children=False — at least some should be True
|
|
pass
|
|
# Count how many have has_children=True
|
|
with_children = sum(
|
|
1 for v in self.env.values()
|
|
if isinstance(v, Component) and v.has_children
|
|
)
|
|
total = sum(1 for v in self.env.values() if isinstance(v, Component))
|
|
# BUG: with_children is 0 — at least some components accept children
|
|
self.assertGreater(
|
|
with_children, 0,
|
|
f"0/{total} components have has_children=True — at least some should"
|
|
)
|
|
|
|
def test_all_components_have_empty_params(self):
|
|
"""Show the scale of the bug — every single component has params=[]."""
|
|
components_with_params = []
|
|
components_without = []
|
|
for name, val in self.env.items():
|
|
if isinstance(val, Component):
|
|
if val.params:
|
|
components_with_params.append(name)
|
|
else:
|
|
components_without.append(name)
|
|
# BUG: ALL components have empty params
|
|
self.assertGreater(
|
|
len(components_with_params), 0,
|
|
f"ALL {len(components_without)} components have params=[] — none have parameters parsed"
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 2. Sync html.py rendering is completely broken
|
|
# ===========================================================================
|
|
|
|
class TestSyncHtmlRendering(unittest.TestCase):
|
|
"""html.py render() stubs _raw_eval/_trampoline — any evaluation crashes."""
|
|
|
|
def test_html_render_simple_element(self):
|
|
"""Even simple elements with keyword attrs need _eval, which is stubbed."""
|
|
from shared.sx.html import render
|
|
# This should work — (div "hello") needs no eval
|
|
result = render(parse('(div "hello")'), {})
|
|
self.assertIn("hello", result)
|
|
|
|
def test_html_render_with_keyword_attr(self):
|
|
"""Keyword attrs go through _eval, which raises RuntimeError."""
|
|
from shared.sx.html import render
|
|
try:
|
|
result = render(parse('(div :class "test" "hello")'), {})
|
|
# If it works, great
|
|
self.assertIn("test", result)
|
|
except RuntimeError as e:
|
|
self.assertIn("sx_ref.py has been removed", str(e))
|
|
self.fail(f"html.py render crashes on keyword attrs: {e}")
|
|
|
|
def test_html_render_symbol_lookup(self):
|
|
"""Symbol lookup goes through _eval, which is stubbed."""
|
|
from shared.sx.html import render
|
|
try:
|
|
result = render(parse('(div title)'), {"title": "Hello"})
|
|
self.assertIn("Hello", result)
|
|
except RuntimeError as e:
|
|
self.assertIn("sx_ref.py has been removed", str(e))
|
|
self.fail(f"html.py render crashes on symbol lookup: {e}")
|
|
|
|
def test_html_render_component(self):
|
|
"""Component rendering needs _eval for kwarg evaluation."""
|
|
from shared.sx.html import render
|
|
env = _load_components_fresh()
|
|
try:
|
|
result = render(
|
|
parse('(~shared:fragments/cart-mini :cart-count 0 :blog-url "" :cart-url "")'),
|
|
env,
|
|
)
|
|
self.assertIn("cart-mini", result)
|
|
except RuntimeError as e:
|
|
self.assertIn("sx_ref.py has been removed", str(e))
|
|
self.fail(f"html.py render crashes on component calls: {e}")
|
|
|
|
def test_sx_jinja_function_broken(self):
|
|
"""The sx() Jinja helper is broken — it uses html_render internally."""
|
|
from shared.sx.jinja_bridge import sx
|
|
env = _load_components_fresh()
|
|
try:
|
|
result = sx('(div "hello")')
|
|
self.assertIn("hello", result)
|
|
except RuntimeError as e:
|
|
self.assertIn("sx_ref.py has been removed", str(e))
|
|
self.fail(f"sx() Jinja function is broken: {e}")
|
|
|
|
|
|
# ===========================================================================
|
|
# 3. Async render_to_html uses Python path, not OCaml
|
|
# ===========================================================================
|
|
|
|
class TestAsyncRenderToHtml(unittest.IsolatedAsyncioTestCase):
|
|
"""helpers.py render_to_html() deliberately uses Python async_eval,
|
|
not the OCaml bridge. But Python eval is now broken."""
|
|
|
|
async def test_render_to_html_uses_python_path(self):
|
|
"""render_to_html goes through async_render, not OCaml bridge."""
|
|
from shared.sx.helpers import render_to_html
|
|
env = _load_components_fresh()
|
|
# The shell component has many &key params — none are bound because params=[]
|
|
try:
|
|
html = await render_to_html(
|
|
"shared:shell/sx-page-shell",
|
|
title="Test", csrf="abc", asset_url="/static",
|
|
sx_js_hash="abc123",
|
|
)
|
|
self.assertIn("Test", html)
|
|
except Exception as e:
|
|
# Expected: either RuntimeError from stubs or EvalError from undefined symbols
|
|
self.fail(
|
|
f"render_to_html (Python path) failed: {type(e).__name__}: {e}\n"
|
|
f"This should go through OCaml bridge instead"
|
|
)
|
|
|
|
async def test_async_render_component_no_params_bound(self):
|
|
"""async_eval.py _arender_component can't bind params because comp.params=[]."""
|
|
from shared.sx.async_eval import async_render
|
|
from shared.sx.primitives_io import RequestContext
|
|
env = _load_components_fresh()
|
|
# Create a simple component manually with correct params
|
|
test_comp = Component(
|
|
name="test/greeting",
|
|
params=["name"],
|
|
has_children=False,
|
|
body=parse('(div (str "Hello " name))'),
|
|
)
|
|
env["~test/greeting"] = test_comp
|
|
try:
|
|
result = await async_render(
|
|
parse('(~test/greeting :name "World")'),
|
|
env,
|
|
RequestContext(),
|
|
)
|
|
self.assertIn("Hello World", result)
|
|
except Exception as e:
|
|
self.fail(
|
|
f"async_render failed even with correct params: {type(e).__name__}: {e}"
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 4. Dead imports from removed sx_ref.py
|
|
# ===========================================================================
|
|
|
|
class TestDeadImports(unittest.TestCase):
|
|
"""Files that import from sx_ref.py will crash when their codepaths execute."""
|
|
|
|
def test_async_eval_defcomp(self):
|
|
"""async_eval.py _asf_defcomp should work as a stub (no sx_ref import)."""
|
|
from shared.sx.async_eval import _asf_defcomp
|
|
env = {}
|
|
asyncio.run(_asf_defcomp(
|
|
[Symbol("defcomp"), Symbol("~test"), [], [Symbol("div")]],
|
|
env, None
|
|
))
|
|
# Should register a minimal component in env
|
|
self.assertIn("~test", env)
|
|
|
|
def test_async_eval_defmacro(self):
|
|
"""async_eval.py _asf_defmacro should work as a stub (no sx_ref import)."""
|
|
from shared.sx.async_eval import _asf_defmacro
|
|
env = {}
|
|
asyncio.run(_asf_defmacro(
|
|
[Symbol("defmacro"), Symbol("test"), [], [Symbol("div")]],
|
|
env, None
|
|
))
|
|
self.assertIn("test", env)
|
|
|
|
def test_async_eval_defstyle(self):
|
|
"""async_eval.py _asf_defstyle should be a no-op (no sx_ref import)."""
|
|
from shared.sx.async_eval import _asf_defstyle
|
|
result = asyncio.run(_asf_defstyle(
|
|
[Symbol("defstyle"), Symbol("test"), [], [Symbol("div")]],
|
|
{}, None
|
|
))
|
|
# Should return NIL without crashing
|
|
self.assertIsNotNone(result)
|
|
|
|
def test_async_eval_defhandler(self):
|
|
"""async_eval.py _asf_defhandler should be a no-op (no sx_ref import)."""
|
|
from shared.sx.async_eval import _asf_defhandler
|
|
result = asyncio.run(_asf_defhandler(
|
|
[Symbol("defhandler"), Symbol("test"), [], [Symbol("div")]],
|
|
{}, None
|
|
))
|
|
self.assertIsNotNone(result)
|
|
|
|
def test_async_eval_continuation_reset(self):
|
|
"""async_eval.py _asf_reset imports eval_expr/trampoline from sx_ref."""
|
|
# The cont_fn inside _asf_reset will crash when invoked
|
|
from shared.sx.async_eval import _ASYNC_RENDER_FORMS
|
|
reset_fn = _ASYNC_RENDER_FORMS.get("reset")
|
|
# reset is defined in async_eval — the import is deferred to execution
|
|
# Just verify the module doesn't have the import available
|
|
try:
|
|
from shared.sx.ref.sx_ref import eval_expr
|
|
self.fail("sx_ref.py should not exist")
|
|
except (ImportError, ModuleNotFoundError):
|
|
pass # Expected
|
|
|
|
def test_ocaml_bridge_jit_compile(self):
|
|
"""ocaml_bridge.py _compile_adapter_module imports from sx_ref."""
|
|
try:
|
|
from shared.sx.ref.sx_ref import eval_expr, trampoline, PRIMITIVES
|
|
self.fail("sx_ref.py should not exist — JIT compilation path is broken")
|
|
except (ImportError, ModuleNotFoundError):
|
|
pass # Expected — confirms the bug
|
|
|
|
def test_parser_reader_macro(self):
|
|
"""parser.py _try_reader_macro imports trampoline/call_lambda from sx_ref."""
|
|
try:
|
|
from shared.sx.ref.sx_ref import trampoline, call_lambda
|
|
self.fail("sx_ref.py should not exist — reader macros are broken")
|
|
except (ImportError, ModuleNotFoundError):
|
|
pass # Expected — confirms the bug
|
|
|
|
def test_primitives_scope_prims(self):
|
|
"""primitives.py _lazy_scope_primitives silently fails to load scope prims."""
|
|
from shared.sx.primitives import _PRIMITIVES
|
|
# collect!, collected, clear-collected!, emitted, emit!, context
|
|
# These are needed for CSSX but the import from sx_ref silently fails
|
|
missing = []
|
|
for name in ("collect!", "collected", "clear-collected!", "emitted", "emit!", "context"):
|
|
if name not in _PRIMITIVES:
|
|
missing.append(name)
|
|
if missing:
|
|
self.fail(
|
|
f"Scope primitives missing from _PRIMITIVES (sx_ref import failed silently): {missing}\n"
|
|
f"CSSX components depend on these for collect!/collected"
|
|
)
|
|
|
|
def test_deps_transitive_deps_ref_path(self):
|
|
"""deps.py transitive_deps imports from sx_ref when SX_USE_REF=1."""
|
|
# The fallback path should still work
|
|
from shared.sx.deps import transitive_deps
|
|
env = _load_components_fresh()
|
|
# Should work via fallback, not crash
|
|
try:
|
|
result = transitive_deps("~cssx/tw", env)
|
|
self.assertIsInstance(result, set)
|
|
except (ImportError, ModuleNotFoundError) as e:
|
|
self.fail(f"transitive_deps crashed: {e}")
|
|
|
|
def test_handlers_python_fallback(self):
|
|
"""handlers.py eval_handler Python fallback imports async_eval_ref."""
|
|
# When not using OCaml, handler evaluation falls through to async_eval
|
|
# The ref path (SX_USE_REF=1) would crash
|
|
try:
|
|
from shared.sx.ref.async_eval_ref import async_eval_to_sx
|
|
self.fail("async_eval_ref.py should not exist")
|
|
except (ImportError, ModuleNotFoundError):
|
|
pass # Expected
|
|
|
|
|
|
# ===========================================================================
|
|
# 5. ~cssx/tw signature mismatch
|
|
# ===========================================================================
|
|
|
|
class TestCssxTwSignature(unittest.TestCase):
|
|
"""~cssx/tw changed from (&key tokens) to (tokens) positional,
|
|
but callers use :tokens keyword syntax."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.env = _load_components_fresh()
|
|
|
|
def test_cssx_tw_source_uses_positional(self):
|
|
"""Verify the current source has positional (tokens) not (&key tokens)."""
|
|
import os
|
|
cssx_path = os.path.join(
|
|
os.path.dirname(__file__), "..", "templates", "cssx.sx"
|
|
)
|
|
with open(cssx_path) as f:
|
|
source = f.read()
|
|
# Check if it's positional or keyword
|
|
if "(defcomp ~cssx/tw (tokens)" in source:
|
|
# Positional — callers using :tokens will break
|
|
self.fail(
|
|
"~cssx/tw uses positional (tokens) but callers use :tokens keyword syntax.\n"
|
|
"Should be: (defcomp ~cssx/tw (&key tokens) ...)"
|
|
)
|
|
elif "(defcomp ~cssx/tw (&key tokens)" in source:
|
|
pass # Correct
|
|
else:
|
|
# Unknown signature
|
|
for line in source.split("\n"):
|
|
if "defcomp ~cssx/tw" in line:
|
|
self.fail(f"Unexpected ~cssx/tw signature: {line.strip()}")
|
|
|
|
def test_cssx_tw_callers_use_keyword(self):
|
|
"""Scan for callers that use :tokens keyword syntax."""
|
|
import glob as glob_mod
|
|
sx_dir = os.path.join(os.path.dirname(__file__), "../../..")
|
|
keyword_callers = []
|
|
positional_callers = []
|
|
for fp in glob_mod.glob(os.path.join(sx_dir, "**/*.sx"), recursive=True):
|
|
try:
|
|
with open(fp) as f:
|
|
content = f.read()
|
|
except Exception:
|
|
continue
|
|
if "~cssx/tw" not in content:
|
|
continue
|
|
for line_no, line in enumerate(content.split("\n"), 1):
|
|
if "~cssx/tw" in line and "defcomp" not in line:
|
|
if ":tokens" in line:
|
|
keyword_callers.append(f"{fp}:{line_no}")
|
|
elif "(~cssx/tw " in line:
|
|
positional_callers.append(f"{fp}:{line_no}")
|
|
|
|
if keyword_callers:
|
|
# If signature is positional but callers use :tokens, that's a bug
|
|
import os as os_mod
|
|
cssx_path = os.path.join(
|
|
os.path.dirname(__file__), "..", "templates", "cssx.sx"
|
|
)
|
|
with open(cssx_path) as f:
|
|
source = f.read()
|
|
if "(defcomp ~cssx/tw (tokens)" in source:
|
|
self.fail(
|
|
f"~cssx/tw uses positional params but {len(keyword_callers)} callers use :tokens:\n"
|
|
+ "\n".join(keyword_callers[:5])
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 6. OCaml bridge rendering (should work — this is the good path)
|
|
# ===========================================================================
|
|
|
|
class TestOcamlBridgeRendering(unittest.IsolatedAsyncioTestCase):
|
|
"""The OCaml bridge should handle all rendering correctly."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
from shared.sx.ocaml_bridge import _DEFAULT_BIN
|
|
bin_path = os.path.abspath(_DEFAULT_BIN)
|
|
if not os.path.isfile(bin_path):
|
|
raise unittest.SkipTest("OCaml binary not found")
|
|
|
|
async def asyncSetUp(self):
|
|
from shared.sx.ocaml_bridge import OcamlBridge
|
|
self.bridge = OcamlBridge()
|
|
await self.bridge.start()
|
|
|
|
async def asyncTearDown(self):
|
|
if hasattr(self, 'bridge'):
|
|
await self.bridge.stop()
|
|
|
|
async def test_simple_element(self):
|
|
result = await self.bridge.render('(div "hello")')
|
|
self.assertIn("hello", result)
|
|
|
|
async def test_element_with_keyword_attrs(self):
|
|
result = await self.bridge.render('(div :class "test" "hello")')
|
|
self.assertIn('class="test"', result)
|
|
self.assertIn("hello", result)
|
|
|
|
async def test_component_with_params(self):
|
|
"""OCaml should handle component parameter binding correctly."""
|
|
# Use load_source to define a component (bypasses _ensure_components lock)
|
|
await self.bridge.load_source('(defcomp ~test/greet (&key name) (div (str "Hello " name)))')
|
|
result = await self.bridge.render('(~test/greet :name "World")')
|
|
self.assertIn("Hello World", result)
|
|
|
|
async def test_let_binding(self):
|
|
result = await self.bridge.render('(let ((x "hello")) (div x))')
|
|
self.assertIn("hello", result)
|
|
|
|
async def test_conditional(self):
|
|
result = await self.bridge.render('(if true (div "yes") (div "no"))')
|
|
self.assertIn("yes", result)
|
|
self.assertNotIn("no", result)
|
|
|
|
async def test_cssx_tw_keyword_call(self):
|
|
"""Test that ~cssx/tw works when called with :tokens keyword.
|
|
Components are loaded by _ensure_components() automatically."""
|
|
try:
|
|
result = await self.bridge.render('(div (~cssx/tw :tokens "bg-red-500") "content")')
|
|
# Should produce a spread with CSS class, not an error
|
|
self.assertNotIn("error", result.lower())
|
|
except Exception as e:
|
|
self.fail(f"~cssx/tw :tokens keyword call failed: {e}")
|
|
|
|
async def test_cssx_tw_positional_call(self):
|
|
"""Test that ~cssx/tw works when called positionally."""
|
|
try:
|
|
result = await self.bridge.render('(div (~cssx/tw "bg-red-500") "content")')
|
|
self.assertNotIn("error", result.lower())
|
|
except Exception as e:
|
|
self.fail(f"~cssx/tw positional call failed: {e}")
|
|
|
|
async def test_repeated_renders_dont_crash(self):
|
|
"""Verify OCaml bridge handles multiple sequential renders."""
|
|
for i in range(5):
|
|
result = await self.bridge.render(f'(div "iter-{i}")')
|
|
self.assertIn(f"iter-{i}", result)
|
|
|
|
|
|
# ===========================================================================
|
|
# 7. Scope primitives missing (collect!, collected, etc.)
|
|
# ===========================================================================
|
|
|
|
class TestScopePrimitives(unittest.TestCase):
|
|
"""Scope primitives needed by CSSX are missing because the import
|
|
from sx_ref.py silently fails."""
|
|
|
|
def test_python_primitives_have_scope_ops(self):
|
|
"""Check that collect!/collected/etc. are in _PRIMITIVES."""
|
|
from shared.sx.primitives import _PRIMITIVES
|
|
required = ["collect!", "collected", "clear-collected!",
|
|
"emitted", "emit!", "context"]
|
|
missing = [p for p in required if p not in _PRIMITIVES]
|
|
if missing:
|
|
self.fail(
|
|
f"Missing Python-side scope primitives: {missing}\n"
|
|
f"These were provided by sx_ref.py — need OCaml bridge or Python stubs"
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 8. Query executor fallback path
|
|
# ===========================================================================
|
|
|
|
class TestQueryExecutorFallback(unittest.TestCase):
|
|
"""query_executor.py imports async_eval for its fallback path."""
|
|
|
|
def test_query_executor_import(self):
|
|
"""query_executor can be imported without crashing."""
|
|
try:
|
|
import shared.sx.query_executor
|
|
except Exception as e:
|
|
self.fail(f"query_executor import crashed: {e}")
|
|
|
|
|
|
# ===========================================================================
|
|
# 9. End-to-end: sx_page shell rendering
|
|
# ===========================================================================
|
|
|
|
class TestShellRendering(unittest.IsolatedAsyncioTestCase):
|
|
"""The shell template needs to render through some path that works."""
|
|
|
|
async def test_sx_page_shell_via_python(self):
|
|
"""render_to_html('shared:shell/sx-page-shell', ...) uses Python path.
|
|
This is the actual failure from the production error log."""
|
|
from shared.sx.helpers import render_to_html
|
|
_load_components_fresh()
|
|
try:
|
|
html = await render_to_html(
|
|
"shared:shell/sx-page-shell",
|
|
title="Test Page",
|
|
csrf="test-csrf",
|
|
asset_url="/static",
|
|
sx_js_hash="abc",
|
|
)
|
|
# Should produce full HTML document
|
|
self.assertIn("<!doctype html>", html.lower())
|
|
self.assertIn("Test Page", html)
|
|
except Exception as e:
|
|
self.fail(
|
|
f"Shell rendering via Python path failed: {type(e).__name__}: {e}\n"
|
|
f"This is the exact error seen in production — "
|
|
f"render_to_html should use OCaml bridge"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|