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

@@ -97,6 +97,29 @@ let read_line_blocking () =
try Some (input_line stdin)
with End_of_file -> None
(** Read exactly N bytes from stdin (blocking). *)
let read_exact_bytes n =
let buf = Bytes.create n in
really_input stdin buf 0 n;
Bytes.to_string buf
(** Read a length-prefixed blob from stdin.
Expects the next line to be "(blob N)" where N is byte count,
followed by exactly N bytes of raw data, then a newline. *)
let read_blob () =
match read_line_blocking () with
| None -> raise (Eval_error "read_blob: stdin closed")
| Some line ->
let line = String.trim line in
match Sx_parser.parse_all line with
| [List [Symbol "blob"; Number n]] ->
let len = int_of_float n in
let data = read_exact_bytes len in
(* consume trailing newline *)
(try ignore (input_line stdin) with End_of_file -> ());
data
| _ -> raise (Eval_error ("read_blob: expected (blob N), got: " ^ line))
(** Batch IO mode — collect requests during aser-slot, resolve after. *)
let io_batch_mode = ref false
let io_queue : (int * string * value list) list ref = ref []
@@ -120,8 +143,8 @@ let io_request name args =
incr io_counter;
let id = !io_counter in
io_queue := (id, name, args) :: !io_queue;
(* Placeholder starts with ( so aser inlines it as pre-serialized SX *)
String (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id)
(* Return SxExpr so serialize/inspect passes it through unquoted *)
SxExpr (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id)
end else begin
let args_str = String.concat " " (List.map serialize_value args) in
send (Printf.sprintf "(io-request \"%s\" %s)" name args_str);
@@ -539,6 +562,17 @@ let make_server_env () =
| [a; b] -> Bool (a == b)
| _ -> raise (Eval_error "identical?: expected 2 args"));
bind "make-sx-expr" (fun args ->
match args with
| [String s] -> SxExpr s
| _ -> raise (Eval_error "make-sx-expr: expected string"));
bind "sx-expr-source" (fun args ->
match args with
| [SxExpr s] -> String s
| [String s] -> String s
| _ -> raise (Eval_error "sx-expr-source: expected sx-expr or string"));
bind "make-continuation" (fun args ->
match args with
| [f] ->
@@ -760,7 +794,7 @@ let compile_adapter env =
(* Command dispatch *)
(* ====================================================================== *)
let dispatch env cmd =
let rec dispatch env cmd =
match cmd with
| List [Symbol "ping"] ->
send_ok_string "ocaml-cek"
@@ -792,6 +826,10 @@ let dispatch env cmd =
| Eval_error msg -> send_error msg
| exn -> send_error (Printexc.to_string exn))
| List [Symbol "eval-blob"] ->
let src = read_blob () in
dispatch env (List [Symbol "eval"; String src])
| List [Symbol "eval"; String src] ->
(try
let exprs = Sx_parser.parse_all src in
@@ -827,6 +865,16 @@ let dispatch env cmd =
| Eval_error msg -> send_error msg
| exn -> send_error (Printexc.to_string exn))
| List [Symbol "aser-blob"] ->
(* Like aser but reads source as a binary blob. *)
let src = read_blob () in
dispatch env (List [Symbol "aser"; String src])
| List [Symbol "aser-slot-blob"] ->
(* Like aser-slot but reads source as a binary blob. *)
let src = read_blob () in
dispatch env (List [Symbol "aser-slot"; String src])
| List [Symbol "aser"; String src] ->
(* Evaluate and serialize as SX wire format.
Calls the SX-defined aser function from adapter-sx.sx.
@@ -920,6 +968,12 @@ let dispatch env cmd =
Hashtbl.remove env.bindings "expand-components?";
send_error (Printexc.to_string exn))
| List (Symbol "sx-page-full-blob" :: shell_kwargs) ->
(* Like sx-page-full but reads page source as a length-prefixed blob
from the next line(s), avoiding string-escape round-trip issues. *)
let page_src = read_blob () in
dispatch env (List (Symbol "sx-page-full" :: String page_src :: shell_kwargs))
| List (Symbol "sx-page-full" :: String page_src :: shell_kwargs) ->
(* Full page render: aser-slot body + render-to-html shell in ONE call.
shell_kwargs are keyword pairs: :title "..." :csrf "..." etc.
@@ -1167,6 +1221,10 @@ let cli_mode mode =
let result = Sx_ref.eval_expr call (Env env) in
(match result with
| String s | SxExpr s -> print_string s
| Dict d when Hashtbl.mem d "__aser_sx" ->
(match Hashtbl.find d "__aser_sx" with
| String s | SxExpr s -> print_string s
| v -> print_string (serialize_value v))
| _ -> print_string (serialize_value result));
flush stdout
| "aser-slot" ->
@@ -1180,6 +1238,10 @@ let cli_mode mode =
let result = Sx_ref.eval_expr call (Env env) in
(match result with
| String s | SxExpr s -> print_string s
| Dict d when Hashtbl.mem d "__aser_sx" ->
(match Hashtbl.find d "__aser_sx" with
| String s | SxExpr s -> print_string s
| v -> print_string (serialize_value v))
| _ -> print_string (serialize_value result));
flush stdout
| _ ->

View File

@@ -393,7 +393,18 @@ let rec inspect = function
| Number n ->
if Float.is_integer n then Printf.sprintf "%d" (int_of_float n)
else Printf.sprintf "%g" n
| String s -> Printf.sprintf "%S" s
| String s ->
let buf = Buffer.create (String.length s + 2) in
Buffer.add_char buf '"';
String.iter (function
| '"' -> Buffer.add_string buf "\\\""
| '\\' -> Buffer.add_string buf "\\\\"
| '\n' -> Buffer.add_string buf "\\n"
| '\r' -> Buffer.add_string buf "\\r"
| '\t' -> Buffer.add_string buf "\\t"
| c -> Buffer.add_char buf c) s;
Buffer.add_char buf '"';
Buffer.contents buf
| Symbol s -> s
| Keyword k -> ":" ^ k
| List items | ListRef { contents = items } ->