- Add missing ./sx/sxc:/app/sxc:ro volume mount to dev-sx compose — container was using stale image copy of docs.sx where ~docs/code had (&key code) instead of (&key src), so highlight output was silently discarded - Register HTML tags as special forms in WASM browser kernel so keyword attrs are preserved during render-to-dom - Add trace-boot, hydrate-debug, eval-at modes to sx-inspect.js for debugging boot phases and island hydration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1369 lines
50 KiB
JavaScript
1369 lines
50 KiB
JavaScript
#!/usr/bin/env node
|
|
// sx-inspect.js — SX-aware Playwright page inspector
|
|
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
|
|
// Modes: inspect, diff, hydrate, eval, interact, screenshot, trace-boot, hydrate-debug, eval-at
|
|
// Output: JSON to stdout
|
|
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
|
|
const SCREENSHOT_DIR = process.env.SX_SCREENSHOT_DIR || '/tmp';
|
|
|
|
// Code display markers — elements that intentionally show SX source
|
|
const CODE_DISPLAY_SELECTORS = [
|
|
'[data-code-view]',
|
|
'pre code',
|
|
'.sx-source',
|
|
'[data-sx-source]',
|
|
'.font-mono[style*="font-size:0.5rem"]', // stepper code view
|
|
].join(', ');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Recursive DOM tree snapshot — tag, id, classes, data-sx-* attrs, text */
|
|
function snapshotScript() {
|
|
return `(function snapshot(el) {
|
|
if (el.nodeType === 3) {
|
|
const text = el.textContent.trim();
|
|
return text ? { type: 'text', value: text } : null;
|
|
}
|
|
if (el.nodeType !== 1) return null;
|
|
const node = { tag: el.tagName.toLowerCase() };
|
|
if (el.id) node.id = el.id;
|
|
const cls = Array.from(el.classList).sort().join(' ');
|
|
if (cls) node.cls = cls;
|
|
const island = el.getAttribute('data-sx-island');
|
|
if (island) node.island = island;
|
|
const lake = el.getAttribute('data-sx-lake');
|
|
if (lake) node.lake = lake;
|
|
const marsh = el.getAttribute('data-sx-marsh');
|
|
if (marsh) node.marsh = marsh;
|
|
const signal = el.getAttribute('data-sx-signal');
|
|
if (signal) node.signal = signal;
|
|
const reactiveAttrs = el.getAttribute('data-sx-reactive-attrs');
|
|
if (reactiveAttrs) node.reactiveAttrs = reactiveAttrs;
|
|
const style = el.getAttribute('style');
|
|
if (style) node.style = style;
|
|
const children = [];
|
|
for (const child of el.childNodes) {
|
|
const s = snapshot(child);
|
|
if (s) children.push(s);
|
|
}
|
|
if (children.length) node.children = children;
|
|
return node;
|
|
})`;
|
|
}
|
|
|
|
/** Diff two tree snapshots, returning list of changes */
|
|
function diffTrees(a, b, path = '') {
|
|
const changes = [];
|
|
if (!a && !b) return changes;
|
|
if (!a) { changes.push({ path, kind: 'added', node: summarize(b) }); return changes; }
|
|
if (!b) { changes.push({ path, kind: 'removed', node: summarize(a) }); return changes; }
|
|
if (a.type === 'text' && b.type === 'text') {
|
|
if (a.value !== b.value)
|
|
changes.push({ path: path || 'text', kind: 'text-changed', ssr: a.value.substring(0, 120), hydrated: b.value.substring(0, 120) });
|
|
return changes;
|
|
}
|
|
if (a.type === 'text' || b.type === 'text') {
|
|
changes.push({ path, kind: 'type-changed', ssr: summarize(a), hydrated: summarize(b) });
|
|
return changes;
|
|
}
|
|
if (a.tag !== b.tag) changes.push({ path, kind: 'tag-changed', ssr: a.tag, hydrated: b.tag });
|
|
if (a.cls !== b.cls) changes.push({ path: path || a.tag, kind: 'class-changed', ssr: a.cls || '', hydrated: b.cls || '' });
|
|
if (a.island !== b.island) changes.push({ path, kind: 'island-changed', ssr: a.island, hydrated: b.island });
|
|
if (a.style !== b.style) changes.push({ path: path || a.tag, kind: 'style-changed', ssr: a.style || '', hydrated: b.style || '' });
|
|
|
|
const ac = a.children || [];
|
|
const bc = b.children || [];
|
|
const maxLen = Math.max(ac.length, bc.length);
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const childPath = path ? `${path} > [${i}]` : `[${i}]`;
|
|
const aTag = ac[i]?.tag || ac[i]?.type || '';
|
|
const bTag = bc[i]?.tag || bc[i]?.type || '';
|
|
const label = bTag ? `${childPath} <${bTag}>` : childPath;
|
|
changes.push(...diffTrees(ac[i] || null, bc[i] || null, label));
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function summarize(node) {
|
|
if (!node) return 'null';
|
|
if (node.type === 'text') return `"${node.value.substring(0, 60)}"`;
|
|
let s = `<${node.tag}`;
|
|
if (node.island) s += ` island="${node.island}"`;
|
|
if (node.lake) s += ` lake="${node.lake}"`;
|
|
if (node.id) s += ` #${node.id}`;
|
|
s += '>';
|
|
return s;
|
|
}
|
|
|
|
async function waitForHydration(page) {
|
|
try {
|
|
await page.waitForSelector('[data-sx-island]', { timeout: 8000 });
|
|
await page.waitForTimeout(1500);
|
|
} catch (e) {
|
|
// No islands on page — that's OK
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Leak detection — excludes code display elements
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function leakDetectionScript(codeSelectors) {
|
|
return `(function(els) {
|
|
const codeEls = new Set();
|
|
document.querySelectorAll(${JSON.stringify(codeSelectors)}).forEach(el => {
|
|
// Mark all code-display elements and their descendants
|
|
el.querySelectorAll('*').forEach(d => codeEls.add(d));
|
|
codeEls.add(el);
|
|
});
|
|
|
|
return els.flatMap(el => {
|
|
const name = el.getAttribute('data-sx-island');
|
|
const leaks = [];
|
|
|
|
// Walk text nodes, skipping code display areas
|
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
|
acceptNode: (node) => {
|
|
let p = node.parentElement;
|
|
while (p && p !== el) {
|
|
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
|
|
p = p.parentElement;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
|
|
let fullText = '';
|
|
let node;
|
|
while (node = walker.nextNode()) fullText += node.textContent;
|
|
|
|
// Raw dict patterns (SX keyword-keyed dicts)
|
|
const dictMatch = fullText.match(/\\{:(?:type|tag|expr|spreads|attrs)\\s[^}]{0,80}/);
|
|
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
|
|
// Unresolved component calls
|
|
const compMatch = fullText.match(/\\(~[\\w/]+[^)]{0,60}/);
|
|
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
|
|
// CSSX tokens leaked as text
|
|
const cssxMatch = fullText.match(/:tokens\\s+"[^"]{0,60}/);
|
|
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
|
|
// String-keyed dicts (JS evaluator format)
|
|
const strDictMatch = fullText.match(/\\{"(?:type|tag|expr|text|cls|step)"\\s[^}]{0,80}/);
|
|
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
|
|
// Raw SX list as text (parens around tag names)
|
|
const sxListMatch = fullText.match(/\\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\\s+:(?:class|id|style)/);
|
|
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
|
|
return leaks;
|
|
});
|
|
})`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler audit — check if event handlers are wired
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handlerAuditScript() {
|
|
return `(function(island) {
|
|
const buttons = island.querySelectorAll('button, [on-click], [data-sx-on-click]');
|
|
const inputs = island.querySelectorAll('input, textarea, select');
|
|
const results = [];
|
|
|
|
buttons.forEach((btn, i) => {
|
|
const tag = btn.tagName.toLowerCase();
|
|
const text = btn.textContent.trim().substring(0, 30);
|
|
const hasReactiveClass = btn.hasAttribute('data-sx-reactive-attrs');
|
|
// Check for attached event listeners via getEventListeners (Chrome DevTools only)
|
|
// Fallback: check for onclick attribute or __sx handler
|
|
const hasOnclick = btn.hasAttribute('onclick') || btn.onclick !== null;
|
|
const hasSxHandler = !!(btn.__sx_listeners || btn._sxListeners);
|
|
// Check all registered listeners
|
|
const listeners = [];
|
|
try {
|
|
const evts = getEventListeners ? getEventListeners(btn) : {};
|
|
for (const [evt, handlers] of Object.entries(evts)) {
|
|
listeners.push({ event: evt, count: handlers.length });
|
|
}
|
|
} catch(e) { /* getEventListeners not available outside DevTools */ }
|
|
|
|
results.push({
|
|
element: tag,
|
|
index: i,
|
|
text: text,
|
|
reactiveAttrs: hasReactiveClass,
|
|
hasOnclick: hasOnclick,
|
|
listenerCount: listeners.length || (hasOnclick ? 1 : 0),
|
|
wired: hasOnclick || listeners.length > 0
|
|
});
|
|
});
|
|
|
|
inputs.forEach((inp, i) => {
|
|
const tag = inp.tagName.toLowerCase();
|
|
const type = inp.type || '';
|
|
const hasBind = inp.hasAttribute('data-sx-bind');
|
|
const hasOnInput = inp.oninput !== null;
|
|
results.push({
|
|
element: tag,
|
|
type: type,
|
|
index: i,
|
|
bind: hasBind,
|
|
hasOnInput: hasOnInput,
|
|
wired: hasOnInput || hasBind
|
|
});
|
|
});
|
|
|
|
return results;
|
|
})`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: inspect
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeInspect(page, url, islandFilter) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const pageErrors = [];
|
|
page.on('pageerror', err => pageErrors.push(err.message));
|
|
|
|
const selector = islandFilter
|
|
? `[data-sx-island*="${islandFilter}"]`
|
|
: '[data-sx-island]';
|
|
|
|
const islands = await page.$$eval(selector, (els, codeSelectors) => {
|
|
return els.map(el => {
|
|
const name = el.getAttribute('data-sx-island');
|
|
|
|
// Lakes with detail
|
|
const lakes = [...el.querySelectorAll('[data-sx-lake]')].map(l => {
|
|
const id = l.getAttribute('data-sx-lake');
|
|
const html = l.innerHTML;
|
|
const hasElements = l.children.length > 0;
|
|
const textOnly = !hasElements && l.textContent.trim().length > 0;
|
|
const looksLikeSx = /^\s*\(/.test(l.textContent.trim());
|
|
return {
|
|
id,
|
|
htmlLength: html.length,
|
|
childElements: l.children.length,
|
|
textPreview: l.textContent.trim().substring(0, 100),
|
|
status: hasElements ? 'rendered' : (looksLikeSx ? 'raw-sx-text' : (textOnly ? 'text-only' : 'empty'))
|
|
};
|
|
});
|
|
|
|
// Marshes
|
|
const marshes = [...el.querySelectorAll('[data-sx-marsh]')].map(m => ({
|
|
id: m.getAttribute('data-sx-marsh'),
|
|
transform: m.getAttribute('data-sx-transform') || null
|
|
}));
|
|
|
|
// Signals
|
|
const signals = [...el.querySelectorAll('[data-sx-signal]')].map(s => {
|
|
const spec = s.getAttribute('data-sx-signal');
|
|
const colonIdx = spec.indexOf(':');
|
|
return {
|
|
store: colonIdx > 0 ? spec.substring(0, colonIdx) : spec,
|
|
value: colonIdx > 0 ? spec.substring(colonIdx + 1).substring(0, 50) : null
|
|
};
|
|
});
|
|
|
|
// Reactive attrs
|
|
const reactiveEls = [...el.querySelectorAll('[data-sx-reactive-attrs]')].map(r => ({
|
|
tag: r.tagName.toLowerCase(),
|
|
attrs: r.getAttribute('data-sx-reactive-attrs'),
|
|
preview: r.outerHTML.substring(0, 80)
|
|
}));
|
|
|
|
return {
|
|
name,
|
|
tag: el.tagName.toLowerCase(),
|
|
stateSize: (el.getAttribute('data-sx-state') || '').length,
|
|
textLength: el.textContent.length,
|
|
textPreview: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 150),
|
|
lakes,
|
|
marshes,
|
|
signals,
|
|
reactiveElements: reactiveEls.length,
|
|
reactiveDetail: reactiveEls.slice(0, 10)
|
|
};
|
|
});
|
|
}, CODE_DISPLAY_SELECTORS);
|
|
|
|
// Leak detection with code display exclusion
|
|
const leaks = await page.$$eval(selector, (els, codeSelectors) => {
|
|
// Build the leak detection inline (can't pass function refs to $$eval)
|
|
const codeEls = new Set();
|
|
document.querySelectorAll(codeSelectors).forEach(el => {
|
|
el.querySelectorAll('*').forEach(d => codeEls.add(d));
|
|
codeEls.add(el);
|
|
});
|
|
|
|
return els.flatMap(el => {
|
|
const name = el.getAttribute('data-sx-island');
|
|
const leaks = [];
|
|
|
|
// Walk text nodes, skipping code display areas
|
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
|
acceptNode: (node) => {
|
|
let p = node.parentElement;
|
|
while (p && p !== el) {
|
|
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
|
|
p = p.parentElement;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
|
|
let fullText = '';
|
|
let node;
|
|
while (node = walker.nextNode()) fullText += node.textContent;
|
|
|
|
const dictMatch = fullText.match(/\{:(?:type|tag|expr|spreads|attrs)\s[^}]{0,80}/);
|
|
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
|
|
const compMatch = fullText.match(/\(~[\w/]+[^)]{0,60}/);
|
|
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
|
|
const cssxMatch = fullText.match(/:tokens\s+"[^"]{0,60}/);
|
|
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
|
|
const strDictMatch = fullText.match(/\{"(?:type|tag|expr|text|cls|step)"\s[^}]{0,80}/);
|
|
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
|
|
const sxListMatch = fullText.match(/\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\s+:(?:class|id|style)/);
|
|
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
|
|
return leaks;
|
|
});
|
|
}, CODE_DISPLAY_SELECTORS);
|
|
|
|
// Handler audit for each island
|
|
const handlers = await page.$$eval(selector, (els) => {
|
|
return els.map(el => {
|
|
const name = el.getAttribute('data-sx-island');
|
|
const buttons = [...el.querySelectorAll('button')];
|
|
const inputs = [...el.querySelectorAll('input, textarea, select')];
|
|
|
|
const buttonAudit = buttons.map((btn, i) => {
|
|
const hasOnclick = btn.onclick !== null;
|
|
const hasReactive = btn.hasAttribute('data-sx-reactive-attrs');
|
|
// SX wires handlers via addEventListener, not onclick property.
|
|
// We can't detect addEventListener from JS. Heuristic: if the
|
|
// button has reactive attrs, it was rendered by an island and
|
|
// likely has handlers. Only flag buttons with NO island markers.
|
|
const inIsland = !!btn.closest('[data-sx-island]');
|
|
const likelyWired = hasOnclick || (inIsland && hasReactive);
|
|
return {
|
|
text: btn.textContent.trim().substring(0, 20),
|
|
hasOnclick,
|
|
reactiveAttrs: hasReactive,
|
|
inIsland,
|
|
wired: likelyWired ? 'yes' : (inIsland ? 'probable' : 'unknown')
|
|
};
|
|
});
|
|
|
|
const inputAudit = inputs.map((inp, i) => {
|
|
const hasBind = inp.hasAttribute('data-sx-bind');
|
|
const hasOnInput = inp.oninput !== null;
|
|
const inIsland = !!inp.closest('[data-sx-island]');
|
|
return {
|
|
type: inp.type || inp.tagName.toLowerCase(),
|
|
hasBind,
|
|
hasOnInput,
|
|
inIsland,
|
|
wired: hasOnInput || hasBind ? 'yes' : (inIsland ? 'probable' : 'unknown')
|
|
};
|
|
});
|
|
|
|
const suspectButtons = buttonAudit.filter(b => b.wired === 'unknown');
|
|
const suspectInputs = inputAudit.filter(i => i.wired === 'unknown');
|
|
|
|
return {
|
|
island: name,
|
|
buttons: buttonAudit.length,
|
|
inputs: inputAudit.length,
|
|
suspectButtons: suspectButtons.length,
|
|
suspectInputs: suspectInputs.length,
|
|
allButtons: buttonAudit,
|
|
detail: suspectButtons.length + suspectInputs.length > 0
|
|
? { suspectButtons, suspectInputs }
|
|
: undefined
|
|
};
|
|
});
|
|
});
|
|
|
|
const globalLeaks = await page.$eval('#sx-root', el => {
|
|
const text = el.textContent;
|
|
const leaks = [];
|
|
const dictMatch = text.match(/\{:(?:type|tag|expr)\s[^}]{0,80}/);
|
|
if (dictMatch) leaks.push({ kind: 'raw-dict-outside-island', sample: dictMatch[0] });
|
|
return leaks;
|
|
}).catch(() => []);
|
|
|
|
return { url, islands, leaks: [...leaks, ...globalLeaks], handlers, pageErrors };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: diff — SSR vs hydrated DOM (full page)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeDiff(browser, url) {
|
|
const snap = snapshotScript();
|
|
|
|
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
|
|
const ssrPage = await ssrCtx.newPage();
|
|
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
const ssrTree = await ssrPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
|
|
const ssrText = await ssrPage.evaluate(() => {
|
|
const root = document.getElementById('sx-root');
|
|
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
|
|
});
|
|
await ssrCtx.close();
|
|
|
|
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
|
|
const hydPage = await hydCtx.newPage();
|
|
const pageErrors = [];
|
|
hydPage.on('pageerror', err => pageErrors.push(err.message));
|
|
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(hydPage);
|
|
const hydTree = await hydPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
|
|
const hydText = await hydPage.evaluate(() => {
|
|
const root = document.getElementById('sx-root');
|
|
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
|
|
});
|
|
await hydCtx.close();
|
|
|
|
const changes = diffTrees(ssrTree, hydTree);
|
|
const textMatch = ssrText === hydText;
|
|
|
|
return {
|
|
url,
|
|
ssrTextPreview: ssrText.substring(0, 300),
|
|
hydratedTextPreview: hydText.substring(0, 300),
|
|
textMatch,
|
|
structuralChanges: changes.length,
|
|
changes: changes.slice(0, 50),
|
|
pageErrors
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: hydrate — SSR vs hydrated comparison focused on lakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeHydrate(browser, url) {
|
|
// SSR: capture lake innerHTML without JS
|
|
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
|
|
const ssrPage = await ssrCtx.newPage();
|
|
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
|
|
const ssrLakes = await ssrPage.$$eval('[data-sx-lake]', els =>
|
|
els.map(el => ({
|
|
id: el.getAttribute('data-sx-lake'),
|
|
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
|
|
html: el.innerHTML.substring(0, 500),
|
|
hasElements: el.children.length > 0,
|
|
text: el.textContent.trim().substring(0, 200)
|
|
})));
|
|
|
|
const ssrIslands = await ssrPage.$$eval('[data-sx-island]', els =>
|
|
els.map(el => ({
|
|
name: el.getAttribute('data-sx-island'),
|
|
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
|
|
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
|
|
})));
|
|
|
|
await ssrCtx.close();
|
|
|
|
// Hydrated: capture same after JS runs
|
|
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
|
|
const hydPage = await hydCtx.newPage();
|
|
const pageErrors = [];
|
|
hydPage.on('pageerror', err => pageErrors.push(err.message));
|
|
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(hydPage);
|
|
|
|
const hydLakes = await hydPage.$$eval('[data-sx-lake]', els =>
|
|
els.map(el => ({
|
|
id: el.getAttribute('data-sx-lake'),
|
|
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
|
|
html: el.innerHTML.substring(0, 500),
|
|
hasElements: el.children.length > 0,
|
|
text: el.textContent.trim().substring(0, 200)
|
|
})));
|
|
|
|
const hydIslands = await hydPage.$$eval('[data-sx-island]', els =>
|
|
els.map(el => ({
|
|
name: el.getAttribute('data-sx-island'),
|
|
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
|
|
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
|
|
})));
|
|
|
|
await hydCtx.close();
|
|
|
|
// Compare lakes
|
|
const lakeReport = [];
|
|
const ssrMap = Object.fromEntries(ssrLakes.map(l => [l.id, l]));
|
|
const hydMap = Object.fromEntries(hydLakes.map(l => [l.id, l]));
|
|
const allIds = new Set([...ssrLakes.map(l => l.id), ...hydLakes.map(l => l.id)]);
|
|
|
|
for (const id of allIds) {
|
|
const ssr = ssrMap[id];
|
|
const hyd = hydMap[id];
|
|
const entry = { id, island: ssr?.island || hyd?.island };
|
|
|
|
if (!ssr) { entry.status = 'added-by-hydration'; }
|
|
else if (!hyd) { entry.status = 'removed-by-hydration'; }
|
|
else if (ssr.html === hyd.html) { entry.status = 'identical'; }
|
|
else {
|
|
const ssrHasEls = ssr.hasElements;
|
|
const hydHasEls = hyd.hasElements;
|
|
if (ssrHasEls && !hydHasEls) {
|
|
entry.status = 'CLOBBERED';
|
|
entry.detail = 'SSR had DOM elements, hydration replaced with text';
|
|
entry.ssrPreview = ssr.text.substring(0, 100);
|
|
entry.hydPreview = hyd.text.substring(0, 100);
|
|
} else if (!ssrHasEls && hydHasEls) {
|
|
entry.status = 'upgraded';
|
|
entry.detail = 'SSR had text, hydration rendered DOM';
|
|
} else {
|
|
entry.status = 'changed';
|
|
entry.ssrPreview = ssr.text.substring(0, 100);
|
|
entry.hydPreview = hyd.text.substring(0, 100);
|
|
}
|
|
}
|
|
lakeReport.push(entry);
|
|
}
|
|
|
|
// Compare islands
|
|
const islandReport = [];
|
|
const ssrIslandMap = Object.fromEntries(ssrIslands.map(i => [i.name, i]));
|
|
const hydIslandMap = Object.fromEntries(hydIslands.map(i => [i.name, i]));
|
|
|
|
for (const name of new Set([...ssrIslands.map(i => i.name), ...hydIslands.map(i => i.name)])) {
|
|
const ssr = ssrIslandMap[name];
|
|
const hyd = hydIslandMap[name];
|
|
const entry = { name };
|
|
|
|
if (ssr?.hasError) entry.ssrError = true;
|
|
if (hyd?.hasError) entry.hydrationError = true;
|
|
if (ssr && hyd && ssr.text === hyd.text) entry.textMatch = true;
|
|
else if (ssr && hyd) {
|
|
entry.textMatch = false;
|
|
entry.ssrPreview = ssr.text.substring(0, 100);
|
|
entry.hydPreview = hyd.text.substring(0, 100);
|
|
}
|
|
islandReport.push(entry);
|
|
}
|
|
|
|
const clobbered = lakeReport.filter(l => l.status === 'CLOBBERED');
|
|
|
|
return {
|
|
url,
|
|
summary: {
|
|
lakes: lakeReport.length,
|
|
identical: lakeReport.filter(l => l.status === 'identical').length,
|
|
clobbered: clobbered.length,
|
|
changed: lakeReport.filter(l => l.status === 'changed').length
|
|
},
|
|
lakes: lakeReport,
|
|
islands: islandReport,
|
|
pageErrors
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: eval — evaluate JS expression
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeEval(page, url, expr) {
|
|
// Capture ALL console during page load (before goto)
|
|
const bootLog = [];
|
|
page.on('console', msg => {
|
|
bootLog.push({ type: msg.type(), text: msg.text().slice(0, 300) });
|
|
});
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
const result = await page.evaluate(expr);
|
|
// Include boot log: errors always, all [sx] lines on request
|
|
const issues = bootLog.filter(l => l.type === 'error' || l.type === 'warning' || l.text.includes('FAIL') || l.text.includes('Error') || l.text.startsWith('[sx]'));
|
|
return { url, expr, result, bootLog: issues.length > 0 ? issues : undefined };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: interact — action sequence
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeInteract(page, url, actionsStr) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
// Capture console during interaction
|
|
const consoleLogs = [];
|
|
page.on('console', msg => {
|
|
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 300) });
|
|
});
|
|
|
|
const actions = actionsStr.split(';').map(a => a.trim()).filter(Boolean);
|
|
const results = [];
|
|
|
|
for (const action of actions) {
|
|
const [cmd, ...rest] = action.split(':');
|
|
const arg = rest.join(':');
|
|
|
|
try {
|
|
switch (cmd) {
|
|
case 'click':
|
|
await page.locator(arg).first().click();
|
|
results.push({ action: 'click', selector: arg, ok: true });
|
|
break;
|
|
case 'fill': {
|
|
const [sel, ...valParts] = arg.split(':');
|
|
const val = valParts.join(':');
|
|
await page.locator(sel).first().fill(val);
|
|
results.push({ action: 'fill', selector: sel, value: val, ok: true });
|
|
break;
|
|
}
|
|
case 'wait':
|
|
await page.waitForTimeout(parseInt(arg) || 500);
|
|
results.push({ action: 'wait', ms: parseInt(arg) || 500 });
|
|
break;
|
|
case 'text': {
|
|
const text = await page.locator(arg).first().textContent();
|
|
results.push({ action: 'text', selector: arg, value: text?.trim() });
|
|
break;
|
|
}
|
|
case 'html': {
|
|
const html = await page.locator(arg).first().innerHTML();
|
|
results.push({ action: 'html', selector: arg, value: html?.substring(0, 500) });
|
|
break;
|
|
}
|
|
case 'attrs': {
|
|
const attrs = await page.locator(arg).first().evaluate(el => {
|
|
const a = {};
|
|
for (const attr of el.attributes) a[attr.name] = attr.value.substring(0, 200);
|
|
return a;
|
|
});
|
|
results.push({ action: 'attrs', selector: arg, value: attrs });
|
|
break;
|
|
}
|
|
case 'screenshot': {
|
|
const ts = Date.now();
|
|
const path = `${SCREENSHOT_DIR}/sx-inspect-${ts}.png`;
|
|
if (arg) {
|
|
await page.locator(arg).first().screenshot({ path });
|
|
} else {
|
|
await page.screenshot({ path });
|
|
}
|
|
results.push({ action: 'screenshot', selector: arg || 'full-page', path });
|
|
break;
|
|
}
|
|
case 'count': {
|
|
const count = await page.locator(arg).count();
|
|
results.push({ action: 'count', selector: arg, value: count });
|
|
break;
|
|
}
|
|
case 'visible': {
|
|
const visible = await page.locator(arg).first().isVisible().catch(() => false);
|
|
results.push({ action: 'visible', selector: arg, value: visible });
|
|
break;
|
|
}
|
|
default:
|
|
results.push({ action: cmd, error: 'unknown action' });
|
|
}
|
|
} catch (e) {
|
|
results.push({ action: cmd, selector: arg, error: e.message });
|
|
}
|
|
}
|
|
|
|
// Include console errors/warnings in output
|
|
const errors = consoleLogs.filter(l => l.type === 'error' || l.type === 'warning');
|
|
return { url, results, console: errors.length > 0 ? errors : undefined };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: screenshot
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeScreenshot(page, url, selector) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const ts = Date.now();
|
|
const path = `${SCREENSHOT_DIR}/sx-screenshot-${ts}.png`;
|
|
|
|
if (selector) {
|
|
await page.locator(selector).first().screenshot({ path });
|
|
} else {
|
|
await page.screenshot({ path });
|
|
}
|
|
|
|
return { url, selector: selector || 'full-page', path };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: listeners — CDP event listener inspection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeListeners(page, url, selector) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const results = {};
|
|
|
|
// Helper: get listeners for a JS expression
|
|
async function getListeners(expr, label) {
|
|
try {
|
|
const { result } = await cdp.send('Runtime.evaluate', { expression: expr });
|
|
if (!result?.objectId) return [];
|
|
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
|
objectId: result.objectId, depth: 0,
|
|
});
|
|
return listeners.map(l => ({
|
|
type: l.type,
|
|
useCapture: l.useCapture,
|
|
passive: l.passive,
|
|
once: l.once,
|
|
handler: l.handler?.description?.slice(0, 200) || 'native',
|
|
scriptId: l.scriptId,
|
|
lineNumber: l.lineNumber,
|
|
columnNumber: l.columnNumber,
|
|
}));
|
|
} catch (e) { return [{ error: e.message }]; }
|
|
}
|
|
|
|
// Get listeners on the target element(s)
|
|
if (selector) {
|
|
const els = await page.$$(selector);
|
|
for (let i = 0; i < Math.min(els.length, 5); i++) {
|
|
const { result } = await cdp.send('Runtime.evaluate', {
|
|
expression: `document.querySelectorAll(${JSON.stringify(selector)})[${i}]`,
|
|
});
|
|
if (result?.objectId) {
|
|
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
|
objectId: result.objectId, depth: 0,
|
|
});
|
|
const tag = await page.evaluate(
|
|
({sel, idx}) => {
|
|
const el = document.querySelectorAll(sel)[idx];
|
|
return el ? `<${el.tagName.toLowerCase()} ${el.getAttribute('href') || el.getAttribute('data-sx-island') || ''}>` : '?';
|
|
}, {sel: selector, idx: i});
|
|
results[`element[${i}] ${tag}`] = listeners.map(l => ({
|
|
type: l.type,
|
|
handler: l.handler?.description?.slice(0, 150) || 'native',
|
|
capture: l.useCapture,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Document listeners
|
|
results['document'] = (await getListeners('document', 'document'))
|
|
.filter(l => ['click', 'submit', 'input', 'change', 'keydown', 'keyup'].includes(l.type));
|
|
|
|
// Window listeners
|
|
results['window'] = (await getListeners('window', 'window'))
|
|
.filter(l => ['click', 'popstate', 'hashchange', 'beforeunload', 'load', 'DOMContentLoaded'].includes(l.type));
|
|
|
|
return { url, selector, listeners: results };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: trace — click and capture full execution trace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeTrace(page, url, selector) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const trace = {
|
|
console: [],
|
|
network: [],
|
|
pushState: [],
|
|
errors: [],
|
|
};
|
|
|
|
// Capture console
|
|
page.on('console', msg => {
|
|
trace.console.push({
|
|
type: msg.type(),
|
|
text: msg.text().slice(0, 300),
|
|
});
|
|
});
|
|
|
|
// Capture page errors
|
|
page.on('pageerror', err => {
|
|
trace.errors.push(err.message.slice(0, 300));
|
|
});
|
|
|
|
// Capture network requests
|
|
page.on('request', req => {
|
|
if (req.resourceType() === 'document' || req.resourceType() === 'xhr' || req.resourceType() === 'fetch') {
|
|
trace.network.push({
|
|
type: req.resourceType(),
|
|
method: req.method(),
|
|
url: req.url(),
|
|
isNav: req.isNavigationRequest(),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Inject pushState/replaceState monitoring
|
|
await page.evaluate(() => {
|
|
for (const method of ['pushState', 'replaceState']) {
|
|
const orig = history[method];
|
|
history[method] = function() {
|
|
console.log(`[spa-trace] ${method}: ${arguments[2]}`);
|
|
return orig.apply(this, arguments);
|
|
};
|
|
}
|
|
});
|
|
|
|
// Snapshot before
|
|
const before = await page.evaluate(() => ({
|
|
url: location.href,
|
|
title: document.title,
|
|
}));
|
|
|
|
// Perform the action
|
|
if (!selector) {
|
|
return { error: 'trace mode requires a selector to click' };
|
|
}
|
|
|
|
const el = page.locator(selector).first();
|
|
const elInfo = await page.evaluate((sel) => {
|
|
const el = document.querySelector(sel);
|
|
return el ? { tag: el.tagName, text: el.textContent?.slice(0, 50), href: el.getAttribute('href') } : null;
|
|
}, selector);
|
|
|
|
let navigated = false;
|
|
page.once('framenavigated', () => { navigated = true; });
|
|
|
|
await el.click();
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Snapshot after
|
|
const after = await page.evaluate(() => ({
|
|
url: location.href,
|
|
title: document.title,
|
|
}));
|
|
|
|
return {
|
|
url,
|
|
selector,
|
|
element: elInfo,
|
|
before,
|
|
after,
|
|
navigated,
|
|
urlChanged: before.url !== after.url,
|
|
trace,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: cdp — raw Chrome DevTools Protocol command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeCdp(page, url, expr) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
|
|
// expr format: "Domain.method {json params}" e.g. "Runtime.evaluate {\"expression\":\"1+1\"}"
|
|
const spaceIdx = expr.indexOf(' ');
|
|
const method = spaceIdx > 0 ? expr.slice(0, spaceIdx) : expr;
|
|
const params = spaceIdx > 0 ? JSON.parse(expr.slice(spaceIdx + 1)) : {};
|
|
|
|
try {
|
|
const result = await cdp.send(method, params);
|
|
return { method, params, result };
|
|
} catch (e) {
|
|
return { method, params, error: e.message };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: reactive — debug reactive island signal/DOM state across interactions
|
|
//
|
|
// Instruments the live WASM kernel's signal system via global set! hooks,
|
|
// tags island DOM elements for stability tracking, then runs a sequence of
|
|
// actions. After each action, captures:
|
|
// - DOM text of reactive elements
|
|
// - Node stability (same/replaced/new)
|
|
// - Signal trace: swap!, set!, flush, add-sub, remove-sub, deref context
|
|
// - Console errors
|
|
//
|
|
// Hook strategy: SX-defined globals (swap!, deref, signal-set-value!, etc.)
|
|
// can be wrapped via set! because bytecode calls them through GLOBAL_GET.
|
|
// OCaml-native primitives (scope-push!, scope-peek) use CALL_PRIM and
|
|
// bypass globals — those are observed indirectly via deref's context check.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const REACTIVE_HOOKS = `
|
|
(do
|
|
;; signal-set-value! — log every signal mutation
|
|
(let ((orig signal-set-value!))
|
|
(set! signal-set-value!
|
|
(fn (s v)
|
|
(log-info (str "RX:SIG:" (signal-value s) "->" v))
|
|
(orig s v))))
|
|
|
|
;; flush-subscribers — log subscriber count at flush time
|
|
(let ((orig flush-subscribers))
|
|
(set! flush-subscribers
|
|
(fn (s)
|
|
(log-info (str "RX:FLUSH:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
|
|
(orig s))))
|
|
|
|
;; swap! — log the swap call (note: 2 explicit params, no &rest)
|
|
(let ((orig swap!))
|
|
(set! swap!
|
|
(fn (s f)
|
|
(log-info (str "RX:SWAP:old=" (signal-value s)))
|
|
(orig s f))))
|
|
|
|
;; signal-add-sub! — log re-subscriptions
|
|
(let ((orig signal-add-sub!))
|
|
(set! signal-add-sub!
|
|
(fn (s f)
|
|
(log-info (str "RX:ADD:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
|
|
(orig s f))))
|
|
|
|
;; signal-remove-sub! — log unsubscriptions
|
|
(let ((orig signal-remove-sub!))
|
|
(set! signal-remove-sub!
|
|
(fn (s f)
|
|
(log-info (str "RX:RM:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
|
|
(orig s f))))
|
|
|
|
;; deref — log whether reactive context is visible (the key diagnostic)
|
|
(let ((orig deref))
|
|
(set! deref
|
|
(fn (s)
|
|
(when (signal? s)
|
|
(let ((ctx (scope-peek "sx-reactive")))
|
|
(log-info (str "RX:DEREF:ctx=" (if (nil? ctx) "nil" "ok") ":val=" (signal-value s)))))
|
|
(orig s))))
|
|
|
|
true)
|
|
`;
|
|
|
|
async function modeReactive(page, url, island, actionsStr) {
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
const consoleLogs = [];
|
|
page.on('console', msg => {
|
|
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
|
|
});
|
|
|
|
// Install hooks + tag elements
|
|
const setup = await page.evaluate(({ islandName, hooks }) => {
|
|
const K = window.SxKernel;
|
|
if (!K || !K.eval) return { ok: false, error: 'SxKernel not found' };
|
|
|
|
try { K.eval(hooks); }
|
|
catch (e) { return { ok: false, error: 'Hook install: ' + e.message }; }
|
|
|
|
const el = document.querySelector(`[data-sx-island="${islandName}"]`);
|
|
if (!el) return { ok: false, error: `Island "${islandName}" not found` };
|
|
|
|
const all = el.querySelectorAll('*');
|
|
for (let i = 0; i < all.length; i++) all[i].__rxId = i;
|
|
return { ok: true, tagged: all.length };
|
|
}, { islandName: island, hooks: REACTIVE_HOOKS });
|
|
|
|
if (!setup.ok) return { url, island, error: setup.error };
|
|
|
|
// --- Snapshot: DOM state + node stability ---
|
|
const snapshot = async () => {
|
|
return page.evaluate((name) => {
|
|
const root = document.querySelector(`[data-sx-island="${name}"]`);
|
|
if (!root) return { error: 'island gone' };
|
|
|
|
// Reactive element text
|
|
const dom = {};
|
|
for (const el of root.querySelectorAll('[data-sx-reactive-attrs]')) {
|
|
const tag = el.tagName.toLowerCase();
|
|
const cls = (el.className || '').split(' ').find(c => /^(text-|font-)/.test(c)) || '';
|
|
let key = tag + (cls ? '.' + cls : '');
|
|
while (dom[key] !== undefined) key += '+';
|
|
dom[key] = el.textContent?.trim().slice(0, 120);
|
|
}
|
|
|
|
// Node stability
|
|
const all = root.querySelectorAll('*');
|
|
let same = 0, fresh = 0;
|
|
for (const el of all) {
|
|
if (el.__rxId !== undefined) same++; else fresh++;
|
|
}
|
|
|
|
// Buttons
|
|
const buttons = Array.from(root.querySelectorAll('button')).map(b => ({
|
|
text: b.textContent?.trim(), same: b.__rxId !== undefined
|
|
}));
|
|
|
|
return { dom, nodes: { same, fresh, total: all.length }, buttons };
|
|
}, island);
|
|
};
|
|
|
|
// --- Drain console logs between steps ---
|
|
let cursor = consoleLogs.length;
|
|
const drain = () => {
|
|
const fresh = consoleLogs.slice(cursor);
|
|
cursor = consoleLogs.length;
|
|
const rx = fresh.filter(l => l.text.includes('RX:')).map(l => l.text.replace('[sx] ', ''));
|
|
const errors = fresh.filter(l => l.type === 'error').map(l => l.text);
|
|
return { rx, errors: errors.length ? errors : undefined };
|
|
};
|
|
|
|
// --- Run ---
|
|
const steps = [];
|
|
steps.push({ step: 'initial', ...await snapshot(), ...drain() });
|
|
|
|
for (const action of (actionsStr || '').split(';').map(s => s.trim()).filter(Boolean)) {
|
|
const [cmd, ...rest] = action.split(':');
|
|
const arg = rest.join(':');
|
|
try {
|
|
if (cmd === 'click') {
|
|
await page.locator(arg).first().click();
|
|
await page.waitForTimeout(150);
|
|
} else if (cmd === 'wait') {
|
|
await page.waitForTimeout(parseInt(arg) || 200);
|
|
} else {
|
|
steps.push({ step: action, error: 'unknown' });
|
|
continue;
|
|
}
|
|
} catch (e) {
|
|
steps.push({ step: action, error: e.message });
|
|
continue;
|
|
}
|
|
steps.push({ step: action, ...await snapshot(), ...drain() });
|
|
}
|
|
|
|
return { url, island, tagged: setup.tagged, steps };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: trace-boot — full console capture during boot (ALL prefixes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeTraceBoot(page, url, filterPrefix) {
|
|
const allLogs = [];
|
|
page.on('console', msg => {
|
|
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
|
|
});
|
|
page.on('pageerror', err => {
|
|
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
|
|
});
|
|
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
// Categorise
|
|
const phases = { platform: [], boot: [], errors: [], warnings: [], other: [] };
|
|
for (const l of allLogs) {
|
|
if (l.type === 'error' || l.type === 'pageerror') phases.errors.push(l);
|
|
else if (l.type === 'warning') phases.warnings.push(l);
|
|
else if (l.text.startsWith('[sx-platform]')) phases.platform.push(l);
|
|
else if (l.text.startsWith('[sx]')) phases.boot.push(l);
|
|
else phases.other.push(l);
|
|
}
|
|
|
|
// Apply optional prefix filter
|
|
const filtered = filterPrefix
|
|
? allLogs.filter(l => l.text.includes(filterPrefix))
|
|
: null;
|
|
|
|
return {
|
|
url,
|
|
total: allLogs.length,
|
|
summary: {
|
|
platform: phases.platform.length,
|
|
boot: phases.boot.length,
|
|
errors: phases.errors.length,
|
|
warnings: phases.warnings.length,
|
|
other: phases.other.length,
|
|
},
|
|
phases,
|
|
...(filtered ? { filtered } : {}),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: hydrate-debug — re-run hydration on one island with full tracing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeHydrateDebug(page, url, islandName) {
|
|
const allLogs = [];
|
|
page.on('console', msg => {
|
|
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
|
|
});
|
|
page.on('pageerror', err => {
|
|
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
|
|
});
|
|
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
// Capture SSR HTML before re-hydration
|
|
const ssrHtml = await page.evaluate((name) => {
|
|
const el = name
|
|
? document.querySelector(`[data-sx-island="${name}"]`)
|
|
: document.querySelector('[data-sx-island]');
|
|
return el ? { name: el.getAttribute('data-sx-island'), html: el.innerHTML.substring(0, 2000) } : null;
|
|
}, islandName);
|
|
|
|
if (!ssrHtml) {
|
|
return { url, error: `Island not found: ${islandName || '(first)'}` };
|
|
}
|
|
|
|
const targetIsland = ssrHtml.name;
|
|
|
|
// Mark console position
|
|
const logCursorBefore = allLogs.length;
|
|
|
|
// Run instrumented re-hydration: clear flag, inject tracing, re-hydrate
|
|
const result = await page.evaluate((name) => {
|
|
const K = globalThis.SxKernel;
|
|
if (!K) return { error: 'SxKernel not available' };
|
|
|
|
const el = document.querySelector(`[data-sx-island="${name}"]`);
|
|
if (!el) return { error: `Element not found: ${name}` };
|
|
|
|
// Clear hydration flag
|
|
el.removeAttribute('data-sx-hydrated');
|
|
delete el._sxBoundislandhydrated;
|
|
delete el['_sxBound' + 'island-hydrated'];
|
|
|
|
// Check env state
|
|
const compName = '~' + name;
|
|
const checks = {};
|
|
checks.compType = K.eval(`(type-of ${compName})`);
|
|
checks.renderDomList = K.eval('(type-of render-dom-list)');
|
|
checks.cssx = K.eval('(type-of ~cssx/tw)');
|
|
checks.stateAttr = el.getAttribute('data-sx-state') || '{}';
|
|
|
|
// Parse state
|
|
window.__hd_state = checks.stateAttr;
|
|
checks.stateParsed = K.eval('(inspect (or (first (sx-parse (host-global "__hd_state"))) {}))');
|
|
|
|
// Get component params
|
|
checks.params = K.eval(`(inspect (component-params (env-get (global-env) "${compName}")))`);
|
|
|
|
// Manual render with error capture (NOT cek-try — let errors propagate)
|
|
let renderResult;
|
|
try {
|
|
window.__hd_state2 = checks.stateAttr;
|
|
const rendered = K.eval(`
|
|
(let ((comp (env-get (global-env) "${compName}"))
|
|
(kwargs (or (first (sx-parse (host-global "__hd_state2"))) {})))
|
|
(let ((local (env-merge (component-closure comp) (get-render-env nil))))
|
|
(for-each
|
|
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
|
(component-params comp))
|
|
(let ((el (render-to-dom (component-body comp) local nil)))
|
|
(host-get el "outerHTML"))))
|
|
`);
|
|
renderResult = { ok: true, html: (typeof rendered === 'string' ? rendered.substring(0, 2000) : String(rendered).substring(0, 2000)) };
|
|
} catch (e) {
|
|
renderResult = { ok: false, error: e.message };
|
|
}
|
|
|
|
// Now run the actual hydrate-island via boot.sx
|
|
try {
|
|
K.eval(`(hydrate-island (host-global "__hd_el"))`);
|
|
} catch (e) {
|
|
// ignore — we already got the manual render
|
|
}
|
|
|
|
// Capture hydrated HTML
|
|
const hydratedHtml = el.innerHTML.substring(0, 2000);
|
|
|
|
delete window.__hd_state;
|
|
delete window.__hd_state2;
|
|
|
|
return { checks, renderResult, hydratedHtml };
|
|
}, targetIsland);
|
|
|
|
// Collect console messages from the re-hydration
|
|
const hydrateLogs = allLogs.slice(logCursorBefore);
|
|
|
|
// Compare SSR vs hydrated
|
|
const ssrClasses = (ssrHtml.html.match(/class="[^"]*"/g) || []).length;
|
|
const hydClasses = (result.hydratedHtml || '').match(/class="[^"]*"/g) || [];
|
|
const manualClasses = (result.renderResult?.html || '').match(/class="[^"]*"/g) || [];
|
|
|
|
return {
|
|
url,
|
|
island: targetIsland,
|
|
ssrHtml: ssrHtml.html.substring(0, 500),
|
|
checks: result.checks,
|
|
manualRender: result.renderResult,
|
|
hydratedHtml: (result.hydratedHtml || '').substring(0, 500),
|
|
classCounts: {
|
|
ssr: ssrClasses,
|
|
manual: manualClasses.length,
|
|
hydrated: hydClasses.length,
|
|
},
|
|
hydrateLogs: hydrateLogs.length > 0 ? hydrateLogs : undefined,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode: eval-at — inject eval breakpoint at a specific boot phase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function modeEvalAt(browser, url, phase, expr) {
|
|
const validPhases = [
|
|
'before-modules', 'after-modules',
|
|
'before-pages', 'after-pages',
|
|
'before-components', 'after-components',
|
|
'before-hydrate', 'after-hydrate',
|
|
'after-boot',
|
|
];
|
|
if (!validPhases.includes(phase)) {
|
|
return { error: `Invalid phase: ${phase}. Valid: ${validPhases.join(', ')}` };
|
|
}
|
|
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
|
|
const allLogs = [];
|
|
page.on('console', msg => {
|
|
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
|
|
});
|
|
|
|
// Inject a hook that pauses the boot at the desired phase.
|
|
// We do this by intercepting sx-platform-2.js and injecting eval calls.
|
|
await page.route('**/*sx-platform-2.js*', async (route) => {
|
|
const resp = await route.fetch();
|
|
let body = await resp.text();
|
|
|
|
// Pass expression via a global to avoid JS/SX quoting hell.
|
|
// K.eval() handles parsing internally, so we just pass the SX string.
|
|
// Also set up common DOM helpers as globals for easy SX access.
|
|
const evalCode = [
|
|
`window.__sxEvalAtExpr = ${JSON.stringify(expr)};`,
|
|
`window.__sxEl0 = document.querySelectorAll('[data-sx-island]')[0] || null;`,
|
|
`window.__sxEl1 = document.querySelectorAll('[data-sx-island]')[1] || null;`,
|
|
`try { console.log("[sx-eval-at] ${phase}: " + K.eval(window.__sxEvalAtExpr)); }`,
|
|
`catch(e) { console.log("[sx-eval-at] ${phase}: JS-ERROR: " + e.message); }`,
|
|
].join('\n ');
|
|
|
|
// Map phase names to injection points in the platform code
|
|
const injections = {
|
|
'before-modules': { before: 'loadWebStack();' },
|
|
'after-modules': { after: 'loadWebStack();' },
|
|
'before-pages': { before: 'K.eval("(process-page-scripts)");' },
|
|
'after-pages': { after: 'K.eval("(process-page-scripts)");' },
|
|
'before-components': { before: 'K.eval("(process-sx-scripts nil)");' },
|
|
'after-components': { after: 'K.eval("(process-sx-scripts nil)");' },
|
|
'before-hydrate': { before: 'K.eval("(sx-hydrate-islands nil)");' },
|
|
'after-hydrate': { after: 'K.eval("(sx-hydrate-islands nil)");' },
|
|
'after-boot': { after: 'console.log("[sx] boot done");' },
|
|
};
|
|
|
|
const inj = injections[phase];
|
|
if (inj.before) {
|
|
body = body.replace(inj.before, evalCode + '\n ' + inj.before);
|
|
} else if (inj.after) {
|
|
body = body.replace(inj.after, inj.after + '\n ' + evalCode);
|
|
}
|
|
|
|
await route.fulfill({ body, headers: { ...resp.headers(), 'content-type': 'application/javascript' } });
|
|
});
|
|
|
|
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
|
|
await waitForHydration(page);
|
|
|
|
// Extract our eval-at result from console
|
|
const evalAtLogs = allLogs.filter(l => l.text.startsWith('[sx-eval-at]'));
|
|
const evalResult = evalAtLogs.length > 0
|
|
? evalAtLogs[0].text.replace(`[sx-eval-at] ${phase}: `, '')
|
|
: 'INJECTION FAILED — phase marker not found in platform code';
|
|
|
|
// Also collect boot sequence for context
|
|
const bootLogs = allLogs.filter(l =>
|
|
l.text.startsWith('[sx]') || l.text.startsWith('[sx-platform]') || l.text.startsWith('[sx-eval-at]') ||
|
|
l.type === 'error' || l.type === 'warning'
|
|
);
|
|
|
|
await context.close();
|
|
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
|
|
}
|
|
|
|
async function main() {
|
|
const argsJson = process.argv[2] || '{}';
|
|
let args;
|
|
try {
|
|
args = JSON.parse(argsJson);
|
|
} catch (e) {
|
|
console.log(JSON.stringify({ error: `Invalid JSON args: ${e.message}` }));
|
|
process.exit(1);
|
|
}
|
|
|
|
const mode = args.mode || 'inspect';
|
|
const url = args.url || '/';
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
|
|
try {
|
|
let result;
|
|
switch (mode) {
|
|
case 'inspect':
|
|
result = await modeInspect(page, url, args.island);
|
|
break;
|
|
case 'diff':
|
|
result = await modeDiff(browser, url);
|
|
break;
|
|
case 'hydrate':
|
|
result = await modeHydrate(browser, url);
|
|
break;
|
|
case 'eval':
|
|
result = await modeEval(page, url, args.expr || 'document.title');
|
|
break;
|
|
case 'interact':
|
|
result = await modeInteract(page, url, args.actions || '');
|
|
break;
|
|
case 'screenshot':
|
|
result = await modeScreenshot(page, url, args.selector);
|
|
break;
|
|
case 'listeners':
|
|
result = await modeListeners(page, url, args.selector || args.expr);
|
|
break;
|
|
case 'trace':
|
|
result = await modeTrace(page, url, args.selector || args.expr);
|
|
break;
|
|
case 'cdp':
|
|
result = await modeCdp(page, url, args.expr || '');
|
|
break;
|
|
case 'reactive':
|
|
result = await modeReactive(page, url, args.island || '', args.actions || '');
|
|
break;
|
|
case 'trace-boot':
|
|
result = await modeTraceBoot(page, url, args.filter || '');
|
|
break;
|
|
case 'hydrate-debug':
|
|
result = await modeHydrateDebug(page, url, args.island || '');
|
|
break;
|
|
case 'eval-at':
|
|
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
|
|
break;
|
|
default:
|
|
result = { error: `Unknown mode: ${mode}` };
|
|
}
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} catch (e) {
|
|
console.log(JSON.stringify({ error: e.message, stack: e.stack?.split('\n').slice(0, 5) }));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main();
|