#!/usr/bin/env node // test_driveAsync_order.js — Verify DOM mutation order with real _driveAsync // // This test mimics the exact browser flow: // 1. host-callback wraps handler with K.callFn + _driveAsync // 2. dom-listen uses host-callback + host-call addEventListener // 3. Event fires → wrapper runs → _driveAsync drives suspension chain // // If there's a dual-path issue (_driveAsync + CEK chain both driving), // mutations will appear out of order. // // Expected: +active, -active, +active, -active, +active, -active (3 iterations) // Bug: +active, +active, -active, ... (overlapping iterations) const fs = require('fs'); const path = require('path'); const PROJECT_ROOT = path.resolve(__dirname, '../../..'); const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm'); // --- Track ALL mutations in order --- const mutations = []; function makeElement(tag) { const el = { tagName: tag, _attrs: {}, _children: [], _classes: new Set(), _listeners: {}, style: {}, childNodes: [], children: [], textContent: '', nodeType: 1, classList: { add(c) { el._classes.add(c); mutations.push('+' + c); console.log(' [DOM] classList.add("' + c + '") → {' + [...el._classes] + '}'); }, remove(c) { el._classes.delete(c); mutations.push('-' + 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(event, fn) { if (!el._listeners[event]) el._listeners[event] = []; el._listeners[event].push(fn); }, removeEventListener(event, fn) { if (el._listeners[event]) { el._listeners[event] = el._listeners[event].filter(f => f !== fn); } }, dispatchEvent(e) { const name = typeof e === 'string' ? e : e.type; (el._listeners[name] || []).forEach(fn => fn(e)); }, 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); } console.log('WASM kernel loaded'); // --- Register FFI with the REAL _driveAsync (same as sx-platform.js) --- K.registerNative('host-global', function(args) { var name = args[0]; if (name in globalThis) return globalThis[name]; return null; }); K.registerNative('host-get', function(args) { var obj = args[0], prop = args[1]; if (obj == null) return null; var v = obj[prop]; return v === undefined ? null : v; }); K.registerNative('host-set!', function(args) { if (args[0] != null) args[0][args[1]] = args[2]; }); K.registerNative('host-call', function(args) { var obj = args[0], method = args[1]; var callArgs = []; for (var i = 2; i < args.length; i++) callArgs.push(args[i]); if (obj == null) return null; if (typeof obj[method] === 'function') { try { return obj[method].apply(obj, callArgs); } catch(e) { console.error('[sx] host-call error:', e); return null; } } return null; }); K.registerNative('host-new', function(args) { return null; }); K.registerNative('host-typeof', function(args) { return typeof args[0]; }); K.registerNative('host-await', function(args) { return args[0]; }); // THE REAL host-callback (same as sx-platform.js lines 82-97) K.registerNative('host-callback', function(args) { var fn = args[0]; if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn; if (fn && fn.__sx_handle !== undefined) { return function() { var a = Array.prototype.slice.call(arguments); var result = K.callFn(fn, a); // This is the line under investigation: _driveAsync(result); return result; }; } return function() {}; }); // THE REAL _driveAsync (same as sx-platform.js lines 104-138) var _asyncPending = 0; function _driveAsync(result) { if (!result || !result.suspended) return; _asyncPending++; console.log('[driveAsync] suspension detected, pending=' + _asyncPending); var req = result.request; if (!req) { _asyncPending--; return; } var items = req.items || req; var op = (items && items[0]) || req; var opName = (typeof op === 'string') ? op : (op && op.name) || String(op); if (opName === 'wait' || opName === 'io-sleep') { var ms = (items && items[1]) || 0; if (typeof ms !== 'number') ms = parseFloat(ms) || 0; // Use 1ms for test speed setTimeout(function() { try { var resumed = result.resume(null); _asyncPending--; console.log('[driveAsync] resumed, pending=' + _asyncPending + ', suspended=' + (resumed && resumed.suspended)); _driveAsync(resumed); } catch(e) { _asyncPending--; console.error('[driveAsync] resume error:', e); } }, 1); } else { _asyncPending--; console.warn('[driveAsync] unhandled IO:', opName); } } K.eval('(define SX_VERSION "test-drive-async")'); K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")'); K.eval('(define parse sx-parse)'); K.eval('(define serialize sx-serialize)'); // Load the REAL dom-listen (uses host-callback + host-call addEventListener) K.eval(`(define dom-listen (fn (el event-name handler) (let ((cb (host-callback handler))) (host-call el "addEventListener" event-name cb) (fn () (host-call el "removeEventListener" event-name cb)))))`); 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)))'); // Load hyperscript modules — try bytecode first, fall back to source const SX_DIR = path.join(WASM_DIR, 'sx'); const useBytecode = process.argv.includes('--bytecode'); if (useBytecode) { console.log('Loading BYTECODE modules...'); const bcNames = ['hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime']; for (const f of bcNames) { 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, '\\"') + '")))'); console.log(' loaded ' + f + '.sxbc'); } else { console.error(' MISSING ' + bcPath); } } } else { console.log('Loading SOURCE modules...'); const hsFiles = ['tokenizer', 'parser', 'compiler', 'runtime']; for (const f of hsFiles) { K.load(fs.readFileSync(path.join(PROJECT_ROOT, 'lib/hyperscript', f + '.sx'), 'utf8')); } } console.log('Hyperscript modules loaded'); // Create element const btn = makeElement('button'); global._testBtn = btn; K.eval('(define _btn (host-global "_testBtn"))'); // Compile + register handler using hs-on (which uses dom-listen → host-callback → addEventListener) console.log('\n=== Setting up hs-on handler ==='); K.eval(`(hs-on _btn "click" (fn (event) (hs-repeat-times 3 (fn () (do (dom-add-class _btn "active") (hs-wait 300) (dom-remove-class _btn "active") (hs-wait 300))))))`); console.log('Handler registered, listeners:', Object.keys(btn._listeners)); console.log('Click listeners count:', (btn._listeners.click || []).length); // Simulate click — fires the event listener which goes through host-callback + _driveAsync console.log('\n=== Simulating click ==='); mutations.length = 0; btn.dispatchEvent({ type: 'click', target: btn }); // Wait for all async resumes to complete await new Promise(resolve => { function check() { if (_asyncPending === 0 && mutations.length > 0) { // Give a tiny extra delay to make sure nothing else fires setTimeout(() => { if (_asyncPending === 0) resolve(); else check(); }, 10); } else { setTimeout(check, 5); } } setTimeout(check, 50); }); // Verify mutation order console.log('\n=== Results ==='); console.log('Mutations:', mutations.join(', ')); console.log('Count:', mutations.length, '(expected: 6)'); const expected = ['+active', '-active', '+active', '-active', '+active', '-active']; let pass = true; if (mutations.length !== expected.length) { console.error(`FAIL: expected ${expected.length} mutations, got ${mutations.length}`); pass = false; } else { for (let i = 0; i < expected.length; i++) { if (mutations[i] !== expected[i]) { console.error(`FAIL at index ${i}: expected ${expected[i]}, got ${mutations[i]}`); pass = false; } } } if (pass) { console.log('PASS: mutation order is correct'); } else { console.log('FAIL: mutation order is wrong'); console.log('Expected:', expected.join(', ')); console.log('Got: ', mutations.join(', ')); } process.exit(pass ? 0 : 1); } main().catch(e => { console.error('FATAL:', e); process.exit(1); });