Cluster 26. Three parts:
(a) `tests/hs-run-filtered.js`: mock style is now a Proxy that dispatches
a synthetic `resize` DOM event on the owning element whenever
`width` / `height` changes (via `setProperty` or direct assignment).
Detail carries numeric `width` / `height` parsed from the current
inline style. Strengthens the old no-op ResizeObserver stub into an
`HsResizeObserver` class with a per-element callback registry
(collision-proof name vs. cluster 27's IntersectionObserver); HS's
`on resize` uses the plain DOM event path, not the observer API.
Adds `ResizeObserverEntry` for code that references it.
(b) `tests/playwright/generate-sx-tests.py`: new pattern for
`(page.)?evaluate(() => [{] document.{getElementById|querySelector}(…).style.PROP = 'VAL'; [}])`
emitting `(host-set! (host-get target "style") "PROP" "VAL")`.
(c) `spec/tests/test-hyperscript-behavioral.sx`: regenerated — the three
resize fixtures now carry the style mutation step between activate
and assert.
No parser/compiler/runtime changes: `on resize` already parses via
`parse-compound-event-name`, and `hs-on` binds via `dom-listen` which is
plain `addEventListener("resize", …)`.
Suite hs-upstream-resize: 0/3 → 3/3. Smoke 0-195: 164/195 → 165/195
(the +1 smoke bump is logAll-generator work uncommitted in the main tree
at verification time, unrelated to this cluster).
579 lines
34 KiB
JavaScript
Executable File
579 lines
34 KiB
JavaScript
Executable File
#!/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 ──────────────────────────────────────────────────
|
|
// Resize observer support (cluster 26): `on resize` routes through the
|
|
// `resize` DOM event. The mock style proxy dispatches that event
|
|
// whenever width/height changes programmatically, with
|
|
// `detail = {width, height}` parsed from the current inline style.
|
|
// A registry is kept for an optional ResizeObserver-style API, but the
|
|
// runtime path uses plain addEventListener("resize").
|
|
globalThis.__hsResizeRegistry = globalThis.__hsResizeRegistry || new Map();
|
|
function _parsePx(v) {
|
|
if (v == null) return 0;
|
|
const s = String(v).trim();
|
|
const m = s.match(/^(-?[\d.]+)/);
|
|
return m ? parseFloat(m[1]) : 0;
|
|
}
|
|
globalThis.__hsFireResize = function(el) {
|
|
if (!el || !el._isEl) return;
|
|
const w = _parsePx(el.style && el.style.width);
|
|
const h = _parsePx(el.style && el.style.height);
|
|
const ev = new Ev('resize', { bubbles: false, detail: { width: w, height: h } });
|
|
try { el.dispatchEvent(ev); } catch (e) {}
|
|
// Also notify any ResizeObserver instances (future-proofing).
|
|
const cbs = globalThis.__hsResizeRegistry.get(el);
|
|
if (cbs) for (const cb of cbs) { try { cb([{ target: el, contentRect: { width: w, height: h } }]); } catch (e) {} }
|
|
};
|
|
// Default CSS values for unset inline styles (matches browser UA defaults
|
|
// for block-level elements — our tests mostly exercise divs).
|
|
function mkStyle(tag, ownerEl) {
|
|
const inline = ['SPAN','A','B','I','EM','STRONG','CODE','LABEL','SMALL','SUB','SUP','U','MARK'];
|
|
const display = (tag && inline.includes(tag.toUpperCase())) ? 'inline' : 'block';
|
|
const s = { display, visibility: 'visible', opacity: '1' };
|
|
s.setProperty = function(p, v) {
|
|
const prev = s[p];
|
|
s[p] = v;
|
|
if ((p === 'width' || p === 'height') && ownerEl && String(prev) !== String(v)) {
|
|
if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl);
|
|
}
|
|
};
|
|
s.getPropertyValue = function(p) { return s[p] || ''; };
|
|
s.removeProperty = function(p) { delete s[p]; };
|
|
if (typeof Proxy !== 'undefined') {
|
|
return new Proxy(s, {
|
|
set(t, k, v) {
|
|
const prev = t[k];
|
|
t[k] = v;
|
|
if ((k === 'width' || k === 'height') && ownerEl && String(prev) !== String(v)) {
|
|
if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl);
|
|
}
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
return s;
|
|
}
|
|
class El {
|
|
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this._isEl=true; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(this.tagName, this); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; const _el=this; const _datasetBacking={}; this.dataset=new Proxy(_datasetBacking,{set(o,k,v){o[k]=v;if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());_el.attributes[attr]=String(v);}return true;},deleteProperty(o,k){delete o[k];if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());delete _el.attributes[attr];}return true;}}); this.open=false; this.value=''; this.defaultValue=''; this.checked=false; this.defaultChecked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.defaultSelected=false; this.selected=false; 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;this.defaultValue=v;} if(n==='name')this.name=v; if(n==='type')this.type=v; if(n==='checked'){this.checked=true;this.defaultChecked=true;} if(n==='selected'){this.selected=true;this.defaultSelected=true;} if(n==='multiple')this.multiple=true; if(n==='disabled')this.disabled=true; if(typeof n==='string'&&n.startsWith('data-')){const key=n.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());this.dataset[key]=String(v);} if(n==='style'){const s=String(v);for(const d of s.split(';')){const c=d.indexOf(':');if(c>0){const k=d.slice(0,c).trim();const val=d.slice(c+1).trim();if(k)this.style.setProperty(k,val);}} } }
|
|
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); if(this.tagName==='SELECT'&&c.tagName==='OPTION'){this.options.push(c);if(c.selected&&this.selectedIndex<0)this.selectedIndex=this.options.length-1;} 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);}
|
|
get nextSibling(){const p=this.parentElement;if(!p)return null;const i=p.children.indexOf(this);return i>=0&&i<p.children.length-1?p.children[i+1]:null;}
|
|
get nextElementSibling(){return this.nextSibling;}
|
|
get previousSibling(){const p=this.parentElement;if(!p)return null;const i=p.children.indexOf(this);return i>0?p.children[i-1]:null;}
|
|
get previousElementSibling(){return this.previousSibling;}
|
|
reset(){
|
|
// Form reset: walk descendants, restore defaultValue / defaultChecked /
|
|
// defaultSelected.
|
|
const walk = (el) => {
|
|
for (const c of (el.children || [])) {
|
|
const tag = c.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
if (c.type === 'checkbox' || c.type === 'radio') c.checked = !!c.defaultChecked;
|
|
else c.value = c.defaultValue || '';
|
|
} else if (tag === 'OPTION') {
|
|
c.selected = !!c.defaultSelected;
|
|
} else if (tag === 'SELECT') {
|
|
for (const o of (c.options || [])) o.selected = !!o.defaultSelected;
|
|
const first = (c.options || []).findIndex(o => o.selected);
|
|
c.selectedIndex = first >= 0 ? first : 0;
|
|
c.value = first >= 0 && c.options[first] ? (c.options[first].value || '') : '';
|
|
}
|
|
walk(c);
|
|
}
|
|
};
|
|
walk(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 = '';
|
|
}
|
|
// Textarea: its value is its textContent. Capture defaultValue on first
|
|
// set so later reset() can restore.
|
|
if (this.tagName === 'TEXTAREA') {
|
|
this.value = this.textContent;
|
|
if (!('_origDefault' in this)) { this.defaultValue = this.textContent; this._origDefault = true; }
|
|
}
|
|
}
|
|
get outerHTML() {
|
|
const tag = this.tagName.toLowerCase();
|
|
// Merge .id / .className (set directly) into attribute output.
|
|
const attrOrder = [];
|
|
const attrMap = {};
|
|
if (this.id) { attrMap['id'] = this.id; attrOrder.push('id'); }
|
|
if (this.className) { attrMap['class'] = this.className; attrOrder.push('class'); }
|
|
for (const k of Object.keys(this.attributes)) {
|
|
if (!(k in attrMap)) { attrMap[k] = this.attributes[k]; attrOrder.push(k); }
|
|
else attrMap[k] = this.attributes[k]; // prefer explicit attr value when set
|
|
}
|
|
const attrs = attrOrder.map(k => ` ${k}="${String(attrMap[k]).replace(/"/g, '"')}"`).join('');
|
|
if (VOID_TAGS.has(tag)) return `<${tag}${attrs}>`;
|
|
let inner = '';
|
|
if (this.children && this.children.length) {
|
|
inner = this.children.map(c => c.outerHTML || c.textContent || '').join('');
|
|
} else if (this.innerHTML) {
|
|
inner = this.innerHTML;
|
|
} else if (this.innerText !== undefined && this.innerText !== '') {
|
|
inner = this.innerText;
|
|
} else if (this.textContent) {
|
|
inner = this.textContent;
|
|
}
|
|
return `<${tag}${attrs}>${inner}</${tag}>`;
|
|
}
|
|
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) {
|
|
// Parse the HTML into real El instances so they can be queried, clicked,
|
|
// and HS-activated. Text-only content becomes a textContent append.
|
|
if (typeof html !== 'string') html = String(html);
|
|
const parsed = parseHTMLFragments(html);
|
|
const p = (pos || '').toLowerCase();
|
|
if (parsed.length === 0) {
|
|
if (p === 'beforeend' || p === 'afterbegin') this.textContent = (this.textContent || '') + html;
|
|
return;
|
|
}
|
|
if (p === 'beforeend') {
|
|
for (const c of parsed) this.appendChild(c);
|
|
this.innerHTML = (this.innerHTML || '') + html;
|
|
} else if (p === 'afterbegin') {
|
|
const first = this.children[0] || null;
|
|
for (const c of parsed) { if (first) this.insertBefore(c, first); else this.appendChild(c); }
|
|
this.innerHTML = html + (this.innerHTML || '');
|
|
} else if (p === 'beforebegin' && this.parentElement) {
|
|
for (const c of parsed) this.parentElement.insertBefore(c, this);
|
|
} else if (p === 'afterend' && this.parentElement) {
|
|
const parent = this.parentElement;
|
|
const idx = parent.children.indexOf(this);
|
|
const nextSibling = parent.children[idx + 1] || null;
|
|
for (const c of parsed) {
|
|
if (nextSibling) parent.insertBefore(c, nextSibling);
|
|
else parent.appendChild(c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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;} }
|
|
|
|
const VOID_TAGS = new Set(['input','br','hr','img','meta','link','area','base','col','embed','source','track','wbr']);
|
|
function parseHTMLFragments(html) {
|
|
const results = [];
|
|
// Match self-closing `<tag .../>`, paired `<tag>...</tag>`, or void
|
|
// elements with no close tag (input, br, hr, etc.).
|
|
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]; let inner = m[3] || '';
|
|
// If the regex matched a void-style `<tag ...>` with no close tag,
|
|
// ensure we don't treat subsequent markup as inner content.
|
|
if (inner === '' && !m[0].endsWith('/>') && !VOID_TAGS.has(tag.toLowerCase()) && m[0].endsWith('>')) {
|
|
// Generic `<tag ...>` with no close tag — still leave inner empty;
|
|
// this keeps behaviour lenient without running past the next tag.
|
|
}
|
|
const el = new El(tag);
|
|
const attrRe = /([\w-]+)(?:="([^"]*)")?/g; let am;
|
|
while ((am = attrRe.exec(attrs))) {
|
|
const nm = am[1]; const val = am[2];
|
|
if (val !== undefined) el.setAttribute(nm, val);
|
|
else el.setAttribute(nm, '');
|
|
}
|
|
// 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;
|
|
// Textarea: its "value" comes from inner text, not an attr.
|
|
if (el.tagName === 'TEXTAREA') {
|
|
el.value = inner;
|
|
el.defaultValue = inner;
|
|
}
|
|
// Option: textContent is the label; if no value attr, it defaults to
|
|
// the label. Track defaultSelected separately from runtime selected.
|
|
if (el.tagName === 'OPTION' && !el.attributes.value) {
|
|
el.value = inner.trim();
|
|
}
|
|
}
|
|
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();
|
|
// Comma-separated selector list — any clause matches
|
|
if (s.includes(',')) {
|
|
return s.split(',').some(part => mt(e, part.trim()));
|
|
}
|
|
// Strip pseudo-classes we handle at the fnd() level (nth-of-type, first/last-of-type)
|
|
const pseudo = s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/);
|
|
const base = pseudo ? pseudo[1] : s;
|
|
if(base.startsWith('#'))return e.id===base.slice(1);
|
|
if(base.startsWith('.'))return e.classList.contains(base.slice(1));
|
|
if(base.startsWith('[')) {
|
|
const m = base.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
|
|
if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]);
|
|
}
|
|
if(base.includes('.')) { const [tag, cls] = base.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
|
|
if(base.includes('#')) { const [tag, id] = base.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
|
|
return e.tagName.toLowerCase() === base.toLowerCase();
|
|
}
|
|
function fnd(e,s) {
|
|
const pseudo = s && typeof s==='string' ? s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/) : null;
|
|
if (pseudo) {
|
|
const all = fndAll(e, pseudo[1]);
|
|
if (pseudo[2] === 'first-of-type') return all[0] || null;
|
|
if (pseudo[2] === 'last-of-type') return all[all.length - 1] || null;
|
|
const idx = parseInt(pseudo[3] || '1') - 1;
|
|
return all[idx] || null;
|
|
}
|
|
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){if(s==='html')return _html;if(s==='body')return _body;return fnd(_body,s)||(mt(_html,s)?_html:null);}, querySelectorAll(s){const r=fndAll(_body,s);if(s==='html'||mt(_html,s))r.unshift(_html);return r;},
|
|
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(){}};
|
|
// HsResizeObserver — cluster-26 resize mock. Keeps a per-element callback
|
|
// registry so code that observes via `new ResizeObserver(cb)` still works,
|
|
// but HS's `on resize` uses the plain `resize` DOM event dispatched by the
|
|
// style proxy in mkStyle (above).
|
|
class HsResizeObserver {
|
|
constructor(cb) { this._cb = cb; this._els = new Set(); }
|
|
observe(el) {
|
|
if (!el) return; this._els.add(el);
|
|
const reg = globalThis.__hsResizeRegistry;
|
|
if (!reg.has(el)) reg.set(el, new Set());
|
|
reg.get(el).add(this._cb);
|
|
}
|
|
unobserve(el) {
|
|
if (!el) return; this._els.delete(el);
|
|
const reg = globalThis.__hsResizeRegistry;
|
|
if (reg.has(el)) reg.get(el).delete(this._cb);
|
|
}
|
|
disconnect() {
|
|
const reg = globalThis.__hsResizeRegistry;
|
|
for (const el of this._els) { if (reg.has(el)) reg.get(el).delete(this._cb); }
|
|
this._els.clear();
|
|
}
|
|
}
|
|
globalThis.ResizeObserver=HsResizeObserver; globalThis.ResizeObserverEntry=class{};
|
|
globalThis.IntersectionObserver=class{observe(){}disconnect(){}};
|
|
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
|
|
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
|
|
globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});
|
|
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;
|
|
// SX lists (arrive as {_type:'list', items:[...]}) don't expose length/size
|
|
// through JS property access. Hand-roll common collection queries so
|
|
// compiled HS `x.length` / `x.size` works on scoped lists.
|
|
if(a[0] && a[0]._type==='list' && (a[1]==='length' || a[1]==='size')) return a[0].items.length;
|
|
if(a[0] && a[0]._type==='dict' && a[1]==='size') return Object.keys(a[0]).filter(k=>k!=='_type').length;
|
|
// innerText is DOM-level alias for textContent (close enough for mock purposes)
|
|
if(a[0] instanceof El && a[1]==='innerText') return String(a[0].textContent||'');
|
|
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: '<div>yay</div>', 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'){
|
|
const target=items&&items[1];
|
|
const evName=typeof items[2]==='string'?items[2]:'';
|
|
const timeout=items&&items.length>3?items[3]:undefined;
|
|
if(typeof timeout==='number'){
|
|
// `wait for EV or Nms` — timeout wins immediately in the mock (tests use 0ms)
|
|
doResume(null);
|
|
} else if(target && target instanceof El && evName){
|
|
const handler=function(ev){
|
|
target.removeEventListener(evName,handler);
|
|
doResume(ev);
|
|
};
|
|
target.addEventListener(evName,handler);
|
|
} else {
|
|
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 _current-test-name "")');
|
|
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :name _current-test-name :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`);
|
|
if(f==='spec/tests/test-framework.sx'){
|
|
K.eval(`(defmacro deftest (name &rest body)
|
|
\`(do (set! _current-test-name ,name)
|
|
(let ((result (try-call (fn () ,@body))))
|
|
(if (get result "ok")
|
|
(report-pass ,name)
|
|
(report-fail ,name (get result "error"))))))`);
|
|
}
|
|
}
|
|
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<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 startTest = parseInt(process.env.HS_START || '0');
|
|
const endTest = parseInt(process.env.HS_END || String(testCount));
|
|
process.stdout.write(`Running tests ${startTest}-${endTest-1} of ${testCount} (step limit: ${STEP_LIMIT})...\n`);
|
|
|
|
let passed=0,failed=0;
|
|
const cats={};const errTypes={};
|
|
|
|
const SUITE_FILTER = process.env.HS_SUITE;
|
|
for(let i=startTest;i<Math.min(endTest,testCount);i++){
|
|
if(SUITE_FILTER && names[i].s!==SUITE_FILTER) continue;
|
|
const {s:suite,n:name}=names[i];
|
|
if(!cats[suite])cats[suite]={p:0,f:0,errs:[]};
|
|
|
|
// Reset body
|
|
_body.children=[];_body.childNodes=[];_body.innerHTML='';_body.textContent='';
|
|
globalThis.__test_selection='';
|
|
|
|
// Enable step limit for timeout protection
|
|
setStepLimit(STEP_LIMIT);
|
|
_testDeadline = Date.now() + 10000; // 10 second wall-clock timeout per test
|
|
if(process.env.HS_VERBOSE)process.stderr.write(`T${i} `);
|
|
|
|
let ok=false,err=null;
|
|
try{
|
|
// Use SX-level guard to catch errors, avoiding __sxR side-channel issues
|
|
// Returns a dict with :ok and :error keys
|
|
K.eval(`(define _test-result (_run-test-thunk (get (nth _test-registry ${i}) "thunk")))`);
|
|
const isOk=K.eval('(get _test-result "ok")');
|
|
if(isOk===true){ok=true;}
|
|
else{
|
|
const errMsg=K.eval('(get _test-result "error")');
|
|
err=errMsg?String(errMsg).slice(0,150):'unknown error';
|
|
}
|
|
}catch(e){err=(e.message||'').slice(0,150);}
|
|
setStepLimit(0); // disable step limit between tests
|
|
|
|
const elapsed = Date.now() - (_testDeadline - 3000); // ms since test start
|
|
if(ok){passed++;cats[suite].p++;}
|
|
else{
|
|
failed++;cats[suite].f++;cats[suite].errs.push({name,err});
|
|
let t='other';
|
|
if(err&&err.includes('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;
|
|
}
|
|
_testDeadline = 0;
|
|
if((i+1)%100===0)process.stdout.write(` ${i+1}/${testCount} (${passed} pass, ${failed} fail)\n`);
|
|
if(elapsed > 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`);
|
|
|
|
// Full failure list
|
|
process.stdout.write('\nAll failures:\n');
|
|
for(const[cat,s]of Object.entries(cats))for(const{name,err}of s.errs){
|
|
process.stdout.write(` [${cat}] ${name}: ${err}\n`);
|
|
}
|
|
|
|
// Test-specific debug dump
|
|
if(process.env.HS_DEBUG_TEST){
|
|
const target = process.env.HS_DEBUG_TEST;
|
|
for(let i=0;i<names.length;i++){
|
|
if(names[i].n===target){
|
|
console.log('FOUND TARGET INDEX:', i, 'suite:', names[i].s);
|
|
break;
|
|
}
|
|
}
|
|
}
|