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>
231 lines
10 KiB
JavaScript
231 lines
10 KiB
JavaScript
#!/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); });
|