#!/usr/bin/env node // test_hs_repeat.js — Debug hyperscript repeat+wait continuation bug // // Runs the exact expression that fails in the browser: // on click repeat 3 times add .active to me then wait 300ms // then remove .active then wait 300ms end // // Uses the real WASM kernel with perform/resume_vm, NOT mock IO. // Waits are shortened to 1ms. All IO suspensions are logged. // // Usage: node hosts/ocaml/browser/test_hs_repeat.js const fs = require('fs'); const path = require('path'); const PROJECT_ROOT = path.resolve(__dirname, '../../..'); const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm'); // --- DOM stubs with class tracking --- function makeElement(tag) { const el = { tagName: tag, _attrs: {}, _children: [], _classes: new Set(), style: {}, childNodes: [], children: [], textContent: '', nodeType: 1, classList: { add(c) { el._classes.add(c); console.log(` [dom] classList.add("${c}") → {${[...el._classes]}}`); }, remove(c) { el._classes.delete(c); console.log(` [dom] classList.remove("${c}") → {${[...el._classes]}}`); }, contains(c) { return el._classes.has(c); }, toggle(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(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); el.children.push(c); return c; }, insertBefore(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; }, removeChild(c) { return c; }, replaceChild(n) { return n; }, cloneNode() { return makeElement(tag); }, addEventListener() {}, removeEventListener() {}, dispatchEvent() {}, get innerHTML() { return el._children.map(c => { if (c._isText) return c.textContent || ''; if (c._isComment) return ''; return c.outerHTML || ''; }).join(''); }, set innerHTML(v) { el._children = []; el.childNodes = []; el.children = []; }, get outerHTML() { let s = '<' + tag; for (const k of Object.keys(el._attrs).sort()) s += ` ${k}="${el._attrs[k]}"`; s += '>'; if (['br','hr','img','input','meta','link'].includes(tag)) return s; return s + el.innerHTML + ''; }, dataset: new Proxy({}, { get(_, k) { return el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())]; }, set(_, k, v) { el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())] = v; return true; } }), 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); } console.log('WASM kernel loaded'); // Register FFI primitives K.registerNative('host-global', args => { const name = args[0]; return (name in globalThis) ? globalThis[name] : null; }); K.registerNative('host-get', args => { const [obj, prop] = args; if (obj == null) return null; const v = obj[prop]; 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; const r = obj[method].apply(obj, rest); return r === undefined ? null : r; }); 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-hs-1.0")'); K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")'); K.eval('(define parse sx-parse)'); K.eval('(define serialize sx-serialize)'); // Stub DOM primitives that HS runtime calls // dom-listen fires handler immediately (simulates the event) K.eval('(define dom-add-class (fn (el cls) (dict-set! (get el "classes") cls true) nil))'); K.eval('(define dom-remove-class (fn (el cls) (dict-delete! (get el "classes") cls) nil))'); K.eval('(define dom-has-class? (fn (el cls) (dict-has? (get el "classes") cls)))'); K.eval('(define dom-listen (fn (target event-name handler) (handler {:type event-name :target target})))'); // Load hyperscript modules const hsFiles = [ 'lib/hyperscript/tokenizer.sx', 'lib/hyperscript/parser.sx', 'lib/hyperscript/compiler.sx', 'lib/hyperscript/runtime.sx', ]; for (const f of hsFiles) { const src = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8'); const r = K.load(src); if (typeof r === 'string' && r.startsWith('Error')) { console.error(`Load failed: ${f}: ${r}`); process.exit(1); } } console.log('Hyperscript modules loaded'); // Compile the expression const compiled = K.eval('(hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end")'); console.log('Compiled:', K.eval(`(inspect '${typeof compiled === 'string' ? compiled : '?'})`)); // Actually get it as a string const compiledStr = K.eval('(inspect (hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end"))'); console.log('Compiled SX:', compiledStr); // Create handler function (same as hs-handler does) K.eval('(define _test-me {:tag "button" :id "test" :classes {} :_hs-activated true})'); // Build the handler — wraps compiled SX in (fn (me) (let ((it nil) (event ...)) )) const handlerSrc = K.eval('(inspect (hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end"))'); K.eval(`(define _test-handler (eval-expr (list 'fn '(me) (list 'let '((it nil) (event {:type "click" :target _test-me})) (hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end")))))`); console.log('\n=== Invoking handler (simulates click event) ==='); console.log('Expected: 3 iterations × (add .active, wait 300, remove .active, wait 300)'); console.log('Expected: 6 IO suspensions total\n'); // Call the handler — this will suspend on the first hs-wait (perform) let suspensionCount = 0; let result; try { result = K.callFn(K.eval('_test-handler'), [K.eval('_test-me')]); } catch(e) { console.error('Initial call error:', e.message); process.exit(1); } // Drive async suspension chain with real timeouts (1ms instead of 300ms) function driveAsync(res) { return new Promise((resolve) => { function step(r) { if (!r || !r.suspended) { console.log(`\n=== Done. Total suspensions: ${suspensionCount} (expected: 6) ===`); console.log(`Result: ${r === null ? 'null' : typeof r === 'object' ? JSON.stringify(r) : r}`); resolve(); return; } suspensionCount++; 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); const arg = items && items[1]; console.log(`Suspension #${suspensionCount}: op=${opName} arg=${arg}`); if (opName === 'io-sleep' || opName === 'wait') { // Resume after 1ms (not real 300ms) setTimeout(() => { try { const resumed = r.resume(null); console.log(` Resumed: suspended=${resumed && resumed.suspended}, type=${typeof resumed}`); step(resumed); } catch(e) { console.error(` Resume error: ${e.message}`); resolve(); } }, 1); } else { console.log(` Unhandled IO op: ${opName}`); resolve(); } } step(res); }); } await driveAsync(result); // Check final element state const classes = K.eval('(get _test-me "classes")'); console.log('\nFinal element classes:', JSON.stringify(classes)); } main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });