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:
2026-04-02 11:31:57 +00:00
parent 9ed1100ef6
commit d40a9c6796
178 changed files with 13591 additions and 9110 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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 *)

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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',
];

View 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();
})();