Files
rose-ash/hosts/ocaml/browser/test_hs_repeat.js
giles 7492ceac4e Restore hyperscript work on stable site base (908f4f80)
Reset to last known-good state (908f4f80) where links, stepper, and
islands all work, then recovered all hyperscript implementation,
conformance tests, behavioral tests, Playwright specs, site sandbox,
IO-aware server loading, and upstream test suite from f271c88a.

Excludes runtime changes (VM resolve hook, VmSuspended browser handler,
sx_ref.ml guard recovery) that need careful re-integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:29:56 +00:00

230 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 '<!--' + (c.textContent || '') + '-->';
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 + '</' + tag + '>';
},
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 ...)) <sx>))
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); });