New spec tests: test-cek-try-seq (CEK try/seq), test-htmx (htmx directive coverage, 292L), test-hs-diag, test-perform-chain (IO suspension chains). tests/hs-*.js: Node.js-side hyperscript runners for browser-mode testing (hs-behavioral-node, hs-behavioral-runner, hs-parse-audit, hs-run-timed). Vendors shared/static/scripts/htmx.min.js. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
449 lines
17 KiB
JavaScript
449 lines
17 KiB
JavaScript
#!/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)}`);
|
|
}
|
|
}
|
|
}
|