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:
@@ -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}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user