Files
rose-ash/tests/playwright/hs-behavioral.spec.js
giles 299f3e748d Implicit then, between, starts/ends with — 339/831 (41%)
Biggest win: HS sources from upstream HTML had newlines replaced with
spaces, losing command separation. Now multi-space sequences become
'then' keywords, matching _hyperscript's implicit newline-as-separator
behavior. +42 tests passing.

Parser: 'is between X and Y', 'is not between', 'starts with',
'ends with' comparison operators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:15:01 +00:00

336 lines
14 KiB
JavaScript

// @ts-check
/**
* Hyperscript behavioral tests — SX tests in Playwright sandbox.
*
* Tests are registered during file load (deferred), then each is run
* individually via page.evaluate with a 3s Promise.race timeout.
* Hanging tests fail with TIMEOUT. After a timeout, the page is
* closed and a fresh one is created to avoid cascading hangs.
*/
const { test, expect } = require('playwright/test');
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
const SX_DIR = path.join(WASM_DIR, 'sx');
const WEB_MODULES = [
'render', 'core-signals', 'signals', 'deps', 'router',
'page-helpers', 'freeze', 'dom', 'browser',
'adapter-html', 'adapter-sx', 'adapter-dom',
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
];
const HS_MODULES = [
'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
];
// Cache module sources — avoid re-reading files on reboot
const MODULE_CACHE = {};
function getModuleSrc(mod) {
if (MODULE_CACHE[mod]) return MODULE_CACHE[mod];
const sxPath = path.join(SX_DIR, mod + '.sx');
const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
try {
MODULE_CACHE[mod] = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8');
} catch(e) { MODULE_CACHE[mod] = null; }
return MODULE_CACHE[mod];
}
// Cache test file sources
const TEST_FILES = ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx'];
const TEST_FILE_CACHE = {};
for (const f of TEST_FILES) {
TEST_FILE_CACHE[f] = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
}
async function bootSandbox(page) {
await page.goto('about:blank');
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
await page.addScriptTag({ content: kernelSrc });
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
await page.evaluate(() => {
const K = window.SxKernel;
K.registerNative('host-global', a => { const n=a[0]; return (n in globalThis)?globalThis[n]:null; });
K.registerNative('host-get', a => { if(a[0]==null)return null; const v=a[0][a[1]]; return v===undefined?null:v; });
K.registerNative('host-set!', a => { if(a[0]!=null)a[0][a[1]]=a[2]; return a[2]; });
K.registerNative('host-call', a => {
const[o,m,...r]=a;
if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}
if(typeof o[m]!=='function')return null;
try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}
});
K.registerNative('host-new', a => {
const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];
return typeof C==='function'?new C(...a.slice(1)):null;
});
K.registerNative('host-callback', a => {
const fn=a[0];
if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;
if(fn&&fn.__sx_handle!==undefined){
return function(){const r=K.callFn(fn,Array.from(arguments));if(window._driveAsync)window._driveAsync(r);return r;};
}
return function(){};
});
K.registerNative('host-typeof', a => {
const o=a[0]; if(o==null)return'nil';
if(o instanceof Element)return'element'; if(o instanceof Text)return'text';
if(o instanceof DocumentFragment)return'fragment'; if(o instanceof Document)return'document';
if(o instanceof Event)return'event'; if(o instanceof Promise)return'promise';
return typeof o;
});
K.registerNative('host-await', a => {
const[p,cb]=a;if(p&&typeof p.then==='function'){const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};p.then(f);}
});
K.registerNative('load-library!', () => false);
window._driveAsync = function driveAsync(result) {
if(!result||!result.suspended)return;
const req=result.request;const items=req&&(req.items||req);
const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);
const arg=items&&items[1];
function doResume(val,delay){setTimeout(()=>{try{const r=result.resume(val);driveAsync(r);}catch(e){}},delay);}
if(opName==='io-sleep'||opName==='wait')doResume(null,Math.min(typeof arg==='number'?arg:0,10));
else if(opName==='io-fetch')doResume({ok:true,text:''},1);
};
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 loadErrors = [];
await page.evaluate(() => { if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad(); });
for (const mod of [...WEB_MODULES, ...HS_MODULES]) {
const src = getModuleSrc(mod);
if (!src) { loadErrors.push(mod); continue; }
const err = await page.evaluate(s => {
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
}, src);
if (err) loadErrors.push(mod + ': ' + err);
}
await page.evaluate(() => { if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); });
// Deferred test registration + helpers
await page.evaluate(() => {
const K = window.SxKernel;
K.eval('(define _test-registry (list))');
K.eval('(define _test-suite "")');
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
K.eval(`(define try-call (fn (thunk)
(set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk})))
{:ok true}))`);
K.eval(`(define report-pass (fn (name)
(let ((i (- (len _test-registry) 1)))
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
K.eval(`(define report-fail (fn (name error)
(let ((i (- (len _test-registry) 1)))
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
// eval-hs: compile and evaluate a hyperscript expression/command, return its value.
// If src contains 'return', use as-is. If it starts with a command keyword (set/put/get),
// use as-is (the last expression is the result). Otherwise wrap in 'return'.
K.eval(`(define eval-hs (fn (src)
(let ((has-cmd (or (string-contains? src "return ")
(string-contains? src "then ")
(= "set " (slice src 0 4))
(= "put " (slice src 0 4))
(= "get " (slice src 0 4)))))
(let ((wrapped (if has-cmd src (str "return " src))))
(let ((sx (hs-to-sx-from-source wrapped)))
(eval-expr sx))))))`);
});
for (const f of TEST_FILES) {
const err = await page.evaluate(s => {
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
}, TEST_FILE_CACHE[f]);
if (err) loadErrors.push(f + ': ' + err);
}
return loadErrors;
}
// ===========================================================================
test.describe('Hyperscript behavioral tests', () => {
test.describe.configure({ timeout: 600000 });
test('upstream conformance', async ({ browser }) => {
let page = await browser.newPage();
let loadErrors = await bootSandbox(page);
expect(loadErrors).toEqual([]);
// Get test list
const testList = await page.evaluate(() => {
const K = window.SxKernel;
const count = K.eval('(len _test-registry)');
const tests = [];
for (let i = 0; i < count; i++) {
tests.push({
s: K.eval(`(get (nth _test-registry ${i}) "suite")`) || '',
n: K.eval(`(get (nth _test-registry ${i}) "name")`) || `test-${i}`,
});
}
return tests;
});
// Run each test individually with timeout
const results = [];
let consecutiveTimeouts = 0;
for (let i = 0; i < testList.length; i++) {
const t = testList[i];
// If page is dead (after timeout), reboot
if (consecutiveTimeouts > 0) {
// After a timeout, the page.evaluate from Promise.race is orphaned.
// We must close + reopen to get a clean page.
try { await page.close(); } catch(_) {}
page = await browser.newPage();
loadErrors = await bootSandbox(page);
if (loadErrors.length > 0) {
for (let j = i; j < testList.length; j++)
results.push({ s: testList[j].s, n: testList[j].n, p: false, e: 'reboot failed' });
break;
}
consecutiveTimeouts = 0;
}
let result;
try {
result = await Promise.race([
page.evaluate(async (idx) => {
const K = window.SxKernel;
const newBody = document.createElement('body');
document.documentElement.replaceChild(newBody, document.body);
const thunk = K.eval(`(get (nth _test-registry ${idx}) "thunk")`);
if (!thunk) return { p: false, e: 'no thunk' };
let lastErr = null;
const orig = console.error;
console.error = function() {
const m = Array.from(arguments).join(' ');
if (m.startsWith('[sx]')) lastErr = m;
orig.apply(console, arguments);
};
// Drive async suspension chains (wait, fetch, etc.)
let pending = 0;
const oldDrive = window._driveAsync;
window._driveAsync = function driveAsync(result) {
if (!result || !result.suspended) return;
pending++;
const req = result.request;
const items = req && (req.items || req);
const op = items && items[0];
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
const arg = items && items[1];
function doResume(val, delay) {
setTimeout(() => {
try { const r = result.resume(val); pending--; driveAsync(r); }
catch(e) { pending--; }
}, delay);
}
if (opName === 'io-sleep' || opName === 'wait') doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10));
else if (opName === 'io-fetch') doResume({ok: true, text: ''}, 1);
else if (opName === 'io-settle') doResume(null, 5);
else if (opName === 'io-wait-event') doResume(null, 5);
else pending--;
};
try {
const r = K.callFn(thunk, []);
// If thunk itself suspended, drive it
if (r && r.suspended) window._driveAsync(r);
// Wait for all pending async chains to settle
if (pending > 0) {
await new Promise(resolve => {
let waited = 0;
const check = () => {
if (pending <= 0 || waited > 2000) resolve();
else { waited += 10; setTimeout(check, 10); }
};
setTimeout(check, 10);
});
}
console.error = orig;
window._driveAsync = oldDrive;
return lastErr ? { p: false, e: lastErr.replace(/[\\"]/g, ' ').slice(0, 150) } : { p: true, e: null };
} catch(e) {
console.error = orig;
window._driveAsync = oldDrive;
return { p: false, e: (e.message || '').replace(/[\\"]/g, ' ').slice(0, 150) };
}
}, i),
new Promise(resolve => setTimeout(() => resolve({ p: false, e: 'TIMEOUT' }), 3000))
]);
} catch(e) {
result = { p: false, e: 'CRASH: ' + (e.message || '').slice(0, 80) };
}
if (result.e === 'TIMEOUT' || (result.e && result.e.startsWith('CRASH'))) {
consecutiveTimeouts++;
}
results.push({ s: t.s, n: t.n, p: result.p, e: result.e });
}
try { await page.close(); } catch(_) {}
// Tally
let passed = 0, failed = 0;
const cats = {};
const errTypes = {};
for (const r of results) {
if (r.p) passed++; else {
failed++;
const e = r.e || '';
let t = 'other';
if (e === 'TIMEOUT') t = 'timeout';
else if (e.includes('NOT IMPLEMENTED')) t = 'stub';
else if (e.includes('callFn')) t = 'crash';
else if (e.includes('Assertion')) t = 'assert-fail';
else if (e.includes('Unhandled')) t = 'unhandled';
else if (e.includes('Expected')) t = 'wrong-value';
else if (e.includes('Cannot read')) t = 'null-ref';
else if (e.includes('Undefined')) t = 'undef-sym';
else if (e.includes('no thunk')) t = 'no-thunk';
else if (e.includes('reboot')) t = 'reboot-fail';
if (!errTypes[t]) errTypes[t] = 0;
errTypes[t]++;
}
if (!cats[r.s]) cats[r.s] = { p: 0, f: 0 };
if (r.p) cats[r.s].p++; else cats[r.s].f++;
}
console.log(`\n Upstream conformance: ${passed}/${results.length} (${(100*passed/results.length).toFixed(0)}%)`);
for (const [cat, s] of Object.entries(cats).sort((a,b) => b[1].p - a[1].p)) {
const mark = s.f === 0 ? `${s.p}` : `${s.p}/${s.p+s.f}`;
console.log(` ${cat}: ${mark}`);
}
console.log(` Failure types:`);
for (const [t, n] of Object.entries(errTypes).sort((a,b) => b[1] - a[1])) {
console.log(` ${t}: ${n}`);
}
// Show ALL crash errors (deduplicated by error message)
const uniqueErrors = {};
for (const r of results.filter(r => !r.p)) {
const e = (r.e || '').slice(0, 100);
if (!uniqueErrors[e]) uniqueErrors[e] = { count: 0, example: r };
uniqueErrors[e].count++;
}
console.log(` Unique error messages (${Object.keys(uniqueErrors).length}):`);
for (const [e, info] of Object.entries(uniqueErrors).sort((a,b) => b[1].count - a[1].count).slice(0, 25)) {
console.log(` [${info.count}x] ${e}`);
}
// Show samples of "bar" error specifically
const barSamples = results.filter(r => !r.p && (r.e||'').includes('Expected , got')).slice(0, 15);
if (barSamples.length > 0) {
console.log(` Expected-got failures (${barSamples.length}):`);
for (const s of barSamples) console.log(` ${s.s}/${s.n}`);
}
expect(results.length).toBeGreaterThanOrEqual(830);
expect(passed).toBeGreaterThanOrEqual(300);
});
});