reactive-spread (module-loaded via K.load) doesn't apply CSSX classes to DOM elements. Returns null instead of "sx-text-center". This is the root cause of hydration stripping all styling — every rendering function is define-bound from module load. 12 pass, 1 fail — the reactive-spread test is the blocker. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
258 lines
12 KiB
JavaScript
258 lines
12 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');
|
|
|
|
// 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 <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); });
|