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:
148
sx/sx/geography/index.sx
Normal file
148
sx/sx/geography/index.sx
Normal file
@@ -0,0 +1,148 @@
|
||||
;; Geography index — architecture overview
|
||||
;; Describes the rendering pipeline: OCaml evaluator → wire formats → client
|
||||
|
||||
(defcomp ~geography/index-content () :affinity :server
|
||||
(<>
|
||||
(h2 :class "text-3xl font-bold text-stone-800 mb-4" "Geography")
|
||||
(p :class "text-lg text-stone-600 mb-8"
|
||||
"Where code runs and how it gets there. Geography maps the rendering pipeline from server-side evaluation through wire formats to client-side hydration.")
|
||||
|
||||
;; Architecture diagram
|
||||
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 mb-8"
|
||||
(h3 :class "text-xl font-semibold text-stone-700 mb-4" "Rendering Pipeline")
|
||||
(div :class "space-y-4"
|
||||
;; Server
|
||||
(div :class "flex items-start gap-4"
|
||||
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-sky-700 bg-sky-50 rounded px-2 py-1 text-center" "OCaml kernel")
|
||||
(div
|
||||
(p :class "text-stone-700"
|
||||
"The evaluator is a CEK machine written in SX and bootstrapped to OCaml. It evaluates page definitions, expands components, resolves IO (helpers, queries), and serializes the result as SX wire format.")
|
||||
(p :class "text-sm text-stone-500 mt-1"
|
||||
"spec/evaluator.sx → hosts/ocaml/ → aser-slot with batch IO")))
|
||||
|
||||
;; Wire format
|
||||
(div :class "flex items-start gap-4"
|
||||
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-amber-700 bg-amber-50 rounded px-2 py-1 text-center" "Wire format")
|
||||
(div
|
||||
(p :class "text-stone-700"
|
||||
"The aser (async-serialize) mode produces SX text — HTML tags and component calls serialized as s-expressions. Components with server affinity are expanded; client components stay as calls. The wire format is placed in a "
|
||||
(code :class "text-sm" "<script type=\"text/sx\">")
|
||||
" tag inside the HTML shell.")
|
||||
(p :class "text-sm text-stone-500 mt-1"
|
||||
"web/adapter-sx.sx → SxExpr values pass through serialize unquoted")))
|
||||
|
||||
;; Client
|
||||
(div :class "flex items-start gap-4"
|
||||
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-emerald-700 bg-emerald-50 rounded px-2 py-1 text-center" "sx-browser.js")
|
||||
(div
|
||||
(p :class "text-stone-700"
|
||||
"The client engine parses the SX wire format, evaluates component definitions, renders the DOM, and hydrates reactive islands. It includes the same CEK evaluator (transpiled from the spec), the parser, all web adapters, and the orchestration layer for fetch/swap/polling.")
|
||||
(p :class "text-sm text-stone-500 mt-1"
|
||||
"spec/ + web/ → hosts/javascript/cli.py → sx-browser.js (~400KB)")))))
|
||||
|
||||
;; What lives where
|
||||
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "What lives where")
|
||||
|
||||
(div :class "grid md:grid-cols-2 gap-4 mb-8"
|
||||
;; Spec
|
||||
(div :class "border border-stone-200 rounded-lg p-4"
|
||||
(h4 :class "font-semibold text-stone-700 mb-2" "Spec (shared)")
|
||||
(p :class "text-sm text-stone-600 mb-2" "The canonical SX language, bootstrapped identically to OCaml, JavaScript, and Python:")
|
||||
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||
(li "CEK evaluator — frames, step function, call dispatch")
|
||||
(li "Parser — tokenizer, s-expression reader, serializer")
|
||||
(li "Primitives — ~80 built-in pure functions")
|
||||
(li "Render modes — HTML, SX wire, DOM")))
|
||||
|
||||
;; Web adapters
|
||||
(div :class "border border-stone-200 rounded-lg p-4"
|
||||
(h4 :class "font-semibold text-stone-700 mb-2" "Web Adapters")
|
||||
(p :class "text-sm text-stone-600 mb-2" "SX-defined modules that run on both server and client:")
|
||||
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||
(li "adapter-sx.sx — aser wire format (server component expansion)")
|
||||
(li "adapter-html.sx — server HTML rendering")
|
||||
(li "adapter-dom.sx — client DOM rendering")
|
||||
(li "orchestration.sx — fetch, swap, polling, navigation")
|
||||
(li "engine.sx — trigger parsing, request building")))
|
||||
|
||||
;; OCaml kernel
|
||||
(div :class "border border-stone-200 rounded-lg p-4"
|
||||
(h4 :class "font-semibold text-stone-700 mb-2" "OCaml Kernel (server)")
|
||||
(p :class "text-sm text-stone-600 mb-2" "Persistent process connected via a binary pipe protocol:")
|
||||
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||
(li "CEK evaluator + VM bytecode compiler")
|
||||
(li "Batch IO bridge — defers helper/query calls to Python")
|
||||
(li "Length-prefixed blob protocol — no string escaping")
|
||||
(li "Component hot-reload on .sx file changes")))
|
||||
|
||||
;; sx-browser.js
|
||||
(div :class "border border-stone-200 rounded-lg p-4"
|
||||
(h4 :class "font-semibold text-stone-700 mb-2" "sx-browser.js (client)")
|
||||
(p :class "text-sm text-stone-600 mb-2" "Single JS bundle transpiled from spec + web adapters:")
|
||||
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||
(li "Parses SX wire format from script tags")
|
||||
(li "Renders component trees to DOM")
|
||||
(li "Hydrates reactive islands (signals, effects)")
|
||||
(li "Client-side routing with defpage")
|
||||
(li "HTMX-like fetch/swap orchestration"))))
|
||||
|
||||
;; Rendering modes
|
||||
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "Rendering Modes")
|
||||
|
||||
(div :class "overflow-x-auto mb-8"
|
||||
(table :class "w-full text-sm"
|
||||
(thead
|
||||
(tr :class "border-b border-stone-200"
|
||||
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Mode")
|
||||
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Runs on")
|
||||
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Components")
|
||||
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Output")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" "render-to-html")
|
||||
(td :class "px-3 py-2" "Server (OCaml)")
|
||||
(td :class "px-3 py-2" "Expanded recursively")
|
||||
(td :class "px-3 py-2" "HTML string"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" "aser / aser-slot")
|
||||
(td :class "px-3 py-2" "Server (OCaml)")
|
||||
(td :class "px-3 py-2" "Server-affinity expanded; client preserved")
|
||||
(td :class "px-3 py-2" "SX wire format"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" "render-to-dom")
|
||||
(td :class "px-3 py-2" "Client (sx-browser.js)")
|
||||
(td :class "px-3 py-2" "Expanded recursively")
|
||||
(td :class "px-3 py-2" "DOM nodes"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" "client routing")
|
||||
(td :class "px-3 py-2" "Client (sx-browser.js)")
|
||||
(td :class "px-3 py-2" "defpage content evaluated locally")
|
||||
(td :class "px-3 py-2" "DOM swap")))))
|
||||
|
||||
;; Topics
|
||||
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "Topics")
|
||||
|
||||
(div :class "grid md:grid-cols-2 gap-4"
|
||||
(a :href "/sx/(geography.(hypermedia))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "Hypermedia Lakes")
|
||||
(p :class "text-sm text-stone-500" "Server-driven UI with sx-get/post/put/delete — fetch, swap, and the request lifecycle."))
|
||||
|
||||
(a :href "/sx/(geography.(reactive))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "Reactive Islands")
|
||||
(p :class "text-sm text-stone-500" "Client-side signals and effects hydrated from server-rendered HTML. defisland, deref, lakes."))
|
||||
|
||||
(a :href "/sx/(geography.(marshes))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "Marshes")
|
||||
(p :class "text-sm text-stone-500" "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive views reshape server content."))
|
||||
|
||||
(a :href "/sx/(geography.(scopes))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "Scopes")
|
||||
(p :class "text-sm text-stone-500" "Render-time dynamic scope — the primitive beneath provide, collect!, spreads, and islands."))
|
||||
|
||||
(a :href "/sx/(geography.(cek))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "CEK Machine")
|
||||
(p :class "text-sm text-stone-500" "The evaluator internals — frames, continuations, tail-call optimization, and the VM bytecode compiler."))
|
||||
|
||||
(a :href "/sx/(geography.(isomorphism))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||
(h4 :class "font-semibold text-stone-700" "Isomorphism")
|
||||
(p :class "text-sm text-stone-500" "One spec, multiple hosts — how the same SX code runs on OCaml, JavaScript, and Python.")))))
|
||||
@@ -587,7 +587,8 @@
|
||||
:path "/geography/"
|
||||
:auth :public
|
||||
:layout :sx-docs
|
||||
:content (~layouts/doc :path "/sx/(geography)"))
|
||||
:content (~layouts/doc :path "/sx/(geography)"
|
||||
(~geography/index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reactive Islands section (under Geography)
|
||||
|
||||
@@ -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