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>
270 lines
19 KiB
JavaScript
270 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Run HS behavioral tests with per-test fork-based timeout.
|
|
* Forks itself for each test to handle infinite loops.
|
|
*/
|
|
const { execFileSync } = require('child_process');
|
|
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');
|
|
|
|
// If called with --run-single N, run just that one test
|
|
if (process.argv.includes('--run-single')) {
|
|
const idx = parseInt(process.argv[process.argv.indexOf('--run-single') + 1]);
|
|
|
|
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
|
const K = globalThis.SxKernel;
|
|
|
|
// Minimal DOM mock
|
|
class El {
|
|
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style={}; this.attributes={}; this.children=[]; this.childNodes=[]; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; this.dataset={}; this.open=false; this.value=''; this.checked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.options=[]; }
|
|
setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value')this.value=v; if(n==='disabled')this.disabled=true; }
|
|
getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
|
|
removeAttribute(n) { delete this.attributes[n]; if(n==='disabled')this.disabled=false; }
|
|
hasAttribute(n) { return n in this.attributes; }
|
|
addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
|
|
removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
|
|
dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp&&this.parentElement){this.parentElement.dispatchEvent(ev);} return !ev.defaultPrevented; }
|
|
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); this._updateText(); return c; }
|
|
removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; this._updateText(); return c; }
|
|
insertBefore(n,r) { if(n.parentElement)n.parentElement.removeChild(n); const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; return n; }
|
|
replaceChild(n,o) { const i=this.children.indexOf(o); if(i>=0){this.children[i]=n;this.childNodes[i]=n;} n.parentElement=this;n.parentNode=this; o.parentElement=null;o.parentNode=null; return o; }
|
|
querySelector(s) { return fnd(this,s); }
|
|
querySelectorAll(s) { return fndAll(this,s); }
|
|
closest(s) { let e=this; while(e){if(mt(e,s))return e; e=e.parentElement;} return null; }
|
|
matches(s) { return mt(this,s); }
|
|
contains(o) { if(o===this)return true; for(const c of this.children)if(c===o||c.contains(o))return true; return false; }
|
|
cloneNode(d) { const e=new El(this.tagName.toLowerCase()); Object.assign(e.attributes,this.attributes); e.id=this.id; e.className=this.className; e.classList._sync(this.className); Object.assign(e.style,this.style); e.textContent=this.textContent; e.innerHTML=this.innerHTML; e.value=this.value; if(d)for(const c of this.children)e.appendChild(c.cloneNode(true)); return e; }
|
|
focus(){} blur(){} click(){this.dispatchEvent(new Ev('click',{bubbles:true}));} remove(){if(this.parentElement)this.parentElement.removeChild(this);}
|
|
_updateText() {}
|
|
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 i=this.parentElement.children.indexOf(this); return this.parentElement.children[i+1]||null; }
|
|
get previousElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return i>0?this.parentElement.children[i-1]:null; }
|
|
showModal(){this.open=true;this.setAttribute('open','');} show(){this.open=true;} close(){this.open=false;this.removeAttribute('open');}
|
|
getAnimations(){return [];} getBoundingClientRect(){return{top:0,left:0,width:100,height:100,right:100,bottom:100};} scrollIntoView(){}
|
|
get ownerDocument() { return document; }
|
|
get offsetParent() { return this.parentElement; }
|
|
get offsetTop() { return 0; } get offsetLeft() { return 0; }
|
|
get scrollTop() { return 0; } set scrollTop(v) {} get scrollLeft() { return 0; } set scrollLeft(v) {}
|
|
get scrollHeight() { return 100; } get scrollWidth() { return 100; }
|
|
get clientHeight() { return 100; } get clientWidth() { return 100; }
|
|
insertAdjacentHTML(pos, html) {
|
|
const el = parseHTMLFragment(html);
|
|
if (pos === 'beforeend' || pos === 'beforeEnd') this.appendChild(el);
|
|
else if (pos === 'afterbegin' || pos === 'afterBegin') { if (this.children.length) this.insertBefore(el, this.children[0]); else this.appendChild(el); }
|
|
else if (pos === 'beforebegin' || pos === 'beforeBegin') { if (this.parentElement) this.parentElement.insertBefore(el, this); }
|
|
else if (pos === 'afterend' || pos === 'afterEnd') { if (this.parentElement) { const i = this.parentElement.children.indexOf(this); if (i >= 0 && i < this.parentElement.children.length - 1) this.parentElement.insertBefore(el, this.parentElement.children[i+1]); else this.parentElement.appendChild(el); } }
|
|
}
|
|
}
|
|
class CL { constructor(e){this._el=e;this._set=new Set();} _sync(str){this._set=new Set((str||'').split(/\s+/).filter(Boolean));} add(...c){for(const x of c)this._set.add(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} remove(...c){for(const x of c)this._set.delete(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} toggle(c,f){if(f!==undefined){if(f)this.add(c);else this.remove(c);return f;} if(this._set.has(c)){this.remove(c);return false;}else{this.add(c);return true;}} contains(c){return this._set.has(c);} get length(){return this._set.size;} [Symbol.iterator](){return this._set[Symbol.iterator]();} }
|
|
class Ev { constructor(t,o={}){this.type=t;this.bubbles=o.bubbles||false;this.cancelable=o.cancelable!==false;this.defaultPrevented=false;this._sp=false;this._si=false;this.target=null;this.currentTarget=null;this.detail=o.detail||null;} preventDefault(){this.defaultPrevented=true;} stopPropagation(){this._sp=true;} stopImmediatePropagation(){this._sp=true;this._si=true;} }
|
|
|
|
function parseHTMLFragment(html) {
|
|
// Minimal HTML parser for test setup
|
|
const el = new El('div');
|
|
const m = html.match(/^<(\w+)([^>]*)>([\s\S]*?)<\/\1>$/);
|
|
if (m) {
|
|
const tag = m[1]; const attrs = m[2]; const inner = m[3];
|
|
const child = new El(tag);
|
|
const attrRe = /(\w[\w-]*)="([^"]*)"/g; let am;
|
|
while ((am = attrRe.exec(attrs))) child.setAttribute(am[1], am[2]);
|
|
if (inner) { child.textContent = inner; child.innerHTML = inner; }
|
|
return child;
|
|
}
|
|
el.innerHTML = html; el.textContent = html;
|
|
return el;
|
|
}
|
|
|
|
function mt(e,s) {
|
|
if(!e||!e.tagName)return false;
|
|
s = s.trim();
|
|
if(s.startsWith('#'))return e.id===s.slice(1);
|
|
if(s.startsWith('.'))return e.classList.contains(s.slice(1));
|
|
if(s.startsWith('[')) {
|
|
const m = s.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
|
|
if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]);
|
|
}
|
|
if(s.includes('.')) { const [tag, cls] = s.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
|
|
if(s.includes('#')) { const [tag, id] = s.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
|
|
return e.tagName.toLowerCase() === s.toLowerCase();
|
|
}
|
|
function fnd(e,s) { for(const c of(e.children||[])){if(mt(c,s))return c;const f=fnd(c,s);if(f)return f;} return null; }
|
|
function fndAll(e,s) { const r=[];for(const c of(e.children||[])){if(mt(c,s))r.push(c);r.push(...fndAll(c,s));}return r; }
|
|
|
|
const _body = new El('body');
|
|
const _html = new El('html'); _html.appendChild(_body);
|
|
const document = {
|
|
body: _body, documentElement: _html,
|
|
createElement(t){return new El(t);}, createElementNS(n,t){return new El(t);},
|
|
createDocumentFragment(){const f=new El('fragment');f.nodeType=11;return f;},
|
|
createTextNode(t){return{nodeType:3,textContent:t,data:t};},
|
|
getElementById(i){return fnd(_body,'#'+i);},
|
|
querySelector(s){return fnd(_body,s);}, querySelectorAll(s){return fndAll(_body,s);},
|
|
createEvent(t){return new Ev(t);}, addEventListener(){}, removeEventListener(){},
|
|
};
|
|
globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLElement=El; globalThis.Element=El;
|
|
globalThis.Event=Ev; globalThis.CustomEvent=Ev; globalThis.NodeList=Array; globalThis.HTMLCollection=Array;
|
|
globalThis.getComputedStyle=(e)=>e?e.style:{}; globalThis.requestAnimationFrame=(f)=>{f();return 0;};
|
|
globalThis.cancelAnimationFrame=()=>{}; globalThis.MutationObserver=class{observe(){}disconnect(){}};
|
|
globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}};
|
|
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
|
|
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
|
|
globalThis.console = { ...console, error: () => {}, warn: () => {} };
|
|
|
|
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(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}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 El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;});
|
|
K.registerNative('host-await',a=>{});
|
|
K.registerNative('load-library!',()=>false);
|
|
|
|
globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspended)return;const req=r.request;const items=req&&(req.items||req);const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);function doResume(v){try{const x=r.resume(v);driveAsync(x,d+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)');
|
|
|
|
const WEB=['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=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
|
K.beginModuleLoad();
|
|
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){process.stderr.write(`LOAD ERROR: ${mod}: ${e.message}\n`);}}
|
|
K.endModuleLoad();
|
|
|
|
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 (n) true))');
|
|
K.eval('(define report-fail (fn (n e) true))');
|
|
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){process.stderr.write(`TEST LOAD ERROR: ${f}: ${e.message}\n`);}}
|
|
|
|
const thunk=K.eval(`(get (nth _test-registry ${idx}) "thunk")`);
|
|
const suite=K.eval(`(get (nth _test-registry ${idx}) "suite")`)||'';
|
|
if(!thunk){process.stdout.write(JSON.stringify({ok:false,err:'no thunk',suite}));process.exit(0);}
|
|
try{const r=K.callFn(thunk,[]);if(r&&r.suspended)globalThis._driveAsync(r);process.stdout.write(JSON.stringify({ok:true,suite}));}
|
|
catch(e){process.stdout.write(JSON.stringify({ok:false,err:(e.message||'').slice(0,200),suite}));}
|
|
process.exit(0);
|
|
}
|
|
|
|
// ─── Main process ────────────────────────────────────────────────
|
|
// First pass: load kernel to get test count and names
|
|
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
|
const K = globalThis.SxKernel;
|
|
K.registerNative('host-global',a=>null);K.registerNative('host-get',a=>null);
|
|
K.registerNative('host-set!',a=>null);K.registerNative('host-call',a=>null);
|
|
K.registerNative('host-new',a=>null);K.registerNative('host-callback',a=>function(){});
|
|
K.registerNative('host-typeof',a=>'nil');K.registerNative('host-await',a=>null);
|
|
K.registerNative('load-library!',()=>false);
|
|
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)');
|
|
const WEB=['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=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
|
K.beginModuleLoad();
|
|
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){}}
|
|
K.endModuleLoad();
|
|
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 (n) true))');K.eval('(define report-fail (fn (n e) true))');
|
|
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){}}
|
|
|
|
const testCount = K.eval('(len _test-registry)');
|
|
const names = [];
|
|
for(let i=0;i<testCount;i++) names.push({
|
|
s: K.eval(`(get (nth _test-registry ${i}) "suite")`)||'',
|
|
n: K.eval(`(get (nth _test-registry ${i}) "name")`)||`test-${i}`,
|
|
});
|
|
|
|
const TIMEOUT_MS = parseInt(process.env.HS_TIMEOUT || '8000');
|
|
const CONCURRENCY = parseInt(process.env.HS_CONCURRENCY || '6');
|
|
const startFrom = parseInt(process.argv[2] || '0');
|
|
const endAt = parseInt(process.argv[3] || String(testCount));
|
|
|
|
console.log(`Running tests ${startFrom}-${endAt-1} of ${testCount} (timeout=${TIMEOUT_MS}ms, concurrency=${CONCURRENCY})...`);
|
|
|
|
let passed=0,failed=0;
|
|
const cats={};const errTypes={};
|
|
|
|
async function runTest(i) {
|
|
return new Promise((resolve) => {
|
|
const child = require('child_process').fork(__filename, ['--run-single', String(i)], {
|
|
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
timeout: TIMEOUT_MS,
|
|
});
|
|
let stdout = '';
|
|
child.stdout.on('data', d => stdout += d);
|
|
child.on('close', (code, signal) => {
|
|
if (signal === 'SIGTERM' || code === null) {
|
|
resolve({ ok: false, err: 'TIMEOUT', suite: names[i].s });
|
|
} else {
|
|
try { resolve(JSON.parse(stdout)); }
|
|
catch(e) { resolve({ ok: false, err: stdout.slice(0,150) || `exit ${code}`, suite: names[i].s }); }
|
|
}
|
|
});
|
|
child.on('error', (e) => {
|
|
resolve({ ok: false, err: 'FORK: ' + (e.message||'').slice(0,100), suite: names[i].s });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
// Run in batches
|
|
for (let i = startFrom; i < endAt; i += CONCURRENCY) {
|
|
const batch = [];
|
|
for (let j = i; j < Math.min(i + CONCURRENCY, endAt); j++) batch.push(j);
|
|
const results = await Promise.all(batch.map(runTest));
|
|
|
|
for (let k = 0; k < results.length; k++) {
|
|
const r = results[k];
|
|
const idx = batch[k];
|
|
const suite = r.suite || names[idx].s;
|
|
if (!cats[suite]) cats[suite] = { p: 0, f: 0, errs: [] };
|
|
|
|
if (r.ok) { passed++; cats[suite].p++; }
|
|
else {
|
|
failed++; cats[suite].f++; cats[suite].errs.push({ name: names[idx].n, err: r.err });
|
|
let t = 'other';
|
|
if (r.err === 'TIMEOUT') t = 'timeout';
|
|
else if (r.err && r.err.includes('NOT IMPLEMENTED')) t = 'stub';
|
|
else if (r.err && r.err.includes('Assertion')) t = 'assert-fail';
|
|
else if (r.err && r.err.includes('Expected')) t = 'wrong-value';
|
|
else if (r.err && r.err.includes('Undefined symbol')) t = 'undef-sym';
|
|
else if (r.err && r.err.includes('Unhandled')) t = 'unhandled';
|
|
errTypes[t] = (errTypes[t] || 0) + 1;
|
|
}
|
|
}
|
|
if ((i + CONCURRENCY) % 50 < CONCURRENCY) {
|
|
process.stderr.write(` ${Math.min(i + CONCURRENCY, endAt)}/${endAt} (${passed} pass)...\n`);
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
const ra = a[1].p / (a[1].p + a[1].f); const rb = b[1].p / (b[1].p + b[1].f); return rb - ra;
|
|
})) {
|
|
const total = s.p + s.f; const pct = (100 * s.p / total).toFixed(0);
|
|
const mark = s.f === 0 ? `\u2713 ${s.p}` : `${s.p}/${total} (${pct}%)`;
|
|
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}`);
|
|
|
|
const ue = {};
|
|
for (const [cat, s] of Object.entries(cats)) for (const { err } of s.errs) { const e = (err || '').slice(0, 120); ue[e] = (ue[e] || 0) + 1; }
|
|
console.log(`\nUnique errors (${Object.keys(ue).length}):`);
|
|
for (const [e, n] of Object.entries(ue).sort((a, b) => b[1] - a[1]).slice(0, 30)) console.log(` [${n}x] ${e}`);
|
|
}
|
|
|
|
main().catch(e => { console.error(e); process.exit(1); });
|