Files
rose-ash/hosts/ocaml/browser/test_wasm_native.js
giles 4cb4551753 Add build-all.sh, bytecode test mode — 5 failures with SX_TEST_BYTECODE=1
The hydration bug root cause: .sxbc bytecoded modules break effect
closures. Source-loaded modules work (15/15), bytecoded fail (10/15).

Run: SX_TEST_BYTECODE=1 node hosts/ocaml/browser/test_wasm_native.js

Failing: scoped static class, signal attr initial, reactive-spread,
CSSX in island scope, reactive attr update.

Also adds build-all.sh (full WASM pipeline) and sx_build target="wasm"
MCP tool integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:26:31 +00:00

269 lines
13 KiB
JavaScript

#!/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 '<!--' + (c.textContent || '') + '-->';
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 + '</' + tag + '>';
},
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"))'), '<div class="card">content</div>');
// 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');
// Verify the effect body ACTUALLY EXECUTES (not just returning a value).
// In browser WASM, the effect body silently doesn't run.
assert('define+effect body executes',
K.eval('(do (define test-fx-log (fn (el log) (effect (fn () (append! log "ran") (dom-set-attr el "class" "fx"))))) (let ((el (dom-create-element "div" nil)) (log (list))) (test-fx-log el log) (str (len log) ":" (first log))))'),
'1:ran');
// 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"))))) (dom-get-attr el "class"))'),
'sx-text-center');
// Full hydration pattern: render-to-dom with CSSX inside island scope
assertIncludes('render-to-dom CSSX in island scope',
K.eval("(host-get (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom '(div (~cssx/tw :tokens \"text-center font-bold\") \"hello\") (global-env) nil)))) \"outerHTML\")"),
'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 <script type="text/sx"> in DOM — skip in Node
];
for (const [name, expr] of bootSteps) {
const before = K.eval(SCOPED_TEST);
K.eval(expr);
const after = K.eval(SCOPED_TEST);
console.log(` ${name}: before=${before} after=${after} ${before !== after ? '*** CHANGED ***' : 'ok'}`);
}
}
// Summary
console.log(`WASM native tests: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
}
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });