SxExpr aser wire format fix + Playwright test infrastructure + blob protocol

Aser serialization: aser-call/fragment now return SxExpr instead of String.
serialize/inspect passes SxExpr through unquoted, preventing the double-
escaping (\" → \\\" ) that broke client-side parsing when aser wire format
was output via raw! into <script> tags. Added make-sx-expr + sx-expr-source
primitives to OCaml and JS hosts.

Binary blob protocol: eval, aser, aser-slot, and sx-page-full now send SX
source as length-prefixed blobs instead of escaped strings. Eliminates pipe
desync from concurrent requests and removes all string-escape round-trips
between Python and OCaml.

Bridge safety: re-entrancy guard (_in_io_handler) raises immediately if an
IO handler tries to call the bridge, preventing silent deadlocks.

Fetch error logging: orchestration.sx error callback now logs method + URL
via log-warn. Platform catches (fetchAndRestore, fetchPreload, bindBoostForm)
also log errors instead of silently swallowing them.

Transpiler fixes: makeEnv, scopePeek, scopeEmit, makeSxExpr added as
platform function definitions + transpiler mappings — were referenced in
transpiled code but never defined as JS functions.

Playwright test infrastructure:
- nav() captures JS errors and fails fast with the actual error message
- Checks for [object Object] rendering artifacts
- New tests: delete-row interaction, full page refresh, back button,
  direct load with fresh context, code block content verification
- Default base URL changed to localhost:8013 (standalone dev server)
- docker-compose.dev-sx.yml: port 8013 exposed for local testing
- test-sx-build.sh: build + unit tests + Playwright smoke tests

Geography content: index page component written (sx/sx/geography/index.sx)
describing OCaml evaluator, wire formats, rendering pipeline, and topic
links. Wiring blocked by aser-expand-component children passing issue.

Tests: 1080/1080 JS, 952/952 OCaml, 66/66 Playwright

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 22:17:43 +00:00
parent 6d73edf297
commit df461beec2
17 changed files with 684 additions and 82 deletions

View File

@@ -15,7 +15,7 @@ import pytest
from playwright.sync_api import Page, expect
import os
BASE = os.environ.get("SX_TEST_BASE", "https://sx.rose-ash.com")
BASE = os.environ.get("SX_TEST_BASE", "http://localhost:8013")
# ---------------------------------------------------------------------------
@@ -28,10 +28,33 @@ def browser_context_args():
def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content."""
"""Navigate to an SX URL and wait for rendered content.
Captures JS errors during page load and fails immediately with the
actual error message instead of waiting for a 30s timeout.
"""
js_errors: list[str] = []
page.on("pageerror", lambda err: js_errors.append(str(err)))
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Poll briefly for JS errors — pageerror fires async during networkidle
for _ in range(10):
if js_errors:
break
page.wait_for_timeout(100)
# Fail fast on JS errors — don't wait for content that will never appear
if js_errors:
pytest.fail(f"JS error on {path}: {js_errors[0]}")
# 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=30000)
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=10000)
# Check for rendering artifacts that indicate broken serialization
main_text = page.locator("#main-panel").text_content() or ""
if "[object Object]" in main_text:
pytest.fail(f"Rendering artifact on {path}: [object Object] in page content")
# ---------------------------------------------------------------------------
@@ -56,6 +79,20 @@ class TestFormSubmission:
nav(page, "(geography.(hypermedia.(example.form-submission)))")
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
def test_has_code_examples(self, page: Page):
"""Page must show component source and handler source code."""
nav(page, "(geography.(hypermedia.(example.form-submission)))")
code_blocks = page.locator("pre code")
expect(code_blocks.first).not_to_be_empty(timeout=5000)
# Should have at least component code and handler code
count = code_blocks.count()
assert count >= 2, f"Expected at least 2 code blocks, got {count}"
# Handler code should contain defhandler
all_code = " ".join(code_blocks.nth(i).text_content() or ""
for i in range(count))
assert "defhandler" in all_code or "sx-post" in all_code, \
f"Code blocks should contain handler or component source"
def test_submit_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
page.fill("input[name='name']", "TestUser")
@@ -78,6 +115,16 @@ class TestDeleteRow:
nav(page, "(geography.(hypermedia.(example.delete-row)))")
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
def test_delete_removes_row(self, page: Page):
nav(page, "(geography.(hypermedia.(example.delete-row)))")
rows = page.locator("#delete-rows tr")
initial_count = rows.count()
assert initial_count >= 2, f"Expected at least 2 rows, got {initial_count}"
# Accept the confirm dialog
page.on("dialog", lambda d: d.accept())
rows.first.locator("button:has-text('delete')").click()
expect(rows).to_have_count(initial_count - 1, timeout=5000)
class TestInlineEdit:
def test_page_loads(self, page: Page):
@@ -642,3 +689,46 @@ class TestClientNavigation:
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("25")
def test_full_refresh_preserves_content(self, page: Page):
"""Navigate to a page, then hard-refresh — content must re-render."""
errors = _setup_error_capture(page)
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
# Hard refresh
page.reload(wait_until="networkidle")
page.wait_for_timeout(2000)
expect(page.locator("#main-panel")).to_contain_text("Click to Load", timeout=10000)
_check_no_fatal_errors(errors)
def test_back_button_after_navigation(self, page: Page):
"""Navigate A → B, then back — A must re-render without errors."""
errors = _setup_error_capture(page)
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
# Navigate to temperature
temp_link = page.locator("a[sx-push-url]:has-text('Temperature')").first
expect(temp_link).to_be_visible(timeout=5000)
temp_link.click()
page.wait_for_timeout(3000)
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
# Go back
page.go_back()
page.wait_for_timeout(3000)
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
_check_no_fatal_errors(errors)
def test_direct_load_no_js_errors(self, browser):
"""Fresh browser context — direct page load must have zero JS errors."""
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
try:
errors = _setup_error_capture(page)
page.goto(f"{BASE}/sx/(geography.(hypermedia.(example.delete-row)))", wait_until="networkidle")
page.wait_for_timeout(3000)
page.wait_for_selector("#main-panel", timeout=15000)
_check_no_fatal_errors(errors)
finally:
ctx.close()