#!/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 (same eval)', 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'); // CRITICAL: define in separate eval (matches real module-load pattern). // This is how reactive-spread/reactive-attr work: defined at module load, // called later during hydration. The effect closure must capture host objects. K.eval('(define test-set-attr-sep (fn (el name val) (effect (fn () (dom-set-attr el name val)))))'); assert('define+effect+host-obj (separate eval)', K.eval('(let ((el (dom-create-element "div" nil))) (test-set-attr-sep el "class" "from-sep-define") (dom-get-attr el "class"))'), 'from-sep-define'); // Module-loaded define (via K.load, same as real module loading). // reactive-spread is loaded this way — test that effect fires. K.load('(define test-set-attr-mod (fn (el name val) (effect (fn () (dom-set-attr el name val)))))'); assert('define+effect+host-obj (module-loaded)', K.eval('(let ((el (dom-create-element "div" nil))) (test-set-attr-mod el "class" "from-mod") (dom-get-attr el "class"))'), 'from-mod'); // The actual reactive-spread pattern: module-loaded function creates effect // that calls cek-call on a render-fn returning a spread, then applies attrs. assert('reactive-spread from module', K.eval('(let ((el (dom-create-element "div" nil)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (reactive-spread el (fn () (~cssx/tw :tokens "text-center"))))) (host-get el "className"))'), 'sx-text-center'); // 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