#!/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.js and injecting eval calls. await page.route('**/*sx-platform.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();