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>
This commit is contained in:
2026-04-09 19:29:56 +00:00
parent 908f4f80d4
commit 7492ceac4e
55 changed files with 32933 additions and 437 deletions

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env node
// _pre-screen-worker-full.js — Test full HS pipeline: compile + activate
// This tests what actually happens in the browser: hs-activate on an element.
//
// Input: HS_BATCH_FILE env var points to a JSON array of source strings.
// Output: One line per source: RESULT:{"source":"...","status":"ok|error","detail":"..."}
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = process.cwd();
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
// --- Minimal DOM stubs ---
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); },
remove(c) { el._classes.delete(c); },
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); return c; },
insertBefore(c) { el._children.push(c); return c; },
removeChild(c) { return c; },
replaceChild(n) { return n; },
cloneNode() { return makeElement(tag); },
get innerHTML() { return ''; },
set innerHTML(v) { el._children = []; },
get outerHTML() { return `<${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; },
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
};
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() {} };
// 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 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 "pre-screen-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
K.eval('(define dom-add-class (fn (el cls) nil))');
K.eval('(define dom-remove-class (fn (el cls) nil))');
K.eval('(define dom-has-class? (fn (el cls) false))');
K.eval('(define dom-listen (fn (target event-name handler) nil))');
K.eval('(define dom-toggle-class (fn (el cls) nil))');
K.eval('(define dom-set-style! (fn (el prop val) nil))');
K.eval('(define dom-get-style (fn (el prop) ""))');
K.eval('(define dom-set-attr! (fn (el k v) nil))');
K.eval('(define dom-get-attr (fn (el k) nil))');
K.eval('(define dom-remove-attr! (fn (el k) nil))');
K.eval('(define dom-set-text! (fn (el t) nil))');
K.eval('(define dom-get-text (fn (el) ""))');
K.eval('(define dom-set-html! (fn (el h) nil))');
K.eval('(define dom-get-html (fn (el) ""))');
K.eval('(define dom-set-value! (fn (el v) nil))');
K.eval('(define dom-get-value (fn (el) ""))');
K.eval('(define dom-query-all (fn (sel) (list)))');
K.eval('(define dom-query (fn (sel) nil))');
K.eval('(define dom-query-in (fn (el sel) nil))');
K.eval('(define dom-query-all-in (fn (el sel) (list)))');
K.eval('(define dom-parent (fn (el) nil))');
K.eval('(define dom-children (fn (el) (list)))');
K.eval('(define dom-closest (fn (el sel) nil))');
K.eval('(define dom-matches? (fn (el sel) false))');
K.eval('(define dom-append! (fn (parent child) nil))');
K.eval('(define dom-remove! (fn (el) nil))');
K.eval('(define dom-create (fn (tag) {:tag tag :id "" :classes {} :style {} :_hs-activated false}))');
K.eval('(define io-sleep (fn (ms) nil))');
K.eval('(define io-dispatch (fn (el event-name detail) nil))');
K.eval('(define io-log (fn (&rest args) nil))');
// Load hyperscript modules (tokenizer, parser, compiler, runtime)
// Skip integration.sx — it needs load-library! which is browser-only.
// We define hs-handler inline below.
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);
}
}
// Define hs-handler (from integration.sx) — wraps compiled SX in (fn (me) (let ((it nil) (event nil)) <sx>))
K.eval(`(define hs-handler (fn (src)
(let ((sx (hs-to-sx-from-source src)))
(eval-expr
(list 'fn '(me)
(list 'let '((it nil) (event nil)) sx))))))`);
// Read batch input
const batchFile = process.env.HS_BATCH_FILE;
if (!batchFile) { console.error('No HS_BATCH_FILE env var'); process.exit(1); }
const sources = JSON.parse(fs.readFileSync(batchFile, 'utf8'));
// Test each source — full pipeline: compile + wrap in handler + activate
for (const src of sources) {
const escaped = src.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let status = 'ok';
let detail = '';
try {
// Step 1: Compile
const compileExpr = `(hs-to-sx-from-source "${escaped}")`;
const compiled = K.eval(compileExpr);
if (compiled === null || compiled === undefined) {
status = 'error';
detail = 'compile returned nil';
} else if (typeof compiled === 'string' && compiled.startsWith('Error')) {
status = 'error';
detail = compiled.slice(0, 200);
} else {
// Step 2: Try to create a handler (hs-handler wraps in fn+let)
const handlerExpr = `(hs-handler "${escaped}")`;
const handler = K.eval(handlerExpr);
if (handler === null || handler === undefined) {
status = 'error';
detail = 'handler returned nil';
} else if (typeof handler === 'string' && handler.startsWith('Error')) {
status = 'error';
detail = handler.slice(0, 200);
}
}
} catch (e) {
status = 'error';
detail = e.message ? e.message.slice(0, 200) : String(e).slice(0, 200);
}
console.log(`RESULT:${JSON.stringify({ source: src, status, detail })}`);
}

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env node
// _pre-screen-worker-timed.js — Test each source with per-source timing
// Reports exact time for each source so we can identify slow ones.
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = process.cwd();
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
// --- Minimal DOM stubs ---
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); },
remove(c) { el._classes.delete(c); },
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); return c; },
insertBefore(c) { el._children.push(c); return c; },
removeChild(c) { return c; },
replaceChild(n) { return n; },
cloneNode() { return makeElement(tag); },
get innerHTML() { return ''; },
set innerHTML(v) { el._children = []; },
get outerHTML() { return `<${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; },
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
};
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() {} };
// 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 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 "pre-screen-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
K.eval('(define dom-add-class (fn (el cls) nil))');
K.eval('(define dom-remove-class (fn (el cls) nil))');
K.eval('(define dom-has-class? (fn (el cls) false))');
K.eval('(define dom-listen (fn (target event-name handler) nil))');
K.eval('(define dom-toggle-class (fn (el cls) nil))');
K.eval('(define dom-set-style! (fn (el prop val) nil))');
K.eval('(define dom-get-style (fn (el prop) ""))');
K.eval('(define dom-set-attr! (fn (el k v) nil))');
K.eval('(define dom-get-attr (fn (el k) nil))');
K.eval('(define dom-remove-attr! (fn (el k) nil))');
K.eval('(define dom-set-text! (fn (el t) nil))');
K.eval('(define dom-get-text (fn (el) ""))');
K.eval('(define dom-set-html! (fn (el h) nil))');
K.eval('(define dom-get-html (fn (el) ""))');
K.eval('(define dom-set-value! (fn (el v) nil))');
K.eval('(define dom-get-value (fn (el) ""))');
K.eval('(define dom-query-all (fn (sel) (list)))');
K.eval('(define dom-query (fn (sel) nil))');
K.eval('(define dom-query-in (fn (el sel) nil))');
K.eval('(define dom-query-all-in (fn (el sel) (list)))');
K.eval('(define dom-parent (fn (el) nil))');
K.eval('(define dom-children (fn (el) (list)))');
K.eval('(define dom-closest (fn (el sel) nil))');
K.eval('(define dom-matches? (fn (el sel) false))');
K.eval('(define dom-append! (fn (parent child) nil))');
K.eval('(define dom-remove! (fn (el) nil))');
K.eval('(define dom-create (fn (tag) {:tag tag :id "" :classes {} :style {} :_hs-activated false}))');
K.eval('(define io-sleep (fn (ms) nil))');
K.eval('(define io-dispatch (fn (el event-name detail) nil))');
K.eval('(define io-log (fn (&rest args) nil))');
// 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);
}
}
// Define hs-handler inline
K.eval(`(define hs-handler (fn (src)
(let ((sx (hs-to-sx-from-source src)))
(eval-expr
(list 'fn '(me)
(list 'let '((it nil) (event nil)) sx))))))`);
// Read batch input
const batchFile = process.env.HS_BATCH_FILE;
if (!batchFile) { console.error('No HS_BATCH_FILE env var'); process.exit(1); }
const sources = JSON.parse(fs.readFileSync(batchFile, 'utf8'));
// Test each source with timing
for (const src of sources) {
const escaped = src.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let status = 'ok';
let detail = '';
const t0 = Date.now();
try {
// Test compilation
const compileResult = K.eval(`(inspect (hs-to-sx-from-source "${escaped}"))`);
if (typeof compileResult === 'string' && compileResult.startsWith('Error')) {
status = 'error';
detail = compileResult.slice(0, 200);
} else {
// Test handler creation
const handlerResult = K.eval(`(hs-handler "${escaped}")`);
if (typeof handlerResult === 'string' && handlerResult.startsWith('Error')) {
status = 'error';
detail = handlerResult.slice(0, 200);
}
}
} catch (e) {
status = 'error';
detail = e.message ? e.message.slice(0, 200) : String(e).slice(0, 200);
}
const elapsed = Date.now() - t0;
console.log(`RESULT:${JSON.stringify({ source: src, status, detail, ms: elapsed })}`);
}

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
// _pre-screen-worker.js — Child process that loads WASM kernel + HS modules
// and tests a batch of hyperscript sources for compilation hangs.
//
// Input: HS_BATCH_FILE env var points to a JSON array of source strings.
// Output: One line per source: RESULT:{"source":"...","status":"ok|error|hang"}
//
// Each source gets a 2-second timeout via a per-source alarm.
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = process.cwd();
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
// --- Minimal DOM stubs (same as test_hs_repeat.js) ---
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); },
remove(c) { el._classes.delete(c); },
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); return c; },
insertBefore(c) { el._children.push(c); return c; },
removeChild(c) { return c; },
replaceChild(n) { return n; },
cloneNode() { return makeElement(tag); },
get innerHTML() { return ''; },
set innerHTML(v) { el._children = []; },
get outerHTML() { return `<${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; },
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
};
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() {} };
// 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 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 "pre-screen-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 for HS runtime
K.eval('(define dom-add-class (fn (el cls) nil))');
K.eval('(define dom-remove-class (fn (el cls) nil))');
K.eval('(define dom-has-class? (fn (el cls) false))');
K.eval('(define dom-listen (fn (target event-name handler) nil))');
// Load hyperscript modules (tokenizer, parser, compiler — skip runtime for pure compilation)
const hsFiles = [
'lib/hyperscript/tokenizer.sx',
'lib/hyperscript/parser.sx',
'lib/hyperscript/compiler.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);
}
}
// Read batch input
const batchFile = process.env.HS_BATCH_FILE;
if (!batchFile) { console.error('No HS_BATCH_FILE env var'); process.exit(1); }
const sources = JSON.parse(fs.readFileSync(batchFile, 'utf8'));
// Test each source with a per-source timeout
for (const src of sources) {
// Escape the source for embedding in SX string literal
const escaped = src.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const expr = `(inspect (hs-to-sx-from-source "${escaped}"))`;
let status = 'ok';
let timedOut = false;
// Use a synchronous approach: just try eval directly.
// The parent process handles the overall batch timeout.
try {
const result = K.eval(expr);
if (result === null || result === undefined) {
status = 'error';
} else if (typeof result === 'string' && result.startsWith('Error')) {
status = 'error';
}
} catch (e) {
status = 'error';
}
console.log(`RESULT:${JSON.stringify({ source: src, status })}`);
}

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Generate hs-behavioral.spec.js from upstream _hyperscript test data.
Reads spec/tests/hyperscript-upstream-tests.json and produces a data-driven
Playwright test file that runs each test in the WASM sandbox.
Usage: python3 tests/playwright/generate-hs-tests.py
"""
import json
import re
import os
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'tests/playwright/hs-behavioral-data.js')
with open(INPUT) as f:
raw_tests = json.load(f)
def normalize_html(html):
"""Clean up HTML for our harness — ensure IDs exist for element targeting."""
# Remove | separators (upstream convention for multi-element make())
html = html.replace(' | ', '')
# If no id in the HTML, add id="el" to first element
if ' id=' not in html and ' id =' not in html:
html = re.sub(r'^<(\w+)', r'<\1 id="el"', html, count=1)
return html
def normalize_action(action, html):
"""Convert upstream action to work with our byId/qs helpers."""
if not action or action == '(see body)':
return ''
# Replace element variable references with DOM lookups
# Common pattern: div.click(), form.click(), d1.click(), etc.
# First handle ID-based: d1.click() -> byId("d1").click()
action = re.sub(r'\b([a-z]\d+)\.', lambda m: f'byId("{m.group(1)}").', action)
# div1.something -> byId("div1") if there's an id, else qs("div")
action = re.sub(r'\bdiv1\.', 'byId("div1") && byId("div1").', action)
action = re.sub(r'\bdiv2\.', 'byId("div2") && byId("div2").', action)
# Generic tag.action: div.click() -> qs("div").click()
for tag in ['div', 'form', 'button', 'input', 'span', 'p', 'a', 'section']:
action = re.sub(rf'\b{tag}\.', f'qs("{tag}").', action)
# Handle document.getElementById patterns
action = action.replace('document.getElementById', 'byId')
return action
def parse_checks(check, html):
"""Convert Chai-style assertions to {expr, op, expected} tuples.
Upstream tests often have pre-action AND post-action assertions joined by &&.
Since we run checks only AFTER the action, we keep only the LAST assertion
for each expression (which represents the post-action expected state).
"""
if not check or check == '(no explicit assertion)':
return []
all_checks = []
# Split on ' && ' to handle multiple assertions
parts = check.split(' && ')
for part in parts:
part = part.strip()
if not part:
continue
# Pattern: something.should.equal(value)
m = re.match(r'(.+?)\.should\.equal\((.+?)\)$', part)
if m:
expr, expected = m.group(1).strip(), m.group(2).strip()
expr = normalize_expr(expr)
all_checks.append({'expr': expr, 'op': '==', 'expected': expected})
continue
# Pattern: should.equal(null, something)
m = re.match(r'should\.equal\(null,\s*(.+?)\)', part)
if m:
expr = normalize_expr(m.group(1).strip())
all_checks.append({'expr': expr, 'op': '==', 'expected': 'null'})
continue
# Pattern: assert.isNull(expr)
m = re.match(r'assert\.isNull\((.+?)\)', part)
if m:
expr = normalize_expr(m.group(1).strip())
all_checks.append({'expr': expr, 'op': '==', 'expected': 'null'})
continue
# Pattern: assert.isNotNull(expr)
m = re.match(r'assert\.isNotNull\((.+?)\)', part)
if m:
expr = normalize_expr(m.group(1).strip())
all_checks.append({'expr': expr, 'op': '!=', 'expected': 'null'})
continue
# Pattern: something.should.deep.equal(value)
m = re.match(r'(.+?)\.should\.deep\.equal\((.+?)\)$', part)
if m:
expr, expected = m.group(1).strip(), m.group(2).strip()
expr = normalize_expr(expr)
all_checks.append({'expr': expr, 'op': 'deep==', 'expected': expected})
continue
# Deduplicate: keep only the LAST check for each expression
# (upstream pattern: first check = pre-action state, last = post-action state)
seen = {}
for c in all_checks:
seen[c['expr']] = c
return list(seen.values())
def normalize_expr(expr):
"""Normalize element references in assertion expressions."""
# ID-based: d1.innerHTML -> byId("d1").innerHTML
expr = re.sub(r'\b([a-z]\d+)\.', lambda m: f'byId("{m.group(1)}").', expr)
expr = re.sub(r'\bdiv1\.', 'byId("div1").', expr)
expr = re.sub(r'\bdiv2\.', 'byId("div2").', expr)
expr = re.sub(r'\bdiv3\.', 'byId("div3").', expr)
# Bare variable names that are IDs: bar.classList -> byId("bar").classList
# Match word.property where word is not a known tag or JS global
known_tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section'}
known_globals = {'document', 'window', 'Math', 'JSON', 'console', 'byId', 'qs', 'qsa'}
def replace_bare_var(m):
name = m.group(1)
prop = m.group(2)
if name in known_tags or name in known_globals:
return m.group(0)
return f'byId("{name}").{prop}'
expr = re.sub(r'\b([a-z][a-zA-Z]*)\.(classList|innerHTML|textContent|style|parentElement|getAttribute|hasAttribute|children|firstChild|value|dataset|className|outerHTML)', replace_bare_var, expr)
# Tag-based: div.classList -> qs("div").classList
for tag in known_tags:
expr = re.sub(rf'\b{tag}\.', f'qs("{tag}").', expr)
# getComputedStyle(div) -> getComputedStyle(qs("div"))
for tag in known_tags:
expr = expr.replace(f'getComputedStyle({tag})', f'getComputedStyle(qs("{tag}"))')
# window.results -> window.results (OK as-is)
# Remove any double dots from prior replacements
expr = expr.replace('..', '.')
return expr
# Process tests
output_tests = []
skipped = 0
for t in raw_tests:
if t.get('complexity') != 'simple':
skipped += 1
continue
html = normalize_html(t['html'])
action = normalize_action(t['action'], html)
checks = parse_checks(t['check'], html)
# Skip tests with no usable checks (log tests etc)
if not checks and not action:
skipped += 1
continue
# Skip tests with syntax that causes parser hangs
hang_patterns = ['[@', '{color', '{font', '{display', '{opacity',
'${', 'transition ', 'as ', 'js(', 'make a', 'measure',
'fetch ', '\\\\']
if any(p in html for p in hang_patterns):
skipped += 1
continue
output_tests.append({
'category': t['category'],
'name': t['name'],
'html': html,
'action': action,
'checks': checks,
'async': t.get('async', False),
})
# Write JS module
with open(OUTPUT, 'w') as f:
f.write('// Auto-generated from _hyperscript upstream test suite\n')
f.write('// Source: spec/tests/hyperscript-upstream-tests.json\n')
f.write(f'// {len(output_tests)} tests ({skipped} skipped)\n')
f.write('//\n')
f.write('// DO NOT EDIT — regenerate with: python3 tests/playwright/generate-hs-tests.py\n\n')
f.write('module.exports = ')
f.write(json.dumps(output_tests, indent=2))
f.write(';\n')
print(f'Generated {len(output_tests)} tests ({skipped} skipped) -> {OUTPUT}')
# Category breakdown
from collections import Counter
cats = Counter(t['category'] for t in output_tests)
for cat, n in cats.most_common():
print(f' {cat}: {n}')

View File

@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""
Generate spec/tests/test-hyperscript-behavioral.sx from upstream _hyperscript test data.
Reads spec/tests/hyperscript-upstream-tests.json and produces SX deftest forms
that run in the Playwright sandbox with real DOM.
Usage: python3 tests/playwright/generate-sx-tests.py
"""
import json
import re
import os
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-behavioral.sx')
with open(INPUT) as f:
raw_tests = json.load(f)
def parse_html(html):
"""Parse HTML into list of element dicts.
Uses Python's html.parser for reliability with same-tag siblings."""
from html.parser import HTMLParser
# Remove | separators
html = html.replace(' | ', '')
elements = []
stack = []
class Parser(HTMLParser):
def handle_starttag(self, tag, attrs):
el = {
'tag': tag, 'id': None, 'classes': [], 'hs': None,
'attrs': {}, 'inner': '', 'depth': len(stack)
}
for name, val in attrs:
if name == 'id': el['id'] = val
elif name == 'class': el['classes'] = (val or '').split()
elif name == '_': el['hs'] = val
elif name == 'style': el['attrs']['style'] = val or ''
elif val is not None: el['attrs'][name] = val
stack.append(el)
# Only collect top-level elements
if el['depth'] == 0:
elements.append(el)
def handle_endtag(self, tag):
if stack and stack[-1]['tag'] == tag:
stack.pop()
def handle_data(self, data):
pass
Parser().feed(html)
return elements
def parse_action(action):
"""Convert upstream action to SX. Returns list of SX expressions."""
if not action or action == '(see body)':
return []
exprs = []
# Split on ';' for multi-step actions
for part in action.split(';'):
part = part.strip()
if not part:
continue
# Pattern: var.click()
m = re.match(r'(\w+)\.click\(\)', part)
if m:
name = m.group(1)
exprs.append(f'(dom-dispatch {ref(name)} "click" nil)')
continue
# Pattern: var.dispatchEvent(new CustomEvent("name"))
m = re.match(r'(\w+)\.dispatchEvent\(new CustomEvent\("(\w+)"\)\)', part)
if m:
exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" nil)')
continue
# Pattern: var.dispatchEvent(new CustomEvent("name", {detail: {...}}))
m = re.match(r'(\w+)\.dispatchEvent\(new CustomEvent\("(\w+)"', part)
if m:
exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" nil)')
continue
# Pattern: var.setAttribute("name", "value")
m = re.match(r'(\w+)\.setAttribute\("(\w+)",\s*"([^"]*)"\)', part)
if m:
exprs.append(f'(dom-set-attr {ref(m.group(1))} "{m.group(2)}" "{m.group(3)}")')
continue
# Pattern: var.focus()
m = re.match(r'(\w+)\.focus\(\)', part)
if m:
exprs.append(f'(dom-focus {ref(m.group(1))})')
continue
# Pattern: var.appendChild(document.createElement("TAG"))
m = re.match(r'(\w+)\.appendChild\(document\.createElement\("(\w+)"\)', part)
if m:
exprs.append(f'(dom-append {ref(m.group(1))} (dom-create-element "{m.group(2)}"))')
continue
# Skip unrecognized
exprs.append(f';; SKIP action: {part[:60]}')
return exprs
def parse_checks(check):
"""Convert Chai assertions to SX assert forms. Returns list of SX expressions.
Only keeps post-action assertions (last occurrence per expression)."""
if not check or check == '(no explicit assertion)':
return []
all_checks = []
for part in check.split(' && '):
part = part.strip()
if not part:
continue
# Pattern: var.classList.contains("cls").should.equal(bool)
m = re.match(r'(\w+)\.classList\.contains\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
if m:
name, cls, expected = m.group(1), m.group(2), m.group(3)
if expected == 'true':
all_checks.append(('class', name, cls, True))
else:
all_checks.append(('class', name, cls, False))
continue
# Pattern: var.innerHTML.should.equal("value")
m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
continue
# Pattern: var.innerHTML.should.equal(value) — non-string
m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part)
if m:
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
continue
# Pattern: var.textContent.should.equal("value")
m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('textContent', m.group(1), m.group(2), None))
continue
# Pattern: var.style.prop.should.equal("value")
m = re.match(r'(\w+)\.style\.(\w+)\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('style', m.group(1), m.group(2), m.group(3)))
continue
# Pattern: var.getAttribute("name").should.equal("value")
m = re.match(r'(\w+)\.getAttribute\("([^"]+)"\)\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('attr', m.group(1), m.group(2), m.group(3)))
continue
# Pattern: var.hasAttribute("name").should.equal(bool)
m = re.match(r'(\w+)\.hasAttribute\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
if m:
all_checks.append(('hasAttr', m.group(1), m.group(2), m.group(3) == 'true'))
continue
# Pattern: getComputedStyle(var).prop.should.equal("value")
m = re.match(r'getComputedStyle\((\w+)\)\.(\w+)\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('computedStyle', m.group(1), m.group(2), m.group(3)))
continue
# Pattern: var.parentElement assert
m = re.match(r'assert\.isNull\((\w+)\.parentElement\)', part)
if m:
all_checks.append(('noParent', m.group(1), None, None))
continue
m = re.match(r'assert\.isNotNull\((\w+)\.parentElement\)', part)
if m:
all_checks.append(('hasParent', m.group(1), None, None))
continue
# Pattern: var.value.should.equal("value") — input value
m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part)
if m:
all_checks.append(('value', m.group(1), m.group(2), None))
continue
# Skip unrecognized
all_checks.append(('skip', part[:60], None, None))
# Deduplicate: keep last per (type, name, key)
seen = {}
for c in all_checks:
key = (c[0], c[1], c[2] if c[0] == 'class' else None)
seen[key] = c
return list(seen.values())
def ref(name):
"""Convert a JS variable name to SX element reference.
For IDs we use dom-query-by-id at runtime (safer than variable refs).
For tags we use the let-bound variable."""
tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section', 'ul', 'li'}
if name in tags:
return f'_el-{name}'
# ID references — use dom-query-by-id for reliability
return f'(dom-query-by-id "{name}")'
def check_to_sx(check):
"""Convert a parsed check tuple to an SX assertion."""
typ, name, key, val = check
r = ref(name)
if typ == 'class' and val:
return f'(assert (dom-has-class? {r} "{key}"))'
elif typ == 'class' and not val:
return f'(assert (not (dom-has-class? {r} "{key}")))'
elif typ == 'innerHTML':
escaped = key.replace('"', '\\"') if isinstance(key, str) else key
return f'(assert= "{escaped}" (dom-inner-html {r}))'
elif typ == 'textContent':
escaped = key.replace('"', '\\"')
return f'(assert= "{escaped}" (dom-text-content {r}))'
elif typ == 'style':
return f'(assert= "{val}" (dom-get-style {r} "{key}"))'
elif typ == 'attr':
return f'(assert= "{val}" (dom-get-attr {r} "{key}"))'
elif typ == 'hasAttr' and val:
return f'(assert (dom-has-attr? {r} "{key}"))'
elif typ == 'hasAttr' and not val:
return f'(assert (not (dom-has-attr? {r} "{key}")))'
elif typ == 'computedStyle':
# Can't reliably test computed styles in sandbox
return f';; SKIP computed style: {name}.{key} == {val}'
elif typ == 'noParent':
return f'(assert (nil? (dom-parent {r})))'
elif typ == 'hasParent':
return f'(assert (not (nil? (dom-parent {r}))))'
elif typ == 'value':
return f'(assert= "{key}" (dom-get-prop {r} "value"))'
else:
return f';; SKIP check: {typ} {name} {key} {val}'
def generate_test(test, idx):
"""Generate SX deftest for an upstream test."""
elements = parse_html(test['html'])
actions = parse_action(test['action'])
checks = parse_checks(test['check'])
if not elements and not test.get('html', '').strip():
# eval-only test — no HTML at all
return None # Will get a failing stub
if not elements:
return None # HTML exists but couldn't parse it
lines = []
lines.append(f' (deftest "{test["name"]}"')
lines.append(' (hs-cleanup!)')
# Assign unique variable names to each element
var_names = []
used_names = set()
for i, el in enumerate(elements):
if el['id']:
var = f'_el-{el["id"]}'
else:
var = f'_el-{el["tag"]}'
# Ensure uniqueness
if var in used_names:
var = f'{var}{i}'
used_names.add(var)
var_names.append(var)
# Create elements
bindings = []
for i, el in enumerate(elements):
bindings.append(f'({var_names[i]} (dom-create-element "{el["tag"]}"))')
# Build let block
lines.append(f' (let ({" ".join(bindings)})')
# Set attributes and append
for i, el in enumerate(elements):
var = var_names[i]
if el['id']:
lines.append(f' (dom-set-attr {var} "id" "{el["id"]}")')
for cls in el['classes']:
lines.append(f' (dom-add-class {var} "{cls}")')
if el['hs']:
hs_val = el['hs']
# Clean up the HS source for SX string embedding
hs_val = hs_val.replace('\\', '').replace('\n', ' ').strip()
if not hs_val:
continue
# Double quotes in HS source → use single-quoted SX string
if '"' in hs_val:
# Can't embed in SX string — wrap in a comment and skip activation
lines.append(f' ;; HS source contains quotes: {hs_val[:60]}')
continue
lines.append(f' (dom-set-attr {var} "_" "{hs_val}")')
for aname, aval in el['attrs'].items():
# Skip attributes with characters that can't be embedded in SX strings
if '\\' in aval or '\n' in aval or aname.startswith('[') or '"' in aval:
lines.append(f' ;; SKIP attr {aname} (contains special chars)')
continue
lines.append(f' (dom-set-attr {var} "{aname}" "{aval}")')
lines.append(f' (dom-append (dom-body) {var})')
if el['hs']:
lines.append(f' (hs-activate! {var})')
# Actions
for action in actions:
lines.append(f' {action}')
# Assertions
for check in checks:
sx = check_to_sx(check)
lines.append(f' {sx}')
lines.append(' ))') # close let + deftest
return '\n'.join(lines)
# Generate the file
output = []
output.append(';; Hyperscript behavioral tests — auto-generated from upstream _hyperscript test suite')
output.append(';; Source: spec/tests/hyperscript-upstream-tests.json (346 tests)')
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-tests.py')
output.append('')
output.append(';; ── Test helpers ──────────────────────────────────────────────────')
output.append('')
output.append('(define hs-test-el')
output.append(' (fn (tag hs-src)')
output.append(' (let ((el (dom-create-element tag)))')
output.append(' (dom-set-attr el "_" hs-src)')
output.append(' (dom-append (dom-body) el)')
output.append(' (hs-activate! el)')
output.append(' el)))')
output.append('')
output.append('(define hs-cleanup!')
output.append(' (fn ()')
output.append(' (dom-set-inner-html (dom-body) "")))')
output.append('')
# Group by category
from collections import OrderedDict
categories = OrderedDict()
for t in raw_tests:
cat = t['category']
if cat not in categories:
categories[cat] = []
categories[cat].append(t)
total = 0
skipped = 0
for cat, tests in categories.items():
output.append(f';; ── {cat} ({len(tests)} tests) ──')
output.append(f'(defsuite "hs-upstream-{cat}"')
for i, t in enumerate(tests):
sx = generate_test(t, i)
if sx:
output.append(sx)
total += 1
else:
# Generate a failing test stub so the gap is visible
safe_name = t['name'].replace('"', "'")
output.append(f' (deftest "{safe_name}"')
output.append(f' (error "NOT IMPLEMENTED: test HTML could not be parsed into SX"))')
total += 1
output.append(')') # close defsuite
output.append('')
with open(OUTPUT, 'w') as f:
f.write('\n'.join(output))
print(f'Generated {total} tests ({skipped} skipped) -> {OUTPUT}')
for cat, tests in categories.items():
print(f' {cat}: {len(tests)}')

View File

@@ -43,4 +43,89 @@ function trackErrors(page) {
};
}
module.exports = { BASE_URL, waitForSxReady, loadPage, trackErrors };
/**
* Universal smoke checks for any SX page.
* Runs 10 assertions that every page should pass.
* Returns { pass: boolean, failures: string[] }.
*/
async function universalSmoke(page) {
const failures = [];
const warnings = [];
// 1. Not blank — #sx-content has substantial text
const contentLen = await page.evaluate(() => {
const el = document.querySelector('#sx-content') || document.body;
return el.textContent.length;
});
if (contentLen < 50) failures.push(`blank page (${contentLen} chars)`);
// 2. Has heading (soft — some index/demo pages legitimately lack headings)
const headingCount = await page.locator('h1, h2, h3, h4').count();
if (headingCount === 0) warnings.push('no heading (h1-h4)');
// 3. Title set
const title = await page.title();
if (!title || title === 'about:blank') failures.push(`title not set: "${title}"`);
// 4. Layout intact — #sx-nav exists
const navExists = await page.locator('#sx-nav').count();
if (navExists === 0) failures.push('no #sx-nav');
// 5. No duplicate structural IDs
const dupes = await page.evaluate(() => {
const ids = ['sx-nav', 'sx-content', 'main-panel'];
return ids.filter(id => document.querySelectorAll('#' + id).length > 1);
});
if (dupes.length > 0) failures.push(`duplicate IDs: ${dupes.join(', ')}`);
// 6. No broken hrefs — no [object Object] in links
const brokenLinks = await page.evaluate(() => {
return [...document.querySelectorAll('a[href]')]
.filter(a => a.href.includes('[object Object]'))
.length;
});
if (brokenLinks > 0) failures.push(`${brokenLinks} broken [object Object] links`);
// 7. CSSX present
const cssxCount = await page.locator('style[id="sx-css"], style[data-sx-css]').count();
if (cssxCount === 0) failures.push('no CSSX style tag');
// 8. No hard SX leaks — raw dicts, raw SX elements, [object Object]
// (Unresolved components like ~tw and CSSX :tokens are expected in SSR-only mode)
const leaks = await page.evaluate(() => {
const skip = new Set();
document.querySelectorAll('code, pre, script, style, [data-sx-source]').forEach(el => {
el.querySelectorAll('*').forEach(d => skip.add(d));
skip.add(el);
});
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
let p = node.parentElement;
while (p) {
if (skip.has(p)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let text = '';
let n;
while (n = walker.nextNode()) text += n.textContent;
const found = [];
if (/\{:(?:type|tag|expr|spreads|attrs)\s/.test(text)) found.push('raw-dict');
if (/\((?:div|span|h[1-6]|p|a|button)\s+:(?:class|id|style)/.test(text)) found.push('raw-sx-element');
if (/\[object Object\]/.test(text)) found.push('object-Object');
return found;
});
if (leaks.length > 0) failures.push(`SX leaks: ${leaks.join(', ')}`);
// 9. No console errors (checked by caller via trackErrors)
// — intentionally not checked here; caller should use trackErrors()
// 10. Hydration (optional — pages served via route interception may not hydrate)
// — checked by caller if applicable
return { pass: failures.length === 0, failures, warnings };
}
module.exports = { BASE_URL, waitForSxReady, loadPage, trackErrors, universalSmoke };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
// @ts-check
/**
* Hyperscript behavioral tests — SX tests running in Playwright sandbox.
*
* Loads the WASM kernel + hs stack, defines the test platform,
* loads test-framework.sx + test-hyperscript-behavioral.sx,
* and reports each test individually.
*/
const { test, expect } = require('playwright/test');
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');
const SANDBOX_STACKS = {
web: [
'render', 'core-signals', 'signals', 'deps', 'router',
'page-helpers', 'freeze', 'dom', 'browser',
'adapter-html', 'adapter-sx', 'adapter-dom',
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
],
hs: [
'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
],
};
/**
* Boot WASM kernel with hs stack, define test platform, load test files.
* Returns array of {suite, name, pass, error} for each test.
*/
async function runSxTests(page) {
await page.goto('about:blank');
await page.evaluate(() => { document.body.innerHTML = ''; });
// Inject WASM kernel
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
await page.addScriptTag({ content: kernelSrc });
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
// Register FFI + IO driver
await page.evaluate(() => {
const K = window.SxKernel;
K.registerNative('host-global', a => { const n=a[0]; return (n in globalThis)?globalThis[n]:null; });
K.registerNative('host-get', a => { if(a[0]==null)return null; const v=a[0][a[1]]; return v===undefined?null:v; });
K.registerNative('host-set!', a => { if(a[0]!=null)a[0][a[1]]=a[2]; return a[2]; });
K.registerNative('host-call', a => {
const[o,m,...r]=a;
if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}
if(typeof o[m]!=='function')return null;
try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}
});
K.registerNative('host-new', a => {
const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];
return typeof C==='function'?new C(...a.slice(1)):null;
});
K.registerNative('host-callback', a => {
const fn=a[0];
if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;
if(fn&&fn.__sx_handle!==undefined){
return function(){
const r=K.callFn(fn,Array.from(arguments));
if(window._driveAsync)window._driveAsync(r);
return r;
};
}
return function(){};
});
K.registerNative('host-typeof', a => {
const o=a[0]; if(o==null)return'nil';
if(o instanceof Element)return'element'; if(o instanceof Text)return'text';
if(o instanceof DocumentFragment)return'fragment'; if(o instanceof Document)return'document';
if(o instanceof Event)return'event'; if(o instanceof Promise)return'promise';
return typeof o;
});
K.registerNative('host-await', a => {
const[p,cb]=a;
if(p&&typeof p.then==='function'){
const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};
p.then(f);
}
});
K.registerNative('load-library!', () => false);
// IO suspension driver
window._ioTrace = [];
window._asyncPending = 0;
window._driveAsync = function driveAsync(result) {
if(!result||!result.suspended)return;
window._asyncPending++;
const req=result.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];
function doResume(val,delay){
setTimeout(()=>{
try{const r=result.resume(val);window._asyncPending--;driveAsync(r);}
catch(e){window._asyncPending--;}
},delay);
}
if(opName==='io-sleep'||opName==='wait')doResume(null,Math.min(typeof arg==='number'?arg:0,10));
else if(opName==='io-navigate')window._asyncPending--;
else if(opName==='io-fetch')doResume({ok:true,text:''},1);
else window._asyncPending--;
};
K.eval('(define SX_VERSION "hs-test-1.0")');
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
K.eval('(define parse sx-parse)');
K.eval('(define serialize sx-serialize)');
});
// Load web + hs modules
const allModules = [...SANDBOX_STACKS.web, ...SANDBOX_STACKS.hs];
const loadErrors = [];
await page.evaluate(() => {
if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad();
});
for (const mod of allModules) {
const sxPath = path.join(SX_DIR, mod + '.sx');
const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
let src;
try {
src = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8');
} catch(e) { loadErrors.push(mod + ': file not found'); continue; }
const err = await page.evaluate(s => {
try { window.SxKernel.load(s); return null; }
catch(e) { return e.message; }
}, src);
if (err) loadErrors.push(mod + ': ' + err);
}
await page.evaluate(() => {
if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad();
});
if (loadErrors.length > 0) return { loadErrors, results: [] };
// Define test platform — collects results into an array
await page.evaluate(() => {
const K = window.SxKernel;
K.eval('(define _test-results (list))');
K.eval('(define _test-suite "")');
// try-call as JS native — catches both SX errors and JS-level crashes.
// K.callFn returns null on Eval_error (kernel logs to console.error).
// We capture the last console.error to detect failures.
K.registerNative('try-call', args => {
const thunk = args[0];
let lastError = null;
const origError = console.error;
console.error = function() {
const msg = Array.from(arguments).join(' ');
if (msg.startsWith('[sx]')) lastError = msg;
origError.apply(console, arguments);
};
try {
const r = K.callFn(thunk, []);
console.error = origError;
if (lastError) {
K.eval('(define _tc_err "' + lastError.replace(/\\/g, '\\\\').replace(/"/g, '\\"').slice(0, 200) + '")');
return K.eval('{:ok false :error _tc_err}');
}
return K.eval('{:ok true}');
} catch(e) {
console.error = origError;
const msg = typeof e === 'string' ? e : (e.message || String(e));
K.eval('(define _tc_err "' + msg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').slice(0, 200) + '")');
return K.eval('{:ok false :error _tc_err}');
}
});
K.eval(`(define report-pass
(fn (name) (set! _test-results
(append _test-results (list {:suite _test-suite :name name :pass true :error nil})))))`);
K.eval(`(define report-fail
(fn (name error) (set! _test-results
(append _test-results (list {:suite _test-suite :name name :pass false :error error})))))`);
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
});
// Load test framework + behavioral tests
for (const f of ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx']) {
const src = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
const err = await page.evaluate(s => {
try { window.SxKernel.load(s); return null; }
catch(e) { return 'LOAD ERROR: ' + e.message; }
}, src);
if (err) {
const partial = await page.evaluate(() => window.SxKernel.eval('(len _test-results)'));
return { loadErrors: [f + ': ' + err + ' (' + partial + ' results before crash)'], results: [] };
}
}
// Collect results — serialize via SX inspect for reliability
const resultsRaw = await page.evaluate(() => {
const K = window.SxKernel;
const count = K.eval('(len _test-results)');
const arr = [];
for (let i = 0; i < count; i++) {
arr.push(K.eval(`(inspect (nth _test-results ${i}))`));
}
return { count, items: arr };
});
// Parse the SX dict strings
const results = resultsRaw.items.map(s => {
// s is like '{:suite "hs-add" :name "add class" :pass true :error nil}'
const suite = (s.match(/:suite "([^"]*)"/) || [])[1] || '';
const name = (s.match(/:name "([^"]*)"/) || [])[1] || '';
const pass = s.includes(':pass true');
const errorMatch = s.match(/:error "([^"]*)"/);
const error = errorMatch ? errorMatch[1] : (s.includes(':error nil') ? null : 'unknown');
return { suite, name, pass, error };
});
return { loadErrors, results };
}
// ===========================================================================
// Test suite — one Playwright test per SX test
// ===========================================================================
test.describe('Hyperscript behavioral tests', () => {
test.describe.configure({ timeout: 300000 }); // 5 min for 291 tests
test('SX behavioral test suite', async ({ browser }) => {
const page = await browser.newPage();
const { loadErrors, results } = await runSxTests(page);
await page.close();
expect(loadErrors).toEqual([]);
// Tally and report
let passed = 0, failed = 0;
const failsByCat = {};
for (const r of results) {
if (r.pass) { passed++; }
else {
failed++;
if (!failsByCat[r.suite]) failsByCat[r.suite] = 0;
failsByCat[r.suite]++;
}
}
console.log(`\n Upstream conformance: ${passed}/${results.length} (${(100*passed/results.length).toFixed(0)}%)`);
// Per-category summary
const cats = {};
for (const r of results) {
if (!cats[r.suite]) cats[r.suite] = { p: 0, f: 0 };
if (r.pass) cats[r.suite].p++; else cats[r.suite].f++;
}
for (const [cat, s] of Object.entries(cats).sort((a,b) => b[1].p - a[1].p)) {
const mark = s.f === 0 ? `${s.p}` : `${s.p}/${s.p+s.f}`;
console.log(` ${cat}: ${mark}`);
}
// Hard gate — ratchet this up as implementation improves
expect(results.length).toBeGreaterThan(0);
expect(passed).toBeGreaterThanOrEqual(460);
});
});

View File

@@ -0,0 +1,198 @@
[
{
"source": "on click toggle .foo for 10ms",
"detail": "Error: Unhandled exception: \"Expected 'in' at position 6\""
},
{
"source": "on click set #div2",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 4\""
},
{
"source": "on click set {bar: 2, baz: 3} on obj",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 12\""
},
{
"source": "on click set my style[",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 5\""
},
{
"source": "on click set arr to [1, 2, 3] set arr[0] to ",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 14\""
},
{
"source": "on click set arr to [1, 2, 3] set idx to 0 set arr[idx] to ",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 18\""
},
{
"source": "on click put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 3\""
},
{
"source": "on click set arr to [1, 2, 3] put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 13\""
},
{
"source": "on click set arr to [1, 2, 3] set idx to 0 put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 17\""
},
{
"source": "on click if true put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 5\""
},
{
"source": "on click if true log me then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 8\""
},
{
"source": "on click if false else put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 6\""
},
{
"source": "on click if false else if true put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 8\""
},
{
"source": "on click if false else if true put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 8\""
},
{
"source": "on click if false otherwise put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 6\""
},
{
"source": "on click if false else if false else put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on click if false else if false else put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on click if false put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 5\""
},
{
"source": "on click if true wait 10 ms then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on click if false else wait 10 ms then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 10\""
},
{
"source": "on click if false end put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 6\""
},
{
"source": "on click \\n if window.tmp then\\n put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 11\""
},
{
"source": "on click repeat for x in [1, 2, 3] put x at end of me end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 15\""
},
{
"source": "on click repeat for x in null put x at end of me end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on click repeat for x in [1, 2, 3]\\n log me put x at end of me\\n wait 1ms\\n end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 19\""
},
{
"source": "on click for x in [1, 2, 3] put x at end of me end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 14\""
},
{
"source": "on click for x in null put x at end of me end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 8\""
},
{
"source": "on click for x in [1, 2, 3]\\n put x at end of me\\n wait 1ms\\n end",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 16\""
},
{
"source": "on click repeat 3 times put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 6\""
},
{
"source": "on click repeat 3 + 3 times put ",
"detail": "Error: Unhandled exception: \"Expected 'times' at position 4\""
},
{
"source": "on click take .foo from .div for #d3",
"detail": "Error: Unhandled exception: \"Expected 'in' at position 8\""
},
{
"source": "on click take .foo from .div for event.target",
"detail": "Error: Unhandled exception: \"Expected 'in' at position 8\""
},
{
"source": "on click\n append ",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 3\""
},
{
"source": "on click \n append ",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 3\""
},
{
"source": "on click append ",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 3\""
},
{
"source": "on click append \\`<button id=",
"detail": "Error: Unhandled exception: \"Expected 'to' at position 5\""
},
{
"source": "on click tell #d2 put your innerText into me",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 5\""
},
{
"source": "on load put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 3\""
},
{
"source": "on click[false] log event then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on click[buttons==0] log event then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 11\""
},
{
"source": "on click[buttons==1] log event then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 11\""
},
{
"source": "on click[buttons==1 and buttons==0] log event then put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 15\""
},
{
"source": "on foo(bar)[bar] put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 9\""
},
{
"source": "on every click put increment() into my.innerHTML then wait for a customEvent",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 4\""
},
{
"source": "on foo put increment() into my.innerHTML end on bar put increment() into my.innerHTML",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 3\""
},
{
"source": "on foo put increment() into my.innerHTML on bar put increment() into my.innerHTML",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 3\""
},
{
"source": "on click 1 put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 4\""
},
{
"source": "on mutation put ",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 3\""
},
{
"source": "on click put func() into me",
"detail": "Error: Unhandled exception: \"Expected into/before/after at position 4\""
}
]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,224 @@
[
"on click add .foo",
"on click add .foo--bar",
"on click add .foo to #bar",
"on click add .foo to <p/> in me",
"on click add .foo to my children",
"on click add .foo .bar",
"on click add .foo:bar-doh",
"on click add .rey to .bar when it matches .doh",
"on click add @rey to .bar when it matches .doh",
"on click add .foo to the children of #bar",
"on click remove .foo",
"on click remove .foo from #bar",
"on click remove me",
"on click remove #that",
"on click remove my.parentElement",
"on click remove .foo .bar",
"on click remove <p/> from me",
"on click toggle .foo",
"on click toggle .foo on #bar",
"on click toggle .foo for 10ms",
"on click toggle .foo until foo from #d1",
"on click toggle between .foo and .bar",
"on click toggle .foo .bar",
"on click toggle *display",
"on click toggle *opacity",
"on click toggle *visibility",
"on click toggle my *display",
"on click toggle my *opacity",
"on click toggle my *visibility",
"on click toggle the *display of #d2",
"on click toggle the *opacity of #d2",
"on click toggle the *visibility of #d2",
"on click set #d1.innerHTML to ",
"on click set innerHTML of #d1 to ",
"on click set parentNode.innerHTML of #d1 to ",
"on click set innerHTML of #d1.parentNode to ",
"on click set the innerHTML of the parentNode of #d1 to ",
"on click set my.style.color to ",
"on click set window.temp to ",
"on click set newVar to ",
"on click set .divs.innerHTML to ",
"on click set @bar to ",
"on click set #div2",
"on click set @bar of #div2 to ",
"on click set *color to ",
"on click set *color of #div2 to ",
"on click set {bar: 2, baz: 3} on obj",
"on click set my style[",
"on click set foo to ",
"on click set arr to [1, 2, 3] set arr[0] to ",
"on click set arr to [1, 2, 3] set idx to 0 set arr[idx] to ",
"on click put ",
"on click put #d1 into #d2",
"on click put #d1 before #d2",
"on click put #d1 after #d2",
"on click set arr to [1, 2, 3] put ",
"on click set arr to [1, 2, 3] set idx to 0 put ",
"on click hide",
"on click 1 hide on click 2 show",
"on click hide add .foo",
"on click hide then add .foo",
"on click hide with display then add .foo",
"on click hide me",
"on click hide me with display",
"on click hide me with opacity",
"on click hide me with *opacity",
"on click hide me with visibility",
"on click hide .hideme",
"on click hide with myHide",
"on click if true put ",
"on click if true log me then put ",
"on click if false else put ",
"on click if false else if true put ",
"on click if false else if true put ",
"on click if false otherwise put ",
"on click if false else if false else put ",
"on click if false else if false else put ",
"on click if false put ",
"on click if true wait 10 ms then put ",
"on click if false else wait 10 ms then put ",
"on click if false end put ",
"on click \\n if window.tmp then\\n put ",
"on click \\n if window.tmp then\\n else\\n if window.tmp then end\\n put ",
"on click repeat for x in [1, 2, 3] put x at end of me end",
"on click repeat for x in null put x at end of me end",
"on click repeat for x in [1, 2, 3]\\n log me put x at end of me\\n wait 1ms\\n end",
"on click for x in [1, 2, 3] put x at end of me end",
"on click for x in null put x at end of me end",
"on click for x in [1, 2, 3]\\n put x at end of me\\n wait 1ms\\n end",
"on click repeat in [1, 2, 3] put it at end of me end",
"on click repeat for x in [",
"on click repeat 3 times put ",
"on click repeat 3 + 3 times put ",
"on click\n\t\t\t\trepeat 2 times\n\t\t\t\t\tfor x in [",
"on click add .foo then wait 20ms then add .bar",
"on click add .foo then wait for foo then add .bar",
"on click wait for foo then put its.detail into me",
"on click wait for foo(bar) then put bar into me",
"on click add .foo then wait for foo from #d2 then add .bar",
"on click add .foo then wait for foo or 0ms then add .bar",
"on click send foo to #bar",
"on foo add .foo-sent",
"on click log 0 send foo to #bar log 3",
"on foo add .foo-sent to sender log 1, me, sender",
"on click send foo(x:42) to #bar",
"on foo put event.detail.x into my.innerHTML",
"on click send foo.bar to #bar",
"on foo.bar add .foo-sent",
"on click send foo.bar(x:42) to #bar",
"on foo.bar put event.detail.x into my.innerHTML",
"on click send foo:bar to #bar",
"on foo:bar add .foo-sent",
"on click send foo:bar(x:42) to #bar",
"on foo:bar put event.detail.x into my.innerHTML",
"def bar return #bar on click send foo to bar()",
"on click take .foo from .div",
"on click take .foo from .div for #d3",
"on click take .foo from .div for event.target",
"on click take @data-foo from .div",
"on click take @data-foo=baz from .div",
"on click take @data-foo=baz with ",
"on click take @data-foo=baz with my @data-foo from .div",
"on click take @data-foo from .div for #d3",
"on click take @data-foo from .div for event.target",
"on click take .foo .bar",
"on click take .foo .bar from .div1",
"on click log me",
"on click log me, my",
"on click log me, my with console.debug",
"on click log me, my with console.error",
"on click call document.getElementById(",
"on click call globalFunction(",
"on click call globalFunction()",
"on click call global_function()",
"on click call $()",
"on click increment value then put value into me",
"on click set value to 20 then increment value by 2 then put value into me",
"on click increment value by 2 then put it into me",
"on click increment @value then put @value into me",
"on click set value to 5.2 then increment value by 6.1 then put value into me",
"on click increment my.innerHTML",
"on click set value to 20 then increment value by 0 then put value into me",
"on click decrement value then put value into me",
"on click set value to 20 then decrement value by 2 then put value into me",
"on click decrement @value then put @value into me",
"on click set value to 6.1 then decrement value by 5.1 then put value into me",
"on click decrement my.innerHTML",
"on click set value to 20 then decrement value by 0 then put value into me",
"on click\n set value to ",
"on click\n append ",
"on click \n append ",
"on click append ",
"on click get ",
"on click append \\`<button id=",
"on click increment window.temp",
"on click add .foo tell #d2 add .bar",
"on click add .foo tell #d2 add .bar to me",
"on click add .foo tell <p/> in me add .bar",
"on click tell #d2 add .bar end add .foo",
"on click tell null add .bar end add .foo",
"on click tell #d2 add .bar to you",
"on click tell #d2 put your innerText into me",
"on click tell #d2 put @foo into me",
"on click tell #d2 remove yourself",
"on click tell #d2 remove yourself on click tell #d3 remove yourself",
"on click send example.event to #d1",
"on example.event add .called",
"on click send example:event to #d1",
"on example:event add .called",
"on click send ",
"on ",
"on click from #bar add .clicked",
"on click from #bar set #bar.innerHTML to #bar.innerHTML + ",
"on someCustomEvent put 1 into me",
"on click elsewhere add .clicked",
"on click from elsewhere add .clicked",
"on click send custom(foo:",
"on custom(foo) call me.classList.add(foo)",
"on click send fromBar to #d2",
"on fromBar(type) call me.classList.add(type)",
"on load put ",
"on click[false] log event then put ",
"on click[buttons==0] log event then put ",
"on click[buttons==1] log event then put ",
"on click[buttons==1 and buttons==0] log event then put ",
"on example[foo] increment @count then put it into me",
"on foo(bar)[bar] put ",
"on every click put increment() into my.innerHTML then wait for a customEvent",
"on foo put increment() into my.innerHTML end on bar put increment() into my.innerHTML",
"on foo put increment() into my.innerHTML on bar put increment() into my.innerHTML",
"on foo wait for bar then call increment()",
"on foo queue first wait for bar then call increment()",
"on foo queue last wait for bar then call increment()",
"on foo queue all wait for bar then call increment()",
"on click queue none put increment() into my.innerHTML then wait for a customEvent",
"on click or foo call increment()",
"on click in #d1 put it into window.tmp",
"on click 1 put ",
"on mutation put ",
"on mutation of attributes put ",
"on mutation of @foo put ",
"on mutation of @bar put ",
"on mutation of childList put ",
"on mutation of characterData put ",
"on mutation of @foo or @bar put ",
"on mutation of attributes from #d1 put ",
"on click throwBar() catch e put e into me",
"on click throw ",
"on click wait 1ms then throw ",
"on click increment :x if :x is 1 wait 1ms then throw ",
"on click increment :x if :x is 1 throw ",
"on click wait a tick then throw ",
"on click increment :x finally if :x is 1 wait 1ms then throw ",
"on click increment :x finally if :x is 1 throw ",
" \ton click from #doesntExist \t\tthrow ",
" \ton click from #d1 or click from #d2 \t\t increment @count then put @count into me\t",
"init set my.foo to 42 end on click put my.foo into my.innerHTML",
"def func() put 42 into #d3",
"on click call func()",
"def func() return 42",
"on click put func() into me",
"def func() put 42 into me"
]

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
// pre-screen-sources.js — Identify hyperscript sources that hang the WASM parser
//
// For each unique _="..." source in hs-behavioral-data.js, spawns a child process
// that loads the WASM kernel + HS modules, then tries to compile. If it takes >3s,
// it's marked as hanging.
//
// Output:
// tests/playwright/hs-safe-sources.json — sources that compile OK (or error)
// tests/playwright/hs-hanging-sources.json — sources that hang the parser/compiler
//
// Usage: node tests/playwright/pre-screen-sources.js
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
const DATA_FILE = path.join(__dirname, 'hs-behavioral-data.js');
const WORKER_FILE = path.join(__dirname, '_pre-screen-worker-timed.js');
// Extract unique sources
const data = require(DATA_FILE);
const sourcesSet = new Set();
for (const t of data) {
const matches = t.html.matchAll(/_=['"]([^'"]+)['"]/g);
for (const m of matches) sourcesSet.add(m[1]);
}
const allSources = [...sourcesSet];
console.log(`Found ${allSources.length} unique hyperscript sources`);
// Process in batches — each batch is a fresh child process
const BATCH_SIZE = 15;
const TIMEOUT_MS = 30000; // 30s for a batch of 15 (allows ~2s per source)
const safe = [];
const errors = [];
const hanging = [];
const timings = []; // {source, ms, status}
function testBatch(sources) {
// Write sources to temp file for the worker
const tmpFile = '/tmp/hs-batch-input.json';
fs.writeFileSync(tmpFile, JSON.stringify(sources));
try {
const output = execFileSync(process.execPath, [WORKER_FILE], {
timeout: TIMEOUT_MS,
encoding: 'utf8',
env: { ...process.env, HS_BATCH_FILE: tmpFile },
cwd: PROJECT_ROOT,
});
// Parse results from worker stdout
const lines = output.trim().split('\n');
for (const line of lines) {
if (line.startsWith('RESULT:')) {
const { source, status, detail, ms } = JSON.parse(line.slice(7));
timings.push({ source, status, ms });
if (status === 'ok') {
safe.push(source);
} else if (status === 'error') {
errors.push({ source, detail });
safe.push(source); // errors are still "safe" — they don't hang
} else {
hanging.push(source);
}
}
}
return true;
} catch (e) {
if (e.killed || e.signal === 'SIGTERM') {
// Whole batch timed out — need to bisect
return false;
}
// Other error (crash) — treat all as hanging
console.error(` Batch crashed: ${e.message.slice(0, 200)}`);
return false;
}
}
function bisect(sources) {
if (sources.length === 0) return;
if (sources.length === 1) {
// Single source that we know hangs
console.log(` HANG: ${sources[0].slice(0, 80)}`);
hanging.push(sources[0]);
return;
}
console.log(` Bisecting ${sources.length} sources...`);
// Try first half
const mid = Math.ceil(sources.length / 2);
const first = sources.slice(0, mid);
const second = sources.slice(mid);
if (!testBatch(first)) {
bisect(first);
}
if (!testBatch(second)) {
bisect(second);
}
}
// Process batches
const totalBatches = Math.ceil(allSources.length / BATCH_SIZE);
for (let i = 0; i < allSources.length; i += BATCH_SIZE) {
const batch = allSources.slice(i, i + BATCH_SIZE);
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
process.stdout.write(`Batch ${batchNum}/${totalBatches} (${batch.length} sources)... `);
if (testBatch(batch)) {
console.log(`OK (${safe.length} safe, ${hanging.length} hanging so far)`);
} else {
console.log(`TIMEOUT — bisecting`);
bisect(batch);
console.log(` After bisect: ${safe.length} safe, ${hanging.length} hanging`);
}
}
// Write results
const safeFile = path.join(__dirname, 'hs-safe-sources.json');
const hangFile = path.join(__dirname, 'hs-hanging-sources.json');
const errFile = path.join(__dirname, 'hs-error-sources.json');
fs.writeFileSync(safeFile, JSON.stringify(safe, null, 2) + '\n');
fs.writeFileSync(hangFile, JSON.stringify(hanging, null, 2) + '\n');
fs.writeFileSync(errFile, JSON.stringify(errors, null, 2) + '\n');
console.log(`\nDone!`);
console.log(` Safe (no hang): ${safe.length} (written to ${path.relative(PROJECT_ROOT, safeFile)})`);
console.log(` Errors: ${errors.length} (written to ${path.relative(PROJECT_ROOT, errFile)})`);
console.log(` Hanging: ${hanging.length} (written to ${path.relative(PROJECT_ROOT, hangFile)})`);
if (hanging.length > 0) {
console.log(`\nHanging sources:`);
for (const s of hanging) {
console.log(` - ${s}`);
}
}
if (errors.length > 0) {
console.log(`\nError sources:`);
for (const e of errors) {
console.log(` - ${e.source}`);
console.log(` ${e.detail}`);
}
}
// Timing summary — show slowest sources
if (timings.length > 0) {
timings.sort((a, b) => b.ms - a.ms);
console.log(`\nSlowest sources (top 20):`);
for (let i = 0; i < Math.min(20, timings.length); i++) {
const t = timings[i];
console.log(` ${t.ms}ms [${t.status}] ${t.source.slice(0, 80)}`);
}
const total = timings.reduce((s, t) => s + t.ms, 0);
const avg = Math.round(total / timings.length);
console.log(`\nTotal: ${total}ms, Avg: ${avg}ms, Max: ${timings[0].ms}ms`);
// Flag anything over 3s as "slow" (potential near-hang)
const slow = timings.filter(t => t.ms > 3000);
if (slow.length > 0) {
console.log(`\nWARNING: ${slow.length} sources took >3s:`);
for (const t of slow) {
console.log(` ${t.ms}ms ${t.source}`);
}
}
}

