#!/usr/bin/env node /** * Run hyperscript behavioral tests in Node.js using the WASM kernel. * No browser needed — uses a minimal DOM mock. */ const fs = require('fs'); const path = require('path'); const PROJECT = path.resolve(__dirname, '..'); const WASM_DIR = path.join(PROJECT, 'shared/static/wasm'); const SX_DIR = path.join(WASM_DIR, 'sx'); // Load WASM kernel eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8')); const K = globalThis.SxKernel; // ─── Minimal DOM mock ─────────────────────────────────────────── class MockElement { constructor(tag) { this.tagName = tag.toUpperCase(); this.nodeName = this.tagName; this.nodeType = 1; this.id = ''; this.className = ''; this.classList = new MockClassList(this); this.style = {}; this.attributes = {}; this.children = []; this.childNodes = []; this.parentElement = null; this.parentNode = null; this.textContent = ''; this.innerHTML = ''; this.outerHTML = ''; this._listeners = {}; this._hs = {}; // hyperscript data this.dataset = {}; this.open = false; // for dialog } setAttribute(name, val) { this.attributes[name] = val; if (name === 'id') this.id = val; if (name === 'class') { this.className = val; this.classList._sync(val); } } getAttribute(name) { return this.attributes[name] !== undefined ? this.attributes[name] : null; } removeAttribute(name) { delete this.attributes[name]; } hasAttribute(name) { return name in this.attributes; } addEventListener(evt, fn, opts) { if (!this._listeners[evt]) this._listeners[evt] = []; this._listeners[evt].push(fn); } removeEventListener(evt, fn) { if (this._listeners[evt]) this._listeners[evt] = this._listeners[evt].filter(f => f !== fn); } dispatchEvent(event) { event.target = this; event.currentTarget = this; const fns = this._listeners[event.type] || []; for (const fn of fns) { try { fn(event); } catch(e) {} } // Bubble if (event.bubbles && !event._stopped && this.parentElement) { event.currentTarget = this.parentElement; this.parentElement.dispatchEvent(event); } return !event.defaultPrevented; } appendChild(child) { if (child.parentElement) child.parentElement.removeChild(child); child.parentElement = this; child.parentNode = this; this.children.push(child); this.childNodes.push(child); return child; } removeChild(child) { this.children = this.children.filter(c => c !== child); this.childNodes = this.childNodes.filter(c => c !== child); child.parentElement = null; child.parentNode = null; return child; } insertBefore(newChild, refChild) { const idx = this.children.indexOf(refChild); if (idx >= 0) { this.children.splice(idx, 0, newChild); this.childNodes.splice(idx, 0, newChild); } else { this.children.push(newChild); this.childNodes.push(newChild); } newChild.parentElement = this; newChild.parentNode = this; return newChild; } replaceChild(newChild, oldChild) { const idx = this.children.indexOf(oldChild); if (idx >= 0) { this.children[idx] = newChild; this.childNodes[idx] = newChild; } newChild.parentElement = this; newChild.parentNode = this; oldChild.parentElement = null; oldChild.parentNode = null; return oldChild; } querySelector(sel) { return findInTree(this, sel); } querySelectorAll(sel) { return findAllInTree(this, sel); } closest(sel) { let el = this; while (el) { if (matchesSelector(el, sel)) return el; el = el.parentElement; } return null; } matches(sel) { return matchesSelector(this, sel); } contains(other) { if (other === this) return true; for (const c of this.children) { if (c === other || c.contains(other)) return true; } return false; } cloneNode(deep) { const el = new MockElement(this.tagName.toLowerCase()); Object.assign(el.attributes, this.attributes); el.id = this.id; el.className = this.className; el.classList._sync(this.className); Object.assign(el.style, this.style); el.textContent = this.textContent; el.innerHTML = this.innerHTML; if (deep) { for (const c of this.children) el.appendChild(c.cloneNode(true)); } return el; } focus() {} blur() {} click() { this.dispatchEvent(new MockEvent('click', { bubbles: true })); } remove() { if (this.parentElement) this.parentElement.removeChild(this); } get firstElementChild() { return this.children[0] || null; } get lastElementChild() { return this.children[this.children.length - 1] || null; } get nextElementSibling() { if (!this.parentElement) return null; const idx = this.parentElement.children.indexOf(this); return this.parentElement.children[idx + 1] || null; } get previousElementSibling() { if (!this.parentElement) return null; const idx = this.parentElement.children.indexOf(this); return idx > 0 ? this.parentElement.children[idx - 1] : null; } // Dialog methods showModal() { this.open = true; this.setAttribute('open', ''); } show() { this.open = true; this.setAttribute('open', ''); } close() { this.open = false; this.removeAttribute('open'); } // Transition stub getAnimations() { return []; } getBoundingClientRect() { return { top: 0, left: 0, width: 100, height: 100, right: 100, bottom: 100 }; } scrollIntoView() {} } class MockClassList { constructor(el) { this._el = el; this._set = new Set(); } _sync(str) { this._set = new Set((str || '').split(/\s+/).filter(Boolean)); } add(...cls) { for (const c of cls) this._set.add(c); this._el.className = [...this._set].join(' '); } remove(...cls) { for (const c of cls) this._set.delete(c); this._el.className = [...this._set].join(' '); } toggle(cls, force) { if (force !== undefined) { if (force) this.add(cls); else this.remove(cls); return force; } if (this._set.has(cls)) { this.remove(cls); return false; } else { this.add(cls); return true; } } contains(cls) { return this._set.has(cls); } get length() { return this._set.size; } [Symbol.iterator]() { return this._set[Symbol.iterator](); } } class MockEvent { constructor(type, opts = {}) { this.type = type; this.bubbles = opts.bubbles || false; this.cancelable = opts.cancelable !== false; this.defaultPrevented = false; this._stopped = false; this._stoppedImmediate = false; this.target = null; this.currentTarget = null; this.detail = opts.detail || null; } preventDefault() { this.defaultPrevented = true; } stopPropagation() { this._stopped = true; } stopImmediatePropagation() { this._stopped = true; this._stoppedImmediate = true; } } class MockCustomEvent extends MockEvent { constructor(type, opts = {}) { super(type, opts); this.detail = opts.detail || null; } } function matchesSelector(el, sel) { if (!el || !el.tagName) return false; if (sel.startsWith('#')) return el.id === sel.slice(1); if (sel.startsWith('.')) return el.classList.contains(sel.slice(1)); if (sel.includes('#')) { const [tag, id] = sel.split('#'); return el.tagName.toLowerCase() === tag && el.id === id; } return el.tagName.toLowerCase() === sel.toLowerCase(); } function findInTree(el, sel) { for (const c of (el.children || [])) { if (matchesSelector(c, sel)) return c; const found = findInTree(c, sel); if (found) return found; } return null; } function findAllInTree(el, sel) { const results = []; for (const c of (el.children || [])) { if (matchesSelector(c, sel)) results.push(c); results.push(...findAllInTree(c, sel)); } return results; } // ─── Global DOM mock ──────────────────────────────────────────── const _body = new MockElement('body'); const _html = new MockElement('html'); _html.appendChild(_body); const document = { body: _body, documentElement: _html, createElement(tag) { return new MockElement(tag); }, createElementNS(ns, tag) { return new MockElement(tag); }, createDocumentFragment() { return new MockElement('fragment'); }, createTextNode(text) { const t = { nodeType: 3, textContent: text, data: text }; return t; }, getElementById(id) { return findInTree(_body, '#' + id); }, querySelector(sel) { return findInTree(_body, sel); }, querySelectorAll(sel) { return findAllInTree(_body, sel); }, createEvent(type) { return new MockEvent(type); }, addEventListener() {}, removeEventListener() {}, }; globalThis.document = document; globalThis.window = globalThis; globalThis.HTMLElement = MockElement; globalThis.Element = MockElement; globalThis.Event = MockEvent; globalThis.CustomEvent = MockCustomEvent; globalThis.NodeList = Array; globalThis.HTMLCollection = Array; globalThis.getComputedStyle = (el) => el.style; globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); globalThis.cancelAnimationFrame = (id) => clearTimeout(id); globalThis.MutationObserver = class { observe() {} disconnect() {} }; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.IntersectionObserver = class { observe() {} disconnect() {} }; globalThis.navigator = { userAgent: 'node-test' }; globalThis.location = { href: 'http://localhost/', pathname: '/', search: '', hash: '' }; globalThis.history = { pushState() {}, replaceState() {}, back() {}, forward() {} }; // ─── Host FFI ─────────────────────────────────────────────────── K.registerNative('host-global', a => { const n = a[0]; return (n in globalThis) ? globalThis[n] : null; }); K.registerNative('host-get', a => { if (a[0] == null) return null; const v = a[0][a[1]]; return v === undefined ? null : v; }); K.registerNative('host-set!', a => { if (a[0] != null) a[0][a[1]] = a[2]; return a[2]; }); K.registerNative('host-call', a => { const [o, m, ...r] = a; if (o == null) { const f = globalThis[m]; return typeof f === 'function' ? f.apply(null, r) : null; } if (typeof o[m] !== 'function') return null; try { const v = o[m].apply(o, r); return v === undefined ? null : v; } catch(e) { return null; } }); K.registerNative('host-new', a => { const C = typeof a[0] === 'string' ? globalThis[a[0]] : a[0]; return typeof C === 'function' ? new C(...a.slice(1)) : null; }); K.registerNative('host-callback', a => { const fn = a[0]; if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn; if (fn && fn.__sx_handle !== undefined) { return function() { const r = K.callFn(fn, Array.from(arguments)); if (globalThis._driveAsync) globalThis._driveAsync(r); return r; }; } return function() {}; }); K.registerNative('host-typeof', a => { const o = a[0]; if (o == null) return 'nil'; if (o instanceof MockElement) return 'element'; if (o && o.nodeType === 3) return 'text'; if (o instanceof MockEvent || o instanceof MockCustomEvent) return 'event'; if (o instanceof Promise) return 'promise'; return typeof o; }); K.registerNative('host-await', a => { const [p, cb] = a; if (p && typeof p.then === 'function') { const f = (cb && cb.__sx_handle !== undefined) ? v => K.callFn(cb, [v]) : () => {}; p.then(f); } }); K.registerNative('load-library!', () => false); // Drive async suspension — synchronous, with depth limit globalThis._driveAsync = function driveAsync(result, depth) { depth = depth || 0; if (depth > 200) return; // prevent infinite loops if (!result || !result.suspended) return; const req = result.request; const items = req && (req.items || req); const op = items && items[0]; const opName = typeof op === 'string' ? op : (op && op.name) || String(op); const arg = items && items[1]; function doResume(val) { try { const r = result.resume(val); driveAsync(r, depth + 1); } catch(e) {} } if (opName === 'io-sleep' || opName === 'wait') doResume(null); else if (opName === 'io-fetch') doResume({ ok: true, text: '' }); else if (opName === 'io-settle') doResume(null); else if (opName === 'io-wait-event') doResume(null); else if (opName === 'io-transition') doResume(null); }; K.eval('(define SX_VERSION "hs-test-1.0")'); K.eval('(define SX_ENGINE "ocaml-vm-sandbox")'); K.eval('(define parse sx-parse)'); K.eval('(define serialize sx-serialize)'); // ─── Load modules ─────────────────────────────────────────────── const WEB_MODULES = [ 'render', 'core-signals', 'signals', 'deps', 'router', 'page-helpers', 'freeze', 'dom', 'browser', 'adapter-html', 'adapter-sx', 'adapter-dom', 'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot', ]; const HS_MODULES = ['hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration']; K.beginModuleLoad(); for (const mod of [...WEB_MODULES, ...HS_MODULES]) { const sxPath = path.join(SX_DIR, mod + '.sx'); const libPath = path.join(PROJECT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx'); let src; try { src = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8'); } catch(e) { console.error(`SKIP: ${mod}`); continue; } try { K.load(src); } catch(e) { console.error(`LOAD ERROR: ${mod}: ${e.message}`); } } K.endModuleLoad(); // ─── Register tests ───────────────────────────────────────────── K.eval('(define _test-registry (list))'); K.eval('(define _test-suite "")'); K.eval('(define push-suite (fn (name) (set! _test-suite name)))'); K.eval('(define pop-suite (fn () (set! _test-suite "")))'); K.eval(`(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk}))) {:ok true}))`); K.eval(`(define report-pass (fn (name) (let ((i (- (len _test-registry) 1))) (when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`); K.eval(`(define report-fail (fn (name error) (let ((i (- (len _test-registry) 1))) (when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`); // Load test files for (const f of ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx']) { try { K.load(fs.readFileSync(path.join(PROJECT, f), 'utf8')); } catch(e) { console.error(`LOAD ERROR: ${f}: ${e.message}`); } } // ─── Run tests ────────────────────────────────────────────────── const testCount = K.eval('(len _test-registry)'); console.log(`Loaded ${testCount} tests`); let passed = 0, failed = 0; const cats = {}; const errTypes = {}; const failDetails = []; for (let i = 0; i < testCount; i++) { const suite = K.eval(`(get (nth _test-registry ${i}) "suite")`) || ''; const name = K.eval(`(get (nth _test-registry ${i}) "name")`) || `test-${i}`; // Reset body _body.children = []; _body.childNodes = []; _body.innerHTML = ''; process.stderr.write(` [${i}/${testCount}] ${suite} > ${name}\n`); let ok = false; let err = null; try { const thunk = K.eval(`(get (nth _test-registry ${i}) "thunk")`); if (!thunk) { err = 'no thunk'; } else { const r = K.callFn(thunk, []); if (r && r.suspended) globalThis._driveAsync(r); ok = true; } } catch(e) { err = (e.message || '').slice(0, 150); } if (!cats[suite]) cats[suite] = { p: 0, f: 0, errs: [] }; if (ok) { passed++; cats[suite].p++; } else { failed++; cats[suite].f++; cats[suite].errs.push({ name, err }); let t = 'other'; if (err === 'TIMEOUT') t = 'timeout'; else if (err && err.includes('NOT IMPLEMENTED')) t = 'stub'; else if (err && err.includes('Assertion')) t = 'assert-fail'; else if (err && err.includes('Expected')) t = 'wrong-value'; else if (err && err.includes('Undefined symbol')) t = 'undef-sym'; else if (err && err.includes('Unhandled')) t = 'unhandled'; errTypes[t] = (errTypes[t] || 0) + 1; } } // ─── Report ───────────────────────────────────────────────────── console.log(`\nResults: ${passed}/${passed + failed} (${(100 * passed / (passed + failed)).toFixed(0)}%)\n`); console.log('By category:'); for (const [cat, s] of Object.entries(cats).sort((a, b) => (b[1].p / (b[1].p + b[1].f)) - (a[1].p / (a[1].p + a[1].f)))) { const mark = s.f === 0 ? `✓ ${s.p}` : `${s.p}/${s.p + s.f}`; console.log(` ${cat}: ${mark}`); } console.log('\nFailure types:'); for (const [t, n] of Object.entries(errTypes).sort((a, b) => b[1] - a[1])) { console.log(` ${t}: ${n}`); } // Unique errors const uniqueErrors = {}; for (const [cat, s] of Object.entries(cats)) { for (const { name, err } of s.errs) { const e = (err || '').slice(0, 100); if (!uniqueErrors[e]) uniqueErrors[e] = 0; uniqueErrors[e]++; } } console.log(`\nUnique errors (${Object.keys(uniqueErrors).length}):`); for (const [e, n] of Object.entries(uniqueErrors).sort((a, b) => b[1] - a[1]).slice(0, 30)) { console.log(` [${n}x] ${e}`); } // Show per-category failures for categories with < 5 failures console.log('\nDetailed failures (categories with <10 fails):'); for (const [cat, s] of Object.entries(cats).sort((a, b) => a[1].f - b[1].f)) { if (s.f > 0 && s.f < 10) { console.log(` ${cat} (${s.p}/${s.p + s.f}):`); for (const { name, err } of s.errs) { console.log(` FAIL: ${name}: ${(err || '').slice(0, 100)}`); } } }