diff --git a/hosts/ocaml/browser/test_wasm.sh b/hosts/ocaml/browser/test_wasm.sh new file mode 100755 index 00000000..086bf4a1 --- /dev/null +++ b/hosts/ocaml/browser/test_wasm.sh @@ -0,0 +1,308 @@ +#!/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/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); +' diff --git a/hosts/ocaml/browser/test_wasm_native.js b/hosts/ocaml/browser/test_wasm_native.js new file mode 100644 index 00000000..a8c84e98 --- /dev/null +++ b/hosts/ocaml/browser/test_wasm_native.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +// test_wasm_native.js — Run WASM kernel tests in Node.js using the actual +// WASM binary (not js_of_ocaml JS fallback). This tests the exact same +// kernel that runs in the browser. +// +// Usage: node hosts/ocaml/browser/test_wasm_native.js +// SX_TEST_BYTECODE=1 node hosts/ocaml/browser/test_wasm_native.js + +const fs = require('fs'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '../../..'); +const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm'); + +// --- DOM stubs --- +function makeElement(tag) { + const el = { + tagName: tag, _attrs: {}, _children: [], style: {}, + childNodes: [], children: [], textContent: '', + nodeType: 1, + setAttribute(k, v) { el._attrs[k] = String(v); }, + getAttribute(k) { return el._attrs[k] || null; }, + removeAttribute(k) { delete el._attrs[k]; }, + appendChild(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; }, + insertBefore(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; }, + removeChild(c) { return c; }, + replaceChild(n) { return n; }, + cloneNode() { return makeElement(tag); }, + addEventListener() {}, removeEventListener() {}, dispatchEvent() {}, + get innerHTML() { + return el._children.map(c => { + if (c._isText) return c.textContent || ''; + if (c._isComment) return ''; + return c.outerHTML || ''; + }).join(''); + }, + set innerHTML(v) { el._children = []; el.childNodes = []; el.children = []; }, + get outerHTML() { + let s = '<' + tag; + for (const k of Object.keys(el._attrs).sort()) s += ` ${k}="${el._attrs[k]}"`; + s += '>'; + if (['br','hr','img','input','meta','link'].includes(tag)) return s; + return s + el.innerHTML + ''; + }, + dataset: new Proxy({}, { + get(_, k) { return el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())]; }, + set(_, k, v) { el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())] = v; return true; } + }), + querySelectorAll() { return []; }, + querySelector() { return null; }, + }; + return el; +} + +global.window = global; +global.document = { + createElement: makeElement, + createDocumentFragment() { return makeElement('fragment'); }, + head: makeElement('head'), body: makeElement('body'), + querySelector() { return null; }, querySelectorAll() { return []; }, + createTextNode(s) { return { _isText: true, textContent: String(s), nodeType: 3 }; }, + addEventListener() {}, + createComment(s) { return { _isComment: true, textContent: s || '', nodeType: 8 }; }, + getElementsByTagName() { return []; }, +}; +global.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; +global.CustomEvent = class { constructor(n, o) { this.type = n; this.detail = (o || {}).detail || {}; } }; +global.MutationObserver = class { observe() {} disconnect() {} }; +global.requestIdleCallback = fn => setTimeout(fn, 0); +global.matchMedia = () => ({ matches: false }); +global.navigator = { serviceWorker: { register() { return Promise.resolve(); } } }; +global.location = { href: '', pathname: '/', hostname: 'localhost' }; +global.history = { pushState() {}, replaceState() {} }; +global.fetch = () => Promise.resolve({ ok: true, text() { return Promise.resolve(''); } }); +global.XMLHttpRequest = class { open() {} send() {} }; + +// --- Load WASM kernel --- +async function main() { + // The WASM loader sets globalThis.SxKernel after async init + require(path.join(WASM_DIR, 'sx_browser.bc.wasm.js')); + + // Poll for SxKernel (WASM init is async) + const K = await new Promise((resolve, reject) => { + let tries = 0; + const poll = setInterval(() => { + if (globalThis.SxKernel) { clearInterval(poll); resolve(globalThis.SxKernel); } + else if (++tries > 200) { clearInterval(poll); reject(new Error('SxKernel not found after 10s')); } + }, 50); + }); + + console.log('WASM kernel loaded (native WASM, not JS fallback)'); + + // --- Register 8 FFI host primitives --- + K.registerNative('host-global', args => { + const name = args[0]; + return (name in globalThis) ? 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 => { if (args[0] != null) args[0][args[1]] = args[2]; return args[2]; }); + K.registerNative('host-call', args => { + const [obj, method, ...rest] = args; + if (obj == null || typeof obj[method] !== 'function') return null; + const r = obj[method].apply(obj, rest); + return r === undefined ? null : r; + }); + K.registerNative('host-new', args => new (Function.prototype.bind.apply(args[0], [null, ...args.slice(1)]))); + K.registerNative('host-callback', args => function() { return K.callFn(args[0], Array.from(arguments)); }); + K.registerNative('host-typeof', args => typeof args[0]); + K.registerNative('host-await', args => args[0]); + + K.eval('(define SX_VERSION "test-wasm-1.0")'); + K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")'); + K.eval('(define parse sx-parse)'); + K.eval('(define serialize sx-serialize)'); + + // --- Load web stack modules --- + const useBytecode = process.env.SX_TEST_BYTECODE === '1'; + const sxDir = path.join(WASM_DIR, 'sx'); + const modules = [ + 'render', 'core-signals', 'signals', 'deps', 'router', 'page-helpers', 'freeze', + 'bytecode', 'compiler', 'vm', 'dom', 'browser', + 'adapter-html', 'adapter-sx', 'adapter-dom', + 'cssx', 'boot-helpers', 'hypersx', + 'harness', 'harness-reactive', 'harness-web', + 'engine', 'orchestration', 'boot', + ]; + + if (K.beginModuleLoad) K.beginModuleLoad(); + for (const mod of modules) { + let loaded = false; + if (useBytecode) { + try { + const bcSrc = fs.readFileSync(path.join(sxDir, mod + '.sxbc'), 'utf8'); + global.__sxbcText = bcSrc; + const r = K.eval('(load-sxbc (first (parse (host-global "__sxbcText"))))'); + delete global.__sxbcText; + if (typeof r !== 'string' || !r.startsWith('Error')) { loaded = true; } + } catch (e) { delete global.__sxbcText; } + } + if (!loaded) { + const src = fs.readFileSync(path.join(sxDir, mod + '.sx'), 'utf8'); + K.load(src); + } + } + if (K.endModuleLoad) K.endModuleLoad(); + + // --- Test runner --- + let 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)}`); } + } + + // --- Tests --- + + const SCOPED_TEST = '(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")'; + + // Basic + assert('arithmetic', K.eval('(+ 1 2)'), 3); + assert('div preserves keywords', K.eval('(inspect (div :class "test" "hello"))'), '(div :class "test" "hello")'); + assert('render div+class', K.eval('(render-to-html (div :class "card" "content"))'), '
content
'); + + // DOM rendering + assert('dom class attr', + K.eval('(dom-get-attr (render-to-dom (div :class "test" "hello") (global-env) nil) "class")'), + 'test'); + + // Reactive: scoped static class + assert('scoped static class', + K.eval(SCOPED_TEST), 'scoped'); + + // Reactive: signal deref initial value in scope + 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'); + + // Reactive: signal text in scope + assertIncludes('signal text in scope', + 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'); + + // CRITICAL: define vs let closure with host objects + effect + // This is the root cause of the hydration rendering bug. + // A function defined with `define` that takes a host object (DOM element) + // and uses `effect` to modify it — the effect body doesn't see the element. + assert('define+effect+host-obj', + K.eval('(do (define test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-define") (dom-get-attr el "class")))'), + 'from-define'); + + // Same thing with let works (proves it's define-specific) + assert('let+effect+host-obj', + K.eval('(let ((test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val)))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-let") (dom-get-attr el "class")))'), + 'from-let'); + + // Reactive: signal update propagation + // Note: render-to-dom needs the UNEVALUATED expression (as in real browser boot + // where expressions come from parsing). Use quote to prevent eager eval of (deref s). + 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 '(div :class (deref test-reactive-sig) \"content\") (global-env) nil))))) (reset! test-reactive-sig \"after\") (dom-get-attr el \"class\")))"), + 'after'); + + // ===================================================================== + // Section: Boot step bisection + // Simulate boot steps to find which one breaks scoped rendering + // ===================================================================== + if (process.env.SX_TEST_BOOT_BISECT === '1') { + console.log('\n=== Boot step bisection ==='); + const bootSteps = [ + ['init-css-tracking', '(init-css-tracking)'], + ['process-page-scripts', '(process-page-scripts)'], + // process-sx-scripts needs