Fix aser server-affinity expansion: keyword values, OOB wrapper, page helpers

Three bugs in aser-expand-component (adapter-sx.sx):
- Keyword values were eval'd (eval-expr can't handle <>, HTML tags);
  now asered, matching the aser's rendering capabilities
- Missing default nil binding for unset &key params (caused
  "Undefined symbol" errors for optional params like header-rows)
- aserCall string-quoted keyword values that were already serialized
  SX — now inlines values starting with "(" directly

Server-affinity annotations for layout/nav shells:
- ~shared:layout/app-body, ~shared:layout/oob-sx — page structure
- ~layouts/nav-sibling-row, ~layouts/nav-children — server-side data
- ~layouts/doc already had :affinity :server
- ~cssx/flush marked :affinity :client (browser-only state)

Navigation fix: restore oob_page_sx wrapper for HTMX responses
so #main-panel section exists for sx-select/sx-swap targeting.

OCaml bridge: lazy page helper injection into kernel via IO proxy
(define name (fn (...) (helper "name" ...))) — enables aser_slot
to evaluate highlight/component-source etc. via coroutine bridge.

Playwright tests: added pageerror listener to test_no_console_errors,
new test_navigate_from_home_to_geography for HTMX nav regression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:06:24 +00:00
parent 171c18d3be
commit 109ca7c70b
10 changed files with 201 additions and 31 deletions

View File

@@ -57,7 +57,7 @@
;; Current section with prev/next siblings.
;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
;; Current page is larger in the leaf (bottom) row.
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth)
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth) :affinity :server
(let* ((sibs (or siblings (list)))
(count (len sibs))
;; opacity = (n/x * 3/4) + 1/4
@@ -97,7 +97,7 @@
(str (get next-node "label") " \u2192")))))))
;; Children links — shown as clearly clickable buttons.
(defcomp ~layouts/nav-children (&key items)
(defcomp ~layouts/nav-children (&key items) :affinity :server
(div :class "max-w-3xl mx-auto px-4 py-3"
(div :class "flex flex-wrap justify-center gap-2"
(map (fn (item)

View File

@@ -33,4 +33,5 @@ def _load_sx_page_files() -> None:
# helper is registered as an IO primitive in primitives_io.py,
# intercepted by async_eval before hitting the CEK machine.
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
load_page_dir(os.path.dirname(__file__), "sx")

View File

@@ -194,14 +194,24 @@ async def eval_sx_url(raw_path: str) -> Any:
]
try:
content_sx = await _eval_slot(wrapped_ast, env, ctx)
import os as _os
if _os.environ.get("SX_USE_OCAML") == "1":
from shared.sx.ocaml_bridge import get_bridge
from shared.sx.parser import serialize
from shared.sx.types import SxExpr
bridge = await get_bridge()
sx_text = serialize(wrapped_ast)
ocaml_ctx = {"_helper_service": "sx"}
content_sx = SxExpr(await bridge.aser(sx_text, ctx=ocaml_ctx))
else:
content_sx = await _eval_slot(wrapped_ast, env, ctx)
except Exception as e:
logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True)
return None
# Return response — Python wraps in page shell (CSS, scripts, headers)
if is_htmx_request():
return sx_response(content_sx)
return sx_response(await oob_page_sx(content=content_sx))
else:
tctx = await get_template_context()
html = await full_page_sx(tctx, header_rows="", content=content_sx)

View File

@@ -31,7 +31,7 @@ def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content."""
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Wait for SX to render — look for any heading or paragraph in main panel
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=15000)
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=30000)
# ---------------------------------------------------------------------------
@@ -506,6 +506,37 @@ class TestSpecExplorer:
# Key doc pages (smoke tests)
# ---------------------------------------------------------------------------
class TestHomePage:
def test_home_loads(self, page: Page):
nav(page, "")
expect(page.locator("#main-panel")).to_contain_text("sx", timeout=10000)
def test_no_console_errors(self, page: Page):
"""Home page should have no JS errors (console or uncaught)."""
errors = []
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
page.goto(f"{BASE}/sx/", wait_until="networkidle")
page.wait_for_timeout(3000)
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
assert not fatal, f"JS errors on home page: {fatal}"
def test_navigate_from_home_to_geography(self, page: Page):
"""Click Geography nav link from home — content must render."""
errors = []
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
nav(page, "")
# Click the Geography link in the nav children
geo_link = page.locator("a[sx-push-url]:has-text('Geography')").first
expect(geo_link).to_be_visible(timeout=10000)
geo_link.click()
page.wait_for_timeout(5000)
# Content must still be visible after navigation
expect(page.locator("#main-panel")).to_contain_text("Geography", timeout=10000)
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
assert not fatal, f"JS errors after navigation: {fatal}"
class TestDocPages:
@pytest.mark.parametrize("path,expected", [
("(geography.(reactive))", "Reactive Islands"),