#!/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); });