#!/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('
'); * 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 };