Three root causes for reactive attribute updates not propagating in WASM: 1. `context` CEK special form only searched kont provide frames, missing `scope-push!` entries in the native scope_stacks hashtable. Unified by adding scope_stacks fallback to step_sf_context. 2. `flush-subscribers` used bare `(sub)` call which failed to invoke complex closures in for-each HO callbacks. Changed to `(cek-call sub nil)`. 3. Test eagerly evaluated `(deref s)` before render-to-dom saw it. Fixed tests to use quoted expressions matching real browser boot. WASM native: 10/10, WASM shell: 26/26. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
309 lines
14 KiB
Bash
Executable File
309 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
|
# WASM kernel tests in Node.js — verifies the compiled sx_browser.bc.js
|
|
# handles HTML tags, rendering, signals, and components correctly.
|
|
# Does NOT require a running server or browser.
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/../../.."
|
|
|
|
node -e '
|
|
// --- DOM stubs that track state ---
|
|
function makeElement(tag) {
|
|
var el = {
|
|
tagName: tag,
|
|
_attrs: {},
|
|
_children: [],
|
|
style: {},
|
|
childNodes: [],
|
|
children: [],
|
|
textContent: "",
|
|
setAttribute: function(k, v) { el._attrs[k] = v; },
|
|
getAttribute: function(k) { return el._attrs[k] || null; },
|
|
removeAttribute: function(k) { delete el._attrs[k]; },
|
|
appendChild: function(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
|
insertBefore: function(c, ref) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
|
removeChild: function(c) { return c; },
|
|
replaceChild: function(n, o) { return n; },
|
|
cloneNode: function() { return makeElement(tag); },
|
|
addEventListener: function() {},
|
|
removeEventListener: function() {},
|
|
dispatchEvent: function() {},
|
|
get innerHTML() {
|
|
// Reconstruct from children for simple cases
|
|
return el._children.map(function(c) {
|
|
if (c._isText) return c.textContent || "";
|
|
if (c._isComment) return "<!--" + (c.textContent || "") + "-->";
|
|
return c.outerHTML || "";
|
|
}).join("");
|
|
},
|
|
set innerHTML(v) { el._children = []; el.childNodes = []; el.children = []; el.textContent = v; },
|
|
get outerHTML() {
|
|
var s = "<" + tag;
|
|
var keys = Object.keys(el._attrs).sort();
|
|
for (var i = 0; i < keys.length; i++) {
|
|
s += " " + keys[i] + "=\"" + el._attrs[keys[i]] + "\"";
|
|
}
|
|
s += ">";
|
|
var voids = ["br","hr","img","input","meta","link"];
|
|
if (voids.indexOf(tag) >= 0) return s;
|
|
s += el.innerHTML;
|
|
s += "</" + tag + ">";
|
|
return s;
|
|
},
|
|
dataset: new Proxy({}, {
|
|
get: function(t, k) { return el._attrs["data-" + k.replace(/[A-Z]/g, function(c) { return "-" + c.toLowerCase(); })]; },
|
|
set: function(t, k, v) { el._attrs["data-" + k.replace(/[A-Z]/g, function(c) { return "-" + c.toLowerCase(); })] = v; return true; }
|
|
}),
|
|
querySelectorAll: function() { return []; },
|
|
querySelector: function() { return null; },
|
|
};
|
|
return el;
|
|
}
|
|
|
|
global.window = global;
|
|
global.document = {
|
|
createElement: makeElement,
|
|
createDocumentFragment: function() {
|
|
var f = makeElement("fragment");
|
|
f.tagName = undefined;
|
|
return f;
|
|
},
|
|
head: makeElement("head"),
|
|
body: makeElement("body"),
|
|
querySelector: function() { return null; },
|
|
querySelectorAll: function() { return []; },
|
|
createTextNode: function(s) { return {_isText:true, textContent:String(s), nodeType:3}; },
|
|
addEventListener: function() {},
|
|
createComment: function(s) { return {_isComment:true, textContent:s||"", nodeType:8}; },
|
|
getElementsByTagName: function() { return []; },
|
|
};
|
|
global.localStorage = {getItem:function(){return null},setItem:function(){},removeItem:function(){}};
|
|
global.CustomEvent = class { constructor(n,o){this.type=n;this.detail=(o||{}).detail||{}} };
|
|
global.MutationObserver = class { observe(){} disconnect(){} };
|
|
global.requestIdleCallback = function(fn) { return setTimeout(fn,0); };
|
|
global.matchMedia = function() { return {matches:false}; };
|
|
global.navigator = {serviceWorker:{register:function(){return Promise.resolve()}}};
|
|
global.location = {href:"",pathname:"/",hostname:"localhost"};
|
|
global.history = {pushState:function(){},replaceState:function(){}};
|
|
global.fetch = function() { return Promise.resolve({ok:true,text:function(){return Promise.resolve("")}}); };
|
|
global.setTimeout = setTimeout;
|
|
global.clearTimeout = clearTimeout;
|
|
global.XMLHttpRequest = class { open(){} send(){} };
|
|
|
|
// --- Load kernel ---
|
|
require("./shared/static/wasm/sx_browser.bc.js");
|
|
var K = globalThis.SxKernel;
|
|
if (!K) { console.error("FAIL: SxKernel not found"); process.exit(1); }
|
|
|
|
// --- Register 8 FFI host primitives (normally done by sx-platform-2.js) ---
|
|
K.registerNative("host-global", function(args) {
|
|
var name = args[0];
|
|
if (typeof globalThis !== "undefined" && name in globalThis) return globalThis[name];
|
|
return null;
|
|
});
|
|
K.registerNative("host-get", function(args) {
|
|
var obj = args[0], prop = args[1];
|
|
if (obj == null) return null;
|
|
var v = obj[prop];
|
|
return v === undefined ? null : v;
|
|
});
|
|
K.registerNative("host-set!", function(args) {
|
|
var obj = args[0], prop = args[1], val = args[2];
|
|
if (obj != null) obj[prop] = val;
|
|
return val;
|
|
});
|
|
K.registerNative("host-call", function(args) {
|
|
var obj = args[0], method = args[1];
|
|
var callArgs = args.slice(2);
|
|
if (obj == null || typeof obj[method] !== "function") return null;
|
|
var r = obj[method].apply(obj, callArgs);
|
|
return r === undefined ? null : r;
|
|
});
|
|
K.registerNative("host-new", function(args) {
|
|
var ctor = args[0];
|
|
var ctorArgs = args.slice(1);
|
|
return new (Function.prototype.bind.apply(ctor, [null].concat(ctorArgs)));
|
|
});
|
|
K.registerNative("host-callback", function(args) {
|
|
var fn = args[0];
|
|
return function() { return K.callFn(fn, Array.from(arguments)); };
|
|
});
|
|
K.registerNative("host-typeof", function(args) {
|
|
return typeof args[0];
|
|
});
|
|
K.registerNative("host-await", function(args) { return args[0]; });
|
|
|
|
// Platform constants
|
|
K.eval("(define SX_VERSION \"test-1.0\")");
|
|
K.eval("(define SX_ENGINE \"ocaml-vm-test\")");
|
|
K.eval("(define parse sx-parse)");
|
|
K.eval("(define serialize sx-serialize)");
|
|
|
|
var pass = 0, fail = 0;
|
|
function assert(name, got, expected) {
|
|
if (got === expected) { pass++; }
|
|
else { fail++; console.error("FAIL: " + name + "\n got: " + JSON.stringify(got) + "\n expected: " + JSON.stringify(expected)); }
|
|
}
|
|
function assertIncludes(name, got, substr) {
|
|
if (typeof got === "string" && got.includes(substr)) { pass++; }
|
|
else { fail++; console.error("FAIL: " + name + "\n got: " + JSON.stringify(got) + "\n expected to include: " + JSON.stringify(substr)); }
|
|
}
|
|
function assertNotError(name, got) {
|
|
if (typeof got === "string" && got.startsWith("Error:")) { fail++; console.error("FAIL: " + name + ": " + got); }
|
|
else { pass++; }
|
|
}
|
|
|
|
// =====================================================================
|
|
// Section 1: HTML tags and rendering
|
|
// =====================================================================
|
|
|
|
assert("arithmetic", K.eval("(+ 1 2)"), 3);
|
|
assert("string", K.eval("(str \"hello\" \" world\")"), "hello world");
|
|
|
|
// Tags as special forms — keywords preserved
|
|
assert("div preserves keywords",
|
|
K.eval("(inspect (div :class \"test\" \"hello\"))"),
|
|
"(div :class \"test\" \"hello\")");
|
|
|
|
assert("span preserves keywords",
|
|
K.eval("(inspect (span :id \"x\" \"content\"))"),
|
|
"(span :id \"x\" \"content\")");
|
|
|
|
// render-to-html
|
|
assert("render div+class", K.eval("(render-to-html (div :class \"card\" \"content\"))"), "<div class=\"card\">content</div>");
|
|
assert("render h1+class", K.eval("(render-to-html (h1 :class \"title\" \"Hello\"))"), "<h1 class=\"title\">Hello</h1>");
|
|
assert("render a+href", K.eval("(render-to-html (a :href \"/about\" \"About\"))"), "<a href=\"/about\">About</a>");
|
|
assert("render nested", K.eval("(render-to-html (div :class \"outer\" (span :class \"inner\" \"text\")))"), "<div class=\"outer\"><span class=\"inner\">text</span></div>");
|
|
assertIncludes("void element br", K.eval("(render-to-html (br))"), "br");
|
|
|
|
// Component rendering
|
|
K.eval("(defcomp ~test-card (&key title) (div :class \"card\" (h2 title)))");
|
|
assert("component render", K.eval("(render-to-html (~test-card :title \"Hello\"))"), "<div class=\"card\"><h2>Hello</h2></div>");
|
|
|
|
K.eval("(defcomp ~test-wrap (&key label) (div :class \"wrap\" (span label)))");
|
|
assert("component nested", K.eval("(render-to-html (~test-wrap :label \"hi\"))"), "<div class=\"wrap\"><span>hi</span></div>");
|
|
|
|
// Core primitives
|
|
assert("list length", K.eval("(list 1 2 3)").items.length, 3);
|
|
assert("first", K.eval("(first (list 1 2 3))"), 1);
|
|
assert("len", K.eval("(len (list 1 2 3))"), 3);
|
|
assert("map", K.eval("(len (map (fn (x) (+ x 1)) (list 1 2 3)))"), 3);
|
|
|
|
// HTML tag registry
|
|
assertNotError("HTML_TAGS defined", K.eval("(type-of HTML_TAGS)"));
|
|
assert("is-html-tag? div", K.eval("(is-html-tag? \"div\")"), true);
|
|
assert("is-html-tag? fake", K.eval("(is-html-tag? \"fake\")"), false);
|
|
|
|
// =====================================================================
|
|
// Load web stack modules (same as sx-platform-2.js loadWebStack)
|
|
// =====================================================================
|
|
var fs = require("fs");
|
|
var webStackFiles = [
|
|
"shared/static/wasm/sx/render.sx",
|
|
"shared/static/wasm/sx/core-signals.sx",
|
|
"shared/static/wasm/sx/signals.sx",
|
|
"shared/static/wasm/sx/deps.sx",
|
|
"shared/static/wasm/sx/router.sx",
|
|
"shared/static/wasm/sx/page-helpers.sx",
|
|
"shared/static/wasm/sx/freeze.sx",
|
|
"shared/static/wasm/sx/dom.sx",
|
|
"shared/static/wasm/sx/browser.sx",
|
|
"shared/static/wasm/sx/adapter-html.sx",
|
|
"shared/static/wasm/sx/adapter-sx.sx",
|
|
"shared/static/wasm/sx/adapter-dom.sx",
|
|
"shared/static/wasm/sx/cssx.sx",
|
|
"shared/static/wasm/sx/boot-helpers.sx",
|
|
"shared/static/wasm/sx/hypersx.sx",
|
|
"shared/static/wasm/sx/engine.sx",
|
|
"shared/static/wasm/sx/orchestration.sx",
|
|
"shared/static/wasm/sx/boot.sx",
|
|
];
|
|
var loadFails = [];
|
|
var useBytecode = process.env.SX_TEST_BYTECODE === "1";
|
|
if (K.beginModuleLoad) K.beginModuleLoad();
|
|
for (var i = 0; i < webStackFiles.length; i++) {
|
|
var loaded = false;
|
|
if (useBytecode) {
|
|
var bcPath = webStackFiles[i].replace(/\.sx$/, ".sxbc");
|
|
try {
|
|
var bcSrc = fs.readFileSync(bcPath, "utf8");
|
|
global.__sxbcText = bcSrc;
|
|
var bcResult = K.eval("(load-sxbc (first (parse (host-global \"__sxbcText\"))))");
|
|
delete global.__sxbcText;
|
|
if (typeof bcResult !== "string" || !bcResult.startsWith("Error")) {
|
|
loaded = true;
|
|
} else {
|
|
loadFails.push(bcPath + " (sxbc): " + bcResult);
|
|
}
|
|
} catch(e) { delete global.__sxbcText; }
|
|
}
|
|
if (!loaded) {
|
|
var src = fs.readFileSync(webStackFiles[i], "utf8");
|
|
var r = K.load(src);
|
|
if (typeof r === "string" && r.startsWith("Error")) {
|
|
loadFails.push(webStackFiles[i] + ": " + r);
|
|
}
|
|
}
|
|
}
|
|
if (K.endModuleLoad) K.endModuleLoad();
|
|
if (loadFails.length > 0) {
|
|
console.error("Module load failures:");
|
|
loadFails.forEach(function(f) { console.error(" " + f); });
|
|
}
|
|
|
|
// =====================================================================
|
|
// Section 2: render-to-dom (requires working DOM stubs)
|
|
// All DOM results are host objects — use host-get/dom-get-attr from SX
|
|
// =====================================================================
|
|
|
|
// Basic DOM rendering
|
|
assert("dom tagName",
|
|
K.eval("(host-get (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"tagName\")"),
|
|
"div");
|
|
assert("dom class attr",
|
|
K.eval("(dom-get-attr (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"class\")"),
|
|
"test");
|
|
assertIncludes("dom outerHTML",
|
|
K.eval("(host-get (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"outerHTML\")"),
|
|
"hello");
|
|
|
|
// Nested DOM rendering
|
|
assertIncludes("nested dom outerHTML",
|
|
K.eval("(host-get (render-to-dom (div :class \"outer\" (span :id \"inner\" \"text\")) (global-env) nil) \"outerHTML\")"),
|
|
"class=\"outer\"");
|
|
|
|
// =====================================================================
|
|
// Section 3: Reactive rendering — with-island-scope + deref
|
|
// This is the critical test for the hydration bug.
|
|
// with-island-scope should NOT strip attributes.
|
|
// =====================================================================
|
|
|
|
// 3a. with-island-scope should preserve static attributes
|
|
assert("scoped static class",
|
|
K.eval("(dom-get-attr (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class \"scoped\" \"text\") (global-env) nil)))) \"class\")"),
|
|
"scoped");
|
|
|
|
// 3b. Signal deref in text position should render initial value
|
|
assertIncludes("signal text initial value",
|
|
K.eval("(host-get (let ((s (signal 42)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div (deref s)) (global-env) nil)))) \"outerHTML\")"),
|
|
"42");
|
|
|
|
// 3c. Signal deref in attribute position should set initial value
|
|
assert("signal attr initial value",
|
|
K.eval("(dom-get-attr (let ((s (signal \"active\")) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class (deref s) \"content\") (global-env) nil)))) \"class\")"),
|
|
"active");
|
|
|
|
// 3d. After signal update, reactive DOM should update
|
|
// render-to-dom needs unevaluated expr (as in real browser boot from parsed source)
|
|
K.eval("(define test-reactive-sig (signal \"before\"))");
|
|
assert("reactive attr update",
|
|
K.eval("(let ((d (list))) (let ((el (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (quote (div :class (deref test-reactive-sig) \"content\")) (global-env) nil))))) (reset! test-reactive-sig \"after\") (dom-get-attr el \"class\")))"),
|
|
"after");
|
|
|
|
// =====================================================================
|
|
// Summary
|
|
// =====================================================================
|
|
console.log("WASM kernel tests: " + pass + " passed, " + fail + " failed");
|
|
if (fail > 0) process.exit(1);
|
|
'
|