diff --git a/hosts/ocaml/browser/test_bytecode_repeat.js b/hosts/ocaml/browser/test_bytecode_repeat.js new file mode 100644 index 00000000..32b3b365 --- /dev/null +++ b/hosts/ocaml/browser/test_bytecode_repeat.js @@ -0,0 +1,230 @@ +#!/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); });