sx-tools: WASM kernel updates, TW/CSSX rework, content refresh, new debugging tools
Build tooling: updated OCaml bootstrapper, compile-modules, bundle.sh, sx-build-all. WASM browser: rebuilt sx_browser.bc.js/wasm, sx-platform-2.js, .sxbc bytecode files. CSSX/Tailwind: reworked cssx.sx templates and tw-layout, added tw-type support. Content: refreshed essays, plans, geography, reactive islands, docs, demos, handlers. New tools: bisect_sxbc.sh, test-spa.js, render-trace.sx, morph playwright spec. Tests: added test-match.sx, test-examples.sx, updated test-tw.sx and web tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1078,6 +1078,8 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
|
||||
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
|
||||
PRIMITIVES["into"] = function(target, coll) {
|
||||
if (target === "list") return Array.isArray(coll) ? coll.slice() : Object.entries(coll).map(function(e) { return [e[0], e[1]]; });
|
||||
if (target === "dict") { var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } return r; }
|
||||
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
|
||||
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
|
||||
return r;
|
||||
|
||||
@@ -609,7 +609,7 @@ let rec handle_tool name args =
|
||||
"render.sx"; "core-signals.sx"; "signals.sx"; "deps.sx"; "router.sx";
|
||||
"page-helpers.sx"; "freeze.sx"; "bytecode.sx"; "compiler.sx"; "vm.sx";
|
||||
"dom.sx"; "browser.sx"; "adapter-html.sx"; "adapter-sx.sx"; "adapter-dom.sx";
|
||||
"cssx.sx";
|
||||
"cssx.sx"; "tw-layout.sx"; "tw-type.sx"; "tw.sx";
|
||||
"boot-helpers.sx"; "hypersx.sx"; "harness.sx"; "harness-reactive.sx";
|
||||
"harness-web.sx"; "engine.sx"; "orchestration.sx"; "boot.sx";
|
||||
] in
|
||||
|
||||
@@ -199,11 +199,9 @@ let make_test_env () =
|
||||
|
||||
bind "env-bind!" (fun args ->
|
||||
match args with
|
||||
| [e; String k; v] ->
|
||||
let ue = uw e in
|
||||
if k = "x" || k = "children" || k = "i" then
|
||||
Printf.eprintf "[env-bind!] '%s' env-id=%d bindings-before=%d\n%!" k (Obj.obj (Obj.repr ue) : int) (Hashtbl.length ue.Sx_types.bindings);
|
||||
Sx_types.env_bind ue k v
|
||||
| [Dict d; String k; v] -> Hashtbl.replace d k v; v
|
||||
| [Dict d; Keyword k; v] -> Hashtbl.replace d k v; v
|
||||
| [e; String k; v] -> Sx_types.env_bind (uw e) k v
|
||||
| [e; Keyword k; v] -> Sx_types.env_bind (uw e) k v
|
||||
| _ -> raise (Eval_error "env-bind!: expected env, key, value"));
|
||||
|
||||
@@ -232,7 +230,12 @@ let make_test_env () =
|
||||
|
||||
bind "identical?" (fun args ->
|
||||
match args with
|
||||
| [a; b] -> Bool (a == b)
|
||||
| [a; b] -> Bool (match a, b with
|
||||
| Number x, Number y -> x = y
|
||||
| String x, String y -> x = y
|
||||
| Bool x, Bool y -> x = y
|
||||
| Nil, Nil -> true
|
||||
| _ -> a == b)
|
||||
| _ -> raise (Eval_error "identical?: expected 2 args"));
|
||||
|
||||
(* --- Continuation support --- *)
|
||||
@@ -456,17 +459,27 @@ let make_test_env () =
|
||||
(match stack with _ :: rest -> Hashtbl.replace _scope_stacks name (List [] :: rest) | [] -> ()); Nil
|
||||
| _ -> Nil);
|
||||
bind "regex-find-all" (fun args ->
|
||||
(* Stub: supports simple ~name pattern for component scanning *)
|
||||
(* Stub: supports ~name patterns for component scanning *)
|
||||
match args with
|
||||
| [String pattern; String text] ->
|
||||
let prefix = if String.length pattern > 2 && pattern.[0] = '(' then
|
||||
(* Extract literal prefix from pattern like "(~[a-z/.-]+" → "~" *)
|
||||
let s = String.sub pattern 1 (String.length pattern - 1) in
|
||||
let p = try String.sub s 0 (String.index s '[')
|
||||
with Not_found -> try String.sub s 0 (String.index s '(')
|
||||
with Not_found -> s in
|
||||
if String.length p > 0 then p else "~"
|
||||
else pattern in
|
||||
(* Extract the literal prefix from patterns like:
|
||||
"(~[a-z/.-]+" → prefix "~", has_group=true
|
||||
"\(~([a-zA-Z_]..." → prefix "(~", has_group=true *)
|
||||
let prefix, has_group =
|
||||
if String.length pattern >= 4 && pattern.[0] = '\\' && pattern.[1] = '(' then
|
||||
(* Pattern like \(~(...) — literal "(" + "~" prefix, group after *)
|
||||
let s = String.sub pattern 2 (String.length pattern - 2) in
|
||||
let lit_end = try String.index s '(' with Not_found -> try String.index s '[' with Not_found -> String.length s in
|
||||
let lit = String.sub s 0 lit_end in
|
||||
("(" ^ lit, true)
|
||||
else if String.length pattern > 2 && pattern.[0] = '(' then
|
||||
let s = String.sub pattern 1 (String.length pattern - 1) in
|
||||
let p = try String.sub s 0 (String.index s '[')
|
||||
with Not_found -> try String.sub s 0 (String.index s '(')
|
||||
with Not_found -> s in
|
||||
((if String.length p > 0 then p else "~"), true)
|
||||
else (pattern, false)
|
||||
in
|
||||
let results = ref [] in
|
||||
let len = String.length text in
|
||||
let plen = String.length prefix in
|
||||
@@ -480,7 +493,12 @@ let make_test_env () =
|
||||
|| c = '-' || c = '/' || c = '_' || c = '.' do
|
||||
incr j
|
||||
done;
|
||||
results := String (String.sub text !i (!j - !i)) :: !results;
|
||||
let full_match = String.sub text !i (!j - !i) in
|
||||
(* If pattern has capture group, strip the literal prefix to simulate group 1 *)
|
||||
let result = if has_group then
|
||||
String.sub full_match plen (String.length full_match - plen)
|
||||
else full_match in
|
||||
results := String result :: !results;
|
||||
i := !j
|
||||
end else incr i
|
||||
done;
|
||||
@@ -870,6 +888,76 @@ let make_test_env () =
|
||||
Dict d
|
||||
| _ -> Nil);
|
||||
|
||||
(* --- Stubs for offline/IO tests --- *)
|
||||
bind "log-info" (fun _args -> Nil);
|
||||
bind "log-warn" (fun _args -> Nil);
|
||||
bind "log-error" (fun _args -> Nil);
|
||||
bind "execute-action" (fun _args -> Nil);
|
||||
|
||||
(* --- make-page-def for defpage tests --- *)
|
||||
bind "make-page-def" (fun args ->
|
||||
let convert_val = function Keyword k -> String k | v -> v in
|
||||
let make_pdef name slots =
|
||||
let d = Hashtbl.create 8 in
|
||||
Hashtbl.replace d "__type" (String "page");
|
||||
Hashtbl.replace d "name" (String name);
|
||||
(* Defaults for missing fields *)
|
||||
Hashtbl.replace d "stream" (Bool false);
|
||||
Hashtbl.replace d "shell" Nil;
|
||||
Hashtbl.replace d "fallback" Nil;
|
||||
Hashtbl.replace d "data" Nil;
|
||||
(* Override with actual slot values *)
|
||||
Hashtbl.iter (fun k v -> Hashtbl.replace d k (convert_val v)) slots;
|
||||
Dict d
|
||||
in
|
||||
match args with
|
||||
| [String name; Dict slots; _env] -> make_pdef name slots
|
||||
| [String name; Dict slots] -> make_pdef name slots
|
||||
| _ -> Nil);
|
||||
|
||||
(* --- component-io-refs for deps.sx tests --- *)
|
||||
bind "component-io-refs" (fun args ->
|
||||
match args with
|
||||
| [Component c] ->
|
||||
(* Scan body for IO calls — look for known IO functions *)
|
||||
let rec scan = function
|
||||
| List (Symbol s :: _) when
|
||||
s = "fetch" || s = "fetch-data" || s = "query" || s = "action" ||
|
||||
s = "state-get" || s = "state-set!" ||
|
||||
s = "request-arg" || s = "request-form" || s = "request-method" || s = "now" ||
|
||||
s = "request-header" || s = "request-json" || s = "request-content-type" ||
|
||||
s = "execute-action" || s = "submit-mutation" -> [s]
|
||||
| List items | ListRef { contents = items } -> List.concat_map scan items
|
||||
| _ -> []
|
||||
in
|
||||
let refs = scan c.c_body in
|
||||
let unique = List.sort_uniq String.compare refs in
|
||||
List (List.map (fun s -> String s) unique)
|
||||
| _ -> List []);
|
||||
bind "component-set-io-refs!" (fun _args -> Nil);
|
||||
|
||||
(* --- Fragment binding for aser tests --- *)
|
||||
bind "<>" (fun args -> List args);
|
||||
|
||||
(* --- component-deps / component-set-deps! for deps.sx --- *)
|
||||
let _comp_deps : (string, value) Hashtbl.t = Hashtbl.create 16 in
|
||||
bind "component-deps" (fun args ->
|
||||
match args with
|
||||
| [Component c] -> (match Hashtbl.find_opt _comp_deps c.c_name with Some v -> v | None -> Nil)
|
||||
| [Island i] -> (match Hashtbl.find_opt _comp_deps i.i_name with Some v -> v | None -> Nil)
|
||||
| _ -> Nil);
|
||||
bind "component-set-deps!" (fun args ->
|
||||
match args with
|
||||
| [Component c; v] -> Hashtbl.replace _comp_deps c.c_name v; Nil
|
||||
| [Island i; v] -> Hashtbl.replace _comp_deps i.i_name v; Nil
|
||||
| _ -> Nil);
|
||||
|
||||
(* --- submit-mutation stub for offline tests --- *)
|
||||
bind "submit-mutation" (fun args ->
|
||||
match args with
|
||||
| _ :: _ -> String "confirmed"
|
||||
| _ -> Nil);
|
||||
|
||||
env
|
||||
|
||||
(* ====================================================================== *)
|
||||
@@ -1054,6 +1142,7 @@ let run_spec_tests env test_files =
|
||||
in
|
||||
(* Render adapter for test-render-html.sx *)
|
||||
load_module "render.sx" spec_dir;
|
||||
load_module "canonical.sx" spec_dir;
|
||||
load_module "adapter-html.sx" web_dir;
|
||||
load_module "adapter-sx.sx" web_dir;
|
||||
(* Web modules for web/tests/ *)
|
||||
@@ -1074,6 +1163,12 @@ let run_spec_tests env test_files =
|
||||
load_module "content.sx" lib_dir;
|
||||
load_module "types.sx" lib_dir;
|
||||
load_module "sx-swap.sx" lib_dir;
|
||||
(* Shared templates: TW styling engine *)
|
||||
let templates_dir = Filename.concat project_dir "shared/sx/templates" in
|
||||
load_module "tw.sx" templates_dir;
|
||||
load_module "tw-layout.sx" templates_dir;
|
||||
load_module "tw-type.sx" templates_dir;
|
||||
load_module "cssx.sx" templates_dir;
|
||||
(* SX docs site: components, handlers, demos *)
|
||||
let sx_comp_dir = Filename.concat project_dir "sx/sxc" in
|
||||
let sx_sx_dir = Filename.concat project_dir "sx/sx" in
|
||||
@@ -1097,6 +1192,23 @@ let run_spec_tests env test_files =
|
||||
load_module "cek.sx" sx_geo_dir;
|
||||
load_module "reactive-runtime.sx" sx_sx_dir;
|
||||
|
||||
(* Create short-name aliases for reactive-islands tests *)
|
||||
let alias short full =
|
||||
try let v = Sx_types.env_get env full in
|
||||
ignore (Sx_types.env_bind env short v)
|
||||
with _ -> () in
|
||||
alias "~reactive-islands/counter" "~reactive-islands/index/demo-counter";
|
||||
alias "~reactive-islands/temperature" "~reactive-islands/index/demo-temperature";
|
||||
alias "~reactive-islands/stopwatch" "~reactive-islands/index/demo-stopwatch";
|
||||
alias "~reactive-islands/reactive-list" "~reactive-islands/index/demo-reactive-list";
|
||||
alias "~reactive-islands/input-binding" "~reactive-islands/index/demo-input-binding";
|
||||
alias "~reactive-islands/error-boundary" "~reactive-islands/index/demo-error-boundary";
|
||||
alias "~reactive-islands/dynamic-class" "~reactive-islands/index/demo-dynamic-class";
|
||||
alias "~reactive-islands/store-writer" "~reactive-islands/index/demo-store-writer";
|
||||
alias "~reactive-islands/store-reader" "~reactive-islands/index/demo-store-reader";
|
||||
alias "~marshes/demo-marsh-product" "~reactive-islands/marshes/demo-marsh-product";
|
||||
alias "~marshes/demo-marsh-settle" "~reactive-islands/marshes/demo-marsh-settle";
|
||||
|
||||
(* Determine test files — scan spec/tests/, lib/tests/, web/tests/ *)
|
||||
let lib_tests_dir = Filename.concat project_dir "lib/tests" in
|
||||
let web_tests_dir = Filename.concat project_dir "web/tests" in
|
||||
@@ -1111,10 +1223,10 @@ let run_spec_tests env test_files =
|
||||
ignore (Sx_types.env_bind env "render-to-sx" (NativeFn ("render-to-sx", fun args ->
|
||||
match args with
|
||||
| [String src] ->
|
||||
(* String input: parse then evaluate via aser *)
|
||||
(* String input: parse then evaluate via aser (quote the parsed AST so aser sees raw structure) *)
|
||||
let exprs = Sx_parser.parse_all src in
|
||||
let expr = match exprs with [e] -> e | es -> List (Symbol "do" :: es) in
|
||||
let result = eval_expr (List [Symbol "aser"; expr; Env env]) (Env env) in
|
||||
let result = eval_expr (List [Symbol "aser"; List [Symbol "quote"; expr]; Env env]) (Env env) in
|
||||
(match result with SxExpr s -> String s | String s -> String s | _ -> String (Sx_runtime.value_to_str result))
|
||||
| _ ->
|
||||
(* AST input: delegate to the SX render-to-sx *)
|
||||
|
||||
@@ -273,28 +273,109 @@ def compile_spec_to_ml(spec_dir: str | None = None) -> str:
|
||||
"(Env (Sx_types.make_env ())) (a) ((List []))",
|
||||
)
|
||||
|
||||
# Inject JIT dispatch into continue_with_call's lambda branch.
|
||||
# After params are bound, check jit_call_hook before creating CEK state.
|
||||
lambda_body_pattern = (
|
||||
'(prim_call "slice" [params; (len (args))])); Nil)) in '
|
||||
'(make_cek_state ((lambda_body (f))) (local) (kont))'
|
||||
# Inject JIT dispatch + &rest handling into continue_with_call's lambda branch.
|
||||
# Replace the entire lambda binding + make_cek_state section.
|
||||
cwc_lambda_old = (
|
||||
'else (if sx_truthy ((is_lambda (f))) then '
|
||||
'(let params = (lambda_params (f)) in let local = (env_merge ((lambda_closure (f))) (env)) in '
|
||||
'(if sx_truthy ((prim_call ">" [(len (args)); (len (params))])) then '
|
||||
'(raise (Eval_error (value_to_str (String (sx_str ['
|
||||
'(let _or = (lambda_name (f)) in if sx_truthy _or then _or else (String "lambda")); '
|
||||
'(String " expects "); (len (params)); (String " args, got "); (len (args))])))))'
|
||||
' else (let () = ignore ((List.iter (fun pair -> ignore ('
|
||||
'(env_bind local (sx_to_string (first (pair))) (nth (pair) ((Number 1.0))))))'
|
||||
' (sx_to_list (prim_call "zip" [params; args])); Nil)) in '
|
||||
'(let () = ignore ((List.iter (fun p -> ignore ((env_bind local (sx_to_string p) Nil)))'
|
||||
' (sx_to_list (prim_call "slice" [params; (len (args))])); Nil)) in '
|
||||
'(make_cek_state ((lambda_body (f))) (local) (kont))))))'
|
||||
)
|
||||
lambda_body_jit = (
|
||||
'(prim_call "slice" [params; (len (args))])); Nil)) in '
|
||||
cwc_lambda_new = (
|
||||
'else (if sx_truthy ((is_lambda (f))) then '
|
||||
'(let params = (lambda_params (f)) in let local = (env_merge ((lambda_closure (f))) (env)) in '
|
||||
'(if not (bind_lambda_with_rest params args local) then begin '
|
||||
'let pl = sx_to_list params and al = sx_to_list args in '
|
||||
'if List.length al > List.length pl then '
|
||||
'raise (Eval_error (Printf.sprintf "%s expects %d args, got %d" '
|
||||
'(match lambda_name f with String s -> s | _ -> "lambda") '
|
||||
'(List.length pl) (List.length al))); '
|
||||
'List.iter (fun pair -> ignore (env_bind local (sx_to_string (first pair)) (nth pair (Number 1.0)))) '
|
||||
'(sx_to_list (prim_call "zip" [params; args])); '
|
||||
'List.iter (fun p -> ignore (env_bind local (sx_to_string p) Nil)) '
|
||||
'(sx_to_list (prim_call "slice" [params; len args])) end; '
|
||||
'(match !jit_call_hook, f with '
|
||||
'| Some hook, Lambda l when l.l_name <> None -> '
|
||||
'let args_list = match args with '
|
||||
'List a | ListRef { contents = a } -> a | _ -> [] in '
|
||||
'let args_list = match args with List a | ListRef { contents = a } -> a | _ -> [] in '
|
||||
'(match hook f args_list with '
|
||||
'Some result -> make_cek_value result local kont '
|
||||
'| None -> make_cek_state (lambda_body f) local kont) '
|
||||
'| _ -> make_cek_state ((lambda_body (f))) (local) (kont))'
|
||||
'| _ -> make_cek_state ((lambda_body (f))) (local) (kont))))'
|
||||
)
|
||||
if lambda_body_pattern in output:
|
||||
output = output.replace(lambda_body_pattern, lambda_body_jit, 1)
|
||||
if cwc_lambda_old in output:
|
||||
output = output.replace(cwc_lambda_old, cwc_lambda_new, 1)
|
||||
else:
|
||||
import sys
|
||||
print("WARNING: Could not find lambda body pattern for JIT injection", file=sys.stderr)
|
||||
print("WARNING: Could not find continue_with_call lambda pattern for &rest+JIT injection", file=sys.stderr)
|
||||
|
||||
# Patch call_lambda and continue_with_call to handle &rest in lambda params.
|
||||
# The transpiler can't handle the index-of-based approach, so we inject it.
|
||||
REST_HELPER = """
|
||||
(* &rest lambda param binding — injected by bootstrap.py *)
|
||||
and bind_lambda_with_rest params args local =
|
||||
let param_list = sx_to_list params in
|
||||
let arg_list = sx_to_list args in
|
||||
let rec find_rest i = function
|
||||
| [] -> None
|
||||
| h :: rp :: _ when value_to_str h = "&rest" -> Some (i, value_to_str rp)
|
||||
| _ :: tl -> find_rest (i + 1) tl
|
||||
in
|
||||
match find_rest 0 param_list with
|
||||
| Some (pos, rest_name) ->
|
||||
let positional = List.filteri (fun i _ -> i < pos) param_list in
|
||||
List.iteri (fun i p ->
|
||||
let v = if i < List.length arg_list then List.nth arg_list i else Nil in
|
||||
ignore (env_bind local (value_to_str p) v)
|
||||
) positional;
|
||||
let rest_args = if List.length arg_list > pos
|
||||
then List (List.filteri (fun i _ -> i >= pos) arg_list)
|
||||
else List [] in
|
||||
ignore (env_bind local rest_name rest_args);
|
||||
true
|
||||
| None -> false
|
||||
"""
|
||||
# Inject the helper before call_lambda
|
||||
output = output.replace(
|
||||
"(* call-lambda *)\nand call_lambda",
|
||||
REST_HELPER + "\n(* call-lambda *)\nand call_lambda",
|
||||
)
|
||||
|
||||
# Patch call_lambda to use &rest-aware binding
|
||||
call_lambda_marker = "(* call-lambda *)\nand call_lambda f args caller_env =\n"
|
||||
call_comp_marker = "\n(* call-component *)"
|
||||
if call_lambda_marker in output and call_comp_marker in output:
|
||||
start = output.index(call_lambda_marker)
|
||||
end = output.index(call_comp_marker)
|
||||
new_call_lambda = """(* call-lambda *)
|
||||
and call_lambda f args caller_env =
|
||||
let params = lambda_params f in
|
||||
let local = env_merge (lambda_closure f) caller_env in
|
||||
if not (bind_lambda_with_rest params args local) then begin
|
||||
let pl = sx_to_list params and al = sx_to_list args in
|
||||
if List.length al > List.length pl then
|
||||
raise (Eval_error (Printf.sprintf "%s expects %d args, got %d"
|
||||
(match lambda_name f with String s -> s | _ -> "lambda")
|
||||
(List.length pl) (List.length al)));
|
||||
List.iter (fun pair ->
|
||||
ignore (env_bind local (sx_to_string (first pair)) (nth pair (Number 1.0)))
|
||||
) (sx_to_list (prim_call "zip" [params; args]));
|
||||
List.iter (fun p ->
|
||||
ignore (env_bind local (sx_to_string p) Nil)
|
||||
) (sx_to_list (prim_call "slice" [params; len args]))
|
||||
end;
|
||||
make_thunk (lambda_body f) local
|
||||
"""
|
||||
output = output[:start] + new_call_lambda + output[end:]
|
||||
else:
|
||||
print("WARNING: Could not find call_lambda for &rest injection", file=sys.stderr)
|
||||
|
||||
# Instrument recursive cek_run to capture kont on error (for comp-trace).
|
||||
# The iterative cek_run_iterative already does this, but cek_call uses
|
||||
|
||||
105
hosts/ocaml/browser/bisect_sxbc.sh
Executable file
105
hosts/ocaml/browser/bisect_sxbc.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
# bisect_sxbc.sh — Binary search for which .sxbc file breaks reactive rendering.
|
||||
# Runs test_wasm.sh with SX_TEST_BYTECODE=1, toggling individual files between
|
||||
# bytecode and source to find the culprit.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../../.."
|
||||
|
||||
SXBC_DIR="shared/static/wasm/sx"
|
||||
BACKUP_DIR="/tmp/sxbc-bisect-backup"
|
||||
|
||||
# All .sxbc files in load order
|
||||
FILES=(
|
||||
render core-signals signals deps router page-helpers freeze
|
||||
bytecode compiler vm dom browser
|
||||
adapter-html adapter-sx adapter-dom
|
||||
cssx boot-helpers hypersx
|
||||
harness harness-reactive harness-web
|
||||
engine orchestration boot
|
||||
)
|
||||
|
||||
# Backup all sxbc files
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
for f in "${FILES[@]}"; do
|
||||
cp "$SXBC_DIR/$f.sxbc" "$BACKUP_DIR/$f.sxbc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Test function: returns 0 if the reactive scoped test passes
|
||||
test_passes() {
|
||||
local result
|
||||
result=$(SX_TEST_BYTECODE=1 bash hosts/ocaml/browser/test_wasm.sh 2>&1) || true
|
||||
if echo "$result" | grep -q "scoped static class"; then
|
||||
# Test mentioned = it failed
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Restore all bytecodes
|
||||
restore_all() {
|
||||
for f in "${FILES[@]}"; do
|
||||
cp "$BACKUP_DIR/$f.sxbc" "$SXBC_DIR/$f.sxbc" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
# Remove specific bytecodes (force source loading for those)
|
||||
remove_sxbc() {
|
||||
for f in "$@"; do
|
||||
rm -f "$SXBC_DIR/$f.sxbc"
|
||||
done
|
||||
}
|
||||
|
||||
echo "=== Bytecode bisect: finding which .sxbc breaks reactive rendering ==="
|
||||
echo " ${#FILES[@]} files to search"
|
||||
echo ""
|
||||
|
||||
# First: verify all-bytecode fails
|
||||
restore_all
|
||||
echo "--- All bytecode (should fail) ---"
|
||||
if test_passes; then
|
||||
echo "UNEXPECTED: all-bytecode passes! Nothing to bisect."
|
||||
exit 0
|
||||
fi
|
||||
echo " Confirmed: fails with all bytecode"
|
||||
|
||||
# Second: verify all-source passes
|
||||
for f in "${FILES[@]}"; do rm -f "$SXBC_DIR/$f.sxbc"; done
|
||||
echo "--- All source (should pass) ---"
|
||||
if ! test_passes; then
|
||||
echo "UNEXPECTED: all-source also fails! Bug is not bytecode-specific."
|
||||
restore_all
|
||||
exit 1
|
||||
fi
|
||||
echo " Confirmed: passes with all source"
|
||||
|
||||
# Binary search: find minimal set of bytecode files that causes failure
|
||||
# Strategy: start with all source, add bytecode files one at a time
|
||||
echo ""
|
||||
echo "=== Individual file test ==="
|
||||
culprits=()
|
||||
for f in "${FILES[@]}"; do
|
||||
# Start from all-source, add just this one file as bytecode
|
||||
for g in "${FILES[@]}"; do rm -f "$SXBC_DIR/$g.sxbc"; done
|
||||
cp "$BACKUP_DIR/$f.sxbc" "$SXBC_DIR/$f.sxbc"
|
||||
|
||||
if test_passes; then
|
||||
printf " %-20s bytecode OK\n" "$f"
|
||||
else
|
||||
printf " %-20s *** BREAKS ***\n" "$f"
|
||||
culprits+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore
|
||||
restore_all
|
||||
|
||||
echo ""
|
||||
if [ ${#culprits[@]} -eq 0 ]; then
|
||||
echo "No single file causes the failure — it's a combination."
|
||||
echo "Run with groups to narrow down."
|
||||
else
|
||||
echo "=== CULPRIT FILE(S): ${culprits[*]} ==="
|
||||
echo "These .sxbc files individually cause the reactive rendering to break."
|
||||
fi
|
||||
@@ -66,8 +66,11 @@ cp "$ROOT/web/engine.sx" "$DIST/sx/"
|
||||
cp "$ROOT/web/orchestration.sx" "$DIST/sx/"
|
||||
cp "$ROOT/web/boot.sx" "$DIST/sx/"
|
||||
|
||||
# 9. CSSX (stylesheet language — runtime with tw, ~cssx/tw, cssx-process-token etc.)
|
||||
cp "$ROOT/shared/sx/templates/cssx.sx" "$DIST/sx/"
|
||||
# 9. Styling (tw token engine + legacy cssx)
|
||||
cp "$ROOT/shared/sx/templates/cssx.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw-layout.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw-type.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw.sx" "$DIST/sx/"
|
||||
|
||||
# Summary
|
||||
WASM_SIZE=$(du -sh "$DIST/sx_browser.bc.wasm.assets" | cut -f1)
|
||||
|
||||
@@ -37,7 +37,7 @@ const FILES = [
|
||||
'render.sx', 'core-signals.sx', 'signals.sx', 'deps.sx', 'router.sx',
|
||||
'page-helpers.sx', 'freeze.sx', 'bytecode.sx', 'compiler.sx', 'vm.sx',
|
||||
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
|
||||
'cssx.sx',
|
||||
'cssx.sx', 'tw-layout.sx', 'tw-type.sx', 'tw.sx',
|
||||
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
|
||||
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
|
||||
];
|
||||
|
||||
226
hosts/ocaml/browser/test-spa.js
Normal file
226
hosts/ocaml/browser/test-spa.js
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-spa.js — Deep browser diagnostic for SPA navigation.
|
||||
*
|
||||
* Uses Chrome DevTools Protocol to inspect event listeners,
|
||||
* trace click handling, and detect SPA vs full reload.
|
||||
*
|
||||
* Usage:
|
||||
* node test-spa.js # bytecode mode
|
||||
* node test-spa.js --source # source mode (nosxbc)
|
||||
* node test-spa.js --headed # visible browser
|
||||
*/
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sourceMode = args.includes('--source');
|
||||
const headed = args.includes('--headed');
|
||||
const baseUrl = 'http://localhost:8013/sx/';
|
||||
const url = sourceMode ? baseUrl + '?nosxbc' : baseUrl;
|
||||
const label = sourceMode ? 'SOURCE' : 'BYTECODE';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: !headed });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Capture console
|
||||
page.on('console', msg => {
|
||||
const t = msg.text();
|
||||
if (t.startsWith('[spa-diag]') || t.includes('Not callable') || t.includes('Error:'))
|
||||
console.log(` [browser] ${t}`);
|
||||
});
|
||||
|
||||
console.log(`\n=== SPA Diagnostic: ${label} mode ===\n`);
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 1. Use CDP to get event listeners on a link
|
||||
// ----------------------------------------------------------------
|
||||
console.log('--- 1. Event listeners on Geography link ---');
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
|
||||
const listeners = await page.evaluate(async () => {
|
||||
const link = document.querySelector('a[href="/sx/(geography)"]');
|
||||
if (!link) return { error: 'link not found' };
|
||||
|
||||
// We can't use getEventListeners from page context (it's a DevTools API)
|
||||
// But we can check _sxBound* properties and enumerate own properties
|
||||
const ownProps = {};
|
||||
for (const k of Object.getOwnPropertyNames(link)) {
|
||||
if (k.startsWith('_') || k.startsWith('on'))
|
||||
ownProps[k] = typeof link[k];
|
||||
}
|
||||
|
||||
// Check for jQuery-style event data
|
||||
const jqData = link.__events || link._events || null;
|
||||
|
||||
return {
|
||||
href: link.getAttribute('href'),
|
||||
ownProps,
|
||||
jqData: jqData ? 'present' : 'none',
|
||||
onclick: link.onclick ? 'set' : 'null',
|
||||
parentTag: link.parentElement?.tagName,
|
||||
};
|
||||
});
|
||||
console.log(' Link props:', JSON.stringify(listeners, null, 2));
|
||||
|
||||
// Check should-boost-link? and why it returns false
|
||||
const boostCheck = await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
const link = document.querySelectorAll('a[href]')[1]; // geography link
|
||||
if (!link) return 'no link';
|
||||
try {
|
||||
// Check the conditions should-boost-link? checks
|
||||
const href = link.getAttribute('href');
|
||||
const checks = {
|
||||
href,
|
||||
hasBoostAttr: link.closest('[data-sx-boost]') ? 'yes' : 'no',
|
||||
hasNoBoost: link.hasAttribute('data-sx-no-boost') ? 'yes' : 'no',
|
||||
isExternal: href.startsWith('http') ? 'yes' : 'no',
|
||||
isHash: href.startsWith('#') ? 'yes' : 'no',
|
||||
};
|
||||
// Try calling should-boost-link?
|
||||
try { checks.shouldBoost = K.eval('(should-boost-link? (nth (dom-query-all (dom-body) "a[href]") 1))'); }
|
||||
catch(e) { checks.shouldBoost = 'err: ' + e.message.slice(0, 80); }
|
||||
return checks;
|
||||
} catch(e) { return 'err: ' + e.message; }
|
||||
});
|
||||
console.log(' Boost check:', JSON.stringify(boostCheck, null, 2));
|
||||
|
||||
// Use CDP to get actual event listeners
|
||||
const linkNode = await page.$('a[href="/sx/(geography)"]');
|
||||
if (linkNode) {
|
||||
const { object } = await cdp.send('Runtime.evaluate', {
|
||||
expression: 'document.querySelector(\'a[href="/sx/(geography)"]\')',
|
||||
});
|
||||
if (object?.objectId) {
|
||||
const { listeners: cdpListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
||||
objectId: object.objectId,
|
||||
depth: 0,
|
||||
});
|
||||
console.log(' CDP event listeners on link:', cdpListeners.length);
|
||||
for (const l of cdpListeners) {
|
||||
console.log(` ${l.type}: ${l.handler?.description?.slice(0, 100) || 'native'} (useCapture=${l.useCapture})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check document-level click listeners
|
||||
const { object: docObj } = await cdp.send('Runtime.evaluate', {
|
||||
expression: 'document',
|
||||
});
|
||||
if (docObj?.objectId) {
|
||||
const { listeners: docListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
||||
objectId: docObj.objectId,
|
||||
depth: 0,
|
||||
});
|
||||
const clickListeners = docListeners.filter(l => l.type === 'click');
|
||||
console.log(' CDP document click listeners:', clickListeners.length);
|
||||
for (const l of clickListeners) {
|
||||
console.log(` ${l.type}: ${l.handler?.description?.slice(0, 120) || 'native'} (capture=${l.useCapture})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check window-level listeners too
|
||||
const { object: winObj } = await cdp.send('Runtime.evaluate', {
|
||||
expression: 'window',
|
||||
});
|
||||
if (winObj?.objectId) {
|
||||
const { listeners: winListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
||||
objectId: winObj.objectId,
|
||||
depth: 0,
|
||||
});
|
||||
const winClick = winListeners.filter(l => l.type === 'click');
|
||||
const winPop = winListeners.filter(l => l.type === 'popstate');
|
||||
console.log(' CDP window click listeners:', winClick.length);
|
||||
console.log(' CDP window popstate listeners:', winPop.length);
|
||||
for (const l of winPop) {
|
||||
console.log(` popstate: ${l.handler?.description?.slice(0, 120) || 'native'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 2. Trace what happens when we click
|
||||
// ----------------------------------------------------------------
|
||||
console.log('\n--- 2. Click trace ---');
|
||||
|
||||
// Inject click tracing
|
||||
await page.evaluate(() => {
|
||||
// Trace click event propagation
|
||||
const phases = ['NONE', 'CAPTURE', 'AT_TARGET', 'BUBBLE'];
|
||||
document.addEventListener('click', function(e) {
|
||||
console.log('[spa-diag] click CAPTURE on document: target=' + e.target.tagName +
|
||||
' href=' + (e.target.getAttribute?.('href') || 'none') +
|
||||
' defaultPrevented=' + e.defaultPrevented);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
console.log('[spa-diag] click BUBBLE on document: defaultPrevented=' + e.defaultPrevented +
|
||||
' propagation=' + (e.cancelBubble ? 'stopped' : 'running'));
|
||||
}, false);
|
||||
|
||||
// Monitor pushState
|
||||
const origPush = history.pushState;
|
||||
history.pushState = function() {
|
||||
console.log('[spa-diag] pushState called: ' + JSON.stringify(arguments[2]));
|
||||
return origPush.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Monitor replaceState
|
||||
const origReplace = history.replaceState;
|
||||
history.replaceState = function() {
|
||||
console.log('[spa-diag] replaceState called: ' + JSON.stringify(arguments[2]));
|
||||
return origReplace.apply(this, arguments);
|
||||
};
|
||||
});
|
||||
|
||||
// Detect full reload vs SPA by checking if a new page load happens
|
||||
let fullReload = false;
|
||||
let networkNav = false;
|
||||
page.on('load', () => { fullReload = true; });
|
||||
page.on('request', req => {
|
||||
if (req.isNavigationRequest()) {
|
||||
networkNav = true;
|
||||
console.log(' [network] Navigation request:', req.url());
|
||||
}
|
||||
});
|
||||
|
||||
// Click the link
|
||||
console.log(' Clicking /sx/(geography)...');
|
||||
const urlBefore = page.url();
|
||||
await page.click('a[href="/sx/(geography)"]');
|
||||
await page.waitForTimeout(3000);
|
||||
const urlAfter = page.url();
|
||||
|
||||
console.log(` URL: ${urlBefore.split('8013')[1]} → ${urlAfter.split('8013')[1]}`);
|
||||
console.log(` Full reload: ${fullReload}`);
|
||||
console.log(` Network navigation: ${networkNav}`);
|
||||
|
||||
// Check page content
|
||||
const content = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
h1: document.querySelector('h1')?.textContent?.slice(0, 50) || 'none',
|
||||
bodyLen: document.body.innerHTML.length,
|
||||
}));
|
||||
console.log(' Content:', JSON.stringify(content));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 3. Check SX router state
|
||||
// ----------------------------------------------------------------
|
||||
console.log('\n--- 3. SX router state ---');
|
||||
const routerState = await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
if (!K) return { error: 'no kernel' };
|
||||
const checks = {};
|
||||
try { checks['_page-routes count'] = K.eval('(len _page-routes)'); } catch(e) { checks['_page-routes'] = e.message; }
|
||||
try { checks['current-route'] = K.eval('(browser-location-pathname)'); } catch(e) { checks['current-route'] = e.message; }
|
||||
return checks;
|
||||
});
|
||||
console.log(' Router:', JSON.stringify(routerState));
|
||||
|
||||
console.log('\n=== Done ===\n');
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user