Files
rose-ash/hosts/ocaml/browser/test_bytecode_repeat.js
giles 48c5ac6287 Add failing regression test: bytecode when/do/perform suspension bug
test_bytecode_repeat.js tests hs-repeat-times across source vs bytecode:
- Source: 6 suspensions (3 iterations × 2 waits) ✓
- Bytecode: 3 suspensions (exits early) ✗

Run: node hosts/ocaml/browser/test_bytecode_repeat.js

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

231 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// test_bytecode_repeat.js — Regression test for bytecode when/do/perform bug
//
// Tests that (when cond (do (perform ...) (recurse))) correctly resumes
// the do continuation after perform/cek_resume in bytecode-compiled code.
//
// The bug: bytecode-compiled hs-repeat-times only iterates 2x instead of 3x
// because the do continuation is lost after perform suspension.
//
// Source-loaded code works (CEK handles when/do/perform correctly).
// Bytecode-compiled code fails (VM/CEK handoff loses the continuation).
//
// Usage: node hosts/ocaml/browser/test_bytecode_repeat.js
//
// Expected output when bug is fixed:
// SOURCE: 6 suspensions (3 iterations × 2 waits) ✓
// BYTECODE: 6 suspensions (3 iterations × 2 waits) ✓
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');
// --- Minimal DOM stubs ---
function makeElement(tag) {
const el = {
tagName: tag, _attrs: {}, _classes: new Set(), style: {},
childNodes: [], children: [], textContent: '', nodeType: 1,
classList: {
add(c) { el._classes.add(c); },
remove(c) { el._classes.delete(c); },
contains(c) { return el._classes.has(c); },
},
setAttribute(k, v) { el._attrs[k] = String(v); },
getAttribute(k) { return el._attrs[k] || null; },
removeAttribute(k) { delete el._attrs[k]; },
appendChild(c) { el.children.push(c); el.childNodes.push(c); return c; },
insertBefore(c) { el.children.push(c); el.childNodes.push(c); return c; },
removeChild(c) { return c; }, replaceChild(n) { return n; },
cloneNode() { return makeElement(tag); },
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
get className() { return [...el._classes].join(' '); },
get innerHTML() { return ''; }, set innerHTML(v) {},
get outerHTML() { return '<' + tag + '>'; },
dataset: {}, querySelectorAll() { return []; }, querySelector() { return null; },
};
return el;
}
global.window = global;
global.document = {
createElement: makeElement,
createDocumentFragment() { return makeElement('fragment'); },
head: makeElement('head'), body: makeElement('body'),
querySelector() { return null; }, querySelectorAll() { return []; },
createTextNode(s) { return { _isText: true, textContent: String(s), nodeType: 3 }; },
addEventListener() {},
createComment(s) { return { _isComment: true, textContent: s || '', nodeType: 8 }; },
getElementsByTagName() { return []; },
};
global.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
global.CustomEvent = class { constructor(n, o) { this.type = n; this.detail = (o || {}).detail || {}; } };
global.MutationObserver = class { observe() {} disconnect() {} };
global.requestIdleCallback = fn => setTimeout(fn, 0);
global.matchMedia = () => ({ matches: false });
global.navigator = { serviceWorker: { register() { return Promise.resolve(); } } };
global.location = { href: '', pathname: '/', hostname: 'localhost' };
global.history = { pushState() {}, replaceState() {} };
global.fetch = () => Promise.resolve({ ok: true, text() { return Promise.resolve(''); } });
global.XMLHttpRequest = class { open() {} send() {} };
async function main() {
// Load WASM kernel
require(path.join(WASM_DIR, 'sx_browser.bc.js'));
const K = globalThis.SxKernel;
if (!K) { console.error('FATAL: SxKernel not found'); process.exit(1); }
// Register FFI
K.registerNative('host-global', args => (args[0] in globalThis) ? globalThis[args[0]] : null);
K.registerNative('host-get', args => { if (args[0] == null) return null; const v = args[0][args[1]]; return v === undefined ? null : v; });
K.registerNative('host-set!', args => { if (args[0] != null) args[0][args[1]] = args[2]; return args[2]; });
K.registerNative('host-call', args => {
const [obj, method, ...rest] = args;
if (obj == null || typeof obj[method] !== 'function') return null;
return obj[method].apply(obj, rest) ?? null;
});
K.registerNative('host-new', args => new (Function.prototype.bind.apply(args[0], [null, ...args.slice(1)])));
K.registerNative('host-callback', args => {
const fn = args[0];
return function() { return K.callFn(fn, Array.from(arguments)); };
});
K.registerNative('host-typeof', args => typeof args[0]);
K.registerNative('host-await', args => args[0]);
K.eval('(define SX_VERSION "test-bc-repeat")');
K.eval('(define SX_ENGINE "ocaml-vm-test")');
K.eval('(define parse sx-parse)');
K.eval('(define serialize sx-serialize)');
// DOM stubs for HS runtime
K.eval('(define dom-add-class (fn (el cls) (host-call (host-get el "classList") "add" cls)))');
K.eval('(define dom-remove-class (fn (el cls) (host-call (host-get el "classList") "remove" cls)))');
K.eval('(define dom-has-class? (fn (el cls) (host-call (host-get el "classList") "contains" cls)))');
K.eval('(define dom-listen (fn (target event-name handler) (handler {:type event-name :target target})))');
// --- Test helper: count suspensions ---
function countSuspensions(result) {
return new Promise(resolve => {
let count = 0;
function drive(r) {
if (!r || !r.suspended) { resolve(count); return; }
count++;
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);
if (opName === 'io-sleep' || opName === 'wait') {
setTimeout(() => {
try { drive(r.resume(null)); }
catch(e) { console.error(' resume error:', e.message); resolve(count); }
}, 1);
} else { resolve(count); }
}
drive(result);
});
}
let pass = 0, fail = 0;
function assert(name, got, expected) {
if (got === expected) { pass++; console.log(`${name}`); }
else { fail++; console.error(`${name}: got ${got}, expected ${expected}`); }
}
// =====================================================================
// Test 1: SOURCE — load hs-repeat-times from .sx, call with perform
// =====================================================================
console.log('\n=== Test: SOURCE-loaded hs-repeat-times ===');
// Load from source
const hsFiles = ['tokenizer', 'parser', 'compiler', 'runtime'];
for (const f of hsFiles) {
K.load(fs.readFileSync(path.join(PROJECT_ROOT, 'lib/hyperscript', f + '.sx'), 'utf8'));
}
// Build handler and call it
K.eval(`(define _src-handler
(eval-expr
(list 'fn '(me)
(list 'let '((it nil) (event {:type "click"}))
(hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 1ms then remove .active then wait 1ms end")))))`);
const srcMe = makeElement('button');
K.eval('(define _src-me (host-global "_srcMe"))');
global._srcMe = srcMe;
K.eval('(define _src-me (host-global "_srcMe"))');
let srcResult;
try { srcResult = K.callFn(K.eval('_src-handler'), [srcMe]); }
catch(e) { console.error('Source call error:', e.message); }
const srcSuspensions = await countSuspensions(srcResult);
assert('source: 6 suspensions (3 iters × 2 waits)', srcSuspensions, 6);
// =====================================================================
// Test 2: BYTECODE — load hs-repeat-times from .sxbc, call with perform
// =====================================================================
console.log('\n=== Test: BYTECODE-loaded hs-repeat-times ===');
// Reload from bytecode — overwrite the source-defined versions
if (K.beginModuleLoad) K.beginModuleLoad();
for (const f of ['hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime']) {
const bcPath = path.join(SX_DIR, f + '.sxbc');
if (fs.existsSync(bcPath)) {
const bcSrc = fs.readFileSync(bcPath, 'utf8');
K.load('(load-sxbc (first (parse "' + bcSrc.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '")))');
}
}
if (K.endModuleLoad) K.endModuleLoad();
// Build handler with the bytecode-loaded hs-repeat-times
K.eval(`(define _bc-handler
(eval-expr
(list 'fn '(me)
(list 'let '((it nil) (event {:type "click"}))
(hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 1ms then remove .active then wait 1ms end")))))`);
const bcMe = makeElement('button');
global._bcMe = bcMe;
K.eval('(define _bc-me (host-global "_bcMe"))');
let bcResult;
try { bcResult = K.callFn(K.eval('_bc-handler'), [bcMe]); }
catch(e) { console.error('Bytecode call error:', e.message); }
const bcSuspensions = await countSuspensions(bcResult);
assert('bytecode: 6 suspensions (3 iters × 2 waits)', bcSuspensions, 6);
// =====================================================================
// Test 3: Minimal — just hs-repeat-times + perform, no hyperscript
// =====================================================================
console.log('\n=== Test: Minimal repeat + perform ===');
// Source version
K.eval('(define _src-count 0)');
K.eval(`(define _src-repeat-fn
(fn (n thunk)
(define do-repeat
(fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1))))))
(do-repeat 0)))`);
K.eval(`(define _src-repeat-thunk
(eval-expr '(fn () (_src-repeat-fn 3 (fn () (set! _src-count (+ _src-count 1)) (perform (list 'io-sleep 1)))))))`);
let minSrcResult;
try { minSrcResult = K.callFn(K.eval('_src-repeat-thunk'), []); }
catch(e) { console.error('Minimal source error:', e.message); }
const minSrcSusp = await countSuspensions(minSrcResult);
const minSrcCount = K.eval('_src-count');
assert('minimal source: 3 suspensions', minSrcSusp, 3);
assert('minimal source: count=3', minSrcCount, 3);
// =====================================================================
// Summary
// =====================================================================
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
}
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });