- sx_vm.ml: VM timeout now compares vm_insn_count > step_limit instead of
unconditionally throwing after 65536 instructions when limit > 0
- sx_browser.ml: Expose setStepLimit/resetStepCount APIs on SxKernel;
callFn now returns {__sx_error, message} on Eval_error instead of null
- compiler.sx: emit-set handles array-index targets (host-set! instead of
nth) and 'of' property chains (dom-set-prop with chain navigation)
- hs-run-fast.js: New Node.js test runner with step-limit timeouts,
SX-level guard for error detection, insertAdjacentHTML mock,
range selection (HS_START/HS_END), wall-clock timeout in driveAsync
- hs-debug-test.js: Single-test debugger with DOM state inspection
- hs-verify.js: Assertion verification (proves pass/fail detection works)
Test results: 415/831 (50%), up from 408/831 (49%) baseline.
Fixes: set my style["color"], set X of Y, put at end of (insertAdjacentHTML).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
204 lines
14 KiB
JavaScript
204 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Verify that HS behavioral tests actually check assertions.
|
|
* Runs a few tests manually with verbose output to prove pass/fail works.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const PROJECT = path.resolve(__dirname, '..');
|
|
|
|
// Reuse the full runner's setup but run individual tests manually
|
|
eval(fs.readFileSync(path.join(PROJECT, 'shared/static/wasm/sx_browser.bc.js'), 'utf8'));
|
|
const K = globalThis.SxKernel;
|
|
|
|
// ─── DOM mock (minimal from runner) ─────────────────────────────
|
|
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.value=''; this.checked=false; this.disabled=false; }
|
|
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; }
|
|
getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
|
|
removeAttribute(n) { delete this.attributes[n]; }
|
|
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); 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; 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; }
|
|
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);}
|
|
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(){} show(){} close(){} getAnimations(){return[];} getBoundingClientRect(){return{top:0,left:0,width:100,height:100,right:100,bottom:100};} scrollIntoView(){}
|
|
get ownerDocument() { return document; }
|
|
_setInnerHTML(html) { for(const c of this.children){c.parentElement=null;} this.children=[]; this.childNodes=[]; this.innerHTML=html; this.textContent=html.replace(/<[^>]*>/g,''); }
|
|
insertAdjacentHTML() {}
|
|
}
|
|
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 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={log:()=>{},error:()=>{},warn:()=>{},info:()=>{},debug:()=>{}};
|
|
|
|
// 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];if(a[1]==='innerHTML'&&a[0] instanceof El){a[0]._setInnerHTML(a[2]);}if(a[1]==='textContent'&&a[0] instanceof El){a[0].textContent=String(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';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-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 SD = path.join(PROJECT, 'shared/static/wasm/sx');
|
|
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(SD,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();
|
|
|
|
const _log = process.stdout.write.bind(process.stdout);
|
|
function log(s) { _log(s + '\n'); }
|
|
|
|
function reset() { _body.children=[]; _body.childNodes=[]; _body.innerHTML=''; _body.textContent=''; }
|
|
|
|
// ─── VERIFICATION TESTS ─────────────────────────────────────────
|
|
|
|
log('=== VERIFICATION: Do assertions actually catch failures? ===\n');
|
|
|
|
// Test 1: Positive — add .foo should work
|
|
reset();
|
|
log('1. Positive test: "on click add .foo" → assert .foo present');
|
|
try {
|
|
K.eval(`
|
|
(let ((_el (dom-create-element "div")))
|
|
(dom-set-attr _el "_" "on click add .foo")
|
|
(dom-append (dom-body) _el)
|
|
(hs-activate! _el)
|
|
(dom-dispatch _el "click" nil)
|
|
(assert (dom-has-class? _el "foo") "should have class foo"))
|
|
`);
|
|
log(' PASS ✓');
|
|
} catch(e) { log(' FAIL ✗: ' + (e.message||'').slice(0,150)); }
|
|
|
|
// Test 2: Negative — assert wrong class (SHOULD FAIL)
|
|
reset();
|
|
log('\n2. Negative test: "on click add .foo" → assert .bar present (should fail!)');
|
|
try {
|
|
K.eval(`
|
|
(let ((_el (dom-create-element "div")))
|
|
(dom-set-attr _el "_" "on click add .foo")
|
|
(dom-append (dom-body) _el)
|
|
(hs-activate! _el)
|
|
(dom-dispatch _el "click" nil)
|
|
(assert (dom-has-class? _el "bar") "should have class bar"))
|
|
`);
|
|
log(' PASS ✗ (BUG — should have failed!)');
|
|
} catch(e) { log(' FAIL ✓ (correctly caught): ' + (e.message||'').slice(0,100)); }
|
|
|
|
// Test 3: Positive — toggle class
|
|
reset();
|
|
log('\n3. Positive test: "on click toggle .active" → assert .active present');
|
|
try {
|
|
K.eval(`
|
|
(let ((_el (dom-create-element "div")))
|
|
(dom-set-attr _el "_" "on click toggle .active on me")
|
|
(dom-append (dom-body) _el)
|
|
(hs-activate! _el)
|
|
(dom-dispatch _el "click" nil)
|
|
(assert (dom-has-class? _el "active") "should have class active"))
|
|
`);
|
|
log(' PASS ✓');
|
|
} catch(e) { log(' FAIL ✗: ' + (e.message||'').slice(0,150)); }
|
|
|
|
// Test 4: Negative — assert wrong value
|
|
reset();
|
|
log('\n4. Negative test: assert-equal with wrong value (should fail!)');
|
|
try {
|
|
K.eval(`(assert (= 1 2) "1 should equal 2")`);
|
|
log(' PASS ✗ (BUG — should have failed!)');
|
|
} catch(e) { log(' FAIL ✓ (correctly caught): ' + (e.message||'').slice(0,100)); }
|
|
|
|
// Test 5: set innerHTML
|
|
reset();
|
|
log('\n5. Positive test: "on click set my innerHTML to \'hello\'"');
|
|
try {
|
|
K.eval(`
|
|
(let ((_el (dom-create-element "div")))
|
|
(dom-set-attr _el "_" "on click set my innerHTML to 'hello'")
|
|
(dom-append (dom-body) _el)
|
|
(hs-activate! _el)
|
|
(dom-dispatch _el "click" nil)
|
|
(assert= (dom-get-prop _el "innerHTML") "hello"))
|
|
`);
|
|
log(' PASS ✓');
|
|
} catch(e) { log(' FAIL ✗: ' + (e.message||'').slice(0,150)); }
|
|
|
|
// Test 6: remove class
|
|
reset();
|
|
log('\n6. Positive test: "on click remove .foo" from div with class foo');
|
|
try {
|
|
K.eval(`
|
|
(let ((_el (dom-create-element "div")))
|
|
(dom-add-class _el "foo")
|
|
(dom-set-attr _el "_" "on click remove .foo")
|
|
(dom-append (dom-body) _el)
|
|
(hs-activate! _el)
|
|
(dom-dispatch _el "click" nil)
|
|
(assert (not (dom-has-class? _el "foo")) "should not have class foo"))
|
|
`);
|
|
log(' PASS ✓');
|
|
} catch(e) { log(' FAIL ✗: ' + (e.message||'').slice(0,150)); }
|
|
|
|
// Test 7: Negative — remove class that wasn't added (should pass — no-op)
|
|
reset();
|
|
log('\n7. Check: does callFn returning null count as pass?');
|
|
K.setStepLimit(1000);
|
|
try {
|
|
const r = K.callFn(K.eval('(fn () (assert false "deliberate failure"))'), []);
|
|
log(' callFn returned: ' + JSON.stringify(r) + ' (type: ' + typeof r + ')');
|
|
log(' WARNING: callFn did NOT throw on assertion failure!');
|
|
} catch(e) {
|
|
log(' callFn threw: ' + (e.message||'').slice(0,100));
|
|
log(' OK — assertions propagate as exceptions');
|
|
}
|
|
K.setStepLimit(0);
|
|
|
|
// Test 8: What does callFn return on step limit timeout?
|
|
log('\n8. Check: what happens on step limit timeout?');
|
|
K.setStepLimit(50);
|
|
try {
|
|
const r = K.callFn(K.eval('(fn () (let loop ((i 0)) (loop (+ i 1))))'), []);
|
|
log(' callFn returned: ' + JSON.stringify(r) + ' (type: ' + typeof r + ')');
|
|
if (r === null) log(' WARNING: timeout returns null, not exception — runner counts as PASS!');
|
|
} catch(e) {
|
|
log(' callFn threw: ' + (e.message||'').slice(0,100));
|
|
}
|
|
K.setStepLimit(0);
|
|
|
|
log('\n=== DONE ===');
|