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:
203
tests/playwright/_pre-screen-worker-full.js
Normal file
203
tests/playwright/_pre-screen-worker-full.js
Normal 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 })}`);
|
||||
}
|
||||
192
tests/playwright/_pre-screen-worker-timed.js
Normal file
192
tests/playwright/_pre-screen-worker-timed.js
Normal 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 })}`);
|
||||
}
|
||||
157
tests/playwright/_pre-screen-worker.js
Normal file
157
tests/playwright/_pre-screen-worker.js
Normal 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 })}`);
|
||||
}
|
||||
205
tests/playwright/generate-hs-tests.py
Normal file
205
tests/playwright/generate-hs-tests.py
Normal 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}')
|
||||
386
tests/playwright/generate-sx-tests.py
Normal file
386
tests/playwright/generate-sx-tests.py
Normal 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)}')
|
||||
@@ -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 };
|
||||
|
||||
4032
tests/playwright/hs-behavioral-data.js
Normal file
4032
tests/playwright/hs-behavioral-data.js
Normal file
File diff suppressed because it is too large
Load Diff
263
tests/playwright/hs-behavioral.spec.js
Normal file
263
tests/playwright/hs-behavioral.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
198
tests/playwright/hs-error-sources.json
Normal file
198
tests/playwright/hs-error-sources.json
Normal 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\""
|
||||
}
|
||||
]
|
||||
1
tests/playwright/hs-hanging-sources.json
Normal file
1
tests/playwright/hs-hanging-sources.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
224
tests/playwright/hs-safe-sources.json
Normal file
224
tests/playwright/hs-safe-sources.json
Normal 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"
|
||||
]
|
||||
172
tests/playwright/pre-screen-sources.js
Normal file
172
tests/playwright/pre-screen-sources.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
195
tests/playwright/site-smoke.spec.js
Normal file
195
tests/playwright/site-smoke.spec.js
Normal 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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}` };
|
||||
|
||||
233
tests/playwright/sx-renderer.js
Normal file
233
tests/playwright/sx-renderer.js
Normal 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 };
|
||||
Reference in New Issue
Block a user