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:
739
tests/playwright/sx-inspect.js
Normal file
739
tests/playwright/sx-inspect.js
Normal 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();
|
||||
Reference in New Issue
Block a user