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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user