Data-first HO forms, fix plan pages, aser error handling (1080/1080)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Evaluator: data-first higher-order forms — ho-swap-args auto-detects (map coll fn) vs (map fn coll), both work. Threading + HO: (-> data (map fn)) dispatches through CEK HO machinery via quoted-value splice. 17 new tests in test-cek-advanced.sx. Fix plan pages: add mother-language, isolated-evaluator, rust-wasm-host to page-functions.sx plan() — were in defpage but missing from URL router. Aser error handling: pages.py now catches EvalError separately, renders visible error banner instead of silently sending empty content. All except blocks include traceback in logs. Scope primitives: register collect!/collected/clear-collected!/emitted/ emit!/context in shared/sx/primitives.py so hand-written _aser can resolve them (fixes ~cssx/flush expansion failure). New test file: shared/sx/tests/test_aser_errors.py — 19 pytest tests for error propagation through all aser control flow forms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,28 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import PageDef
|
||||
import traceback
|
||||
|
||||
from .types import EvalError, PageDef
|
||||
|
||||
logger = logging.getLogger("sx.pages")
|
||||
|
||||
|
||||
def _eval_error_sx(e: EvalError, context: str) -> str:
|
||||
"""Render an EvalError as SX content that's visible to the developer."""
|
||||
from .ref.sx_ref import escape_html as _esc
|
||||
msg = _esc(str(e))
|
||||
ctx = _esc(context)
|
||||
return (
|
||||
f'(div :class "sx-eval-error" :style '
|
||||
f'"background:#fef2f2;border:1px solid #fca5a5;'
|
||||
f'color:#991b1b;padding:1rem;margin:1rem 0;'
|
||||
f'border-radius:0.5rem;font-family:monospace;white-space:pre-wrap"'
|
||||
f' (p :style "font-weight:700;margin:0 0 0.5rem" "SX EvalError in {ctx}")'
|
||||
f' (p :style "margin:0" "{msg}"))'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry — service → page-name → PageDef
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -511,8 +528,12 @@ async def execute_page_streaming(
|
||||
aside_sx = await _eval_slot(page_def.aside_expr, data_env, ctx) if page_def.aside_expr else ""
|
||||
menu_sx = await _eval_slot(page_def.menu_expr, data_env, ctx) if page_def.menu_expr else ""
|
||||
await _stream_queue.put(("data-single", content_sx, filter_sx, aside_sx, menu_sx))
|
||||
except EvalError as e:
|
||||
logger.error("Streaming data task failed (EvalError): %s\n%s", e, traceback.format_exc())
|
||||
error_sx = _eval_error_sx(e, "page content")
|
||||
await _stream_queue.put(("data-single", error_sx, "", "", ""))
|
||||
except Exception as e:
|
||||
logger.error("Streaming data task failed: %s", e)
|
||||
logger.error("Streaming data task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("data-done",))
|
||||
|
||||
async def _eval_headers():
|
||||
@@ -524,7 +545,7 @@ async def execute_page_streaming(
|
||||
menu = await layout.mobile_menu(tctx, **layout_kwargs)
|
||||
await _stream_queue.put(("headers", rows, menu))
|
||||
except Exception as e:
|
||||
logger.error("Streaming headers task failed: %s", e)
|
||||
logger.error("Streaming headers task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("headers", "", ""))
|
||||
|
||||
data_task = asyncio.create_task(_eval_data_and_content())
|
||||
@@ -629,7 +650,7 @@ async def execute_page_streaming(
|
||||
elif kind == "data-done":
|
||||
remaining -= 1
|
||||
except Exception as e:
|
||||
logger.error("Streaming resolve failed for %s: %s", kind, e)
|
||||
logger.error("Streaming resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
|
||||
|
||||
yield "\n</body>\n</html>"
|
||||
|
||||
@@ -733,8 +754,13 @@ async def execute_page_streaming_oob(
|
||||
await _stream_queue.put(("data-done",))
|
||||
return
|
||||
await _stream_queue.put(("data-done",))
|
||||
except EvalError as e:
|
||||
logger.error("Streaming OOB data task failed (EvalError): %s\n%s", e, traceback.format_exc())
|
||||
error_sx = _eval_error_sx(e, "page content")
|
||||
await _stream_queue.put(("data", "stream-content", error_sx))
|
||||
await _stream_queue.put(("data-done",))
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB data task failed: %s", e)
|
||||
logger.error("Streaming OOB data task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("data-done",))
|
||||
|
||||
async def _eval_oob_headers():
|
||||
@@ -745,7 +771,7 @@ async def execute_page_streaming_oob(
|
||||
else:
|
||||
await _stream_queue.put(("headers", ""))
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB headers task failed: %s", e)
|
||||
logger.error("Streaming OOB headers task failed: %s\n%s", e, traceback.format_exc())
|
||||
await _stream_queue.put(("headers", ""))
|
||||
|
||||
data_task = asyncio.create_task(_eval_data())
|
||||
@@ -836,7 +862,7 @@ async def execute_page_streaming_oob(
|
||||
elif kind == "data-done":
|
||||
remaining -= 1
|
||||
except Exception as e:
|
||||
logger.error("Streaming OOB resolve failed for %s: %s", kind, e)
|
||||
logger.error("Streaming OOB resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
|
||||
|
||||
return _stream_oob_chunks()
|
||||
|
||||
|
||||
@@ -573,3 +573,32 @@ def prim_json_encode(value) -> str:
|
||||
import json
|
||||
return json.dumps(value, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope primitives — delegate to sx_ref.py's scope stack implementation
|
||||
# (shared global state between transpiled and hand-written evaluators)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _lazy_scope_primitives():
|
||||
"""Register scope/provide/collect primitives from sx_ref.py.
|
||||
|
||||
Called at import time — if sx_ref.py isn't built yet, silently skip.
|
||||
These are needed by the hand-written _aser in async_eval.py when
|
||||
expanding components that use scoped effects (e.g. ~cssx/flush).
|
||||
"""
|
||||
try:
|
||||
from .ref.sx_ref import (
|
||||
sx_collect, sx_collected, sx_clear_collected,
|
||||
sx_emitted, sx_emit, sx_context,
|
||||
)
|
||||
_PRIMITIVES["collect!"] = sx_collect
|
||||
_PRIMITIVES["collected"] = sx_collected
|
||||
_PRIMITIVES["clear-collected!"] = sx_clear_collected
|
||||
_PRIMITIVES["emitted"] = sx_emitted
|
||||
_PRIMITIVES["emit!"] = sx_emit
|
||||
_PRIMITIVES["context"] = sx_context
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_lazy_scope_primitives()
|
||||
|
||||
|
||||
245
shared/sx/tests/test_aser_errors.py
Normal file
245
shared/sx/tests/test_aser_errors.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests for aser (SX wire format) error propagation.
|
||||
|
||||
Verifies that evaluation errors inside control flow forms (case, cond, if,
|
||||
when, let, begin) propagate correctly — they must throw, not silently
|
||||
produce wrong output or fall through to :else branches.
|
||||
|
||||
This test file targets the production bug where a case body referencing an
|
||||
undefined symbol was silently swallowed, causing the case to appear to fall
|
||||
through to :else instead of raising an error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sx.ref.sx_ref import (
|
||||
aser,
|
||||
sx_parse as parse_all,
|
||||
make_env,
|
||||
eval_expr,
|
||||
trampoline,
|
||||
serialize as sx_serialize,
|
||||
)
|
||||
from shared.sx.types import NIL, EvalError
|
||||
|
||||
|
||||
def _render_sx(source: str, env=None) -> str:
|
||||
"""Parse SX source and serialize via aser (sync)."""
|
||||
if env is None:
|
||||
env = make_env()
|
||||
exprs = parse_all(source)
|
||||
result = ""
|
||||
for expr in exprs:
|
||||
val = aser(expr, env)
|
||||
if isinstance(val, str):
|
||||
result += val
|
||||
elif val is None or val is NIL:
|
||||
pass
|
||||
else:
|
||||
result += sx_serialize(val)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Case — matched branch errors must throw, not fall through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCaseErrorPropagation:
|
||||
def test_matched_branch_undefined_symbol_throws(self):
|
||||
"""If the matched case body references an undefined symbol, the aser
|
||||
must throw — NOT silently skip to :else."""
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(case "x" "x" undefined_sym :else "fallback")')
|
||||
|
||||
def test_else_branch_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(case "miss" "x" "ok" :else undefined_sym)')
|
||||
|
||||
def test_matched_branch_nested_error_throws(self):
|
||||
"""Error inside a tag within the matched body must propagate."""
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(case "a" "a" (div (p undefined_nested)) :else (p "index"))')
|
||||
|
||||
def test_unmatched_correctly_falls_through(self):
|
||||
"""Verify :else works when no clause matches (happy path)."""
|
||||
result = _render_sx('(case "miss" "x" "found" :else "fallback")')
|
||||
assert "fallback" in result
|
||||
|
||||
def test_matched_branch_succeeds(self):
|
||||
"""Verify the happy path: matched branch evaluates normally."""
|
||||
result = _render_sx('(case "ok" "ok" (p "matched") :else "fallback")')
|
||||
assert "matched" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cond — matched branch errors must throw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCondErrorPropagation:
|
||||
def test_matched_branch_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(cond true undefined_cond_sym :else "fallback")')
|
||||
|
||||
def test_else_branch_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(cond false "skip" :else undefined_cond_sym)')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# If / When — body errors must throw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIfWhenErrorPropagation:
|
||||
def test_if_true_branch_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(if true undefined_if_sym "fallback")')
|
||||
|
||||
def test_when_body_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(when true undefined_when_sym)')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Let — binding or body errors must throw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLetErrorPropagation:
|
||||
def test_binding_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(let ((x undefined_let_sym)) (p x))')
|
||||
|
||||
def test_body_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(let ((x 1)) (p undefined_let_body_sym))')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Begin/Do — body errors must throw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBeginErrorPropagation:
|
||||
def test_do_body_error_throws(self):
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
_render_sx('(do "ok" undefined_do_sym)')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync aser: components serialize WITHOUT expansion (by design)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSyncAserComponentSerialization:
|
||||
"""The sync aser serializes component calls as SX wire format without
|
||||
expanding the body. This is correct — expansion only happens in the
|
||||
async path with expand_components=True."""
|
||||
|
||||
def test_component_in_case_serializes_without_expanding(self):
|
||||
"""Sync aser should serialize the component call, not expand it."""
|
||||
result = _render_sx(
|
||||
'(do (defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
|
||||
' (case "slug" "slug" (~broken :title "test") '
|
||||
' :else "index"))'
|
||||
)
|
||||
# Component call is serialized as SX, not expanded — no error
|
||||
assert "~broken" in result
|
||||
|
||||
def test_working_component_in_case_serializes(self):
|
||||
result = _render_sx(
|
||||
'(do (defcomp ~working (&key title) (div (p title)))'
|
||||
' (case "ok" "ok" (~working :title "hello") '
|
||||
' :else "index"))'
|
||||
)
|
||||
assert "~working" in result
|
||||
|
||||
def test_unmatched_case_falls_through_correctly(self):
|
||||
result = _render_sx(
|
||||
'(do (defcomp ~page (&key x) (div x))'
|
||||
' (case "miss" "hit" (~page :x "found") '
|
||||
' :else "index"))'
|
||||
)
|
||||
assert "index" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async aser with expand_components=True — the production path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAsyncAserComponentExpansion:
|
||||
"""Tests the production code path: async aser with component expansion
|
||||
enabled. Errors in expanded component bodies must propagate, not be
|
||||
silently swallowed."""
|
||||
|
||||
def _async_render(self, source: str) -> str:
|
||||
"""Render via the async aser with component expansion enabled."""
|
||||
import asyncio
|
||||
from shared.sx.ref.sx_ref import async_aser, _expand_components_cv
|
||||
exprs = parse_all(source)
|
||||
env = make_env()
|
||||
|
||||
async def run():
|
||||
token = _expand_components_cv.set(True)
|
||||
try:
|
||||
result = ""
|
||||
for expr in exprs:
|
||||
val = await async_aser(expr, env, None)
|
||||
if isinstance(val, str):
|
||||
result += val
|
||||
elif val is None or val is NIL:
|
||||
pass
|
||||
else:
|
||||
result += sx_serialize(val)
|
||||
return result
|
||||
finally:
|
||||
_expand_components_cv.reset(token)
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
def test_expanded_component_with_undefined_symbol_throws(self):
|
||||
"""When expand_components is True and the component body references
|
||||
an undefined symbol, the error must propagate — not be swallowed."""
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
self._async_render(
|
||||
'(do (defcomp ~broken (&key title) '
|
||||
' (div (p title) (p no_such_helper)))'
|
||||
' (case "slug" "slug" (~broken :title "test") '
|
||||
' :else "index"))'
|
||||
)
|
||||
|
||||
def test_expanded_working_component_succeeds(self):
|
||||
result = self._async_render(
|
||||
'(do (defcomp ~working (&key title) (div (p title)))'
|
||||
' (case "ok" "ok" (~working :title "hello") '
|
||||
' :else "index"))'
|
||||
)
|
||||
assert "hello" in result
|
||||
|
||||
def test_expanded_unmatched_falls_through(self):
|
||||
result = self._async_render(
|
||||
'(do (defcomp ~page (&key x) (div x))'
|
||||
' (case "miss" "hit" (~page :x "found") '
|
||||
' :else "index"))'
|
||||
)
|
||||
assert "index" in result
|
||||
|
||||
def test_hand_written_aser_also_propagates(self):
|
||||
"""Test the hand-written _aser in async_eval.py (the production
|
||||
path used by page rendering)."""
|
||||
import asyncio
|
||||
from shared.sx.async_eval import (
|
||||
async_eval_slot_to_sx, RequestContext,
|
||||
)
|
||||
from shared.sx.ref.sx_ref import aser
|
||||
|
||||
env = make_env()
|
||||
# Define the component via sync aser
|
||||
for expr in parse_all(
|
||||
'(defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
|
||||
):
|
||||
aser(expr, env)
|
||||
|
||||
case_expr = parse_all(
|
||||
'(case "slug" "slug" (~broken :title "test") :else "index")'
|
||||
)[0]
|
||||
ctx = RequestContext()
|
||||
|
||||
with pytest.raises(Exception, match="Undefined symbol"):
|
||||
asyncio.run(async_eval_slot_to_sx(case_expr, dict(env), ctx))
|
||||
Reference in New Issue
Block a user