#!/usr/bin/env node /** * Run HS behavioral tests — single process, synchronous, with step-limit timeout. * Uses the OCaml VM's built-in step_limit to break infinite loops. */ 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; // Step limit API — exposed from OCaml kernel const STEP_LIMIT = parseInt(process.env.HS_STEP_LIMIT || '200000'); function setStepLimit(n) { K.setStepLimit(n); } function resetStepCount() { K.resetStepCount(); } // ─── DOM mock ────────────────────────────────────────────────── function mkStyle() { const s={}; s.setProperty=function(p,v){s[p]=v;}; s.getPropertyValue=function(p){return s[p]||'';}; s.removeProperty=function(p){delete s[p];}; return s; } 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=mkStyle(); 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._syncText(); 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._syncText(); 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; this._syncText(); 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; this._syncText(); 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); for(const k of Object.keys(this.style)){if(typeof this.style[k]!=='function')e.style[k]=this.style[k];} 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);} _syncText() { // Sync textContent from children const t = this.children.map(c => c.textContent || '').join(''); if (t) this.textContent = t; } _setInnerHTML(html) { // Clear children for (const c of this.children) { c.parentElement = null; c.parentNode = null; } this.children = []; this.childNodes = []; this.innerHTML = html; // Parse simple HTML and add children if (html) { const parsed = parseHTMLFragments(html); for (const c of parsed) this.appendChild(c); this.textContent = this.children.map(c => c.textContent || '').join('') || html.replace(/<[^>]*>/g, ''); } else { this.textContent = ''; } } 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) { // For non-HTML content (plain text/numbers), just append to innerHTML if (typeof html !== 'string') html = String(html); if (pos === 'beforeend' || pos === 'beforeEnd') { this.innerHTML = (this.innerHTML || '') + html; this.textContent = (this.textContent || '') + html.replace(/<[^>]*>/g, ''); } else if (pos === 'afterbegin' || pos === 'afterBegin') { this.innerHTML = html + (this.innerHTML || ''); this.textContent = html.replace(/<[^>]*>/g, '') + (this.textContent || ''); } else if (pos === 'beforebegin' || pos === 'beforeBegin') { if (this.parentElement) { this.parentElement.insertAdjacentHTML('beforeend', html); } } else if (pos === 'afterend' || pos === 'afterEnd') { if (this.parentElement) { this.parentElement.insertAdjacentHTML('beforeend', html); } } } } 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 parseHTMLFragments(html) { const results = []; const re = /<(\w+)([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g; let m; let lastIndex = 0; while ((m = re.exec(html)) !== null) { // Text before this tag if (m.index > lastIndex) { const text = html.slice(lastIndex, m.index).trim(); if (text) { const tn = {nodeType:3, textContent:text, data:text}; // Can't push text nodes directly to El children; wrap if needed } } const tag = m[1]; const attrs = m[2]; const inner = m[3] || ''; const el = new El(tag); const attrRe = /([\w-]+)="([^"]*)"/g; let am; while ((am = attrRe.exec(attrs))) el.setAttribute(am[1], am[2]); // Also handle boolean attrs like disabled const boolRe = /\s(\w+)(?=\s|\/|>|$)/g; if (inner) { // Recursively parse inner HTML const innerEls = parseHTMLFragments(inner); if (innerEls.length > 0) { for (const c of innerEls) el.appendChild(c); el.textContent = innerEls.map(c => c.textContent || '').join(''); } else { el.textContent = inner; } el.innerHTML = inner; } results.push(el); lastIndex = re.lastIndex; } // If no tags found, treat as text — create a span with textContent if (results.length === 0 && html.trim()) { const el = new El('span'); el.textContent = html.replace(/<[^>]*>/g, ''); el.innerHTML = html; results.push(el); } return results; } 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));}r.item=function(i){return r[i]||null;};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(){}}; const _origLog = console.log; globalThis.console = { log: () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; // suppress ALL console noise const _log = _origLog; // keep reference for our own output // ─── 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;let v=a[0][a[1]];if(v===undefined)return null;if((a[1]==='innerHTML'||a[1]==='textContent'||a[1]==='value'||a[1]==='className')&&typeof v!=='string')v=String(v!=null?v:'');return v;}); K.registerNative('host-set!',a=>{if(a[0]!=null){const v=a[2]; if(a[1]==='innerHTML'&&a[0] instanceof El){const s=String(v!=null?v:'');a[0]._setInnerHTML(s);a[0][a[1]]=a[0].innerHTML;} else if(a[1]==='textContent'&&a[0] instanceof El){const s=String(v!=null?v:'');a[0].textContent=s;a[0].innerHTML=s;for(const c of a[0].children){c.parentElement=null;c.parentNode=null;}a[0].children=[];a[0].childNodes=[];} else{a[0][a[1]]=v;}} return a[2];}); K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');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); let _testDeadline = 0; // Mock fetch routes const _fetchRoutes = { '/test': { status: 200, body: 'yay', json: '{"foo":1}', html: '
yay
', number: '1.2' }, '/test-json': { status: 200, body: '{"foo":1}', json: '{"foo":1}' }, '/404': { status: 404, body: 'the body' }, '/number': { status: 200, body: '1.2' }, '/users/Joe': { status: 200, body: 'Joe', json: '{"name":"Joe"}' }, }; function _mockFetch(url) { const route = _fetchRoutes[url] || _fetchRoutes['/test']; return { ok: route.status < 400, status: route.status || 200, url: url || '/test', _body: route.body || '', _json: route.json || route.body || '', _html: route.html || route.body || '' }; } globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspended)return;if(_testDeadline && Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');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'){ const url=typeof items[1]==='string'?items[1]:'/test'; const fmt=typeof items[2]==='string'?items[2]:'text'; const route=_fetchRoutes[url]||_fetchRoutes['/test']; if(fmt==='json'){try{doResume(JSON.parse(route.json||route.body||'{}'));}catch(e){doResume(null);}} else if(fmt==='html'){const frag=new El('fragment');frag.nodeType=11;frag.innerHTML=route.html||route.body||'';frag.textContent=frag.innerHTML.replace(/<[^>]*>/g,'');doResume(frag);} else if(fmt==='response')doResume({ok:(route.status||200)<400,status:route.status||200,url}); else if(fmt.toLowerCase()==='number')doResume(parseFloat(route.number||route.body||'0')); else doResume(route.body||''); } else if(opName==='io-parse-text'){const resp=items&&items[1];doResume(resp&&resp._body?resp._body:typeof resp==='string'?resp:'');} else if(opName==='io-parse-json'){const resp=items&&items[1];try{doResume(JSON.parse(typeof resp==='string'?resp:resp&&resp._json?resp._json:'{}'));}catch(e){doResume(null);}} else if(opName==='io-parse-html'){const frag=new El('fragment');frag.nodeType=11;doResume(frag);} 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 ────────────────────────────────────────────── process.stderr.write('Loading modules...\n'); const t_mod = Date.now(); 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(); process.stderr.write(`Modules loaded in ${Date.now()-t_mod}ms\n`); // ─── Test framework ──────────────────────────────────────────── 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))'); K.eval(`(define _run-test-thunk (fn (thunk) (guard (exn (true {:ok false :error (if (string? exn) exn (str exn))})) (thunk) {:ok true})))`); process.stderr.write('Loading tests...\n'); const t_tests = Date.now(); for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){ const t0=Date.now(); try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){process.stderr.write(`TEST LOAD ERROR: ${f}: ${e.message}\n`);} process.stderr.write(` ${path.basename(f)}: ${Date.now()-t0}ms\n`); } process.stderr.write(`Tests loaded in ${Date.now()-t_tests}ms\n`); const testCount = K.eval('(len _test-registry)'); // Pre-read names const names = []; for(let i=0;i 5000)process.stdout.write(` SLOW: test ${i} took ${elapsed}ms [${suite}] ${name}\n`); if(!ok && err && err.includes('TIMEOUT'))process.stdout.write(` TIMEOUT: test ${i} [${suite}] ${name}\n`); if(!ok && err && err.includes('Expected') && err.includes(', got '))process.stdout.write(` WRONG: test ${i} [${suite}] ${name} — ${err}\n`); if(!ok && err && err.includes("at position"))process.stdout.write(` PARSE: test ${i} [${suite}] ${name} — ${err}\n`); } process.stdout.write(`\nResults: ${passed}/${passed+failed} (${(100*passed/(passed+failed)).toFixed(0)}%)\n\n`); process.stdout.write('By category:\n'); 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?`✓ ${s.p}`:`${s.p}/${total} (${pct}%)`; process.stdout.write(` ${cat}: ${mark}\n`); } process.stdout.write('\nFailure types:\n'); for(const[t,n]of Object.entries(errTypes).sort((a,b)=>b[1]-a[1])) process.stdout.write(` ${t}: ${n}\n`); const ue={}; for(const[cat,s]of Object.entries(cats))for(const{err}of s.errs){const e=(err||'').slice(0,100);ue[e]=(ue[e]||0)+1;} process.stdout.write(`\nUnique errors (${Object.keys(ue).length}):\n`); for(const[e,n]of Object.entries(ue).sort((a,b)=>b[1]-a[1]).slice(0,30)) process.stdout.write(` [${n}x] ${e}\n`);