Restore hyperscript work on stable site base (908f4f80)

Reset to last known-good state (908f4f80) where links, stepper, and
islands all work, then recovered all hyperscript implementation,
conformance tests, behavioral tests, Playwright specs, site sandbox,
IO-aware server loading, and upstream test suite from f271c88a.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 19:29:56 +00:00
parent 908f4f80d4
commit 7492ceac4e
55 changed files with 32933 additions and 437 deletions

View File

@@ -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}` };