View File

@@ -0,0 +1,195 @@
// Site Smoke Tests — visit every page in the nav tree, run universal checks.
// No HTTP server. OCaml subprocess renders pages via epoch protocol.
// Playwright intercepts all navigation and serves rendered HTML + static assets.
const { test, expect } = require('playwright/test');
const path = require('path');
const fs = require('fs');
const { SxRenderer } = require('./sx-renderer');
const { universalSmoke, trackErrors } = require('./helpers');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
const STATIC_DIR = path.join(PROJECT_ROOT, 'shared/static');
const WASM_DIR = path.join(STATIC_DIR, 'wasm');
const FAKE_ORIGIN = 'http://sx-sandbox';
// Mime types for static file serving
const MIME = {
'.js': 'application/javascript',
'.css': 'text/css',
'.wasm': 'application/wasm',
'.sx': 'text/plain',
'.sxbc': 'application/octet-stream',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
/** Resolve a static file request to a local path. */
function resolveStatic(urlPath) {
// /static/wasm/... → shared/static/wasm/...
// /static/scripts/... → shared/static/scripts/...
// /static/... → shared/static/...
if (urlPath.startsWith('/static/')) {
const rel = urlPath.replace(/\?.*$/, ''); // strip query params (cache busters)
return path.join(PROJECT_ROOT, 'shared', rel);
}
return null;
}
/** Resolve .sx/.sxbc file requests (platform lazy-loads these). */
function resolveSxFile(urlPath) {
const clean = urlPath.replace(/\?.*$/, '');
// /sx/sx/... → PROJECT_ROOT/sx/sx/...
// /wasm/sx/... → shared/static/wasm/sx/...
if (clean.startsWith('/wasm/sx/') || clean.startsWith('/static/wasm/sx/')) {
const rel = clean.replace(/^\/(?:static\/)?wasm\/sx\//, '');
return path.join(WASM_DIR, 'sx', rel);
}
return null;
}
// ---- Shared renderer + page specs ----
let renderer;
let pageSpecs; // Map<url, {hasText?: string[], hasIsland?: string[]}>
test.beforeAll(async () => {
renderer = new SxRenderer(PROJECT_ROOT);
await renderer.ready();
pageSpecs = await renderer.pageTestSpecs();
});
test.afterAll(async () => {
if (renderer) renderer.close();
});
// ---- Test sections ----
// Group pages by top-level section for readable output.
// Each section is one test with steps for each page.
const SECTIONS = [
{ name: 'Geography', prefix: '/sx/(geography' },
{ name: 'Language', prefix: '/sx/(language' },
{ name: 'Applications', prefix: '/sx/(applications' },
{ name: 'Tools', prefix: '/sx/(tools' },
{ name: 'Etc', prefix: '/sx/(etc' },
{ name: 'Home', prefix: '/sx/', exact: true },
];
function categorize(href) {
for (const s of SECTIONS) {
if (s.exact ? href === s.prefix : href.startsWith(s.prefix)) return s.name;
}
return 'Other';
}
for (const section of SECTIONS) {
test(`smoke: ${section.name}`, async ({ page }) => {
// Get all URLs for this section
const allUrls = await renderer.navUrls();
const urls = allUrls.filter(([href]) => categorize(href) === section.name);
// Set up route interception — all requests go through us
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
// Static assets from filesystem
const staticPath = resolveStatic(url.pathname);
if (staticPath && fs.existsSync(staticPath)) {
const ext = path.extname(staticPath);
await route.fulfill({
path: staticPath,
contentType: MIME[ext] || 'application/octet-stream',
});
return;
}
// .sx/.sxbc files
const sxPath = resolveSxFile(url.pathname);
if (sxPath && fs.existsSync(sxPath)) {
const ext = path.extname(sxPath);
await route.fulfill({
path: sxPath,
contentType: MIME[ext] || 'text/plain',
});
return;
}
// Page render via OCaml subprocess
try {
const html = await renderer.render(url.pathname);
await route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: html,
});
} catch (e) {
await route.fulfill({
status: 500,
contentType: 'text/plain',
body: `render error: ${e.message}`,
});
}
});
// Visit each page in this section
for (const [href, label] of urls) {
await test.step(`${label}${href}`, async () => {
const errors = trackErrors(page);
await page.goto(`${FAKE_ORIGIN}${href}`, {
waitUntil: 'load',
timeout: 30000,
});
// Wait for full hydration — WASM boot + island mounting
try {
await page.waitForSelector('html[data-sx-ready]', { timeout: 20000 });
} catch (_) {
// Hydration timeout is a hard failure
}
const result = await universalSmoke(page);
// Check hydration completed
const sxReady = await page.evaluate(() =>
document.documentElement.getAttribute('data-sx-ready'));
if (!sxReady) {
result.failures.push('hydration failed: data-sx-ready not set');
result.pass = false;
}
const consoleErrors = errors.errors();
if (consoleErrors.length > 0) {
result.failures.push(`console errors: ${consoleErrors.join('; ')}`);
result.pass = false;
}
// Per-page assertions from page-tests.sx
const spec = pageSpecs.get(href);
if (spec) {
if (spec.hasText) {
const bodyText = await page.evaluate(() => document.body.textContent);
for (const text of spec.hasText) {
if (!bodyText.includes(text)) {
result.failures.push(`missing text: "${text}"`);
result.pass = false;
}
}
}
if (spec.hasIsland) {
for (const island of spec.hasIsland) {
const count = await page.locator(`[data-sx-island="${island}"]`).count();
if (count === 0) {
result.failures.push(`missing island: ${island}`);
result.pass = false;
}
}
}
}
expect.soft(result.failures, `${label}: ${result.failures.join(', ')}`).toEqual([]);
});
}
});
}

