Fix WASM browser: broken links (&rest bytecode) + broken reactive counter (ListRef mutation)

Two bugs fixed:

1. Links: bytecode compiler doesn't handle &rest params — treats them as
   positional, so (first rest) gets a raw string instead of a list.
   Replaced &rest with explicit optional params in all bytecode-compiled
   web SX files (dom-query, dom-add-listener, browser-push-state, etc.).
   The VM already pads missing args with Nil.

2. Reactive counter: signal-remove-sub! used (filter ...) which returns
   immutable List, but signal-add-sub! uses (append!) which only mutates
   ListRef. Subscribers silently vanished after first effect re-run.
   Fixed by adding remove! primitive that mutates ListRef in-place.

Also:
- Added evalVM API to WASM kernel (compile + run through bytecode VM)
- Added scope tracing (scope-push!/pop!/peek/context instrumentation)
- Added Playwright reactive mode for debugging island signal/DOM state
- Replaced cek-call with direct calls in core-signals.sx effect/computed
- Recompiled all 23 bytecode modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 14:08:49 +00:00
parent 553bbf123e
commit 8d3ab040ef
18 changed files with 42899 additions and 3236 deletions

View File

@@ -575,10 +575,17 @@ async function modeHydrate(browser, url) {
// ---------------------------------------------------------------------------
async function modeEval(page, url, expr) {
// Capture ALL console during page load (before goto)
const bootLog = [];
page.on('console', msg => {
bootLog.push({ type: msg.type(), text: msg.text().slice(0, 300) });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const result = await page.evaluate(expr);
return { url, expr, result };
// Include boot log: errors always, all [sx] lines on request
const issues = bootLog.filter(l => l.type === 'error' || l.type === 'warning' || l.text.includes('FAIL') || l.text.includes('Error') || l.text.startsWith('[sx]'));
return { url, expr, result, bootLog: issues.length > 0 ? issues : undefined };
}
// ---------------------------------------------------------------------------
@@ -589,6 +596,12 @@ async function modeInteract(page, url, actionsStr) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Capture console during interaction
const consoleLogs = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 300) });
});
const actions = actionsStr.split(';').map(a => a.trim()).filter(Boolean);
const results = [];
@@ -661,7 +674,9 @@ async function modeInteract(page, url, actionsStr) {
}
}
return { url, results };
// Include console errors/warnings in output
const errors = consoleLogs.filter(l => l.type === 'error' || l.type === 'warning');
return { url, results, console: errors.length > 0 ? errors : undefined };
}
// ---------------------------------------------------------------------------
@@ -685,9 +700,353 @@ async function modeScreenshot(page, url, selector) {
}
// ---------------------------------------------------------------------------
// Main
// Mode: listeners — CDP event listener inspection
// ---------------------------------------------------------------------------
async function modeListeners(page, url, selector) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const cdp = await page.context().newCDPSession(page);
const results = {};
// Helper: get listeners for a JS expression
async function getListeners(expr, label) {
try {
const { result } = await cdp.send('Runtime.evaluate', { expression: expr });
if (!result?.objectId) return [];
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
objectId: result.objectId, depth: 0,
});
return listeners.map(l => ({
type: l.type,
useCapture: l.useCapture,
passive: l.passive,
once: l.once,
handler: l.handler?.description?.slice(0, 200) || 'native',
scriptId: l.scriptId,
lineNumber: l.lineNumber,
columnNumber: l.columnNumber,
}));
} catch (e) { return [{ error: e.message }]; }
}
// Get listeners on the target element(s)
if (selector) {
const els = await page.$$(selector);
for (let i = 0; i < Math.min(els.length, 5); i++) {
const { result } = await cdp.send('Runtime.evaluate', {
expression: `document.querySelectorAll(${JSON.stringify(selector)})[${i}]`,
});
if (result?.objectId) {
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
objectId: result.objectId, depth: 0,
});
const tag = await page.evaluate(
({sel, idx}) => {
const el = document.querySelectorAll(sel)[idx];
return el ? `<${el.tagName.toLowerCase()} ${el.getAttribute('href') || el.getAttribute('data-sx-island') || ''}>` : '?';
}, {sel: selector, idx: i});
results[`element[${i}] ${tag}`] = listeners.map(l => ({
type: l.type,
handler: l.handler?.description?.slice(0, 150) || 'native',
capture: l.useCapture,
}));
}
}
}
// Document listeners
results['document'] = (await getListeners('document', 'document'))
.filter(l => ['click', 'submit', 'input', 'change', 'keydown', 'keyup'].includes(l.type));
// Window listeners
results['window'] = (await getListeners('window', 'window'))
.filter(l => ['click', 'popstate', 'hashchange', 'beforeunload', 'load', 'DOMContentLoaded'].includes(l.type));
return { url, selector, listeners: results };
}
// ---------------------------------------------------------------------------
// Mode: trace — click and capture full execution trace
// ---------------------------------------------------------------------------
async function modeTrace(page, url, selector) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const trace = {
console: [],
network: [],
pushState: [],
errors: [],
};
// Capture console
page.on('console', msg => {
trace.console.push({
type: msg.type(),
text: msg.text().slice(0, 300),
});
});
// Capture page errors
page.on('pageerror', err => {
trace.errors.push(err.message.slice(0, 300));
});
// Capture network requests
page.on('request', req => {
if (req.resourceType() === 'document' || req.resourceType() === 'xhr' || req.resourceType() === 'fetch') {
trace.network.push({
type: req.resourceType(),
method: req.method(),
url: req.url(),
isNav: req.isNavigationRequest(),
});
}
});
// Inject pushState/replaceState monitoring
await page.evaluate(() => {
for (const method of ['pushState', 'replaceState']) {
const orig = history[method];
history[method] = function() {
console.log(`[spa-trace] ${method}: ${arguments[2]}`);
return orig.apply(this, arguments);
};
}
});
// Snapshot before
const before = await page.evaluate(() => ({
url: location.href,
title: document.title,
}));
// Perform the action
if (!selector) {
return { error: 'trace mode requires a selector to click' };
}
const el = page.locator(selector).first();
const elInfo = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el ? { tag: el.tagName, text: el.textContent?.slice(0, 50), href: el.getAttribute('href') } : null;
}, selector);
let navigated = false;
page.once('framenavigated', () => { navigated = true; });
await el.click();
await page.waitForTimeout(2000);
// Snapshot after
const after = await page.evaluate(() => ({
url: location.href,
title: document.title,
}));
return {
url,
selector,
element: elInfo,
before,
after,
navigated,
urlChanged: before.url !== after.url,
trace,
};
}
// ---------------------------------------------------------------------------
// Mode: cdp — raw Chrome DevTools Protocol command
// ---------------------------------------------------------------------------
async function modeCdp(page, url, expr) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const cdp = await page.context().newCDPSession(page);
// expr format: "Domain.method {json params}" e.g. "Runtime.evaluate {\"expression\":\"1+1\"}"
const spaceIdx = expr.indexOf(' ');
const method = spaceIdx > 0 ? expr.slice(0, spaceIdx) : expr;
const params = spaceIdx > 0 ? JSON.parse(expr.slice(spaceIdx + 1)) : {};
try {
const result = await cdp.send(method, params);
return { method, params, result };
} catch (e) {
return { method, params, error: e.message };
}
}
// ---------------------------------------------------------------------------
// Mode: reactive — debug reactive island signal/DOM state across interactions
//
// Instruments the live WASM kernel's signal system via global set! hooks,
// tags island DOM elements for stability tracking, then runs a sequence of
// actions. After each action, captures:
// - DOM text of reactive elements
// - Node stability (same/replaced/new)
// - Signal trace: swap!, set!, flush, add-sub, remove-sub, deref context
// - Console errors
//
// Hook strategy: SX-defined globals (swap!, deref, signal-set-value!, etc.)
// can be wrapped via set! because bytecode calls them through GLOBAL_GET.
// OCaml-native primitives (scope-push!, scope-peek) use CALL_PRIM and
// bypass globals — those are observed indirectly via deref's context check.
// ---------------------------------------------------------------------------
const REACTIVE_HOOKS = `
(do
;; signal-set-value! — log every signal mutation
(let ((orig signal-set-value!))
(set! signal-set-value!
(fn (s v)
(log-info (str "RX:SIG:" (signal-value s) "->" v))
(orig s v))))
;; flush-subscribers — log subscriber count at flush time
(let ((orig flush-subscribers))
(set! flush-subscribers
(fn (s)
(log-info (str "RX:FLUSH:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
(orig s))))
;; swap! — log the swap call (note: 2 explicit params, no &rest)
(let ((orig swap!))
(set! swap!
(fn (s f)
(log-info (str "RX:SWAP:old=" (signal-value s)))
(orig s f))))
;; signal-add-sub! — log re-subscriptions
(let ((orig signal-add-sub!))
(set! signal-add-sub!
(fn (s f)
(log-info (str "RX:ADD:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
(orig s f))))
;; signal-remove-sub! — log unsubscriptions
(let ((orig signal-remove-sub!))
(set! signal-remove-sub!
(fn (s f)
(log-info (str "RX:RM:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
(orig s f))))
;; deref — log whether reactive context is visible (the key diagnostic)
(let ((orig deref))
(set! deref
(fn (s)
(when (signal? s)
(let ((ctx (scope-peek "sx-reactive")))
(log-info (str "RX:DEREF:ctx=" (if (nil? ctx) "nil" "ok") ":val=" (signal-value s)))))
(orig s))))
true)
`;
async function modeReactive(page, url, island, actionsStr) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const consoleLogs = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
// Install hooks + tag elements
const setup = await page.evaluate(({ islandName, hooks }) => {
const K = window.SxKernel;
if (!K || !K.eval) return { ok: false, error: 'SxKernel not found' };
try { K.eval(hooks); }
catch (e) { return { ok: false, error: 'Hook install: ' + e.message }; }
const el = document.querySelector(`[data-sx-island="${islandName}"]`);
if (!el) return { ok: false, error: `Island "${islandName}" not found` };
const all = el.querySelectorAll('*');
for (let i = 0; i < all.length; i++) all[i].__rxId = i;
return { ok: true, tagged: all.length };
}, { islandName: island, hooks: REACTIVE_HOOKS });
if (!setup.ok) return { url, island, error: setup.error };
// --- Snapshot: DOM state + node stability ---
const snapshot = async () => {
return page.evaluate((name) => {
const root = document.querySelector(`[data-sx-island="${name}"]`);
if (!root) return { error: 'island gone' };
// Reactive element text
const dom = {};
for (const el of root.querySelectorAll('[data-sx-reactive-attrs]')) {
const tag = el.tagName.toLowerCase();
const cls = (el.className || '').split(' ').find(c => /^(text-|font-)/.test(c)) || '';
let key = tag + (cls ? '.' + cls : '');
while (dom[key] !== undefined) key += '+';
dom[key] = el.textContent?.trim().slice(0, 120);
}
// Node stability
const all = root.querySelectorAll('*');
let same = 0, fresh = 0;
for (const el of all) {
if (el.__rxId !== undefined) same++; else fresh++;
}
// Buttons
const buttons = Array.from(root.querySelectorAll('button')).map(b => ({
text: b.textContent?.trim(), same: b.__rxId !== undefined
}));
return { dom, nodes: { same, fresh, total: all.length }, buttons };
}, island);
};
// --- Drain console logs between steps ---
let cursor = consoleLogs.length;
const drain = () => {
const fresh = consoleLogs.slice(cursor);
cursor = consoleLogs.length;
const rx = fresh.filter(l => l.text.includes('RX:')).map(l => l.text.replace('[sx] ', ''));
const errors = fresh.filter(l => l.type === 'error').map(l => l.text);
return { rx, errors: errors.length ? errors : undefined };
};
// --- Run ---
const steps = [];
steps.push({ step: 'initial', ...await snapshot(), ...drain() });
for (const action of (actionsStr || '').split(';').map(s => s.trim()).filter(Boolean)) {
const [cmd, ...rest] = action.split(':');
const arg = rest.join(':');
try {
if (cmd === 'click') {
await page.locator(arg).first().click();
await page.waitForTimeout(150);
} else if (cmd === 'wait') {
await page.waitForTimeout(parseInt(arg) || 200);
} else {
steps.push({ step: action, error: 'unknown' });
continue;
}
} catch (e) {
steps.push({ step: action, error: e.message });
continue;
}
steps.push({ step: action, ...await snapshot(), ...drain() });
}
return { url, island, tagged: setup.tagged, steps };
}
// ---------------------------------------------------------------------------
// Main
async function main() {
const argsJson = process.argv[2] || '{}';
let args;
@@ -725,6 +1084,18 @@ async function main() {
case 'screenshot':
result = await modeScreenshot(page, url, args.selector);
break;
case 'listeners':
result = await modeListeners(page, url, args.selector || args.expr);
break;
case 'trace':
result = await modeTrace(page, url, args.selector || args.expr);
break;
case 'cdp':
result = await modeCdp(page, url, args.expr || '');
break;
case 'reactive':
result = await modeReactive(page, url, args.island || '', args.actions || '');
break;
default:
result = { error: `Unknown mode: ${mode}` };
}