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

@@ -14,11 +14,12 @@
(define render-to-sx :effects [render]
(fn (expr (env :as dict))
(let ((result (aser expr env)))
;; aser-call already returns serialized SX strings;
;; only serialize non-string values
(if (= (type-of result) "string")
result
(serialize result)))))
;; aser-call returns SxExpr which serialize passes through unquoted.
;; Plain strings from data need serialization (quoting).
(cond
(= (type-of result) "sx-expr") (sx-expr-source result)
(= (type-of result) "string") result
:else (serialize result)))))
(define aser :effects [render]
(fn ((expr :as any) (env :as dict))
@@ -143,21 +144,15 @@
(let ((result (aser c env)))
(cond
(nil? result) nil
;; Serialized SX from aser (tags, components, fragments)
;; starts with "(" — use directly without re-quoting
(and (= (type-of result) "string")
(> (string-length result) 0)
(starts-with? result "("))
(append! parts result)
(= (type-of result) "sx-expr")
(append! parts (sx-expr-source result))
;; list results (from map etc.)
(= (type-of result) "list")
(for-each
(fn (item)
(when (not (nil? item))
(if (and (= (type-of item) "string")
(> (string-length item) 0)
(starts-with? item "("))
(append! parts item)
(if (= (type-of item) "sx-expr")
(append! parts (sx-expr-source item))
(append! parts (serialize item)))))
result)
;; Everything else — serialize normally (quotes strings)
@@ -167,8 +162,8 @@
(if (empty? parts)
""
(if (= (len parts) 1)
(first parts)
(str "(<> " (join " " parts) ")"))))))
(make-sx-expr (first parts))
(make-sx-expr (str "(<> " (join " " parts) ")")))))))
(define aser-call :effects [render]
@@ -193,33 +188,23 @@
(let ((val (aser (nth args (inc i)) env)))
(when (not (nil? val))
(append! attr-parts (str ":" (keyword-name arg)))
;; If the aser result is already serialized SX (starts
;; with "("), inline it directly — don't re-serialize
;; which would quote it as a string literal.
(if (and (= (type-of val) "string")
(> (string-length val) 0)
(starts-with? val "("))
(append! attr-parts val)
(if (= (type-of val) "sx-expr")
(append! attr-parts (sx-expr-source val))
(append! attr-parts (serialize val))))
(set! skip true)
(set! i (inc i)))
(let ((val (aser arg env)))
(when (not (nil? val))
(cond
;; Serialized SX (tags, components) — use directly
(and (= (type-of val) "string")
(> (string-length val) 0)
(starts-with? val "("))
(append! child-parts val)
(= (type-of val) "sx-expr")
(append! child-parts (sx-expr-source val))
;; List results (from map etc.)
(= (type-of val) "list")
(for-each
(fn (item)
(when (not (nil? item))
(if (and (= (type-of item) "string")
(> (string-length item) 0)
(starts-with? item "("))
(append! child-parts item)
(if (= (type-of item) "sx-expr")
(append! child-parts (sx-expr-source item))
(append! child-parts (serialize item)))))
val)
;; Plain values — serialize normally
@@ -239,7 +224,7 @@
(scope-peek "element-attrs"))
(scope-pop! "element-attrs")
(let ((parts (concat (list name) attr-parts child-parts)))
(str "(" (join " " parts) ")")))))
(make-sx-expr (str "(" (join " " parts) ")"))))))
;; --------------------------------------------------------------------------
@@ -272,6 +257,7 @@
(if (and (= (type-of arg) "keyword")
(< (inc i) (len args)))
;; Keyword arg: bind name = aser'd next arg
;; SxExpr values pass through serialize unquoted automatically
(do
(env-bind! local (keyword-name arg)
(aser (nth args (inc i)) env))

View File

@@ -197,6 +197,7 @@
(clear-loading-state el indicator disabled-elts)
(revert-optimistic optimistic-state)
(when (not (abort-error? err))
(log-warn (str "sx:fetch error " method " " final-url " — " err))
(dom-dispatch el "sx:requestError"
(dict "error" err))))))))))))

View File

@@ -199,7 +199,16 @@
(deftest "component with children serializes unexpanded"
(assert-equal "(~box (p \"inside\"))"
(render-sx "(do (defcomp ~box (&key &rest children) (div children))
(~box (p \"inside\")))"))))
(~box (p \"inside\")))")))
(deftest "string keyword arg starting with paren is quoted"
(assert-equal "(~info :text \"(hello world)\")"
(render-sx "(do (defcomp ~info (&key text) (code text))
(~info :text \"(hello world)\"))")))
(deftest "string child starting with paren is quoted"
(assert-equal "(p \"(not code)\")"
(render-sx "(let ((x \"(not code)\")) (p x))"))))
;; --------------------------------------------------------------------------