Files
rose-ash/tests/node/sx-harness.js
giles c6e7ce9596 Add Node.js test harness for SX WASM kernel with happy-dom
Boots the full WASM kernel (js_of_ocaml mode) + 24 .sxbc modules
into a happy-dom DOM environment. Provides helpers for SX eval,
DOM queries, island hydration, and click simulation.

Smoke test covers: kernel eval, DOM manipulation via SX, component
rendering, signals (reset!/swap!/computed), effects, and island
hydration with button rendering — 19/19 pass in ~2.5s.

Known limitation: reactive text nodes inside islands don't re-render
after click (signal updates but cek-reactive-text doesn't flush).

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

237 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/**
* sx-harness.js — Node.js test harness for the SX WASM kernel.
*
* Uses happy-dom for DOM APIs and loads the kernel + bytecode
* in the same way the browser does, but 10-50x faster than Playwright.
*
* Usage:
* const { createSxEnv } = require('./sx-harness');
* const env = await createSxEnv();
* env.setHTML('<div id="main-panel"><span data-sx-island="counter"></span></div>');
* env.boot();
* // inspect DOM, signals, etc.
* env.close();
*/
const { Window } = require('happy-dom');
const fs = require('fs');
const path = require('path');
const WASM_DIR = path.resolve(__dirname, '../../shared/static/wasm');
const SX_DIR = path.join(WASM_DIR, 'sx');
/**
* Create an SX environment with happy-dom + WASM kernel.
* @param {object} opts
* @param {string} opts.html - initial document body HTML
* @param {boolean} opts.boot - run boot-init after loading (default: false)
* @param {string[]} opts.components - SX component source strings to preload
*/
async function createSxEnv(opts = {}) {
const win = new Window({ url: 'http://localhost:8013/sx/' });
const doc = win.document;
// ---- Expose happy-dom globals the kernel expects ----
const g = win;
g.global = g;
// XHR for synchronous bytecode loading
g.XMLHttpRequest = createSyncXHR();
// requestIdleCallback (not in happy-dom)
g.requestIdleCallback = (fn) => g.setTimeout(fn, 0);
// matchMedia stub
g.matchMedia = () => ({ matches: false, addEventListener: () => {} });
// navigator
if (!g.navigator) g.navigator = {};
g.navigator.serviceWorker = { register: () => Promise.resolve() };
// ---- Set up as globalThis for the kernel ----
const origGlobal = globalThis;
const keysToRestore = [];
// Hoist happy-dom globals to Node globalThis so require() + kernel see them
const propsToHoist = [
'window', 'document', 'Element', 'Text', 'DocumentFragment', 'Document',
'Event', 'CustomEvent', 'MutationObserver', 'AbortController', 'Headers',
'HTMLElement', 'Node', 'Promise',
'localStorage', 'sessionStorage', 'location', 'history', 'navigator',
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback',
'fetch', 'XMLHttpRequest', 'matchMedia',
];
for (const k of propsToHoist) {
if (k in g) {
keysToRestore.push([k, globalThis[k]]);
globalThis[k] = g[k];
}
}
globalThis.window = g;
keysToRestore.push(['window', undefined]);
// ---- Console capture ----
const logs = [];
const origConsoleLog = console.log;
const origConsoleError = console.error;
const origConsoleWarn = console.warn;
console.log = (...args) => { logs.push({ type: 'log', text: args.join(' ') }); };
console.error = (...args) => { logs.push({ type: 'error', text: args.join(' ') }); };
console.warn = (...args) => { logs.push({ type: 'warn', text: args.join(' ') }); };
// ---- Load WASM kernel (js_of_ocaml mode) ----
const kernelPath = path.join(WASM_DIR, 'sx_browser.bc.js');
// Clear from require cache so each env gets a fresh kernel
delete require.cache[require.resolve(kernelPath)];
require(kernelPath);
if (!globalThis.SxKernel) {
throw new Error('SxKernel not set after loading sx_browser.bc.js');
}
const K = globalThis.SxKernel;
// ---- Load platform (registers FFI, loads .sxbc web stack) ----
const platformPath = path.join(WASM_DIR, 'sx-platform-2.js');
delete require.cache[require.resolve(platformPath)];
require(platformPath);
// ---- Set initial HTML ----
if (opts.html) {
doc.body.innerHTML = opts.html;
}
// ---- Load components ----
if (opts.components) {
for (const src of opts.components) {
K.load(src);
}
}
// ---- Boot ----
if (opts.boot) {
globalThis.Sx.init();
}
// ---- Build environment object ----
const env = {
window: g,
document: doc,
K,
Sx: globalThis.Sx,
logs,
/** Set body HTML. */
setHTML(html) { doc.body.innerHTML = html; },
/** Run SX boot-init. */
boot() { globalThis.Sx.init(); },
/** Evaluate SX expression, return result. */
eval(expr) { return K.eval(expr); },
/** Load SX source (defines, etc). */
load(src) { return K.load(src); },
/** Query a DOM element. */
query(sel) { return doc.querySelector(sel); },
/** Query all matching DOM elements. */
queryAll(sel) { return Array.from(doc.querySelectorAll(sel)); },
/** Get all islands in the document. */
islands() {
return Array.from(doc.querySelectorAll('[data-sx-island]')).map(el => ({
name: el.getAttribute('data-sx-island'),
element: el,
hydrated: el._sxHydrated || false,
}));
},
/** Simulate a click on an element. */
click(selOrEl) {
const el = typeof selOrEl === 'string' ? doc.querySelector(selOrEl) : selOrEl;
if (!el) throw new Error(`click: element not found: ${selOrEl}`);
el.click();
},
/** Get text content of element. */
text(sel) {
const el = doc.querySelector(sel);
return el ? el.textContent : null;
},
/** Wait for microtasks + timers to flush. */
async tick(ms = 0) {
await new Promise(r => g.setTimeout(r, ms));
// Flush any pending microtasks
await new Promise(r => setImmediate(r));
},
/** Get captured console logs. */
getLogs(filter) {
if (!filter) return logs;
return logs.filter(l => l.text.includes(filter));
},
/** Get errors from console. */
getErrors() {
return logs.filter(l => l.type === 'error');
},
/** Clean up. */
close() {
console.log = origConsoleLog;
console.error = origConsoleError;
console.warn = origConsoleWarn;
for (const [k, v] of keysToRestore) {
if (v === undefined) delete globalThis[k];
else globalThis[k] = v;
}
win.close();
},
};
return env;
}
/**
* Create a synchronous XMLHttpRequest that reads from the filesystem.
* The kernel uses sync XHR to load .sxbc files during boot.
*/
function createSyncXHR() {
return class SyncXHR {
constructor() {
this.status = 0;
this.responseText = '';
this._method = '';
this._url = '';
}
open(method, url, async) {
this._method = method;
this._url = url;
}
send() {
// Map URL to local file path
let filePath = this._url;
// Strip query params
filePath = filePath.split('?')[0];
// Handle relative paths — resolve against WASM_DIR
if (!filePath.startsWith('/') && !filePath.startsWith('file:')) {
filePath = path.join(WASM_DIR, filePath);
}
// Strip file:// prefix
filePath = filePath.replace(/^file:\/\//, '');
try {
this.responseText = fs.readFileSync(filePath, 'utf8');
this.status = 200;
} catch (e) {
this.status = 404;
this.responseText = '';
}
}
setRequestHeader() {}
getResponseHeader() { return null; }
};
}
module.exports = { createSxEnv };