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>
237 lines
6.9 KiB
JavaScript
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 };
|