WIP: pre-existing changes from WASM browser work + test infrastructure

Accumulated changes from WASM browser development sessions:
- sx_runtime.ml: signal subscription + notify, env unwrap tolerance
- sx_browser.bc.js: rebuilt js_of_ocaml browser kernel
- sx_browser.bc.wasm.js + assets: WASM browser kernel build
- sx-platform.js browser tests (test_js, test_platform, test_wasm)
- Playwright sx-inspect.js: interactive page inspector tool
- harness-web.sx: DOM assertion updates
- deploy.sh, Dockerfile, dune-project: build config updates
- test-stepper.sx: stepper unit tests
- reader-macro-demo plan update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:40:38 +00:00
parent 10576f86d1
commit c72a5af04d
47 changed files with 5485 additions and 1728 deletions

View File

@@ -0,0 +1,739 @@
#!/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();