#!/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 // 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) { await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 }); await waitForHydration(page); const result = await page.evaluate(expr); return { url, expr, result }; } // --------------------------------------------------------------------------- // Mode: interact — action sequence // --------------------------------------------------------------------------- async function modeInteract(page, url, actionsStr) { await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 }); await waitForHydration(page); 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 }); } } return { url, results }; } // --------------------------------------------------------------------------- // 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 }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- 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; 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();