#!/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 ""; 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 += ""; 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\"))"), "
content
"); assert("render h1+class", K.eval("(render-to-html (h1 :class \"title\" \"Hello\"))"), "

Hello

"); assert("render a+href", K.eval("(render-to-html (a :href \"/about\" \"About\"))"), "About"); assert("render nested", K.eval("(render-to-html (div :class \"outer\" (span :class \"inner\" \"text\")))"), "
text
"); 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\"))"), "

Hello

"); K.eval("(defcomp ~test-wrap (&key label) (div :class \"wrap\" (span label)))"); assert("component nested", K.eval("(render-to-html (~test-wrap :label \"hi\"))"), "
hi
"); // 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/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); '