View File

@@ -1296,6 +1296,129 @@ async function modeEvalAt(browser, url, phase, expr) {
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
}
// ---------------------------------------------------------------------------
// Mode: sandbox stack=site — full website via local OCaml HTTP server
//
// Starts sx_server --http as a subprocess, navigates Playwright to it.
// Full SSR, island hydration, HS activation, SPA navigation — no Docker.
//
// Usage:
// sx_playwright mode=sandbox stack=site url=/sx/(applications.(hyperscript))
// sx_playwright mode=sandbox stack=site url=/ expr="document.title"
// ---------------------------------------------------------------------------
function startSiteServer(projectRoot) {
const { spawn } = require('child_process');
const path = require('path');
const port = 49152 + Math.floor(Math.random() * 16000);
const serverBin = path.join(projectRoot, 'hosts/ocaml/_build/default/bin/sx_server.exe');
const proc = spawn(serverBin, ['--http', String(port)], {
cwd: projectRoot,
env: { ...process.env, SX_PROJECT_DIR: projectRoot, OCAMLRUNPARAM: 'b' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderrBuf = '';
let stdoutBuf = '';
proc.stderr.on('data', chunk => { stderrBuf += chunk.toString(); });
proc.stdout.on('data', chunk => { stdoutBuf += chunk.toString(); });
const ready = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Server did not start within 30s\n' + stderrBuf.slice(-1000))), 30000);
proc.stderr.on('data', () => {
if (stderrBuf.includes('Listening on port')) {
clearTimeout(timeout);
resolve();
}
});
proc.on('error', err => { clearTimeout(timeout); reject(err); });
proc.on('exit', code => { clearTimeout(timeout); reject(new Error('Server exited with code ' + code + '\n' + stderrBuf.slice(-1000))); });
});
return { proc, port, ready, getLog: () => stderrBuf + stdoutBuf };
}
function stopSiteServer(server) {
if (server && server.proc && !server.proc.killed) {
server.proc.kill('SIGTERM');
setTimeout(() => { if (!server.proc.killed) server.proc.kill('SIGKILL'); }, 2000);
}
}
async function modeSandboxSite(page, expr, url, setup) {
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
const consoleLogs = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
const server = startSiteServer(PROJECT_ROOT);
process.on('exit', () => stopSiteServer(server));
try {
await server.ready;
} catch (err) {
stopSiteServer(server);
return { mode: 'sandbox', stack: 'site', error: err.message };
}
const baseUrl = 'http://localhost:' + server.port;
const targetUrl = url || '/';
try {
await page.goto(baseUrl + targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
// Wait for SX boot to complete
await page.waitForFunction(
() => document.documentElement.getAttribute('data-sx-ready') === 'true',
{ timeout: 15000 }
).catch(() => {}); // boot might not set this on all pages
// Run setup SX expression if provided
if (setup) {
await page.evaluate(s => {
try { window.SxKernel.eval(s); } catch(e) { console.error('[site-setup]', e.message); }
}, setup);
}
// Evaluate expr — JS if it starts with "document." or "window.", SX otherwise
const result = await page.evaluate(expr => {
if (!expr) return { result: 'nil' };
const isJs = /^(document\.|window\.|globalThis\.|[\(\[]|function|typeof|!|true|false|\d)/.test(expr.trim());
if (isJs) {
try { return { result: String(eval(expr)) }; }
catch(e) { return { result: 'JS Error: ' + e.message }; }
}
const K = window.SxKernel;
if (!K) return { result: 'Error: SxKernel not available' };
try {
const r = K.eval(expr);
if (r === null || r === undefined) return { result: 'nil' };
if (typeof r === 'string') return { result: r };
return { result: JSON.stringify(r) };
} catch(e) { return { result: 'Error: ' + e.message }; }
}, expr);
const logs = consoleLogs.filter(l =>
l.text.includes('[sx]') || l.text.includes('[sx-platform]') ||
l.type === 'error' || l.type === 'warning'
);
return {
mode: 'sandbox',
stack: 'site',
url: targetUrl,
port: server.port,
result: result.result,
log: logs.length > 0 ? logs : undefined,
};
} finally {
stopSiteServer(server);
}
}
// ---------------------------------------------------------------------------
// Mode: sandbox — offline WASM kernel in a blank page, no server needed
//
@@ -1331,20 +1454,34 @@ const SANDBOX_STACKS = {
],
};
async function modeSandbox(page, expr, files, setup, stack, bytecode) {
async function modeSandbox(page, expr, files, setup, stack, bytecode, url) {
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');
// Full website sandbox — start real OCaml server, navigate directly
if (stack === 'site') {
return await modeSandboxSite(page, expr, url || setup, setup);
}
const usePlatform = stack === 'platform' || stack === 'platform-hs';
const consoleLogs = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
// 1. Navigate to blank page
await page.goto('about:blank');
// 1. Navigate to blank page (use a real URL when platform mode needs XHR)
if (usePlatform) {
await page.route('**/sandbox.html', route => {
route.fulfill({ body: '<!doctype html><html><head></head><body></body></html>', contentType: 'text/html' });
});
await page.goto('http://localhost/wasm/sandbox.html');
} else {
await page.goto('about:blank');
}
// 2. Inject WASM kernel
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
@@ -1352,6 +1489,31 @@ async function modeSandbox(page, expr, files, setup, stack, bytecode) {
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
// 3. Register FFI primitives + IO suspension driver
if (usePlatform) {
// Inject the real sx-platform.js — gives us manifest loader, __sxLoadLibrary,
// __resolve-symbol, beginModuleLoad/endModuleLoad cycle, the works.
// Serve the WASM dir as a file:// base so the platform can fetch manifests & .sxbc
const platformSrc = fs.readFileSync(path.join(WASM_DIR, 'sx-platform.js'), 'utf8');
// Set up a base URL the platform can fetch from — we'll intercept requests
await page.route('**/wasm/**', route => {
const url = new URL(route.request().url());
const filePath = path.join(WASM_DIR, url.pathname.replace(/.*\/wasm\//, ''));
if (fs.existsSync(filePath)) {
route.fulfill({ body: fs.readFileSync(filePath), contentType: 'application/octet-stream' });
} else {
route.fulfill({ status: 404, body: 'Not found: ' + filePath });
}
});
// Inject platform JS as inline script with a fake src for base URL detection
await page.addScriptTag({ content: platformSrc });
// Wait for platform to boot — it runs synchronously since readyState != "loading"
await page.waitForFunction(() => {
return document.documentElement.getAttribute('data-sx-ready') === 'true'
|| !!window.__sxLoadLibrary;
}, { timeout: 30000 });
// Give setTimeout-based JIT enable time to run
await page.waitForTimeout(200);
} else {
await page.evaluate(() => {
const K = window.SxKernel;
@@ -1466,6 +1628,16 @@ async function modeSandbox(page, expr, files, setup, stack, bytecode) {
K.eval('(define parse sx-parse)');
K.eval('(define serialize sx-serialize)');
});
} // end if/else usePlatform
// When using platform stack, modules are already loaded by sx-platform.js.
// Skip the manual module loading below.
if (usePlatform) {
// Platform already loaded everything. Just load extra HS modules if platform-hs.
if (stack === 'platform-hs') {
await page.evaluate(() => window.__sxLoadLibrary('hs-integration'));
}
}
// 4. Load stack modules
const loadErrors = [];
@@ -1481,7 +1653,7 @@ async function modeSandbox(page, expr, files, setup, stack, bytecode) {
return result;
}
if (stack && stack !== 'core') {
if (stack && stack !== 'core' && !usePlatform) {
const modules = resolveStack(stack);
if (modules.length > 0) {
// beginModuleLoad if available
@@ -1721,7 +1893,7 @@ async function main() {
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
break;
case 'sandbox':
result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '', !!args.bytecode);
result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '', !!args.bytecode, args.url || '');
break;
default:
result = { error: `Unknown mode: ${mode}` };

View File

@@ -0,0 +1,233 @@
// SX Site Renderer — OCaml subprocess driver for sandboxed page testing.
// Communicates via epoch protocol (stdin/stdout), no HTTP server needed.
// Usage:
// const renderer = new SxRenderer(projectRoot);
// await renderer.ready();
// const urls = await renderer.navUrls(); // [["href","label"], ...]
// const html = await renderer.render(url); // complete HTML string
// renderer.close();
const { spawn } = require('child_process');
const path = require('path');
class SxRenderer {
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.epoch = 0;
this.pending = null;
this.chunks = []; // Buffer chunks — avoids O(n²) string concat
this.bufferLen = 0; // total bytes across chunks
this.readyResolve = null;
const exe = path.join(projectRoot, 'hosts/ocaml/_build/default/bin/sx_server.exe');
this.proc = spawn(exe, ['--site'], {
cwd: projectRoot,
env: { ...process.env, SX_PROJECT_DIR: projectRoot },
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc.stdout.on('data', (chunk) => this._onData(chunk.toString()));
// Drain stderr to prevent pipe deadlock with large stdout writes.
// OCaml writes JIT logs to stderr during render — if the stderr pipe
// fills up, stdout writes block and we deadlock.
this.stderrBuf = '';
this.proc.stderr.on('data', (chunk) => {
this.stderrBuf += chunk.toString();
});
this.proc.on('error', (err) => {
if (this.pending) {
this.pending.reject(new Error(`subprocess error: ${err.message}`));
this.pending = null;
}
});
this.proc.on('exit', (code) => {
if (this.pending) {
this.pending.reject(new Error(`subprocess exited with code ${code}`));
this.pending = null;
}
});
}
/** Wait for the subprocess to finish loading all .sx files. */
ready() {
return new Promise((resolve) => {
this.readyResolve = resolve;
});
}
/** Render a page URL to complete HTML. */
async render(urlPath) {
const escaped = urlPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return this._send(`(render-page "${escaped}")`);
}
/** Get all nav URLs as [[href, label], ...]. */
async navUrls() {
const raw = await this._send('(nav-urls)');
return this._parsePairList(raw);
}
/** Evaluate an SX expression. */
async eval(expr) {
return this._send(`(eval "${expr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`);
}
/** Get all page test specs as [[url, {has-text: [...], ...}], ...]. */
async pageTestSpecs() {
const raw = await this.eval('(map (fn (k) (list k (get page-test-specs k))) (keys page-test-specs))');
return this._parsePageSpecs(raw);
}
/** Parse page test specs from SX. Returns Map<url, {hasText?: string[], hasIsland?: string[]}>. */
_parsePageSpecs(sx) {
const specs = new Map();
// Each entry: ("url" {:has-text ("a" "b") :has-island ("c")})
const entryRe = /\("([^"]+)"\s+\{([^}]*)\}\)/g;
let m;
while ((m = entryRe.exec(sx))) {
const url = m[1];
const body = m[2];
const spec = {};
const textMatch = body.match(/:has-text\s+\(([^)]*)\)/);
if (textMatch) {
spec.hasText = [...textMatch[1].matchAll(/"([^"]*)"/g)].map(x => x[1]);
}
const islandMatch = body.match(/:has-island\s+\(([^)]*)\)/);
if (islandMatch) {
spec.hasIsland = [...islandMatch[1].matchAll(/"([^"]*)"/g)].map(x => x[1]);
}
specs.set(url, spec);
}
return specs;
}
/** Kill the subprocess. */
close() {
if (this.proc) {
this.proc.kill();
this.proc = null;
}
}
// --- internal ---
_send(command) {
return new Promise((resolve, reject) => {
if (this.pending) {
reject(new Error('concurrent send not supported'));
return;
}
this.epoch++;
this.pending = { epoch: this.epoch, resolve, reject };
this.proc.stdin.write(`(epoch ${this.epoch})\n${command}\n`);
// Response may already be in buffer from a previous stdout chunk
process.nextTick(() => this._tryResolve());
});
}
_onData(chunk) {
this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
this.bufferLen += chunk.length;
// Check for (ready) — startup complete
if (this.readyResolve) {
const buf = this._peekBuffer();
const idx = buf.indexOf('(ready)');
if (idx !== -1) {
this._consumeBytes(idx + 8); // skip (ready)\n
this.readyResolve();
this.readyResolve = null;
}
}
if (this.pending) this._tryResolve();
}
/** Merge all chunks into a single Buffer for parsing. */
_peekBuffer() {
if (this.chunks.length === 1) return this.chunks[0];
const merged = Buffer.concat(this.chunks);
this.chunks = [merged];
return merged;
}
/** Remove the first n bytes from the buffer. */
_consumeBytes(n) {
const buf = this._peekBuffer();
if (n >= buf.length) {
this.chunks = [];
this.bufferLen = 0;
} else {
this.chunks = [buf.slice(n)];
this.bufferLen = buf.length - n;
}
}
_tryResolve() {
if (!this.pending) return;
const ep = this.pending.epoch;
const buf = this._peekBuffer();
const str = buf.toString('utf8', 0, Math.min(buf.length, 40)); // just the header area
// ok-len: length-prefixed binary response
const lenPrefix = `(ok-len ${ep} `;
const lenIdx = str.indexOf(lenPrefix);
if (lenIdx !== -1) {
const afterPrefix = lenIdx + lenPrefix.length;
const closeParen = str.indexOf(')', afterPrefix);
if (closeParen === -1) return; // incomplete header
const n = parseInt(str.slice(afterPrefix, closeParen), 10);
const dataStart = closeParen + 2; // skip ")\n"
const dataEnd = dataStart + n;
if (buf.length < dataEnd) return; // incomplete data — wait for more
const data = buf.toString('utf8', dataStart, dataEnd);
this._consumeBytes(dataEnd + 1); // skip trailing \n
const { resolve } = this.pending;
this.pending = null;
resolve(data);
return;
}
// ok: simple response (single line)
const fullStr = buf.toString('utf8');
const okPrefix = `(ok ${ep} `;
const okIdx = fullStr.indexOf(okPrefix);
if (okIdx !== -1) {
const eol = fullStr.indexOf('\n', okIdx);
if (eol === -1) return;
const line = fullStr.slice(okIdx + okPrefix.length, eol - 1);
this._consumeBytes(eol + 1);
const { resolve } = this.pending;
this.pending = null;
resolve(line);
return;
}
// error
const errPrefix = `(error ${ep} `;
const errIdx = fullStr.indexOf(errPrefix);
if (errIdx !== -1) {
const eol = fullStr.indexOf('\n', errIdx);
if (eol === -1) return;
const msg = fullStr.slice(errIdx + errPrefix.length, eol - 1);
this._consumeBytes(eol + 1);
const { reject } = this.pending;
this.pending = null;
reject(new Error(`SX error: ${msg}`));
return;
}
}
/** Parse an SX list of pairs: (("a" "b") ("c" "d")) → [["a","b"], ["c","d"]] */
_parsePairList(sx) {
const pairs = [];
const re = /\("([^"\\]*(?:\\.[^"\\]*)*)"\s+"([^"\\]*(?:\\.[^"\\]*)*)"\)/g;
let m;
while ((m = re.exec(sx))) {
pairs.push([m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'),
m[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\')]);
}
return pairs;
}
}
module.exports = { SxRenderer };