Files
rose-ash/tests/playwright/sx-inspect.js
giles 75827b4828 Fix source display: add sxc mount, HTML tag forms, Playwright debug tools
- Add missing ./sx/sxc:/app/sxc:ro volume mount to dev-sx compose —
  container was using stale image copy of docs.sx where ~docs/code had
  (&key code) instead of (&key src), so highlight output was silently
  discarded
- Register HTML tags as special forms in WASM browser kernel so keyword
  attrs are preserved during render-to-dom
- Add trace-boot, hydrate-debug, eval-at modes to sx-inspect.js for
  debugging boot phases and island hydration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:39:32 +00:00

1369 lines
50 KiB
JavaScript

#!/usr/bin/env node
// sx-inspect.js — SX-aware Playwright page inspector
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
// Modes: inspect, diff, hydrate, eval, interact, screenshot, trace-boot, hydrate-debug, eval-at
// Output: JSON to stdout
const { chromium } = require('playwright');
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
const SCREENSHOT_DIR = process.env.SX_SCREENSHOT_DIR || '/tmp';
// Code display markers — elements that intentionally show SX source
const CODE_DISPLAY_SELECTORS = [
'[data-code-view]',
'pre code',
'.sx-source',
'[data-sx-source]',
'.font-mono[style*="font-size:0.5rem"]', // stepper code view
].join(', ');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Recursive DOM tree snapshot — tag, id, classes, data-sx-* attrs, text */
function snapshotScript() {
return `(function snapshot(el) {
if (el.nodeType === 3) {
const text = el.textContent.trim();
return text ? { type: 'text', value: text } : null;
}
if (el.nodeType !== 1) return null;
const node = { tag: el.tagName.toLowerCase() };
if (el.id) node.id = el.id;
const cls = Array.from(el.classList).sort().join(' ');
if (cls) node.cls = cls;
const island = el.getAttribute('data-sx-island');
if (island) node.island = island;
const lake = el.getAttribute('data-sx-lake');
if (lake) node.lake = lake;
const marsh = el.getAttribute('data-sx-marsh');
if (marsh) node.marsh = marsh;
const signal = el.getAttribute('data-sx-signal');
if (signal) node.signal = signal;
const reactiveAttrs = el.getAttribute('data-sx-reactive-attrs');
if (reactiveAttrs) node.reactiveAttrs = reactiveAttrs;
const style = el.getAttribute('style');
if (style) node.style = style;
const children = [];
for (const child of el.childNodes) {
const s = snapshot(child);
if (s) children.push(s);
}
if (children.length) node.children = children;
return node;
})`;
}
/** Diff two tree snapshots, returning list of changes */
function diffTrees(a, b, path = '') {
const changes = [];
if (!a && !b) return changes;
if (!a) { changes.push({ path, kind: 'added', node: summarize(b) }); return changes; }
if (!b) { changes.push({ path, kind: 'removed', node: summarize(a) }); return changes; }
if (a.type === 'text' && b.type === 'text') {
if (a.value !== b.value)
changes.push({ path: path || 'text', kind: 'text-changed', ssr: a.value.substring(0, 120), hydrated: b.value.substring(0, 120) });
return changes;
}
if (a.type === 'text' || b.type === 'text') {
changes.push({ path, kind: 'type-changed', ssr: summarize(a), hydrated: summarize(b) });
return changes;
}
if (a.tag !== b.tag) changes.push({ path, kind: 'tag-changed', ssr: a.tag, hydrated: b.tag });
if (a.cls !== b.cls) changes.push({ path: path || a.tag, kind: 'class-changed', ssr: a.cls || '', hydrated: b.cls || '' });
if (a.island !== b.island) changes.push({ path, kind: 'island-changed', ssr: a.island, hydrated: b.island });
if (a.style !== b.style) changes.push({ path: path || a.tag, kind: 'style-changed', ssr: a.style || '', hydrated: b.style || '' });
const ac = a.children || [];
const bc = b.children || [];
const maxLen = Math.max(ac.length, bc.length);
for (let i = 0; i < maxLen; i++) {
const childPath = path ? `${path} > [${i}]` : `[${i}]`;
const aTag = ac[i]?.tag || ac[i]?.type || '';
const bTag = bc[i]?.tag || bc[i]?.type || '';
const label = bTag ? `${childPath} <${bTag}>` : childPath;
changes.push(...diffTrees(ac[i] || null, bc[i] || null, label));
}
return changes;
}
function summarize(node) {
if (!node) return 'null';
if (node.type === 'text') return `"${node.value.substring(0, 60)}"`;
let s = `<${node.tag}`;
if (node.island) s += ` island="${node.island}"`;
if (node.lake) s += ` lake="${node.lake}"`;
if (node.id) s += ` #${node.id}`;
s += '>';
return s;
}
async function waitForHydration(page) {
try {
await page.waitForSelector('[data-sx-island]', { timeout: 8000 });
await page.waitForTimeout(1500);
} catch (e) {
// No islands on page — that's OK
}
}
// ---------------------------------------------------------------------------
// Leak detection — excludes code display elements
// ---------------------------------------------------------------------------
function leakDetectionScript(codeSelectors) {
return `(function(els) {
const codeEls = new Set();
document.querySelectorAll(${JSON.stringify(codeSelectors)}).forEach(el => {
// Mark all code-display elements and their descendants
el.querySelectorAll('*').forEach(d => codeEls.add(d));
codeEls.add(el);
});
return els.flatMap(el => {
const name = el.getAttribute('data-sx-island');
const leaks = [];
// Walk text nodes, skipping code display areas
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
let p = node.parentElement;
while (p && p !== el) {
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let fullText = '';
let node;
while (node = walker.nextNode()) fullText += node.textContent;
// Raw dict patterns (SX keyword-keyed dicts)
const dictMatch = fullText.match(/\\{:(?:type|tag|expr|spreads|attrs)\\s[^}]{0,80}/);
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
// Unresolved component calls
const compMatch = fullText.match(/\\(~[\\w/]+[^)]{0,60}/);
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
// CSSX tokens leaked as text
const cssxMatch = fullText.match(/:tokens\\s+"[^"]{0,60}/);
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
// String-keyed dicts (JS evaluator format)
const strDictMatch = fullText.match(/\\{"(?:type|tag|expr|text|cls|step)"\\s[^}]{0,80}/);
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
// Raw SX list as text (parens around tag names)
const sxListMatch = fullText.match(/\\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\\s+:(?:class|id|style)/);
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
return leaks;
});
})`;
}
// ---------------------------------------------------------------------------
// Handler audit — check if event handlers are wired
// ---------------------------------------------------------------------------
function handlerAuditScript() {
return `(function(island) {
const buttons = island.querySelectorAll('button, [on-click], [data-sx-on-click]');
const inputs = island.querySelectorAll('input, textarea, select');
const results = [];
buttons.forEach((btn, i) => {
const tag = btn.tagName.toLowerCase();
const text = btn.textContent.trim().substring(0, 30);
const hasReactiveClass = btn.hasAttribute('data-sx-reactive-attrs');
// Check for attached event listeners via getEventListeners (Chrome DevTools only)
// Fallback: check for onclick attribute or __sx handler
const hasOnclick = btn.hasAttribute('onclick') || btn.onclick !== null;
const hasSxHandler = !!(btn.__sx_listeners || btn._sxListeners);
// Check all registered listeners
const listeners = [];
try {
const evts = getEventListeners ? getEventListeners(btn) : {};
for (const [evt, handlers] of Object.entries(evts)) {
listeners.push({ event: evt, count: handlers.length });
}
} catch(e) { /* getEventListeners not available outside DevTools */ }
results.push({
element: tag,
index: i,
text: text,
reactiveAttrs: hasReactiveClass,
hasOnclick: hasOnclick,
listenerCount: listeners.length || (hasOnclick ? 1 : 0),
wired: hasOnclick || listeners.length > 0
});
});
inputs.forEach((inp, i) => {
const tag = inp.tagName.toLowerCase();
const type = inp.type || '';
const hasBind = inp.hasAttribute('data-sx-bind');
const hasOnInput = inp.oninput !== null;
results.push({
element: tag,
type: type,
index: i,
bind: hasBind,
hasOnInput: hasOnInput,
wired: hasOnInput || hasBind
});
});
return results;
})`;
}
// ---------------------------------------------------------------------------
// Mode: inspect
// ---------------------------------------------------------------------------
async function modeInspect(page, url, islandFilter) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const pageErrors = [];
page.on('pageerror', err => pageErrors.push(err.message));
const selector = islandFilter
? `[data-sx-island*="${islandFilter}"]`
: '[data-sx-island]';
const islands = await page.$$eval(selector, (els, codeSelectors) => {
return els.map(el => {
const name = el.getAttribute('data-sx-island');
// Lakes with detail
const lakes = [...el.querySelectorAll('[data-sx-lake]')].map(l => {
const id = l.getAttribute('data-sx-lake');
const html = l.innerHTML;
const hasElements = l.children.length > 0;
const textOnly = !hasElements && l.textContent.trim().length > 0;
const looksLikeSx = /^\s*\(/.test(l.textContent.trim());
return {
id,
htmlLength: html.length,
childElements: l.children.length,
textPreview: l.textContent.trim().substring(0, 100),
status: hasElements ? 'rendered' : (looksLikeSx ? 'raw-sx-text' : (textOnly ? 'text-only' : 'empty'))
};
});
// Marshes
const marshes = [...el.querySelectorAll('[data-sx-marsh]')].map(m => ({
id: m.getAttribute('data-sx-marsh'),
transform: m.getAttribute('data-sx-transform') || null
}));
// Signals
const signals = [...el.querySelectorAll('[data-sx-signal]')].map(s => {
const spec = s.getAttribute('data-sx-signal');
const colonIdx = spec.indexOf(':');
return {
store: colonIdx > 0 ? spec.substring(0, colonIdx) : spec,
value: colonIdx > 0 ? spec.substring(colonIdx + 1).substring(0, 50) : null
};
});
// Reactive attrs
const reactiveEls = [...el.querySelectorAll('[data-sx-reactive-attrs]')].map(r => ({
tag: r.tagName.toLowerCase(),
attrs: r.getAttribute('data-sx-reactive-attrs'),
preview: r.outerHTML.substring(0, 80)
}));
return {
name,
tag: el.tagName.toLowerCase(),
stateSize: (el.getAttribute('data-sx-state') || '').length,
textLength: el.textContent.length,
textPreview: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 150),
lakes,
marshes,
signals,
reactiveElements: reactiveEls.length,
reactiveDetail: reactiveEls.slice(0, 10)
};
});
}, CODE_DISPLAY_SELECTORS);
// Leak detection with code display exclusion
const leaks = await page.$$eval(selector, (els, codeSelectors) => {
// Build the leak detection inline (can't pass function refs to $$eval)
const codeEls = new Set();
document.querySelectorAll(codeSelectors).forEach(el => {
el.querySelectorAll('*').forEach(d => codeEls.add(d));
codeEls.add(el);
});
return els.flatMap(el => {
const name = el.getAttribute('data-sx-island');
const leaks = [];
// Walk text nodes, skipping code display areas
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
let p = node.parentElement;
while (p && p !== el) {
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let fullText = '';
let node;
while (node = walker.nextNode()) fullText += node.textContent;
const dictMatch = fullText.match(/\{:(?:type|tag|expr|spreads|attrs)\s[^}]{0,80}/);
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
const compMatch = fullText.match(/\(~[\w/]+[^)]{0,60}/);
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
const cssxMatch = fullText.match(/:tokens\s+"[^"]{0,60}/);
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
const strDictMatch = fullText.match(/\{"(?:type|tag|expr|text|cls|step)"\s[^}]{0,80}/);
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
const sxListMatch = fullText.match(/\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\s+:(?:class|id|style)/);
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
return leaks;
});
}, CODE_DISPLAY_SELECTORS);
// Handler audit for each island
const handlers = await page.$$eval(selector, (els) => {
return els.map(el => {
const name = el.getAttribute('data-sx-island');
const buttons = [...el.querySelectorAll('button')];
const inputs = [...el.querySelectorAll('input, textarea, select')];
const buttonAudit = buttons.map((btn, i) => {
const hasOnclick = btn.onclick !== null;
const hasReactive = btn.hasAttribute('data-sx-reactive-attrs');
// SX wires handlers via addEventListener, not onclick property.
// We can't detect addEventListener from JS. Heuristic: if the
// button has reactive attrs, it was rendered by an island and
// likely has handlers. Only flag buttons with NO island markers.
const inIsland = !!btn.closest('[data-sx-island]');
const likelyWired = hasOnclick || (inIsland && hasReactive);
return {
text: btn.textContent.trim().substring(0, 20),
hasOnclick,
reactiveAttrs: hasReactive,
inIsland,
wired: likelyWired ? 'yes' : (inIsland ? 'probable' : 'unknown')
};
});
const inputAudit = inputs.map((inp, i) => {
const hasBind = inp.hasAttribute('data-sx-bind');
const hasOnInput = inp.oninput !== null;
const inIsland = !!inp.closest('[data-sx-island]');
return {
type: inp.type || inp.tagName.toLowerCase(),
hasBind,
hasOnInput,
inIsland,
wired: hasOnInput || hasBind ? 'yes' : (inIsland ? 'probable' : 'unknown')
};
});
const suspectButtons = buttonAudit.filter(b => b.wired === 'unknown');
const suspectInputs = inputAudit.filter(i => i.wired === 'unknown');
return {
island: name,
buttons: buttonAudit.length,
inputs: inputAudit.length,
suspectButtons: suspectButtons.length,
suspectInputs: suspectInputs.length,
allButtons: buttonAudit,
detail: suspectButtons.length + suspectInputs.length > 0
? { suspectButtons, suspectInputs }
: undefined
};
});
});
const globalLeaks = await page.$eval('#sx-root', el => {
const text = el.textContent;
const leaks = [];
const dictMatch = text.match(/\{:(?:type|tag|expr)\s[^}]{0,80}/);
if (dictMatch) leaks.push({ kind: 'raw-dict-outside-island', sample: dictMatch[0] });
return leaks;
}).catch(() => []);
return { url, islands, leaks: [...leaks, ...globalLeaks], handlers, pageErrors };
}
// ---------------------------------------------------------------------------
// Mode: diff — SSR vs hydrated DOM (full page)
// ---------------------------------------------------------------------------
async function modeDiff(browser, url) {
const snap = snapshotScript();
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
const ssrPage = await ssrCtx.newPage();
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const ssrTree = await ssrPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
const ssrText = await ssrPage.evaluate(() => {
const root = document.getElementById('sx-root');
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
});
await ssrCtx.close();
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
const hydPage = await hydCtx.newPage();
const pageErrors = [];
hydPage.on('pageerror', err => pageErrors.push(err.message));
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(hydPage);
const hydTree = await hydPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
const hydText = await hydPage.evaluate(() => {
const root = document.getElementById('sx-root');
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
});
await hydCtx.close();
const changes = diffTrees(ssrTree, hydTree);
const textMatch = ssrText === hydText;
return {
url,
ssrTextPreview: ssrText.substring(0, 300),
hydratedTextPreview: hydText.substring(0, 300),
textMatch,
structuralChanges: changes.length,
changes: changes.slice(0, 50),
pageErrors
};
}
// ---------------------------------------------------------------------------
// Mode: hydrate — SSR vs hydrated comparison focused on lakes
// ---------------------------------------------------------------------------
async function modeHydrate(browser, url) {
// SSR: capture lake innerHTML without JS
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
const ssrPage = await ssrCtx.newPage();
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const ssrLakes = await ssrPage.$$eval('[data-sx-lake]', els =>
els.map(el => ({
id: el.getAttribute('data-sx-lake'),
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
html: el.innerHTML.substring(0, 500),
hasElements: el.children.length > 0,
text: el.textContent.trim().substring(0, 200)
})));
const ssrIslands = await ssrPage.$$eval('[data-sx-island]', els =>
els.map(el => ({
name: el.getAttribute('data-sx-island'),
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
})));
await ssrCtx.close();
// Hydrated: capture same after JS runs
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
const hydPage = await hydCtx.newPage();
const pageErrors = [];
hydPage.on('pageerror', err => pageErrors.push(err.message));
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(hydPage);
const hydLakes = await hydPage.$$eval('[data-sx-lake]', els =>
els.map(el => ({
id: el.getAttribute('data-sx-lake'),
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
html: el.innerHTML.substring(0, 500),
hasElements: el.children.length > 0,
text: el.textContent.trim().substring(0, 200)
})));
const hydIslands = await hydPage.$$eval('[data-sx-island]', els =>
els.map(el => ({
name: el.getAttribute('data-sx-island'),
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
})));
await hydCtx.close();
// Compare lakes
const lakeReport = [];
const ssrMap = Object.fromEntries(ssrLakes.map(l => [l.id, l]));
const hydMap = Object.fromEntries(hydLakes.map(l => [l.id, l]));
const allIds = new Set([...ssrLakes.map(l => l.id), ...hydLakes.map(l => l.id)]);
for (const id of allIds) {
const ssr = ssrMap[id];
const hyd = hydMap[id];
const entry = { id, island: ssr?.island || hyd?.island };
if (!ssr) { entry.status = 'added-by-hydration'; }
else if (!hyd) { entry.status = 'removed-by-hydration'; }
else if (ssr.html === hyd.html) { entry.status = 'identical'; }
else {
const ssrHasEls = ssr.hasElements;
const hydHasEls = hyd.hasElements;
if (ssrHasEls && !hydHasEls) {
entry.status = 'CLOBBERED';
entry.detail = 'SSR had DOM elements, hydration replaced with text';
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
} else if (!ssrHasEls && hydHasEls) {
entry.status = 'upgraded';
entry.detail = 'SSR had text, hydration rendered DOM';
} else {
entry.status = 'changed';
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
}
}
lakeReport.push(entry);
}
// Compare islands
const islandReport = [];
const ssrIslandMap = Object.fromEntries(ssrIslands.map(i => [i.name, i]));
const hydIslandMap = Object.fromEntries(hydIslands.map(i => [i.name, i]));
for (const name of new Set([...ssrIslands.map(i => i.name), ...hydIslands.map(i => i.name)])) {
const ssr = ssrIslandMap[name];
const hyd = hydIslandMap[name];
const entry = { name };
if (ssr?.hasError) entry.ssrError = true;
if (hyd?.hasError) entry.hydrationError = true;
if (ssr && hyd && ssr.text === hyd.text) entry.textMatch = true;
else if (ssr && hyd) {
entry.textMatch = false;
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
}
islandReport.push(entry);
}
const clobbered = lakeReport.filter(l => l.status === 'CLOBBERED');
return {
url,
summary: {
lakes: lakeReport.length,
identical: lakeReport.filter(l => l.status === 'identical').length,
clobbered: clobbered.length,
changed: lakeReport.filter(l => l.status === 'changed').length
},
lakes: lakeReport,
islands: islandReport,
pageErrors
};
}
// ---------------------------------------------------------------------------
// Mode: eval — evaluate JS expression
// ---------------------------------------------------------------------------
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);
// 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 };
}
// ---------------------------------------------------------------------------
// Mode: interact — action sequence
// ---------------------------------------------------------------------------
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 = [];
for (const action of actions) {
const [cmd, ...rest] = action.split(':');
const arg = rest.join(':');
try {
switch (cmd) {
case 'click':
await page.locator(arg).first().click();
results.push({ action: 'click', selector: arg, ok: true });
break;
case 'fill': {
const [sel, ...valParts] = arg.split(':');
const val = valParts.join(':');
await page.locator(sel).first().fill(val);
results.push({ action: 'fill', selector: sel, value: val, ok: true });
break;
}
case 'wait':
await page.waitForTimeout(parseInt(arg) || 500);
results.push({ action: 'wait', ms: parseInt(arg) || 500 });
break;
case 'text': {
const text = await page.locator(arg).first().textContent();
results.push({ action: 'text', selector: arg, value: text?.trim() });
break;
}
case 'html': {
const html = await page.locator(arg).first().innerHTML();
results.push({ action: 'html', selector: arg, value: html?.substring(0, 500) });
break;
}
case 'attrs': {
const attrs = await page.locator(arg).first().evaluate(el => {
const a = {};
for (const attr of el.attributes) a[attr.name] = attr.value.substring(0, 200);
return a;
});
results.push({ action: 'attrs', selector: arg, value: attrs });
break;
}
case 'screenshot': {
const ts = Date.now();
const path = `${SCREENSHOT_DIR}/sx-inspect-${ts}.png`;
if (arg) {
await page.locator(arg).first().screenshot({ path });
} else {
await page.screenshot({ path });
}
results.push({ action: 'screenshot', selector: arg || 'full-page', path });
break;
}
case 'count': {
const count = await page.locator(arg).count();
results.push({ action: 'count', selector: arg, value: count });
break;
}
case 'visible': {
const visible = await page.locator(arg).first().isVisible().catch(() => false);
results.push({ action: 'visible', selector: arg, value: visible });
break;
}
default:
results.push({ action: cmd, error: 'unknown action' });
}
} catch (e) {
results.push({ action: cmd, selector: arg, error: e.message });
}
}
// 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 };
}
// ---------------------------------------------------------------------------
// Mode: screenshot
// ---------------------------------------------------------------------------
async function modeScreenshot(page, url, selector) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const ts = Date.now();
const path = `${SCREENSHOT_DIR}/sx-screenshot-${ts}.png`;
if (selector) {
await page.locator(selector).first().screenshot({ path });
} else {
await page.screenshot({ path });
}
return { url, selector: selector || 'full-page', path };
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// Mode: trace-boot — full console capture during boot (ALL prefixes)
// ---------------------------------------------------------------------------
async function modeTraceBoot(page, url, filterPrefix) {
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
page.on('pageerror', err => {
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Categorise
const phases = { platform: [], boot: [], errors: [], warnings: [], other: [] };
for (const l of allLogs) {
if (l.type === 'error' || l.type === 'pageerror') phases.errors.push(l);
else if (l.type === 'warning') phases.warnings.push(l);
else if (l.text.startsWith('[sx-platform]')) phases.platform.push(l);
else if (l.text.startsWith('[sx]')) phases.boot.push(l);
else phases.other.push(l);
}
// Apply optional prefix filter
const filtered = filterPrefix
? allLogs.filter(l => l.text.includes(filterPrefix))
: null;
return {
url,
total: allLogs.length,
summary: {
platform: phases.platform.length,
boot: phases.boot.length,
errors: phases.errors.length,
warnings: phases.warnings.length,
other: phases.other.length,
},
phases,
...(filtered ? { filtered } : {}),
};
}
// ---------------------------------------------------------------------------
// Mode: hydrate-debug — re-run hydration on one island with full tracing
// ---------------------------------------------------------------------------
async function modeHydrateDebug(page, url, islandName) {
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
page.on('pageerror', err => {
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Capture SSR HTML before re-hydration
const ssrHtml = await page.evaluate((name) => {
const el = name
? document.querySelector(`[data-sx-island="${name}"]`)
: document.querySelector('[data-sx-island]');
return el ? { name: el.getAttribute('data-sx-island'), html: el.innerHTML.substring(0, 2000) } : null;
}, islandName);
if (!ssrHtml) {
return { url, error: `Island not found: ${islandName || '(first)'}` };
}
const targetIsland = ssrHtml.name;
// Mark console position
const logCursorBefore = allLogs.length;
// Run instrumented re-hydration: clear flag, inject tracing, re-hydrate
const result = await page.evaluate((name) => {
const K = globalThis.SxKernel;
if (!K) return { error: 'SxKernel not available' };
const el = document.querySelector(`[data-sx-island="${name}"]`);
if (!el) return { error: `Element not found: ${name}` };
// Clear hydration flag
el.removeAttribute('data-sx-hydrated');
delete el._sxBoundislandhydrated;
delete el['_sxBound' + 'island-hydrated'];
// Check env state
const compName = '~' + name;
const checks = {};
checks.compType = K.eval(`(type-of ${compName})`);
checks.renderDomList = K.eval('(type-of render-dom-list)');
checks.cssx = K.eval('(type-of ~cssx/tw)');
checks.stateAttr = el.getAttribute('data-sx-state') || '{}';
// Parse state
window.__hd_state = checks.stateAttr;
checks.stateParsed = K.eval('(inspect (or (first (sx-parse (host-global "__hd_state"))) {}))');
// Get component params
checks.params = K.eval(`(inspect (component-params (env-get (global-env) "${compName}")))`);
// Manual render with error capture (NOT cek-try — let errors propagate)
let renderResult;
try {
window.__hd_state2 = checks.stateAttr;
const rendered = K.eval(`
(let ((comp (env-get (global-env) "${compName}"))
(kwargs (or (first (sx-parse (host-global "__hd_state2"))) {})))
(let ((local (env-merge (component-closure comp) (get-render-env nil))))
(for-each
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
(let ((el (render-to-dom (component-body comp) local nil)))
(host-get el "outerHTML"))))
`);
renderResult = { ok: true, html: (typeof rendered === 'string' ? rendered.substring(0, 2000) : String(rendered).substring(0, 2000)) };
} catch (e) {
renderResult = { ok: false, error: e.message };
}
// Now run the actual hydrate-island via boot.sx
try {
K.eval(`(hydrate-island (host-global "__hd_el"))`);
} catch (e) {
// ignore — we already got the manual render
}
// Capture hydrated HTML
const hydratedHtml = el.innerHTML.substring(0, 2000);
delete window.__hd_state;
delete window.__hd_state2;
return { checks, renderResult, hydratedHtml };
}, targetIsland);
// Collect console messages from the re-hydration
const hydrateLogs = allLogs.slice(logCursorBefore);
// Compare SSR vs hydrated
const ssrClasses = (ssrHtml.html.match(/class="[^"]*"/g) || []).length;
const hydClasses = (result.hydratedHtml || '').match(/class="[^"]*"/g) || [];
const manualClasses = (result.renderResult?.html || '').match(/class="[^"]*"/g) || [];
return {
url,
island: targetIsland,
ssrHtml: ssrHtml.html.substring(0, 500),
checks: result.checks,
manualRender: result.renderResult,
hydratedHtml: (result.hydratedHtml || '').substring(0, 500),
classCounts: {
ssr: ssrClasses,
manual: manualClasses.length,
hydrated: hydClasses.length,
},
hydrateLogs: hydrateLogs.length > 0 ? hydrateLogs : undefined,
};
}
// ---------------------------------------------------------------------------
// Mode: eval-at — inject eval breakpoint at a specific boot phase
// ---------------------------------------------------------------------------
async function modeEvalAt(browser, url, phase, expr) {
const validPhases = [
'before-modules', 'after-modules',
'before-pages', 'after-pages',
'before-components', 'after-components',
'before-hydrate', 'after-hydrate',
'after-boot',
];
if (!validPhases.includes(phase)) {
return { error: `Invalid phase: ${phase}. Valid: ${validPhases.join(', ')}` };
}
const context = await browser.newContext();
const page = await context.newPage();
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
// Inject a hook that pauses the boot at the desired phase.
// We do this by intercepting sx-platform-2.js and injecting eval calls.
await page.route('**/*sx-platform-2.js*', async (route) => {
const resp = await route.fetch();
let body = await resp.text();
// Pass expression via a global to avoid JS/SX quoting hell.
// K.eval() handles parsing internally, so we just pass the SX string.
// Also set up common DOM helpers as globals for easy SX access.
const evalCode = [
`window.__sxEvalAtExpr = ${JSON.stringify(expr)};`,
`window.__sxEl0 = document.querySelectorAll('[data-sx-island]')[0] || null;`,
`window.__sxEl1 = document.querySelectorAll('[data-sx-island]')[1] || null;`,
`try { console.log("[sx-eval-at] ${phase}: " + K.eval(window.__sxEvalAtExpr)); }`,
`catch(e) { console.log("[sx-eval-at] ${phase}: JS-ERROR: " + e.message); }`,
].join('\n ');
// Map phase names to injection points in the platform code
const injections = {
'before-modules': { before: 'loadWebStack();' },
'after-modules': { after: 'loadWebStack();' },
'before-pages': { before: 'K.eval("(process-page-scripts)");' },
'after-pages': { after: 'K.eval("(process-page-scripts)");' },
'before-components': { before: 'K.eval("(process-sx-scripts nil)");' },
'after-components': { after: 'K.eval("(process-sx-scripts nil)");' },
'before-hydrate': { before: 'K.eval("(sx-hydrate-islands nil)");' },
'after-hydrate': { after: 'K.eval("(sx-hydrate-islands nil)");' },
'after-boot': { after: 'console.log("[sx] boot done");' },
};
const inj = injections[phase];
if (inj.before) {
body = body.replace(inj.before, evalCode + '\n ' + inj.before);
} else if (inj.after) {
body = body.replace(inj.after, inj.after + '\n ' + evalCode);
}
await route.fulfill({ body, headers: { ...resp.headers(), 'content-type': 'application/javascript' } });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Extract our eval-at result from console
const evalAtLogs = allLogs.filter(l => l.text.startsWith('[sx-eval-at]'));
const evalResult = evalAtLogs.length > 0
? evalAtLogs[0].text.replace(`[sx-eval-at] ${phase}: `, '')
: 'INJECTION FAILED — phase marker not found in platform code';
// Also collect boot sequence for context
const bootLogs = allLogs.filter(l =>
l.text.startsWith('[sx]') || l.text.startsWith('[sx-platform]') || l.text.startsWith('[sx-eval-at]') ||
l.type === 'error' || l.type === 'warning'
);
await context.close();
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
}
async function main() {
const argsJson = process.argv[2] || '{}';
let args;
try {
args = JSON.parse(argsJson);
} catch (e) {
console.log(JSON.stringify({ error: `Invalid JSON args: ${e.message}` }));
process.exit(1);
}
const mode = args.mode || 'inspect';
const url = args.url || '/';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
try {
let result;
switch (mode) {
case 'inspect':
result = await modeInspect(page, url, args.island);
break;
case 'diff':
result = await modeDiff(browser, url);
break;
case 'hydrate':
result = await modeHydrate(browser, url);
break;
case 'eval':
result = await modeEval(page, url, args.expr || 'document.title');
break;
case 'interact':
result = await modeInteract(page, url, args.actions || '');
break;
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;
case 'trace-boot':
result = await modeTraceBoot(page, url, args.filter || '');
break;
case 'hydrate-debug':
result = await modeHydrateDebug(page, url, args.island || '');
break;
case 'eval-at':
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
break;
default:
result = { error: `Unknown mode: ${mode}` };
}
console.log(JSON.stringify(result, null, 2));
} catch (e) {
console.log(JSON.stringify({ error: e.message, stack: e.stack?.split('\n').slice(0, 5) }));
} finally {
await browser.close();
}
}
main();