WIP: pre-existing changes from WASM browser work + test infrastructure
Accumulated changes from WASM browser development sessions: - sx_runtime.ml: signal subscription + notify, env unwrap tolerance - sx_browser.bc.js: rebuilt js_of_ocaml browser kernel - sx_browser.bc.wasm.js + assets: WASM browser kernel build - sx-platform.js browser tests (test_js, test_platform, test_wasm) - Playwright sx-inspect.js: interactive page inspector tool - harness-web.sx: DOM assertion updates - deploy.sh, Dockerfile, dune-project: build config updates - test-stepper.sx: stepper unit tests - reader-macro-demo plan update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,8 +63,12 @@ for app in "${BUILD[@]}"; do
|
||||
echo "=== $app ==="
|
||||
docker build -f "$dir/Dockerfile" -t "$REGISTRY/$app:latest" .
|
||||
docker push "$REGISTRY/$app:latest"
|
||||
docker service update --force "coop_$app" 2>/dev/null \
|
||||
|| echo " (service coop_$app not running — will start on next stack deploy)"
|
||||
case "$app" in
|
||||
sx_docs) svc="sx-web_sx_docs" ;;
|
||||
*) svc="coop_$app" ;;
|
||||
esac
|
||||
docker service update --force "$svc" 2>/dev/null \
|
||||
|| echo " (service $svc not running — will start on next stack deploy)"
|
||||
echo ""
|
||||
done
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
RELOAD: "true"
|
||||
SX_USE_REF: "1"
|
||||
SX_USE_OCAML: "1"
|
||||
SX_USE_WASM: "1"
|
||||
SX_OCAML_BIN: "/app/bin/sx_server"
|
||||
SX_BOUNDARY_STRICT: "1"
|
||||
SX_DEV: "1"
|
||||
|
||||
@@ -306,6 +306,31 @@ if (fullBuild) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load web harnesses (DOM mocking, signals, rendering awareness)
|
||||
const webDir = path.join(projectDir, "web");
|
||||
for (const webFile of ["harness-web.sx", "harness-reactive.sx"]) {
|
||||
const wp = path.join(webDir, webFile);
|
||||
if (fs.existsSync(wp)) {
|
||||
const src = fs.readFileSync(wp, "utf8");
|
||||
const exprs = Sx.parse(src);
|
||||
for (const expr of exprs) {
|
||||
try { Sx.eval(expr, env); } catch (e) {
|
||||
console.error(`Error loading ${webFile}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load stepper-lib (shared stepper functions used by lib/tests/test-stepper.sx)
|
||||
const stepperLibPath = path.join(projectDir, "sx", "sx", "stepper-lib.sx");
|
||||
if (fs.existsSync(stepperLibPath)) {
|
||||
const src = fs.readFileSync(stepperLibPath, "utf8");
|
||||
const exprs = Sx.parse(src);
|
||||
for (const expr of exprs) {
|
||||
try { Sx.eval(expr, env); } catch (e) {
|
||||
console.error(`Error loading stepper-lib.sx: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which tests to run
|
||||
|
||||
62
hosts/ocaml/browser/test_js.js
Normal file
62
hosts/ocaml/browser/test_js.js
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Test js_of_ocaml build of SX kernel
|
||||
const path = require('path');
|
||||
require(path.join(__dirname, '../_build/default/browser/sx_browser.bc.js'));
|
||||
|
||||
const sx = globalThis.SxKernel;
|
||||
console.log('Engine:', sx.engine());
|
||||
|
||||
const tests = [
|
||||
['(+ 1 2)', 3],
|
||||
['(- 10 3)', 7],
|
||||
['(* 6 7)', 42],
|
||||
['(/ 10 2)', 5],
|
||||
['(= 5 5)', true],
|
||||
['(< 3 5)', true],
|
||||
['(> 5 3)', true],
|
||||
['(not false)', true],
|
||||
['(inc 5)', 6],
|
||||
['(dec 5)', 4],
|
||||
['(len (list 1 2 3))', 3],
|
||||
['(len "hello")', 5],
|
||||
['(first (list 10 20))', 10],
|
||||
['(nth "hello" 0)', 'h'],
|
||||
['(nth "hello" 4)', 'o'],
|
||||
['(str "a" "b")', 'ab'],
|
||||
['(join ", " (list "a" "b" "c"))', 'a, b, c'],
|
||||
['(let ((x 10) (y 20)) (+ x y))', 30],
|
||||
['(if true "yes" "no")', 'yes'],
|
||||
['(cond (= 1 2) "one" :else "other")', 'other'],
|
||||
['(case 2 1 "one" 2 "two" :else "other")', 'two'],
|
||||
['(render-to-html (list (quote div) "hello"))', '<div>hello</div>'],
|
||||
['(render-to-html (list (quote span) (list (quote b) "bold")))', '<span><b>bold</b></span>'],
|
||||
// Lambda + closure
|
||||
['(let ((add (fn (a b) (+ a b)))) (add 3 4))', 7],
|
||||
['(let ((x 10)) (let ((f (fn () x))) (f)))', 10],
|
||||
// Higher-order
|
||||
['(len (filter (fn (x) (> x 2)) (list 1 2 3 4 5)))', 3],
|
||||
// Recursion
|
||||
['(let ((fact (fn (n) (if (<= n 1) 1 (* n (fact (- n 1))))))) (fact 5))', 120],
|
||||
];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
for (const [expr, expected] of tests) {
|
||||
try {
|
||||
const result = sx.eval(expr);
|
||||
const ok = typeof expected === 'object'
|
||||
? result && result._type === expected._type
|
||||
: result === expected;
|
||||
if (ok) {
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` FAIL: ${expr} = ${JSON.stringify(result)} (expected ${JSON.stringify(expected)})`);
|
||||
failed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ERROR: ${expr}: ${e.message || e}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
134
hosts/ocaml/browser/test_platform.js
Normal file
134
hosts/ocaml/browser/test_platform.js
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test the full WASM + platform stack in Node.
|
||||
* Loads the kernel, registers FFI stubs, loads .sx web files.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load js_of_ocaml kernel (WASM needs browser; JS works in Node)
|
||||
require(path.join(__dirname, '../_build/default/browser/sx_browser.bc.js'));
|
||||
|
||||
const K = globalThis.SxKernel;
|
||||
console.log('Engine:', K.engine());
|
||||
|
||||
// Register FFI stubs (no real DOM in Node, but the primitives must exist)
|
||||
K.registerNative("host-global", (args) => {
|
||||
const name = args[0];
|
||||
return globalThis[name] || null;
|
||||
});
|
||||
K.registerNative("host-get", (args) => {
|
||||
const [obj, prop] = args;
|
||||
if (obj == null) return null;
|
||||
const v = obj[prop];
|
||||
return v === undefined ? null : v;
|
||||
});
|
||||
K.registerNative("host-set!", (args) => {
|
||||
const [obj, prop, val] = args;
|
||||
if (obj != null) obj[prop] = val;
|
||||
});
|
||||
K.registerNative("host-call", (args) => {
|
||||
const [obj, method, ...rest] = args;
|
||||
if (obj == null) return null;
|
||||
if (typeof obj[method] === 'function') {
|
||||
try { return obj[method].apply(obj, rest); } catch(e) { return null; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
K.registerNative("host-new", (args) => null);
|
||||
K.registerNative("host-callback", (args) => {
|
||||
const fn = args[0];
|
||||
if (typeof fn === 'function') return fn;
|
||||
if (fn && fn.__sx_handle !== undefined)
|
||||
return (...a) => K.callFn(fn, a);
|
||||
return () => {};
|
||||
});
|
||||
K.registerNative("host-typeof", (args) => {
|
||||
const obj = args[0];
|
||||
if (obj == null) return "nil";
|
||||
return typeof obj;
|
||||
});
|
||||
K.registerNative("host-await", (args) => {
|
||||
const [promise, callback] = args;
|
||||
if (promise && typeof promise.then === 'function') {
|
||||
const cb = typeof callback === 'function' ? callback :
|
||||
(callback && callback.__sx_handle !== undefined) ?
|
||||
(v) => K.callFn(callback, [v]) : () => {};
|
||||
promise.then(cb);
|
||||
}
|
||||
});
|
||||
|
||||
// Load .sx web files in order
|
||||
const root = path.join(__dirname, '../../..');
|
||||
const sxFiles = [
|
||||
'spec/render.sx', // HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, parse-element-args
|
||||
'web/signals.sx',
|
||||
'web/deps.sx',
|
||||
'web/router.sx',
|
||||
'web/page-helpers.sx',
|
||||
'lib/bytecode.sx',
|
||||
'lib/compiler.sx',
|
||||
'lib/vm.sx',
|
||||
'web/lib/dom.sx',
|
||||
'web/lib/browser.sx',
|
||||
'web/adapter-html.sx',
|
||||
'web/adapter-sx.sx',
|
||||
// Skip adapter-dom.sx, engine.sx, orchestration.sx, boot.sx — need real DOM
|
||||
];
|
||||
|
||||
let totalExprs = 0;
|
||||
for (const f of sxFiles) {
|
||||
const src = fs.readFileSync(path.join(root, f), 'utf8');
|
||||
const result = K.load(src);
|
||||
if (typeof result === 'string' && result.startsWith('Error')) {
|
||||
console.error(` FAIL loading ${f}: ${result}`);
|
||||
process.exit(1);
|
||||
}
|
||||
totalExprs += (typeof result === 'number' ? result : 0);
|
||||
}
|
||||
console.log(`Loaded ${totalExprs} expressions from ${sxFiles.length} .sx files`);
|
||||
|
||||
// Test the loaded stack
|
||||
const tests = [
|
||||
// Signals
|
||||
['(let ((s (signal 0))) (reset! s 42) (deref s))', 42],
|
||||
['(let ((s (signal 10))) (swap! s inc) (deref s))', 11],
|
||||
// Computed
|
||||
['(let ((a (signal 2)) (b (computed (fn () (* (deref a) 3))))) (deref b))', 6],
|
||||
// Render (OCaml renderer uses XHTML-style void tags)
|
||||
['(render-to-html (quote (div :class "foo" "bar")))', '<div class="foo">bar</div>'],
|
||||
['(render-to-html (quote (br)))', '<br />'],
|
||||
// Compiler + VM
|
||||
['(let ((c (compile (quote (+ 1 2))))) (get c "bytecode"))', { check: v => v && v._type === 'list' }],
|
||||
// dom.sx loaded (functions exist even without real DOM)
|
||||
['(type-of dom-create-element)', 'lambda'],
|
||||
['(type-of dom-listen)', 'lambda'],
|
||||
// browser.sx loaded
|
||||
['(type-of console-log)', 'lambda'],
|
||||
];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
for (const [expr, expected] of tests) {
|
||||
try {
|
||||
const result = K.eval(expr);
|
||||
let ok;
|
||||
if (expected && typeof expected === 'object' && expected.check) {
|
||||
ok = expected.check(result);
|
||||
} else {
|
||||
ok = result === expected;
|
||||
}
|
||||
if (ok) {
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` FAIL: ${expr}`);
|
||||
console.log(` got: ${JSON.stringify(result)}, expected: ${JSON.stringify(expected)}`);
|
||||
failed++;
|
||||
}
|
||||
} catch(e) {
|
||||
console.log(` ERROR: ${expr}: ${e.message || e}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
73
hosts/ocaml/browser/test_wasm.js
Normal file
73
hosts/ocaml/browser/test_wasm.js
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Test WASM build of SX kernel
|
||||
const path = require('path');
|
||||
const build_dir = path.join(__dirname, '../_build/default/browser');
|
||||
|
||||
async function main() {
|
||||
// Load WASM module — require.main.filename must point to build dir
|
||||
// so the WASM loader finds .wasm assets via path.dirname(require.main.filename)
|
||||
require.main.filename = path.join(build_dir, 'test_wasm.js');
|
||||
require(path.join(build_dir, 'sx_browser.bc.wasm.js'));
|
||||
|
||||
// Wait for WASM init
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const sx = globalThis.SxKernel;
|
||||
if (!sx) {
|
||||
console.error('FAIL: SxKernel not available');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Engine:', sx.engine());
|
||||
|
||||
// Basic tests
|
||||
const tests = [
|
||||
['(+ 1 2)', 3],
|
||||
['(- 10 3)', 7],
|
||||
['(* 6 7)', 42],
|
||||
['(/ 10 2)', 5],
|
||||
['(= 5 5)', true],
|
||||
['(< 3 5)', true],
|
||||
['(> 5 3)', true],
|
||||
['(not false)', true],
|
||||
['(inc 5)', 6],
|
||||
['(dec 5)', 4],
|
||||
['(len (list 1 2 3))', 3],
|
||||
['(len "hello")', 5],
|
||||
['(first (list 10 20))', 10],
|
||||
['(nth "hello" 0)', 'h'],
|
||||
['(nth "hello" 4)', 'o'],
|
||||
['(str "a" "b")', 'ab'],
|
||||
['(join ", " (list "a" "b" "c"))', 'a, b, c'],
|
||||
['(let ((x 10) (y 20)) (+ x y))', 30],
|
||||
['(if true "yes" "no")', 'yes'],
|
||||
['(cond (= 1 2) "one" :else "other")', 'other'],
|
||||
['(case 2 1 "one" 2 "two" :else "other")', 'two'],
|
||||
['(render-to-html (list (quote div) "hello"))', '<div>hello</div>'],
|
||||
['(render-to-html (list (quote span) (list (quote b) "bold")))', '<span><b>bold</b></span>'],
|
||||
['(let ((add (fn (a b) (+ a b)))) (add 3 4))', 7],
|
||||
['(let ((x 10)) (let ((f (fn () x))) (f)))', 10],
|
||||
['(len (filter (fn (x) (> x 2)) (list 1 2 3 4 5)))', 3],
|
||||
['(let ((fact (fn (n) (if (<= n 1) 1 (* n (fact (- n 1))))))) (fact 5))', 120],
|
||||
];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
for (const [expr, expected] of tests) {
|
||||
const result = sx.eval(expr);
|
||||
const ok = typeof expected === 'object'
|
||||
? result && result._type === expected._type
|
||||
: result === expected;
|
||||
if (ok) {
|
||||
console.log(` PASS: ${expr} = ${JSON.stringify(result)}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` FAIL: ${expr} = ${JSON.stringify(result)} (expected ${JSON.stringify(expected)})`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -1,2 +1,2 @@
|
||||
(lang dune 3.0)
|
||||
(lang dune 3.19)
|
||||
(name sx)
|
||||
|
||||
@@ -361,11 +361,44 @@ let signal_value s = match s with
|
||||
| _ -> raise (Eval_error "not a signal")
|
||||
let signal_set_value s v = match s with Signal sig' -> sig'.s_value <- v; v | _ -> raise (Eval_error "not a signal")
|
||||
let signal_subscribers s = match s with Signal sig' -> List (List.map (fun _ -> Nil) sig'.s_subscribers) | _ -> List []
|
||||
let signal_add_sub_b _s _f = Nil
|
||||
let signal_remove_sub_b _s _f = Nil
|
||||
let signal_deps _s = List []
|
||||
let signal_set_deps _s _d = Nil
|
||||
let notify_subscribers _s = Nil
|
||||
let signal_add_sub_b s f =
|
||||
match s with
|
||||
| Dict d ->
|
||||
(match Hashtbl.find_opt d "subscribers" with
|
||||
| Some (ListRef r) -> r := !r @ [f]; Nil
|
||||
| Some (List items) -> Hashtbl.replace d "subscribers" (ListRef (ref (items @ [f]))); Nil
|
||||
| _ -> Hashtbl.replace d "subscribers" (ListRef (ref [f])); Nil)
|
||||
| _ -> Nil
|
||||
|
||||
let signal_remove_sub_b s f =
|
||||
match s with
|
||||
| Dict d ->
|
||||
(match Hashtbl.find_opt d "subscribers" with
|
||||
| Some (ListRef r) -> r := List.filter (fun x -> x != f) !r; Nil
|
||||
| Some (List items) -> Hashtbl.replace d "subscribers" (List (List.filter (fun x -> x != f) items)); Nil
|
||||
| _ -> Nil)
|
||||
| _ -> Nil
|
||||
|
||||
let signal_deps s =
|
||||
match s with
|
||||
| Dict d -> (match Hashtbl.find_opt d "deps" with Some v -> v | None -> List [])
|
||||
| _ -> List []
|
||||
|
||||
let signal_set_deps s d =
|
||||
match s with
|
||||
| Dict tbl -> Hashtbl.replace tbl "deps" d; Nil
|
||||
| _ -> Nil
|
||||
|
||||
let notify_subscribers s =
|
||||
let subs = match s with
|
||||
| Dict d -> (match Hashtbl.find_opt d "subscribers" with
|
||||
| Some (ListRef { contents = items }) | Some (List items) -> items
|
||||
| _ -> [])
|
||||
| _ -> []
|
||||
in
|
||||
List.iter (fun f -> ignore (sx_call f [])) subs;
|
||||
Nil
|
||||
|
||||
let flush_subscribers _s = Nil
|
||||
let dispose_computed _s = Nil
|
||||
|
||||
|
||||
186
lib/tests/test-stepper.sx
Normal file
186
lib/tests/test-stepper.sx
Normal file
@@ -0,0 +1,186 @@
|
||||
(defsuite
|
||||
"stepper-lib"
|
||||
(deftest
|
||||
"split-tag: string literal becomes leaf step"
|
||||
(let
|
||||
((result (list)))
|
||||
(split-tag "hello" result)
|
||||
(assert-equal 1 (len result))
|
||||
(assert-equal "leaf" (get (first result) "type"))
|
||||
(assert-equal "hello" (get (first result) "expr"))))
|
||||
(deftest
|
||||
"split-tag: number literal becomes leaf step"
|
||||
(let
|
||||
((result (list)))
|
||||
(split-tag 42 result)
|
||||
(assert-equal 1 (len result))
|
||||
(assert-equal "leaf" (get (first result) "type"))
|
||||
(assert-equal 42 (get (first result) "expr"))))
|
||||
(deftest
|
||||
"split-tag: empty list produces nothing"
|
||||
(let
|
||||
((result (list)))
|
||||
(split-tag (list) result)
|
||||
(assert-equal 0 (len result))))
|
||||
(deftest
|
||||
"split-tag: simple div becomes open+close"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div)")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal 2 (len result))
|
||||
(assert-equal "open" (get (first result) "type"))
|
||||
(assert-equal "div" (get (first result) "tag"))
|
||||
(assert-equal "close" (get (nth result 1) "type"))))
|
||||
(deftest
|
||||
"split-tag: div with text child becomes open+leaf+close"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div \"hello\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal 3 (len result))
|
||||
(assert-equal "open" (get (first result) "type"))
|
||||
(assert-equal "leaf" (get (nth result 1) "type"))
|
||||
(assert-equal "hello" (get (nth result 1) "expr"))
|
||||
(assert-equal "close" (get (nth result 2) "type"))))
|
||||
(deftest
|
||||
"split-tag: keyword attrs captured in open step"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div :class \"foo\" \"text\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal 3 (len result))
|
||||
(assert-equal "open" (get (first result) "type"))
|
||||
(let
|
||||
((attrs (get (first result) "attrs")))
|
||||
(assert-equal 2 (len attrs)))))
|
||||
(deftest
|
||||
"split-tag: nested tags produce nested open/close pairs"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div (span \"hi\"))")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal 5 (len result))
|
||||
(assert-equal "open" (get (first result) "type"))
|
||||
(assert-equal "div" (get (first result) "tag"))
|
||||
(assert-equal "open" (get (nth result 1) "type"))
|
||||
(assert-equal "span" (get (nth result 1) "tag"))
|
||||
(assert-equal "leaf" (get (nth result 2) "type"))
|
||||
(assert-equal "close" (get (nth result 3) "type"))
|
||||
(assert-equal "close" (get (nth result 4) "type"))))
|
||||
(deftest
|
||||
"split-tag: component call becomes expr step"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(~my/comp :title \"hi\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal 1 (len result))
|
||||
(assert-equal "expr" (get (first result) "type"))))
|
||||
(deftest
|
||||
"split-tag: component inside div becomes spread"
|
||||
(let
|
||||
((result (list))
|
||||
(parsed (sx-parse "(div (~cssx/tw :tokens \"foo\") \"text\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(let
|
||||
((open-step (first result)))
|
||||
(assert-equal "open" (get open-step "type"))
|
||||
(assert-equal 1 (len (get open-step "spreads"))))))
|
||||
(deftest
|
||||
"build-code-tokens: string expr produces quoted token"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 0)))
|
||||
(build-code-tokens "hello" tokens ref 0)
|
||||
(assert-equal 1 (len tokens))
|
||||
(assert-equal "\"hello\"" (get (first tokens) "text"))
|
||||
(assert-equal "text-emerald-700" (get (first tokens) "cls"))
|
||||
(assert-equal 0 (get (first tokens) "step"))))
|
||||
(deftest
|
||||
"build-code-tokens: number expr produces token"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 0)))
|
||||
(build-code-tokens 42 tokens ref 0)
|
||||
(assert-equal 1 (len tokens))
|
||||
(assert-equal "42" (get (first tokens) "text"))
|
||||
(assert-equal "text-amber-700" (get (first tokens) "cls"))))
|
||||
(deftest
|
||||
"build-code-tokens: symbol produces token with name"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 0)))
|
||||
(build-code-tokens (make-symbol "foo") tokens ref 0)
|
||||
(assert-equal 1 (len tokens))
|
||||
(assert-equal "foo" (get (first tokens) "text"))
|
||||
(assert-equal "text-stone-700" (get (first tokens) "cls"))))
|
||||
(deftest
|
||||
"build-code-tokens: html tag symbol gets sky color"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 0)))
|
||||
(build-code-tokens (make-symbol "div") tokens ref 0)
|
||||
(assert-equal "text-sky-700 font-semibold" (get (first tokens) "cls"))))
|
||||
(deftest
|
||||
"build-code-tokens: component symbol gets rose color"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 0)))
|
||||
(build-code-tokens (make-symbol "~my/comp") tokens ref 0)
|
||||
(assert-equal "text-rose-600 font-semibold" (get (first tokens) "cls"))))
|
||||
(deftest
|
||||
"build-code-tokens: list produces open-paren + children + close-paren"
|
||||
(let
|
||||
((tokens (list))
|
||||
(ref (dict "v" 0))
|
||||
(parsed (sx-parse "(div \"hi\")")))
|
||||
(build-code-tokens (first parsed) tokens ref 0)
|
||||
(assert-equal "(" (get (first tokens) "text"))
|
||||
(assert-equal ")" (get (last tokens) "text"))))
|
||||
(deftest
|
||||
"build-code-tokens: step counter increments for strings"
|
||||
(let
|
||||
((tokens (list)) (ref (dict "v" 5)))
|
||||
(build-code-tokens "hello" tokens ref 0)
|
||||
(assert-equal 5 (get (first tokens) "step"))
|
||||
(assert-equal 6 (get ref "v"))))
|
||||
(deftest
|
||||
"steps-to-preview: empty steps returns nil"
|
||||
(assert-equal nil (steps-to-preview (list) 5)))
|
||||
(deftest
|
||||
"steps-to-preview: target 0 returns nil"
|
||||
(let
|
||||
((result (list)))
|
||||
(split-tag "hello" result)
|
||||
(assert-equal nil (steps-to-preview result 0))))
|
||||
(deftest
|
||||
"steps-to-preview: single leaf step reconstructs value"
|
||||
(let
|
||||
((result (list)))
|
||||
(split-tag "hello" result)
|
||||
(assert-equal "hello" (steps-to-preview result 1))))
|
||||
(deftest
|
||||
"steps-to-preview: open+close reconstructs empty tag"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div)")))
|
||||
(split-tag (first parsed) result)
|
||||
(let
|
||||
((preview (steps-to-preview result 2)))
|
||||
(assert-equal "div" (symbol-name (first preview))))))
|
||||
(deftest
|
||||
"steps-to-preview: partial steps build partial tree"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div (span \"a\") (span \"b\"))")))
|
||||
(split-tag (first parsed) result)
|
||||
(let
|
||||
((preview (steps-to-preview result 4)))
|
||||
(assert-equal "div" (symbol-name (first preview)))
|
||||
(assert-equal true (> (len preview) 1)))))
|
||||
(deftest
|
||||
"steps-to-preview: full steps build complete tree"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div \"hello\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(let
|
||||
((preview (steps-to-preview result (len result))))
|
||||
(assert-equal "div" (symbol-name (first preview)))
|
||||
(assert-equal "hello" (nth preview 1))))))
|
||||
|
||||
(deftest
|
||||
"split-tag result is a render leak (list of dicts)"
|
||||
(let
|
||||
((result (list)) (parsed (sx-parse "(div \"hello\")")))
|
||||
(split-tag (first parsed) result)
|
||||
(assert-equal true (is-render-leak? result))
|
||||
(assert-equal true (> (len result) 0))
|
||||
(assert-equal true (dict? (first result)))))
|
||||
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-26T11:30:03Z";
|
||||
var SX_VERSION = "2026-03-26T16:13:53Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -6583,6 +6583,400 @@ PRIMITIVES["bridge-event"] = bridgeEvent;
|
||||
PRIMITIVES["resource"] = resource;
|
||||
|
||||
|
||||
// === Transpiled from types (gradual type system) ===
|
||||
|
||||
// base-types
|
||||
var baseTypes = ["number", "string", "boolean", "nil", "symbol", "keyword", "element", "any", "never", "list", "dict", "lambda", "component", "island", "macro", "signal"];
|
||||
PRIMITIVES["base-types"] = baseTypes;
|
||||
|
||||
// type-any?
|
||||
var typeAny_p = function(t) { return (t == "any"); };
|
||||
PRIMITIVES["type-any?"] = typeAny_p;
|
||||
|
||||
// type-never?
|
||||
var typeNever_p = function(t) { return (t == "never"); };
|
||||
PRIMITIVES["type-never?"] = typeNever_p;
|
||||
|
||||
// type-nullable?
|
||||
var typeNullable_p = function(t) { return (isSxTruthy((t == "any")) ? true : (isSxTruthy((t == "nil")) ? true : (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && endsWith(t, "?"))) ? true : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? contains(rest(t), "nil") : false)))); };
|
||||
PRIMITIVES["type-nullable?"] = typeNullable_p;
|
||||
|
||||
// nullable-base
|
||||
var nullableBase = function(t) { return (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && isSxTruthy(endsWith(t, "?")) && !isSxTruthy((t == "?")))) ? slice(t, 0, (stringLength(t) - 1)) : t); };
|
||||
PRIMITIVES["nullable-base"] = nullableBase;
|
||||
|
||||
// subtype?
|
||||
var subtype_p = function(a, b) { return (isSxTruthy(typeAny_p(b)) ? true : (isSxTruthy(typeNever_p(a)) ? true : (isSxTruthy(typeAny_p(a)) ? false : (isSxTruthy((a == b)) ? true : (isSxTruthy((a == "nil")) ? typeNullable_p(b) : (isSxTruthy((isSxTruthy((typeOf(b) == "string")) && endsWith(b, "?"))) ? (function() {
|
||||
var base = nullableBase(b);
|
||||
return sxOr((a == base), (a == "nil"));
|
||||
})() : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(a))) && (first(a) == "or"))) ? isEvery(function(member) { return subtype_p(member, b); }, rest(a)) : (isSxTruthy((isSxTruthy((typeOf(b) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(b))) && (first(b) == "or"))) ? some(function(member) { return subtype_p(a, member); }, rest(b)) : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy((typeOf(b) == "list")) && isSxTruthy((len(a) == 2)) && isSxTruthy((len(b) == 2)) && isSxTruthy((first(a) == "list-of")) && (first(b) == "list-of"))) ? subtype_p(nth(a, 1), nth(b, 1)) : (isSxTruthy((isSxTruthy((a == "list")) && isSxTruthy((typeOf(b) == "list")) && isSxTruthy((len(b) == 2)) && (first(b) == "list-of"))) ? typeAny_p(nth(b, 1)) : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy((len(a) == 2)) && isSxTruthy((first(a) == "list-of")) && (b == "list"))) ? true : false))))))))))); };
|
||||
PRIMITIVES["subtype?"] = subtype_p;
|
||||
|
||||
// type-union
|
||||
var typeUnion = function(a, b) { return (isSxTruthy((a == b)) ? a : (isSxTruthy(typeAny_p(a)) ? "any" : (isSxTruthy(typeAny_p(b)) ? "any" : (isSxTruthy(typeNever_p(a)) ? b : (isSxTruthy(typeNever_p(b)) ? a : (isSxTruthy(subtype_p(a, b)) ? b : (isSxTruthy(subtype_p(b, a)) ? a : (isSxTruthy((a == "nil")) ? (isSxTruthy((isSxTruthy((typeOf(b) == "string")) && !isSxTruthy(endsWith(b, "?")))) ? (String(b) + String("?")) : ["or", a, b]) : (isSxTruthy((b == "nil")) ? (isSxTruthy((isSxTruthy((typeOf(a) == "string")) && !isSxTruthy(endsWith(a, "?")))) ? (String(a) + String("?")) : ["or", a, b]) : ["or", a, b]))))))))); };
|
||||
PRIMITIVES["type-union"] = typeUnion;
|
||||
|
||||
// narrow-type
|
||||
var narrowType = function(t, predicateName) { return (isSxTruthy((predicateName == "nil?")) ? ["nil", narrowExcludeNil(t)] : (isSxTruthy((predicateName == "string?")) ? ["string", narrowExclude(t, "string")] : (isSxTruthy((predicateName == "number?")) ? ["number", narrowExclude(t, "number")] : (isSxTruthy((predicateName == "list?")) ? ["list", narrowExclude(t, "list")] : (isSxTruthy((predicateName == "dict?")) ? ["dict", narrowExclude(t, "dict")] : (isSxTruthy((predicateName == "boolean?")) ? ["boolean", narrowExclude(t, "boolean")] : [t, t])))))); };
|
||||
PRIMITIVES["narrow-type"] = narrowType;
|
||||
|
||||
// narrow-exclude-nil
|
||||
var narrowExcludeNil = function(t) { return (isSxTruthy((t == "nil")) ? "never" : (isSxTruthy((t == "any")) ? "any" : (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && endsWith(t, "?"))) ? nullableBase(t) : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? (function() {
|
||||
var members = filter(function(m) { return !isSxTruthy((m == "nil")); }, rest(t));
|
||||
return (isSxTruthy((len(members) == 1)) ? first(members) : (isSxTruthy(isEmpty(members)) ? "never" : cons("or", members)));
|
||||
})() : t)))); };
|
||||
PRIMITIVES["narrow-exclude-nil"] = narrowExcludeNil;
|
||||
|
||||
// narrow-exclude
|
||||
var narrowExclude = function(t, excluded) { return (isSxTruthy((t == excluded)) ? "never" : (isSxTruthy((t == "any")) ? "any" : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? (function() {
|
||||
var members = filter(function(m) { return !isSxTruthy((m == excluded)); }, rest(t));
|
||||
return (isSxTruthy((len(members) == 1)) ? first(members) : (isSxTruthy(isEmpty(members)) ? "never" : cons("or", members)));
|
||||
})() : t))); };
|
||||
PRIMITIVES["narrow-exclude"] = narrowExclude;
|
||||
|
||||
// infer-type
|
||||
var inferType = function(node, typeEnv, primTypes, typeRegistry) { return (function() {
|
||||
var kind = typeOf(node);
|
||||
return (isSxTruthy((kind == "number")) ? "number" : (isSxTruthy((kind == "string")) ? "string" : (isSxTruthy((kind == "boolean")) ? "boolean" : (isSxTruthy(isNil(node)) ? "nil" : (isSxTruthy((kind == "keyword")) ? "keyword" : (isSxTruthy((kind == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(dictHas(typeEnv, name)) ? get(typeEnv, name) : (isSxTruthy((name == "true")) ? "boolean" : (isSxTruthy((name == "false")) ? "boolean" : (isSxTruthy((name == "nil")) ? "nil" : (isSxTruthy(dictHas(primTypes, name)) ? get(primTypes, name) : "any")))));
|
||||
})() : (isSxTruthy((kind == "dict")) ? "dict" : (isSxTruthy((kind == "list")) ? inferListType(node, typeEnv, primTypes, typeRegistry) : "any"))))))));
|
||||
})(); };
|
||||
PRIMITIVES["infer-type"] = inferType;
|
||||
|
||||
// infer-list-type
|
||||
var inferListType = function(node, typeEnv, primTypes, typeRegistry) { return (isSxTruthy(isEmpty(node)) ? "list" : (function() {
|
||||
var head = first(node);
|
||||
var args = rest(node);
|
||||
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? "any" : (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "if")) ? inferIfType(args, typeEnv, primTypes, typeRegistry) : (isSxTruthy((name == "when")) ? (isSxTruthy((len(args) >= 2)) ? typeUnion(inferType(last(args), typeEnv, primTypes, typeRegistry), "nil") : "nil") : (isSxTruthy(sxOr((name == "cond"), (name == "case"))) ? "any" : (isSxTruthy((name == "let")) ? inferLetType(args, typeEnv, primTypes, typeRegistry) : (isSxTruthy(sxOr((name == "do"), (name == "begin"))) ? (isSxTruthy(isEmpty(args)) ? "nil" : inferType(last(args), typeEnv, primTypes, typeRegistry)) : (isSxTruthy(sxOr((name == "lambda"), (name == "fn"))) ? "lambda" : (isSxTruthy((name == "and")) ? (isSxTruthy(isEmpty(args)) ? "boolean" : inferType(last(args), typeEnv, primTypes, typeRegistry)) : (isSxTruthy((name == "or")) ? (isSxTruthy(isEmpty(args)) ? "boolean" : reduce(typeUnion, "never", map(function(a) { return inferType(a, typeEnv, primTypes, typeRegistry); }, args))) : (isSxTruthy((name == "map")) ? (isSxTruthy((len(args) >= 2)) ? (function() {
|
||||
var fnType = inferType(first(args), typeEnv, primTypes, typeRegistry);
|
||||
return (isSxTruthy((isSxTruthy((typeOf(fnType) == "list")) && (first(fnType) == "->"))) ? ["list-of", last(fnType)] : "list");
|
||||
})() : "list") : (isSxTruthy((name == "filter")) ? (isSxTruthy((len(args) >= 2)) ? inferType(nth(args, 1), typeEnv, primTypes, typeRegistry) : "list") : (isSxTruthy((name == "reduce")) ? "any" : (isSxTruthy((name == "list")) ? "list" : (isSxTruthy((name == "dict")) ? "dict" : (isSxTruthy((name == "quote")) ? "any" : (isSxTruthy((name == "str")) ? "string" : (isSxTruthy((name == "not")) ? "boolean" : (isSxTruthy((name == "get")) ? (isSxTruthy((isSxTruthy((len(args) >= 2)) && !isSxTruthy(isNil(typeRegistry)))) ? (function() {
|
||||
var dictType = inferType(first(args), typeEnv, primTypes, typeRegistry);
|
||||
var keyArg = nth(args, 1);
|
||||
var keyName = (isSxTruthy((typeOf(keyArg) == "keyword")) ? keywordName(keyArg) : (isSxTruthy((typeOf(keyArg) == "string")) ? keyArg : NIL));
|
||||
return (isSxTruthy((isSxTruthy(keyName) && isSxTruthy((typeOf(dictType) == "string")) && dictHas(typeRegistry, dictType))) ? (function() {
|
||||
var resolved = resolveType(dictType, typeRegistry);
|
||||
return (isSxTruthy((isSxTruthy((typeOf(resolved) == "dict")) && dictHas(resolved, keyName))) ? get(resolved, keyName) : "any");
|
||||
})() : "any");
|
||||
})() : "any") : (isSxTruthy(startsWith(name, "~")) ? "element" : (isSxTruthy(dictHas(primTypes, name)) ? get(primTypes, name) : "any")))))))))))))))))));
|
||||
})());
|
||||
})()); };
|
||||
PRIMITIVES["infer-list-type"] = inferListType;
|
||||
|
||||
// infer-if-type
|
||||
var inferIfType = function(args, typeEnv, primTypes, typeRegistry) { return (isSxTruthy((len(args) < 2)) ? "nil" : (function() {
|
||||
var thenType = inferType(nth(args, 1), typeEnv, primTypes, typeRegistry);
|
||||
return (isSxTruthy((len(args) >= 3)) ? typeUnion(thenType, inferType(nth(args, 2), typeEnv, primTypes, typeRegistry)) : typeUnion(thenType, "nil"));
|
||||
})()); };
|
||||
PRIMITIVES["infer-if-type"] = inferIfType;
|
||||
|
||||
// infer-let-type
|
||||
var inferLetType = function(args, typeEnv, primTypes, typeRegistry) { return (isSxTruthy((len(args) < 2)) ? "nil" : (function() {
|
||||
var bindings = first(args);
|
||||
var body = last(args);
|
||||
var extended = merge(typeEnv, {});
|
||||
{ var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var binding = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(binding) == "list")) && (len(binding) >= 2)))) {
|
||||
(function() {
|
||||
var name = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : (String(first(binding))));
|
||||
var valType = inferType(nth(binding, 1), extended, primTypes, typeRegistry);
|
||||
return dictSet(extended, name, valType);
|
||||
})();
|
||||
} } }
|
||||
return inferType(body, extended, primTypes, typeRegistry);
|
||||
})()); };
|
||||
PRIMITIVES["infer-let-type"] = inferLetType;
|
||||
|
||||
// make-diagnostic
|
||||
var makeDiagnostic = function(level, message, component, expr) { return {"level": level, "component": component, "expr": expr, "message": message}; };
|
||||
PRIMITIVES["make-diagnostic"] = makeDiagnostic;
|
||||
|
||||
// check-primitive-call
|
||||
var checkPrimitiveCall = function(name, args, typeEnv, primTypes, primParamTypes, compName, typeRegistry) { return (function() {
|
||||
var diagnostics = [];
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(primParamTypes))) && dictHas(primParamTypes, name)))) {
|
||||
(function() {
|
||||
var sig = get(primParamTypes, name);
|
||||
var positional = get(sig, "positional");
|
||||
var restType = get(sig, "rest-type");
|
||||
return forEach(function(idx) { return (isSxTruthy((idx < len(args))) ? (isSxTruthy((idx < len(positional))) ? (function() {
|
||||
var paramInfo = nth(positional, idx);
|
||||
var argExpr = nth(args, idx);
|
||||
return (function() {
|
||||
var expectedType = nth(paramInfo, 1);
|
||||
return (isSxTruthy(!isSxTruthy(isNil(expectedType))) ? (function() {
|
||||
var actual = inferType(argExpr, typeEnv, primTypes, typeRegistry);
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(expectedType))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, expectedType, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Argument ") + String((idx + 1)) + String(" of `") + String(name) + String("` expects ") + String(expectedType) + String(", got ") + String(actual)), compName, argExpr)) : NIL);
|
||||
})() : NIL);
|
||||
})();
|
||||
})() : (isSxTruthy(!isSxTruthy(isNil(restType))) ? (function() {
|
||||
var argExpr = nth(args, idx);
|
||||
var actual = inferType(argExpr, typeEnv, primTypes, typeRegistry);
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(restType))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, restType, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Argument ") + String((idx + 1)) + String(" of `") + String(name) + String("` expects ") + String(restType) + String(", got ") + String(actual)), compName, argExpr)) : NIL);
|
||||
})() : NIL)) : NIL); }, range(0, len(args), 1));
|
||||
})();
|
||||
}
|
||||
return diagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-primitive-call"] = checkPrimitiveCall;
|
||||
|
||||
// check-component-call
|
||||
var checkComponentCall = function(compName, comp, callArgs, typeEnv, primTypes, typeRegistry) { return (function() {
|
||||
var diagnostics = [];
|
||||
var paramTypes = componentParamTypes(comp);
|
||||
var params = componentParams(comp);
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(paramTypes))) && !isSxTruthy(isEmpty(keys(paramTypes)))))) {
|
||||
(function() {
|
||||
var i = 0;
|
||||
var providedKeys = [];
|
||||
{ var _c = range(0, len(callArgs), 1); for (var _i = 0; _i < _c.length; _i++) { var idx = _c[_i]; if (isSxTruthy((idx < len(callArgs)))) {
|
||||
(function() {
|
||||
var arg = nth(callArgs, idx);
|
||||
return (isSxTruthy((typeOf(arg) == "keyword")) ? (function() {
|
||||
var keyName = keywordName(arg);
|
||||
providedKeys.push(keyName);
|
||||
return (isSxTruthy(((idx + 1) < len(callArgs))) ? (function() {
|
||||
var valExpr = nth(callArgs, (idx + 1));
|
||||
return (isSxTruthy(dictHas(paramTypes, keyName)) ? (function() {
|
||||
var expected = get(paramTypes, keyName);
|
||||
var actual = inferType(valExpr, typeEnv, primTypes, typeRegistry);
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(expected))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, expected, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Keyword :") + String(keyName) + String(" of ") + String(compName) + String(" expects ") + String(expected) + String(", got ") + String(actual)), compName, valExpr)) : NIL);
|
||||
})() : NIL);
|
||||
})() : NIL);
|
||||
})() : NIL);
|
||||
})();
|
||||
} } }
|
||||
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var paramName = _c[_i]; if (isSxTruthy((isSxTruthy(dictHas(paramTypes, paramName)) && isSxTruthy(!isSxTruthy(contains(providedKeys, paramName))) && !isSxTruthy(typeNullable_p(get(paramTypes, paramName)))))) {
|
||||
diagnostics.push(makeDiagnostic("warning", (String("Required param :") + String(paramName) + String(" of ") + String(compName) + String(" not provided")), compName, NIL));
|
||||
} } }
|
||||
return forEach(function(key) { return (isSxTruthy(!isSxTruthy(contains(params, key))) ? append_b(diagnostics, makeDiagnostic("warning", (String("Unknown keyword :") + String(key) + String(" passed to ") + String(compName)), compName, NIL)) : NIL); }, providedKeys);
|
||||
})();
|
||||
}
|
||||
return diagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-component-call"] = checkComponentCall;
|
||||
|
||||
// check-body-walk
|
||||
var checkBodyWalk = function(node, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations) { return (function() {
|
||||
var kind = typeOf(node);
|
||||
return (isSxTruthy((kind == "list")) ? (isSxTruthy(!isSxTruthy(isEmpty(node))) ? (function() {
|
||||
var head = first(node);
|
||||
var args = rest(node);
|
||||
if (isSxTruthy((typeOf(head) == "symbol"))) {
|
||||
(function() {
|
||||
var name = symbolName(head);
|
||||
if (isSxTruthy(startsWith(name, "~"))) {
|
||||
(function() {
|
||||
var compVal = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(compVal) == "component")) ? forEach(function(d) { return append_b(diagnostics, d); }, checkComponentCall(name, compVal, args, typeEnv, primTypes, typeRegistry)) : NIL);
|
||||
})();
|
||||
if (isSxTruthy(!isSxTruthy(isNil(effectAnnotations)))) {
|
||||
(function() {
|
||||
var callerEffects = getEffects(compName, effectAnnotations);
|
||||
return forEach(function(d) { return append_b(diagnostics, d); }, checkEffectCall(name, callerEffects, effectAnnotations, compName));
|
||||
})();
|
||||
}
|
||||
}
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(startsWith(name, "~"))) && isSxTruthy(!isSxTruthy(isNil(primParamTypes))) && dictHas(primParamTypes, name)))) {
|
||||
{ var _c = checkPrimitiveCall(name, args, typeEnv, primTypes, primParamTypes, compName, typeRegistry); for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; diagnostics.push(d); } }
|
||||
}
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(startsWith(name, "~"))) && !isSxTruthy(isNil(effectAnnotations))))) {
|
||||
(function() {
|
||||
var callerEffects = getEffects(compName, effectAnnotations);
|
||||
return forEach(function(d) { return append_b(diagnostics, d); }, checkEffectCall(name, callerEffects, effectAnnotations, compName));
|
||||
})();
|
||||
}
|
||||
if (isSxTruthy(sxOr((name == "let"), (name == "let*")))) {
|
||||
if (isSxTruthy((len(args) >= 2))) {
|
||||
(function() {
|
||||
var bindings = first(args);
|
||||
var bodyExprs = rest(args);
|
||||
var extended = merge(typeEnv, {});
|
||||
{ var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var binding = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(binding) == "list")) && (len(binding) >= 2)))) {
|
||||
(function() {
|
||||
var bname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : (String(first(binding))));
|
||||
var valType = inferType(nth(binding, 1), extended, primTypes, typeRegistry);
|
||||
return dictSet(extended, bname, valType);
|
||||
})();
|
||||
} } }
|
||||
return forEach(function(body) { return checkBodyWalk(body, compName, extended, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations); }, bodyExprs);
|
||||
})();
|
||||
}
|
||||
}
|
||||
return (isSxTruthy((name == "define")) ? (isSxTruthy((len(args) >= 2)) ? (function() {
|
||||
var defName = (isSxTruthy((typeOf(first(args)) == "symbol")) ? symbolName(first(args)) : NIL);
|
||||
var defVal = nth(args, 1);
|
||||
if (isSxTruthy(defName)) {
|
||||
typeEnv[defName] = inferType(defVal, typeEnv, primTypes, typeRegistry);
|
||||
}
|
||||
return checkBodyWalk(defVal, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations);
|
||||
})() : NIL) : NIL);
|
||||
})();
|
||||
}
|
||||
return forEach(function(child) { return checkBodyWalk(child, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations); }, args);
|
||||
})() : NIL) : NIL);
|
||||
})(); };
|
||||
PRIMITIVES["check-body-walk"] = checkBodyWalk;
|
||||
|
||||
// check-component
|
||||
var checkComponent = function(compName, env, primTypes, primParamTypes, typeRegistry, effectAnnotations) { return (function() {
|
||||
var comp = envGet(env, compName);
|
||||
var diagnostics = [];
|
||||
if (isSxTruthy((typeOf(comp) == "component"))) {
|
||||
(function() {
|
||||
var body = componentBody(comp);
|
||||
var params = componentParams(comp);
|
||||
var paramTypes = componentParamTypes(comp);
|
||||
var typeEnv = {};
|
||||
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; typeEnv[p] = (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(paramTypes))) && dictHas(paramTypes, p))) ? get(paramTypes, p) : "any"); } }
|
||||
if (isSxTruthy(componentHasChildren(comp))) {
|
||||
typeEnv["children"] = ["list-of", "element"];
|
||||
}
|
||||
return checkBodyWalk(body, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations);
|
||||
})();
|
||||
}
|
||||
return diagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-component"] = checkComponent;
|
||||
|
||||
// check-all
|
||||
var checkAll = function(env, primTypes, primParamTypes, typeRegistry, effectAnnotations) { return (function() {
|
||||
var allDiagnostics = [];
|
||||
{ var _c = keys(env); for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(d) { return append_b(allDiagnostics, d); }, checkComponent(name, env, primTypes, primParamTypes, typeRegistry, effectAnnotations)) : NIL);
|
||||
})(); } }
|
||||
return allDiagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-all"] = checkAll;
|
||||
|
||||
// build-type-registry
|
||||
var buildTypeRegistry = function(primDeclarations, ioDeclarations) { return (function() {
|
||||
var registry = {};
|
||||
{ var _c = primDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
|
||||
var name = get(decl, "name");
|
||||
var returns = get(decl, "returns");
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(name))) && !isSxTruthy(isNil(returns)))) ? dictSet(registry, name, returns) : NIL);
|
||||
})(); } }
|
||||
{ var _c = ioDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
|
||||
var name = get(decl, "name");
|
||||
var returns = get(decl, "returns");
|
||||
return (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(name))) && !isSxTruthy(isNil(returns)))) ? dictSet(registry, name, returns) : NIL);
|
||||
})(); } }
|
||||
return registry;
|
||||
})(); };
|
||||
PRIMITIVES["build-type-registry"] = buildTypeRegistry;
|
||||
|
||||
// type-def-name
|
||||
var typeDefName = function(td) { return get(td, "name"); };
|
||||
PRIMITIVES["type-def-name"] = typeDefName;
|
||||
|
||||
// type-def-params
|
||||
var typeDefParams = function(td) { return get(td, "params"); };
|
||||
PRIMITIVES["type-def-params"] = typeDefParams;
|
||||
|
||||
// type-def-body
|
||||
var typeDefBody = function(td) { return get(td, "body"); };
|
||||
PRIMITIVES["type-def-body"] = typeDefBody;
|
||||
|
||||
// resolve-type
|
||||
var resolveType = function(t, registry) { return (isSxTruthy(isNil(registry)) ? t : (isSxTruthy((typeOf(t) == "string")) ? (isSxTruthy(dictHas(registry, t)) ? (function() {
|
||||
var td = get(registry, t);
|
||||
return (function() {
|
||||
var params = typeDefParams(td);
|
||||
var body = typeDefBody(td);
|
||||
return (isSxTruthy(isEmpty(params)) ? resolveType(body, registry) : t);
|
||||
})();
|
||||
})() : t) : (isSxTruthy((typeOf(t) == "list")) ? (isSxTruthy(isEmpty(t)) ? t : (function() {
|
||||
var head = first(t);
|
||||
return (isSxTruthy(sxOr((head == "or"), (head == "list-of"), (head == "->"), (head == "dict-of"))) ? cons(head, map(function(m) { return resolveType(m, registry); }, rest(t))) : (isSxTruthy((isSxTruthy((typeOf(head) == "string")) && dictHas(registry, head))) ? (function() {
|
||||
var td = get(registry, head);
|
||||
var params = typeDefParams(td);
|
||||
var body = typeDefBody(td);
|
||||
var args = rest(t);
|
||||
return (isSxTruthy((len(params) == len(args))) ? resolveType(substituteTypeVars(body, params, args), registry) : t);
|
||||
})() : t));
|
||||
})()) : (isSxTruthy((typeOf(t) == "dict")) ? mapDict(function(k, v) { return resolveType(v, registry); }, t) : t)))); };
|
||||
PRIMITIVES["resolve-type"] = resolveType;
|
||||
|
||||
// substitute-type-vars
|
||||
var substituteTypeVars = function(body, params, args) { return (function() {
|
||||
var subst = {};
|
||||
{ var _c = range(0, len(params), 1); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; subst[nth(params, i)] = nth(args, i); } }
|
||||
return substituteInType(body, subst);
|
||||
})(); };
|
||||
PRIMITIVES["substitute-type-vars"] = substituteTypeVars;
|
||||
|
||||
// substitute-in-type
|
||||
var substituteInType = function(t, subst) { return (isSxTruthy((typeOf(t) == "string")) ? (isSxTruthy(dictHas(subst, t)) ? get(subst, t) : t) : (isSxTruthy((typeOf(t) == "list")) ? map(function(m) { return substituteInType(m, subst); }, t) : (isSxTruthy((typeOf(t) == "dict")) ? mapDict(function(k, v) { return substituteInType(v, subst); }, t) : t))); };
|
||||
PRIMITIVES["substitute-in-type"] = substituteInType;
|
||||
|
||||
// subtype-resolved?
|
||||
var subtypeResolved_p = function(a, b, registry) { return (isSxTruthy(isNil(registry)) ? subtype_p(a, b) : (function() {
|
||||
var ra = resolveType(a, registry);
|
||||
var rb = resolveType(b, registry);
|
||||
return (isSxTruthy((isSxTruthy((typeOf(ra) == "dict")) && (typeOf(rb) == "dict"))) ? isEvery(function(key) { return (isSxTruthy(dictHas(ra, key)) && subtypeResolved_p(get(ra, key), get(rb, key), registry)); }, keys(rb)) : subtype_p(ra, rb));
|
||||
})()); };
|
||||
PRIMITIVES["subtype-resolved?"] = subtypeResolved_p;
|
||||
|
||||
// get-effects
|
||||
var getEffects = function(name, effectAnnotations) { return (isSxTruthy(isNil(effectAnnotations)) ? NIL : (isSxTruthy(dictHas(effectAnnotations, name)) ? get(effectAnnotations, name) : NIL)); };
|
||||
PRIMITIVES["get-effects"] = getEffects;
|
||||
|
||||
// effects-subset?
|
||||
var effectsSubset_p = function(calleeEffects, callerEffects) { return (isSxTruthy(isNil(callerEffects)) ? true : (isSxTruthy(isNil(calleeEffects)) ? true : isEvery(function(e) { return contains(callerEffects, e); }, calleeEffects))); };
|
||||
PRIMITIVES["effects-subset?"] = effectsSubset_p;
|
||||
|
||||
// check-effect-call
|
||||
var checkEffectCall = function(calleeName, callerEffects, effectAnnotations, compName) { return (function() {
|
||||
var diagnostics = [];
|
||||
var calleeEffects = getEffects(calleeName, effectAnnotations);
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(callerEffects))) && isSxTruthy(!isSxTruthy(isNil(calleeEffects))) && !isSxTruthy(effectsSubset_p(calleeEffects, callerEffects))))) {
|
||||
diagnostics.push(makeDiagnostic("error", (String("`") + String(calleeName) + String("` has effects ") + String(join(", ", calleeEffects)) + String(" but `") + String(compName) + String("` only allows ") + String((isSxTruthy(isEmpty(callerEffects)) ? "[pure]" : join(", ", callerEffects)))), compName, NIL));
|
||||
}
|
||||
return diagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-effect-call"] = checkEffectCall;
|
||||
|
||||
// build-effect-annotations
|
||||
var buildEffectAnnotations = function(ioDeclarations) { return (function() {
|
||||
var annotations = {};
|
||||
{ var _c = ioDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
|
||||
var name = get(decl, "name");
|
||||
return (isSxTruthy(!isSxTruthy(isNil(name))) ? dictSet(annotations, name, ["io"]) : NIL);
|
||||
})(); } }
|
||||
return annotations;
|
||||
})(); };
|
||||
PRIMITIVES["build-effect-annotations"] = buildEffectAnnotations;
|
||||
|
||||
// check-component-effects
|
||||
var checkComponentEffects = function(compName, env, effectAnnotations) { return (function() {
|
||||
var comp = envGet(env, compName);
|
||||
var diagnostics = [];
|
||||
if (isSxTruthy((typeOf(comp) == "component"))) {
|
||||
(function() {
|
||||
var body = componentBody(comp);
|
||||
return checkBodyWalk(body, compName, {}, {}, NIL, env, diagnostics, NIL, effectAnnotations);
|
||||
})();
|
||||
}
|
||||
return diagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-component-effects"] = checkComponentEffects;
|
||||
|
||||
// check-all-effects
|
||||
var checkAllEffects = function(env, effectAnnotations) { return (function() {
|
||||
var allDiagnostics = [];
|
||||
{ var _c = keys(env); for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(d) { return append_b(allDiagnostics, d); }, checkComponentEffects(name, env, effectAnnotations)) : NIL);
|
||||
})(); } }
|
||||
return allDiagnostics;
|
||||
})(); };
|
||||
PRIMITIVES["check-all-effects"] = checkAllEffects;
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Post-transpilation fixups
|
||||
// =========================================================================
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/start-9e868ccd.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/start-9e868ccd.wasm
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["/builtin/blackbox.ml","/root/.opam/5.2.0/lib/ocaml/std_exit.ml"],"sourcesContent":["(* generated code *)","(**************************************************************************)\n(* *)\n(* OCaml *)\n(* *)\n(* Xavier Leroy, projet Cristal, INRIA Rocquencourt *)\n(* *)\n(* Copyright 1996 Institut National de Recherche en Informatique et *)\n(* en Automatique. *)\n(* *)\n(* All rights reserved. This file is distributed under the terms of *)\n(* the GNU Lesser General Public License version 2.1, with the *)\n(* special exception on linking described in the file LICENSE. *)\n(* *)\n(**************************************************************************)\n\n(* Ensure that [at_exit] functions are called at the end of every program *)\n\nlet _ = do_at_exit()\n"],"names":[],"mappings":"6GCiBQ,IDjBR,QCiBQ,cAAY","ignoreList":[0]}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-02032b37.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-02032b37.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-2dae9b2f.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-2dae9b2f.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-30805295.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-30805295.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-ea36a0db.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-ea36a0db.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1821
shared/static/wasm/sx_browser.bc.wasm.js
Normal file
1821
shared/static/wasm/sx_browser.bc.wasm.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,11 @@ RUN cp -r sx-app-tmp/app.py sx-app-tmp/path_setup.py \
|
||||
([ -d sx-app-tmp/sx ] && cp -r sx-app-tmp/sx ./sx || true) && \
|
||||
rm -rf sx-app-tmp
|
||||
|
||||
# SX spec, library, and web adapter files (loaded by OCaml kernel)
|
||||
COPY spec/ ./spec/
|
||||
COPY lib/ ./lib/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
COPY blog/__init__.py ./blog/__init__.py
|
||||
COPY blog/models/ ./blog/models/
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
(div :class "grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
(div
|
||||
(p :class "text-xs text-stone-500 uppercase tracking-wider mb-1" "SX Source")
|
||||
(~docs/code :code (highlight sx-source "lisp")))
|
||||
(~docs/code :src (highlight sx-source "lisp")))
|
||||
(div
|
||||
(p :class "text-xs text-stone-500 uppercase tracking-wider mb-1" "SMT-LIB Output (live from z3.sx)")
|
||||
(~docs/code :code (highlight smt-output "lisp")))))
|
||||
(~docs/code :src (highlight smt-output "lisp")))))
|
||||
|
||||
(defcomp ~plans/reader-macro-demo/plan-reader-macro-demo-content ()
|
||||
(~docs/page :title "Reader Macro Demo: #z3"
|
||||
@@ -75,12 +75,12 @@
|
||||
"Below is the live SMT-LIB output from translating the full " (code "primitives.sx") " — all 87 primitive declarations. The composition is pure SX: " (code "(z3-translate-file (sx-parse (read-spec-file \"primitives.sx\")))") " — read the file, parse it, translate it. No Python glue.")
|
||||
|
||||
(~docs/subsection :title "primitives.sx (87 primitives)"
|
||||
(~docs/code :code (highlight (z3-translate-file (sx-parse (read-spec-file "primitives.sx"))) "lisp"))))
|
||||
(~docs/code :src (highlight (z3-translate-file (sx-parse (read-spec-file "primitives.sx"))) "lisp"))))
|
||||
|
||||
(~docs/section :title "The translator: z3.sx" :id "z3-source"
|
||||
(p :class "text-stone-600"
|
||||
"The entire translator is a single SX file — s-expressions that walk other s-expressions and emit strings. No host language logic. The same file runs in Python (server) and could run in JavaScript (browser) via the bootstrapped evaluator.")
|
||||
(~docs/code :code (highlight (read-spec-file "z3.sx") "lisp"))
|
||||
(~docs/code :src (highlight (read-spec-file "z3.sx") "lisp"))
|
||||
(p :class "text-stone-600 mt-4"
|
||||
"359 lines. The key functions: " (code "z3-sort") " maps SX types to SMT-LIB sorts. " (code "z3-expr") " recursively translates expressions — identity ops pass through unchanged, " (code "max") "/" (code "min") " become " (code "ite") ", predicates get renamed. " (code "z3-translate") " dispatches on form type. " (code "z3-translate-file") " filters and batch-translates."))
|
||||
|
||||
|
||||
739
tests/playwright/sx-inspect.js
Normal file
739
tests/playwright/sx-inspect.js
Normal file
@@ -0,0 +1,739 @@
|
||||
#!/usr/bin/env node
|
||||
// sx-inspect.js — SX-aware Playwright page inspector
|
||||
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
|
||||
// Modes: inspect, diff, hydrate, eval, interact, screenshot
|
||||
// Output: JSON to stdout
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
|
||||
const SCREENSHOT_DIR = process.env.SX_SCREENSHOT_DIR || '/tmp';
|
||||
|
||||
// Code display markers — elements that intentionally show SX source
|
||||
const CODE_DISPLAY_SELECTORS = [
|
||||
'[data-code-view]',
|
||||
'pre code',
|
||||
'.sx-source',
|
||||
'[data-sx-source]',
|
||||
'.font-mono[style*="font-size:0.5rem"]', // stepper code view
|
||||
].join(', ');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Recursive DOM tree snapshot — tag, id, classes, data-sx-* attrs, text */
|
||||
function snapshotScript() {
|
||||
return `(function snapshot(el) {
|
||||
if (el.nodeType === 3) {
|
||||
const text = el.textContent.trim();
|
||||
return text ? { type: 'text', value: text } : null;
|
||||
}
|
||||
if (el.nodeType !== 1) return null;
|
||||
const node = { tag: el.tagName.toLowerCase() };
|
||||
if (el.id) node.id = el.id;
|
||||
const cls = Array.from(el.classList).sort().join(' ');
|
||||
if (cls) node.cls = cls;
|
||||
const island = el.getAttribute('data-sx-island');
|
||||
if (island) node.island = island;
|
||||
const lake = el.getAttribute('data-sx-lake');
|
||||
if (lake) node.lake = lake;
|
||||
const marsh = el.getAttribute('data-sx-marsh');
|
||||
if (marsh) node.marsh = marsh;
|
||||
const signal = el.getAttribute('data-sx-signal');
|
||||
if (signal) node.signal = signal;
|
||||
const reactiveAttrs = el.getAttribute('data-sx-reactive-attrs');
|
||||
if (reactiveAttrs) node.reactiveAttrs = reactiveAttrs;
|
||||
const style = el.getAttribute('style');
|
||||
if (style) node.style = style;
|
||||
const children = [];
|
||||
for (const child of el.childNodes) {
|
||||
const s = snapshot(child);
|
||||
if (s) children.push(s);
|
||||
}
|
||||
if (children.length) node.children = children;
|
||||
return node;
|
||||
})`;
|
||||
}
|
||||
|
||||
/** Diff two tree snapshots, returning list of changes */
|
||||
function diffTrees(a, b, path = '') {
|
||||
const changes = [];
|
||||
if (!a && !b) return changes;
|
||||
if (!a) { changes.push({ path, kind: 'added', node: summarize(b) }); return changes; }
|
||||
if (!b) { changes.push({ path, kind: 'removed', node: summarize(a) }); return changes; }
|
||||
if (a.type === 'text' && b.type === 'text') {
|
||||
if (a.value !== b.value)
|
||||
changes.push({ path: path || 'text', kind: 'text-changed', ssr: a.value.substring(0, 120), hydrated: b.value.substring(0, 120) });
|
||||
return changes;
|
||||
}
|
||||
if (a.type === 'text' || b.type === 'text') {
|
||||
changes.push({ path, kind: 'type-changed', ssr: summarize(a), hydrated: summarize(b) });
|
||||
return changes;
|
||||
}
|
||||
if (a.tag !== b.tag) changes.push({ path, kind: 'tag-changed', ssr: a.tag, hydrated: b.tag });
|
||||
if (a.cls !== b.cls) changes.push({ path: path || a.tag, kind: 'class-changed', ssr: a.cls || '', hydrated: b.cls || '' });
|
||||
if (a.island !== b.island) changes.push({ path, kind: 'island-changed', ssr: a.island, hydrated: b.island });
|
||||
if (a.style !== b.style) changes.push({ path: path || a.tag, kind: 'style-changed', ssr: a.style || '', hydrated: b.style || '' });
|
||||
|
||||
const ac = a.children || [];
|
||||
const bc = b.children || [];
|
||||
const maxLen = Math.max(ac.length, bc.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const childPath = path ? `${path} > [${i}]` : `[${i}]`;
|
||||
const aTag = ac[i]?.tag || ac[i]?.type || '';
|
||||
const bTag = bc[i]?.tag || bc[i]?.type || '';
|
||||
const label = bTag ? `${childPath} <${bTag}>` : childPath;
|
||||
changes.push(...diffTrees(ac[i] || null, bc[i] || null, label));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function summarize(node) {
|
||||
if (!node) return 'null';
|
||||
if (node.type === 'text') return `"${node.value.substring(0, 60)}"`;
|
||||
let s = `<${node.tag}`;
|
||||
if (node.island) s += ` island="${node.island}"`;
|
||||
if (node.lake) s += ` lake="${node.lake}"`;
|
||||
if (node.id) s += ` #${node.id}`;
|
||||
s += '>';
|
||||
return s;
|
||||
}
|
||||
|
||||
async function waitForHydration(page) {
|
||||
try {
|
||||
await page.waitForSelector('[data-sx-island]', { timeout: 8000 });
|
||||
await page.waitForTimeout(1500);
|
||||
} catch (e) {
|
||||
// No islands on page — that's OK
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leak detection — excludes code display elements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function leakDetectionScript(codeSelectors) {
|
||||
return `(function(els) {
|
||||
const codeEls = new Set();
|
||||
document.querySelectorAll(${JSON.stringify(codeSelectors)}).forEach(el => {
|
||||
// Mark all code-display elements and their descendants
|
||||
el.querySelectorAll('*').forEach(d => codeEls.add(d));
|
||||
codeEls.add(el);
|
||||
});
|
||||
|
||||
return els.flatMap(el => {
|
||||
const name = el.getAttribute('data-sx-island');
|
||||
const leaks = [];
|
||||
|
||||
// Walk text nodes, skipping code display areas
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
let p = node.parentElement;
|
||||
while (p && p !== el) {
|
||||
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let node;
|
||||
while (node = walker.nextNode()) fullText += node.textContent;
|
||||
|
||||
// Raw dict patterns (SX keyword-keyed dicts)
|
||||
const dictMatch = fullText.match(/\\{:(?:type|tag|expr|spreads|attrs)\\s[^}]{0,80}/);
|
||||
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
|
||||
// Unresolved component calls
|
||||
const compMatch = fullText.match(/\\(~[\\w/]+[^)]{0,60}/);
|
||||
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
|
||||
// CSSX tokens leaked as text
|
||||
const cssxMatch = fullText.match(/:tokens\\s+"[^"]{0,60}/);
|
||||
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
|
||||
// String-keyed dicts (JS evaluator format)
|
||||
const strDictMatch = fullText.match(/\\{"(?:type|tag|expr|text|cls|step)"\\s[^}]{0,80}/);
|
||||
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
|
||||
// Raw SX list as text (parens around tag names)
|
||||
const sxListMatch = fullText.match(/\\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\\s+:(?:class|id|style)/);
|
||||
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
|
||||
return leaks;
|
||||
});
|
||||
})`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler audit — check if event handlers are wired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handlerAuditScript() {
|
||||
return `(function(island) {
|
||||
const buttons = island.querySelectorAll('button, [on-click], [data-sx-on-click]');
|
||||
const inputs = island.querySelectorAll('input, textarea, select');
|
||||
const results = [];
|
||||
|
||||
buttons.forEach((btn, i) => {
|
||||
const tag = btn.tagName.toLowerCase();
|
||||
const text = btn.textContent.trim().substring(0, 30);
|
||||
const hasReactiveClass = btn.hasAttribute('data-sx-reactive-attrs');
|
||||
// Check for attached event listeners via getEventListeners (Chrome DevTools only)
|
||||
// Fallback: check for onclick attribute or __sx handler
|
||||
const hasOnclick = btn.hasAttribute('onclick') || btn.onclick !== null;
|
||||
const hasSxHandler = !!(btn.__sx_listeners || btn._sxListeners);
|
||||
// Check all registered listeners
|
||||
const listeners = [];
|
||||
try {
|
||||
const evts = getEventListeners ? getEventListeners(btn) : {};
|
||||
for (const [evt, handlers] of Object.entries(evts)) {
|
||||
listeners.push({ event: evt, count: handlers.length });
|
||||
}
|
||||
} catch(e) { /* getEventListeners not available outside DevTools */ }
|
||||
|
||||
results.push({
|
||||
element: tag,
|
||||
index: i,
|
||||
text: text,
|
||||
reactiveAttrs: hasReactiveClass,
|
||||
hasOnclick: hasOnclick,
|
||||
listenerCount: listeners.length || (hasOnclick ? 1 : 0),
|
||||
wired: hasOnclick || listeners.length > 0
|
||||
});
|
||||
});
|
||||
|
||||
inputs.forEach((inp, i) => {
|
||||
const tag = inp.tagName.toLowerCase();
|
||||
const type = inp.type || '';
|
||||
const hasBind = inp.hasAttribute('data-sx-bind');
|
||||
const hasOnInput = inp.oninput !== null;
|
||||
results.push({
|
||||
element: tag,
|
||||
type: type,
|
||||
index: i,
|
||||
bind: hasBind,
|
||||
hasOnInput: hasOnInput,
|
||||
wired: hasOnInput || hasBind
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
})`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: inspect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeInspect(page, url, islandFilter) {
|
||||
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(page);
|
||||
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', err => pageErrors.push(err.message));
|
||||
|
||||
const selector = islandFilter
|
||||
? `[data-sx-island*="${islandFilter}"]`
|
||||
: '[data-sx-island]';
|
||||
|
||||
const islands = await page.$$eval(selector, (els, codeSelectors) => {
|
||||
return els.map(el => {
|
||||
const name = el.getAttribute('data-sx-island');
|
||||
|
||||
// Lakes with detail
|
||||
const lakes = [...el.querySelectorAll('[data-sx-lake]')].map(l => {
|
||||
const id = l.getAttribute('data-sx-lake');
|
||||
const html = l.innerHTML;
|
||||
const hasElements = l.children.length > 0;
|
||||
const textOnly = !hasElements && l.textContent.trim().length > 0;
|
||||
const looksLikeSx = /^\s*\(/.test(l.textContent.trim());
|
||||
return {
|
||||
id,
|
||||
htmlLength: html.length,
|
||||
childElements: l.children.length,
|
||||
textPreview: l.textContent.trim().substring(0, 100),
|
||||
status: hasElements ? 'rendered' : (looksLikeSx ? 'raw-sx-text' : (textOnly ? 'text-only' : 'empty'))
|
||||
};
|
||||
});
|
||||
|
||||
// Marshes
|
||||
const marshes = [...el.querySelectorAll('[data-sx-marsh]')].map(m => ({
|
||||
id: m.getAttribute('data-sx-marsh'),
|
||||
transform: m.getAttribute('data-sx-transform') || null
|
||||
}));
|
||||
|
||||
// Signals
|
||||
const signals = [...el.querySelectorAll('[data-sx-signal]')].map(s => {
|
||||
const spec = s.getAttribute('data-sx-signal');
|
||||
const colonIdx = spec.indexOf(':');
|
||||
return {
|
||||
store: colonIdx > 0 ? spec.substring(0, colonIdx) : spec,
|
||||
value: colonIdx > 0 ? spec.substring(colonIdx + 1).substring(0, 50) : null
|
||||
};
|
||||
});
|
||||
|
||||
// Reactive attrs
|
||||
const reactiveEls = [...el.querySelectorAll('[data-sx-reactive-attrs]')].map(r => ({
|
||||
tag: r.tagName.toLowerCase(),
|
||||
attrs: r.getAttribute('data-sx-reactive-attrs'),
|
||||
preview: r.outerHTML.substring(0, 80)
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
stateSize: (el.getAttribute('data-sx-state') || '').length,
|
||||
textLength: el.textContent.length,
|
||||
textPreview: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 150),
|
||||
lakes,
|
||||
marshes,
|
||||
signals,
|
||||
reactiveElements: reactiveEls.length,
|
||||
reactiveDetail: reactiveEls.slice(0, 10)
|
||||
};
|
||||
});
|
||||
}, CODE_DISPLAY_SELECTORS);
|
||||
|
||||
// Leak detection with code display exclusion
|
||||
const leaks = await page.$$eval(selector, (els, codeSelectors) => {
|
||||
// Build the leak detection inline (can't pass function refs to $$eval)
|
||||
const codeEls = new Set();
|
||||
document.querySelectorAll(codeSelectors).forEach(el => {
|
||||
el.querySelectorAll('*').forEach(d => codeEls.add(d));
|
||||
codeEls.add(el);
|
||||
});
|
||||
|
||||
return els.flatMap(el => {
|
||||
const name = el.getAttribute('data-sx-island');
|
||||
const leaks = [];
|
||||
|
||||
// Walk text nodes, skipping code display areas
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
let p = node.parentElement;
|
||||
while (p && p !== el) {
|
||||
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let node;
|
||||
while (node = walker.nextNode()) fullText += node.textContent;
|
||||
|
||||
const dictMatch = fullText.match(/\{:(?:type|tag|expr|spreads|attrs)\s[^}]{0,80}/);
|
||||
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
|
||||
const compMatch = fullText.match(/\(~[\w/]+[^)]{0,60}/);
|
||||
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
|
||||
const cssxMatch = fullText.match(/:tokens\s+"[^"]{0,60}/);
|
||||
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
|
||||
const strDictMatch = fullText.match(/\{"(?:type|tag|expr|text|cls|step)"\s[^}]{0,80}/);
|
||||
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
|
||||
const sxListMatch = fullText.match(/\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\s+:(?:class|id|style)/);
|
||||
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
|
||||
return leaks;
|
||||
});
|
||||
}, CODE_DISPLAY_SELECTORS);
|
||||
|
||||
// Handler audit for each island
|
||||
const handlers = await page.$$eval(selector, (els) => {
|
||||
return els.map(el => {
|
||||
const name = el.getAttribute('data-sx-island');
|
||||
const buttons = [...el.querySelectorAll('button')];
|
||||
const inputs = [...el.querySelectorAll('input, textarea, select')];
|
||||
|
||||
const buttonAudit = buttons.map((btn, i) => {
|
||||
const hasOnclick = btn.onclick !== null;
|
||||
const hasReactive = btn.hasAttribute('data-sx-reactive-attrs');
|
||||
// SX wires handlers via addEventListener, not onclick property.
|
||||
// We can't detect addEventListener from JS. Heuristic: if the
|
||||
// button has reactive attrs, it was rendered by an island and
|
||||
// likely has handlers. Only flag buttons with NO island markers.
|
||||
const inIsland = !!btn.closest('[data-sx-island]');
|
||||
const likelyWired = hasOnclick || (inIsland && hasReactive);
|
||||
return {
|
||||
text: btn.textContent.trim().substring(0, 20),
|
||||
hasOnclick,
|
||||
reactiveAttrs: hasReactive,
|
||||
inIsland,
|
||||
wired: likelyWired ? 'yes' : (inIsland ? 'probable' : 'unknown')
|
||||
};
|
||||
});
|
||||
|
||||
const inputAudit = inputs.map((inp, i) => {
|
||||
const hasBind = inp.hasAttribute('data-sx-bind');
|
||||
const hasOnInput = inp.oninput !== null;
|
||||
const inIsland = !!inp.closest('[data-sx-island]');
|
||||
return {
|
||||
type: inp.type || inp.tagName.toLowerCase(),
|
||||
hasBind,
|
||||
hasOnInput,
|
||||
inIsland,
|
||||
wired: hasOnInput || hasBind ? 'yes' : (inIsland ? 'probable' : 'unknown')
|
||||
};
|
||||
});
|
||||
|
||||
const suspectButtons = buttonAudit.filter(b => b.wired === 'unknown');
|
||||
const suspectInputs = inputAudit.filter(i => i.wired === 'unknown');
|
||||
|
||||
return {
|
||||
island: name,
|
||||
buttons: buttonAudit.length,
|
||||
inputs: inputAudit.length,
|
||||
suspectButtons: suspectButtons.length,
|
||||
suspectInputs: suspectInputs.length,
|
||||
allButtons: buttonAudit,
|
||||
detail: suspectButtons.length + suspectInputs.length > 0
|
||||
? { suspectButtons, suspectInputs }
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const globalLeaks = await page.$eval('#sx-root', el => {
|
||||
const text = el.textContent;
|
||||
const leaks = [];
|
||||
const dictMatch = text.match(/\{:(?:type|tag|expr)\s[^}]{0,80}/);
|
||||
if (dictMatch) leaks.push({ kind: 'raw-dict-outside-island', sample: dictMatch[0] });
|
||||
return leaks;
|
||||
}).catch(() => []);
|
||||
|
||||
return { url, islands, leaks: [...leaks, ...globalLeaks], handlers, pageErrors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: diff — SSR vs hydrated DOM (full page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeDiff(browser, url) {
|
||||
const snap = snapshotScript();
|
||||
|
||||
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
|
||||
const ssrPage = await ssrCtx.newPage();
|
||||
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const ssrTree = await ssrPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
|
||||
const ssrText = await ssrPage.evaluate(() => {
|
||||
const root = document.getElementById('sx-root');
|
||||
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
|
||||
});
|
||||
await ssrCtx.close();
|
||||
|
||||
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
|
||||
const hydPage = await hydCtx.newPage();
|
||||
const pageErrors = [];
|
||||
hydPage.on('pageerror', err => pageErrors.push(err.message));
|
||||
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(hydPage);
|
||||
const hydTree = await hydPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
|
||||
const hydText = await hydPage.evaluate(() => {
|
||||
const root = document.getElementById('sx-root');
|
||||
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
|
||||
});
|
||||
await hydCtx.close();
|
||||
|
||||
const changes = diffTrees(ssrTree, hydTree);
|
||||
const textMatch = ssrText === hydText;
|
||||
|
||||
return {
|
||||
url,
|
||||
ssrTextPreview: ssrText.substring(0, 300),
|
||||
hydratedTextPreview: hydText.substring(0, 300),
|
||||
textMatch,
|
||||
structuralChanges: changes.length,
|
||||
changes: changes.slice(0, 50),
|
||||
pageErrors
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: hydrate — SSR vs hydrated comparison focused on lakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeHydrate(browser, url) {
|
||||
// SSR: capture lake innerHTML without JS
|
||||
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
|
||||
const ssrPage = await ssrCtx.newPage();
|
||||
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
|
||||
const ssrLakes = await ssrPage.$$eval('[data-sx-lake]', els =>
|
||||
els.map(el => ({
|
||||
id: el.getAttribute('data-sx-lake'),
|
||||
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
|
||||
html: el.innerHTML.substring(0, 500),
|
||||
hasElements: el.children.length > 0,
|
||||
text: el.textContent.trim().substring(0, 200)
|
||||
})));
|
||||
|
||||
const ssrIslands = await ssrPage.$$eval('[data-sx-island]', els =>
|
||||
els.map(el => ({
|
||||
name: el.getAttribute('data-sx-island'),
|
||||
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
|
||||
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
|
||||
})));
|
||||
|
||||
await ssrCtx.close();
|
||||
|
||||
// Hydrated: capture same after JS runs
|
||||
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
|
||||
const hydPage = await hydCtx.newPage();
|
||||
const pageErrors = [];
|
||||
hydPage.on('pageerror', err => pageErrors.push(err.message));
|
||||
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(hydPage);
|
||||
|
||||
const hydLakes = await hydPage.$$eval('[data-sx-lake]', els =>
|
||||
els.map(el => ({
|
||||
id: el.getAttribute('data-sx-lake'),
|
||||
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
|
||||
html: el.innerHTML.substring(0, 500),
|
||||
hasElements: el.children.length > 0,
|
||||
text: el.textContent.trim().substring(0, 200)
|
||||
})));
|
||||
|
||||
const hydIslands = await hydPage.$$eval('[data-sx-island]', els =>
|
||||
els.map(el => ({
|
||||
name: el.getAttribute('data-sx-island'),
|
||||
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
|
||||
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
|
||||
})));
|
||||
|
||||
await hydCtx.close();
|
||||
|
||||
// Compare lakes
|
||||
const lakeReport = [];
|
||||
const ssrMap = Object.fromEntries(ssrLakes.map(l => [l.id, l]));
|
||||
const hydMap = Object.fromEntries(hydLakes.map(l => [l.id, l]));
|
||||
const allIds = new Set([...ssrLakes.map(l => l.id), ...hydLakes.map(l => l.id)]);
|
||||
|
||||
for (const id of allIds) {
|
||||
const ssr = ssrMap[id];
|
||||
const hyd = hydMap[id];
|
||||
const entry = { id, island: ssr?.island || hyd?.island };
|
||||
|
||||
if (!ssr) { entry.status = 'added-by-hydration'; }
|
||||
else if (!hyd) { entry.status = 'removed-by-hydration'; }
|
||||
else if (ssr.html === hyd.html) { entry.status = 'identical'; }
|
||||
else {
|
||||
const ssrHasEls = ssr.hasElements;
|
||||
const hydHasEls = hyd.hasElements;
|
||||
if (ssrHasEls && !hydHasEls) {
|
||||
entry.status = 'CLOBBERED';
|
||||
entry.detail = 'SSR had DOM elements, hydration replaced with text';
|
||||
entry.ssrPreview = ssr.text.substring(0, 100);
|
||||
entry.hydPreview = hyd.text.substring(0, 100);
|
||||
} else if (!ssrHasEls && hydHasEls) {
|
||||
entry.status = 'upgraded';
|
||||
entry.detail = 'SSR had text, hydration rendered DOM';
|
||||
} else {
|
||||
entry.status = 'changed';
|
||||
entry.ssrPreview = ssr.text.substring(0, 100);
|
||||
entry.hydPreview = hyd.text.substring(0, 100);
|
||||
}
|
||||
}
|
||||
lakeReport.push(entry);
|
||||
}
|
||||
|
||||
// Compare islands
|
||||
const islandReport = [];
|
||||
const ssrIslandMap = Object.fromEntries(ssrIslands.map(i => [i.name, i]));
|
||||
const hydIslandMap = Object.fromEntries(hydIslands.map(i => [i.name, i]));
|
||||
|
||||
for (const name of new Set([...ssrIslands.map(i => i.name), ...hydIslands.map(i => i.name)])) {
|
||||
const ssr = ssrIslandMap[name];
|
||||
const hyd = hydIslandMap[name];
|
||||
const entry = { name };
|
||||
|
||||
if (ssr?.hasError) entry.ssrError = true;
|
||||
if (hyd?.hasError) entry.hydrationError = true;
|
||||
if (ssr && hyd && ssr.text === hyd.text) entry.textMatch = true;
|
||||
else if (ssr && hyd) {
|
||||
entry.textMatch = false;
|
||||
entry.ssrPreview = ssr.text.substring(0, 100);
|
||||
entry.hydPreview = hyd.text.substring(0, 100);
|
||||
}
|
||||
islandReport.push(entry);
|
||||
}
|
||||
|
||||
const clobbered = lakeReport.filter(l => l.status === 'CLOBBERED');
|
||||
|
||||
return {
|
||||
url,
|
||||
summary: {
|
||||
lakes: lakeReport.length,
|
||||
identical: lakeReport.filter(l => l.status === 'identical').length,
|
||||
clobbered: clobbered.length,
|
||||
changed: lakeReport.filter(l => l.status === 'changed').length
|
||||
},
|
||||
lakes: lakeReport,
|
||||
islands: islandReport,
|
||||
pageErrors
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: eval — evaluate JS expression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeEval(page, url, expr) {
|
||||
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(page);
|
||||
const result = await page.evaluate(expr);
|
||||
return { url, expr, result };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: interact — action sequence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeInteract(page, url, actionsStr) {
|
||||
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(page);
|
||||
|
||||
const actions = actionsStr.split(';').map(a => a.trim()).filter(Boolean);
|
||||
const results = [];
|
||||
|
||||
for (const action of actions) {
|
||||
const [cmd, ...rest] = action.split(':');
|
||||
const arg = rest.join(':');
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'click':
|
||||
await page.locator(arg).first().click();
|
||||
results.push({ action: 'click', selector: arg, ok: true });
|
||||
break;
|
||||
case 'fill': {
|
||||
const [sel, ...valParts] = arg.split(':');
|
||||
const val = valParts.join(':');
|
||||
await page.locator(sel).first().fill(val);
|
||||
results.push({ action: 'fill', selector: sel, value: val, ok: true });
|
||||
break;
|
||||
}
|
||||
case 'wait':
|
||||
await page.waitForTimeout(parseInt(arg) || 500);
|
||||
results.push({ action: 'wait', ms: parseInt(arg) || 500 });
|
||||
break;
|
||||
case 'text': {
|
||||
const text = await page.locator(arg).first().textContent();
|
||||
results.push({ action: 'text', selector: arg, value: text?.trim() });
|
||||
break;
|
||||
}
|
||||
case 'html': {
|
||||
const html = await page.locator(arg).first().innerHTML();
|
||||
results.push({ action: 'html', selector: arg, value: html?.substring(0, 500) });
|
||||
break;
|
||||
}
|
||||
case 'attrs': {
|
||||
const attrs = await page.locator(arg).first().evaluate(el => {
|
||||
const a = {};
|
||||
for (const attr of el.attributes) a[attr.name] = attr.value.substring(0, 200);
|
||||
return a;
|
||||
});
|
||||
results.push({ action: 'attrs', selector: arg, value: attrs });
|
||||
break;
|
||||
}
|
||||
case 'screenshot': {
|
||||
const ts = Date.now();
|
||||
const path = `${SCREENSHOT_DIR}/sx-inspect-${ts}.png`;
|
||||
if (arg) {
|
||||
await page.locator(arg).first().screenshot({ path });
|
||||
} else {
|
||||
await page.screenshot({ path });
|
||||
}
|
||||
results.push({ action: 'screenshot', selector: arg || 'full-page', path });
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const count = await page.locator(arg).count();
|
||||
results.push({ action: 'count', selector: arg, value: count });
|
||||
break;
|
||||
}
|
||||
case 'visible': {
|
||||
const visible = await page.locator(arg).first().isVisible().catch(() => false);
|
||||
results.push({ action: 'visible', selector: arg, value: visible });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
results.push({ action: cmd, error: 'unknown action' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.push({ action: cmd, selector: arg, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { url, results };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode: screenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function modeScreenshot(page, url, selector) {
|
||||
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await waitForHydration(page);
|
||||
|
||||
const ts = Date.now();
|
||||
const path = `${SCREENSHOT_DIR}/sx-screenshot-${ts}.png`;
|
||||
|
||||
if (selector) {
|
||||
await page.locator(selector).first().screenshot({ path });
|
||||
} else {
|
||||
await page.screenshot({ path });
|
||||
}
|
||||
|
||||
return { url, selector: selector || 'full-page', path };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const argsJson = process.argv[2] || '{}';
|
||||
let args;
|
||||
try {
|
||||
args = JSON.parse(argsJson);
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ error: `Invalid JSON args: ${e.message}` }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = args.mode || 'inspect';
|
||||
const url = args.url || '/';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
let result;
|
||||
switch (mode) {
|
||||
case 'inspect':
|
||||
result = await modeInspect(page, url, args.island);
|
||||
break;
|
||||
case 'diff':
|
||||
result = await modeDiff(browser, url);
|
||||
break;
|
||||
case 'hydrate':
|
||||
result = await modeHydrate(browser, url);
|
||||
break;
|
||||
case 'eval':
|
||||
result = await modeEval(page, url, args.expr || 'document.title');
|
||||
break;
|
||||
case 'interact':
|
||||
result = await modeInteract(page, url, args.actions || '');
|
||||
break;
|
||||
case 'screenshot':
|
||||
result = await modeScreenshot(page, url, args.selector);
|
||||
break;
|
||||
default:
|
||||
result = { error: `Unknown mode: ${mode}` };
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ error: e.message, stack: e.stack?.split('\n').slice(0, 5) }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,154 +1,320 @@
|
||||
;; ==========================================================================
|
||||
;; web/harness-web.sx — Web platform testing extensions
|
||||
;;
|
||||
;; Extends spec/harness.sx with DOM mocking, event simulation, and
|
||||
;; web-specific assertions. Depends on web/signals.sx for reactive features.
|
||||
;;
|
||||
;; Mock DOM: lightweight element stubs that record operations.
|
||||
;; No real browser needed — runs on any host.
|
||||
;; ==========================================================================
|
||||
(define
|
||||
mock-element
|
||||
:effects ()
|
||||
(fn ((tag :as string) &key class id) {:children (list) :listeners {} :event-log (list) :tag tag :text "" :attrs (merge {} (if class {:class class} {}) (if id {:id id} {}))}))
|
||||
|
||||
(define
|
||||
mock-set-text!
|
||||
:effects (mutation)
|
||||
(fn (el (text :as string)) (dict-set! el "text" text)))
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Mock DOM elements
|
||||
;; --------------------------------------------------------------------------
|
||||
(define
|
||||
mock-append-child!
|
||||
:effects (mutation)
|
||||
(fn (parent child) (append! (get parent "children") child)))
|
||||
|
||||
;; Create a mock element with tag name, attrs dict, children list, and event log
|
||||
(define mock-element :effects []
|
||||
(fn ((tag :as string) &key class id)
|
||||
{:tag tag
|
||||
:attrs (merge {} (if class {:class class} {}) (if id {:id id} {}))
|
||||
:children (list)
|
||||
:text ""
|
||||
:event-log (list)
|
||||
:listeners {}}))
|
||||
(define
|
||||
mock-set-attr!
|
||||
:effects (mutation)
|
||||
(fn (el (name :as string) value) (dict-set! (get el "attrs") name value)))
|
||||
|
||||
;; Set text content on mock element
|
||||
(define mock-set-text! :effects [mutation]
|
||||
(fn (el (text :as string))
|
||||
(dict-set! el "text" text)))
|
||||
(define
|
||||
mock-get-attr
|
||||
:effects ()
|
||||
(fn (el (name :as string)) (get (get el "attrs") name)))
|
||||
|
||||
;; Append child to mock element
|
||||
(define mock-append-child! :effects [mutation]
|
||||
(fn (parent child)
|
||||
(append! (get parent "children") child)))
|
||||
|
||||
;; Set attribute on mock element
|
||||
(define mock-set-attr! :effects [mutation]
|
||||
(fn (el (name :as string) value)
|
||||
(dict-set! (get el "attrs") name value)))
|
||||
|
||||
;; Get attribute from mock element
|
||||
(define mock-get-attr :effects []
|
||||
(fn (el (name :as string))
|
||||
(get (get el "attrs") name)))
|
||||
|
||||
;; Add event listener to mock element
|
||||
(define mock-add-listener! :effects [mutation]
|
||||
(fn (el (event-name :as string) (handler :as lambda))
|
||||
(let ((listeners (get el "listeners")))
|
||||
(when (not (has-key? listeners event-name))
|
||||
(define
|
||||
mock-add-listener!
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(el (event-name :as string) (handler :as lambda))
|
||||
(let
|
||||
((listeners (get el "listeners")))
|
||||
(when
|
||||
(not (has-key? listeners event-name))
|
||||
(dict-set! listeners event-name (list)))
|
||||
(append! (get listeners event-name) handler))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Event simulation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Simulate a click event on a mock element
|
||||
(define simulate-click :effects [mutation]
|
||||
(fn (el)
|
||||
(let ((handlers (get (get el "listeners") "click")))
|
||||
(when handlers
|
||||
(for-each (fn (h) (cek-call h (list {:type "click" :target el})))
|
||||
handlers))
|
||||
(define
|
||||
simulate-click
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(el)
|
||||
(let
|
||||
((handlers (get (get el "listeners") "click")))
|
||||
(when
|
||||
handlers
|
||||
(for-each (fn (h) (cek-call h (list {:target el :type "click"}))) handlers))
|
||||
(append! (get el "event-log") {:type "click"}))))
|
||||
|
||||
;; Simulate an input event with a value
|
||||
(define simulate-input :effects [mutation]
|
||||
(fn (el (value :as string))
|
||||
(define
|
||||
simulate-input
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(el (value :as string))
|
||||
(mock-set-attr! el "value" value)
|
||||
(let ((handlers (get (get el "listeners") "input")))
|
||||
(when handlers
|
||||
(for-each (fn (h) (cek-call h (list {:type "input" :target el})))
|
||||
handlers))
|
||||
(append! (get el "event-log") {:type "input" :value value}))))
|
||||
(let
|
||||
((handlers (get (get el "listeners") "input")))
|
||||
(when
|
||||
handlers
|
||||
(for-each (fn (h) (cek-call h (list {:target el :type "input"}))) handlers))
|
||||
(append! (get el "event-log") {:value value :type "input"}))))
|
||||
|
||||
;; Simulate a custom event (for lake→island bridge)
|
||||
(define simulate-event :effects [mutation]
|
||||
(fn (el (event-name :as string) detail)
|
||||
(let ((handlers (get (get el "listeners") event-name)))
|
||||
(when handlers
|
||||
(for-each (fn (h) (cek-call h (list {:type event-name :detail detail :target el})))
|
||||
handlers))
|
||||
(append! (get el "event-log") {:type event-name :detail detail}))))
|
||||
(define
|
||||
simulate-event
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(el (event-name :as string) detail)
|
||||
(let
|
||||
((handlers (get (get el "listeners") event-name)))
|
||||
(when
|
||||
handlers
|
||||
(for-each (fn (h) (cek-call h (list {:target el :detail detail :type event-name}))) handlers))
|
||||
(append! (get el "event-log") {:detail detail :type event-name}))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; DOM assertions
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Assert mock element has specific text content
|
||||
(define assert-text :effects []
|
||||
(fn (el (expected :as string))
|
||||
(let ((actual (get el "text")))
|
||||
(assert= actual expected
|
||||
(define
|
||||
assert-text
|
||||
:effects ()
|
||||
(fn
|
||||
(el (expected :as string))
|
||||
(let
|
||||
((actual (get el "text")))
|
||||
(assert=
|
||||
actual
|
||||
expected
|
||||
(str "Expected text \"" expected "\", got \"" actual "\"")))))
|
||||
|
||||
;; Assert mock element has an attribute with expected value
|
||||
(define assert-attr :effects []
|
||||
(fn (el (name :as string) expected)
|
||||
(let ((actual (mock-get-attr el name)))
|
||||
(assert= actual expected
|
||||
(define
|
||||
assert-attr
|
||||
:effects ()
|
||||
(fn
|
||||
(el (name :as string) expected)
|
||||
(let
|
||||
((actual (mock-get-attr el name)))
|
||||
(assert=
|
||||
actual
|
||||
expected
|
||||
(str "Expected attr " name "=\"" expected "\", got \"" actual "\"")))))
|
||||
|
||||
;; Assert mock element has a CSS class
|
||||
(define assert-class :effects []
|
||||
(fn (el (class-name :as string))
|
||||
(let ((classes (or (mock-get-attr el "class") "")))
|
||||
(assert (contains? (split classes " ") class-name)
|
||||
(define
|
||||
assert-class
|
||||
:effects ()
|
||||
(fn
|
||||
(el (class-name :as string))
|
||||
(let
|
||||
((classes (or (mock-get-attr el "class") "")))
|
||||
(assert
|
||||
(contains? (split classes " ") class-name)
|
||||
(str "Expected class \"" class-name "\" in \"" classes "\"")))))
|
||||
|
||||
;; Assert mock element does NOT have a CSS class
|
||||
(define assert-no-class :effects []
|
||||
(fn (el (class-name :as string))
|
||||
(let ((classes (or (mock-get-attr el "class") "")))
|
||||
(assert (not (contains? (split classes " ") class-name))
|
||||
(define
|
||||
assert-no-class
|
||||
:effects ()
|
||||
(fn
|
||||
(el (class-name :as string))
|
||||
(let
|
||||
((classes (or (mock-get-attr el "class") "")))
|
||||
(assert
|
||||
(not (contains? (split classes " ") class-name))
|
||||
(str "Expected no class \"" class-name "\" but found in \"" classes "\"")))))
|
||||
|
||||
;; Assert mock element has N children
|
||||
(define assert-child-count :effects []
|
||||
(fn (el (n :as number))
|
||||
(let ((actual (len (get el "children"))))
|
||||
(assert= actual n
|
||||
(str "Expected " n " children, got " actual)))))
|
||||
(define
|
||||
assert-child-count
|
||||
:effects ()
|
||||
(fn
|
||||
(el (n :as number))
|
||||
(let
|
||||
((actual (len (get el "children"))))
|
||||
(assert= actual n (str "Expected " n " children, got " actual)))))
|
||||
|
||||
;; Assert an event was fired on mock element
|
||||
(define assert-event-fired :effects []
|
||||
(fn (el (event-name :as string))
|
||||
(assert (some (fn (e) (= (get e "type") event-name)) (get el "event-log"))
|
||||
(define
|
||||
assert-event-fired
|
||||
:effects ()
|
||||
(fn
|
||||
(el (event-name :as string))
|
||||
(assert
|
||||
(some (fn (e) (= (get e "type") event-name)) (get el "event-log"))
|
||||
(str "Expected event \"" event-name "\" to have been fired"))))
|
||||
|
||||
;; Assert an event was NOT fired on mock element
|
||||
(define assert-no-event :effects []
|
||||
(fn (el (event-name :as string))
|
||||
(assert (not (some (fn (e) (= (get e "type") event-name)) (get el "event-log")))
|
||||
(define
|
||||
assert-no-event
|
||||
:effects ()
|
||||
(fn
|
||||
(el (event-name :as string))
|
||||
(assert
|
||||
(not
|
||||
(some (fn (e) (= (get e "type") event-name)) (get el "event-log")))
|
||||
(str "Expected event \"" event-name "\" to NOT have been fired"))))
|
||||
|
||||
;; Count how many times an event was fired
|
||||
(define event-fire-count :effects []
|
||||
(fn (el (event-name :as string))
|
||||
(len (filter (fn (e) (= (get e "type") event-name)) (get el "event-log")))))
|
||||
(define
|
||||
event-fire-count
|
||||
:effects ()
|
||||
(fn
|
||||
(el (event-name :as string))
|
||||
(len
|
||||
(filter (fn (e) (= (get e "type") event-name)) (get el "event-log")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Web harness constructor — extends make-harness with DOM mock state
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define make-web-harness :effects []
|
||||
(fn (&key platform)
|
||||
(let ((h (make-harness :platform platform)))
|
||||
(harness-set! h "dom" {:root (mock-element "div" :id "root")
|
||||
:elements {}})
|
||||
(define
|
||||
make-web-harness
|
||||
:effects ()
|
||||
(fn
|
||||
(&key platform)
|
||||
(let
|
||||
((h (make-harness :platform platform)))
|
||||
(harness-set! h "dom" {:elements {} :root (mock-element "div" :id "root")})
|
||||
h)))
|
||||
|
||||
(define
|
||||
is-renderable?
|
||||
:effects ()
|
||||
(fn
|
||||
(value)
|
||||
(cond
|
||||
(nil? value)
|
||||
true
|
||||
(string? value)
|
||||
true
|
||||
(number? value)
|
||||
true
|
||||
(boolean? value)
|
||||
true
|
||||
(dict? value)
|
||||
false
|
||||
(not (list? value))
|
||||
false
|
||||
(empty? value)
|
||||
true
|
||||
:else (let
|
||||
((head (first value)))
|
||||
(and (= (type-of head) "symbol") (not (dict? head)))))))
|
||||
|
||||
(define
|
||||
is-render-leak?
|
||||
:effects ()
|
||||
(fn (value) (and (not (nil? value)) (not (is-renderable? value)))))
|
||||
|
||||
(define
|
||||
assert-renderable
|
||||
:effects ()
|
||||
(fn
|
||||
(value label)
|
||||
(assert
|
||||
(is-renderable? value)
|
||||
(str
|
||||
"Render leak in "
|
||||
label
|
||||
": "
|
||||
(type-of value)
|
||||
(cond
|
||||
(dict? value)
|
||||
" — dict would appear as {:key val} text in output"
|
||||
(and (list? value) (not (empty? value)) (dict? (first value)))
|
||||
" — list of dicts would appear as raw data in output"
|
||||
:else " — non-renderable value would appear as text")))))
|
||||
|
||||
(define
|
||||
render-body-audit
|
||||
:effects ()
|
||||
(fn
|
||||
(values)
|
||||
(let
|
||||
((leaks (list)))
|
||||
(for-each
|
||||
(fn (v) (when (is-render-leak? v) (append! leaks {:leak-kind (cond (dict? v) "dict" (and (list? v) (not (empty? v)) (dict? (first v))) "list-of-dicts" :else "other") :value-type (type-of v)})))
|
||||
values)
|
||||
leaks)))
|
||||
|
||||
(define
|
||||
assert-render-body-clean
|
||||
:effects ()
|
||||
(fn
|
||||
(values label)
|
||||
(let
|
||||
((leaks (render-body-audit values)))
|
||||
(assert
|
||||
(empty? leaks)
|
||||
(str
|
||||
"Render body has "
|
||||
(len leaks)
|
||||
" leak(s) in "
|
||||
label
|
||||
". "
|
||||
"render-to-html/render-to-dom render ALL body expressions — "
|
||||
"put side effects in let bindings, not body expressions.")))))
|
||||
|
||||
(define
|
||||
mock-render
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(expr)
|
||||
(cond
|
||||
(nil? expr)
|
||||
nil
|
||||
(string? expr)
|
||||
(let ((el (mock-element "TEXT"))) (mock-set-text! el expr) el)
|
||||
(number? expr)
|
||||
(let ((el (mock-element "TEXT"))) (mock-set-text! el (str expr)) el)
|
||||
(not (list? expr))
|
||||
nil
|
||||
(empty? expr)
|
||||
nil
|
||||
:else (let
|
||||
((head (first expr)))
|
||||
(if
|
||||
(not (= (type-of head) "symbol"))
|
||||
nil
|
||||
(let
|
||||
((el (mock-element (symbol-name head))))
|
||||
(let
|
||||
loop
|
||||
((args (rest expr)))
|
||||
(when
|
||||
(not (empty? args))
|
||||
(let
|
||||
((arg (first args)))
|
||||
(if
|
||||
(= (type-of arg) "keyword")
|
||||
(when
|
||||
(not (empty? (rest args)))
|
||||
(mock-set-attr! el (keyword-name arg) (nth args 1))
|
||||
(loop (rest (rest args))))
|
||||
(do
|
||||
(let
|
||||
((child-el (mock-render arg)))
|
||||
(when child-el (mock-append-child! el child-el)))
|
||||
(loop (rest args)))))))
|
||||
el))))))
|
||||
|
||||
(define
|
||||
mock-render-fragment
|
||||
:effects (mutation)
|
||||
(fn
|
||||
(exprs)
|
||||
(filter (fn (el) (not (nil? el))) (map mock-render exprs))))
|
||||
|
||||
(define
|
||||
assert-single-render-root
|
||||
:effects ()
|
||||
(fn
|
||||
(exprs label)
|
||||
(let
|
||||
((rendered (mock-render-fragment exprs)))
|
||||
(assert
|
||||
(= (len rendered) 1)
|
||||
(str
|
||||
"Expected single render root in "
|
||||
label
|
||||
" but got "
|
||||
(len rendered)
|
||||
" element(s). "
|
||||
"Multi-body let/begin in render-to-html/render-to-dom renders "
|
||||
"ALL expressions — put side effects in let bindings.")))))
|
||||
|
||||
(define
|
||||
assert-tag
|
||||
:effects ()
|
||||
(fn
|
||||
(el expected-tag)
|
||||
(assert
|
||||
(= (get el "tag") expected-tag)
|
||||
(str "Expected <" expected-tag "> but got <" (get el "tag") ">"))))
|
||||
|
||||
Reference in New Issue
Block a user