Build tooling: updated OCaml bootstrapper, compile-modules, bundle.sh, sx-build-all. WASM browser: rebuilt sx_browser.bc.js/wasm, sx-platform-2.js, .sxbc bytecode files. CSSX/Tailwind: reworked cssx.sx templates and tw-layout, added tw-type support. Content: refreshed essays, plans, geography, reactive islands, docs, demos, handlers. New tools: bisect_sxbc.sh, test-spa.js, render-trace.sx, morph playwright spec. Tests: added test-match.sx, test-examples.sx, updated test-tw.sx and web tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
227 lines
8.5 KiB
JavaScript
227 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* test-spa.js — Deep browser diagnostic for SPA navigation.
|
|
*
|
|
* Uses Chrome DevTools Protocol to inspect event listeners,
|
|
* trace click handling, and detect SPA vs full reload.
|
|
*
|
|
* Usage:
|
|
* node test-spa.js # bytecode mode
|
|
* node test-spa.js --source # source mode (nosxbc)
|
|
* node test-spa.js --headed # visible browser
|
|
*/
|
|
|
|
const { chromium } = require('playwright');
|
|
|
|
const args = process.argv.slice(2);
|
|
const sourceMode = args.includes('--source');
|
|
const headed = args.includes('--headed');
|
|
const baseUrl = 'http://localhost:8013/sx/';
|
|
const url = sourceMode ? baseUrl + '?nosxbc' : baseUrl;
|
|
const label = sourceMode ? 'SOURCE' : 'BYTECODE';
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: !headed });
|
|
const page = await browser.newPage();
|
|
|
|
// Capture console
|
|
page.on('console', msg => {
|
|
const t = msg.text();
|
|
if (t.startsWith('[spa-diag]') || t.includes('Not callable') || t.includes('Error:'))
|
|
console.log(` [browser] ${t}`);
|
|
});
|
|
|
|
console.log(`\n=== SPA Diagnostic: ${label} mode ===\n`);
|
|
await page.goto(url);
|
|
await page.waitForTimeout(5000);
|
|
|
|
// ----------------------------------------------------------------
|
|
// 1. Use CDP to get event listeners on a link
|
|
// ----------------------------------------------------------------
|
|
console.log('--- 1. Event listeners on Geography link ---');
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
|
|
const listeners = await page.evaluate(async () => {
|
|
const link = document.querySelector('a[href="/sx/(geography)"]');
|
|
if (!link) return { error: 'link not found' };
|
|
|
|
// We can't use getEventListeners from page context (it's a DevTools API)
|
|
// But we can check _sxBound* properties and enumerate own properties
|
|
const ownProps = {};
|
|
for (const k of Object.getOwnPropertyNames(link)) {
|
|
if (k.startsWith('_') || k.startsWith('on'))
|
|
ownProps[k] = typeof link[k];
|
|
}
|
|
|
|
// Check for jQuery-style event data
|
|
const jqData = link.__events || link._events || null;
|
|
|
|
return {
|
|
href: link.getAttribute('href'),
|
|
ownProps,
|
|
jqData: jqData ? 'present' : 'none',
|
|
onclick: link.onclick ? 'set' : 'null',
|
|
parentTag: link.parentElement?.tagName,
|
|
};
|
|
});
|
|
console.log(' Link props:', JSON.stringify(listeners, null, 2));
|
|
|
|
// Check should-boost-link? and why it returns false
|
|
const boostCheck = await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
const link = document.querySelectorAll('a[href]')[1]; // geography link
|
|
if (!link) return 'no link';
|
|
try {
|
|
// Check the conditions should-boost-link? checks
|
|
const href = link.getAttribute('href');
|
|
const checks = {
|
|
href,
|
|
hasBoostAttr: link.closest('[data-sx-boost]') ? 'yes' : 'no',
|
|
hasNoBoost: link.hasAttribute('data-sx-no-boost') ? 'yes' : 'no',
|
|
isExternal: href.startsWith('http') ? 'yes' : 'no',
|
|
isHash: href.startsWith('#') ? 'yes' : 'no',
|
|
};
|
|
// Try calling should-boost-link?
|
|
try { checks.shouldBoost = K.eval('(should-boost-link? (nth (dom-query-all (dom-body) "a[href]") 1))'); }
|
|
catch(e) { checks.shouldBoost = 'err: ' + e.message.slice(0, 80); }
|
|
return checks;
|
|
} catch(e) { return 'err: ' + e.message; }
|
|
});
|
|
console.log(' Boost check:', JSON.stringify(boostCheck, null, 2));
|
|
|
|
// Use CDP to get actual event listeners
|
|
const linkNode = await page.$('a[href="/sx/(geography)"]');
|
|
if (linkNode) {
|
|
const { object } = await cdp.send('Runtime.evaluate', {
|
|
expression: 'document.querySelector(\'a[href="/sx/(geography)"]\')',
|
|
});
|
|
if (object?.objectId) {
|
|
const { listeners: cdpListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
|
objectId: object.objectId,
|
|
depth: 0,
|
|
});
|
|
console.log(' CDP event listeners on link:', cdpListeners.length);
|
|
for (const l of cdpListeners) {
|
|
console.log(` ${l.type}: ${l.handler?.description?.slice(0, 100) || 'native'} (useCapture=${l.useCapture})`);
|
|
}
|
|
}
|
|
|
|
// Also check document-level click listeners
|
|
const { object: docObj } = await cdp.send('Runtime.evaluate', {
|
|
expression: 'document',
|
|
});
|
|
if (docObj?.objectId) {
|
|
const { listeners: docListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
|
objectId: docObj.objectId,
|
|
depth: 0,
|
|
});
|
|
const clickListeners = docListeners.filter(l => l.type === 'click');
|
|
console.log(' CDP document click listeners:', clickListeners.length);
|
|
for (const l of clickListeners) {
|
|
console.log(` ${l.type}: ${l.handler?.description?.slice(0, 120) || 'native'} (capture=${l.useCapture})`);
|
|
}
|
|
}
|
|
|
|
// Check window-level listeners too
|
|
const { object: winObj } = await cdp.send('Runtime.evaluate', {
|
|
expression: 'window',
|
|
});
|
|
if (winObj?.objectId) {
|
|
const { listeners: winListeners } = await cdp.send('DOMDebugger.getEventListeners', {
|
|
objectId: winObj.objectId,
|
|
depth: 0,
|
|
});
|
|
const winClick = winListeners.filter(l => l.type === 'click');
|
|
const winPop = winListeners.filter(l => l.type === 'popstate');
|
|
console.log(' CDP window click listeners:', winClick.length);
|
|
console.log(' CDP window popstate listeners:', winPop.length);
|
|
for (const l of winPop) {
|
|
console.log(` popstate: ${l.handler?.description?.slice(0, 120) || 'native'}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 2. Trace what happens when we click
|
|
// ----------------------------------------------------------------
|
|
console.log('\n--- 2. Click trace ---');
|
|
|
|
// Inject click tracing
|
|
await page.evaluate(() => {
|
|
// Trace click event propagation
|
|
const phases = ['NONE', 'CAPTURE', 'AT_TARGET', 'BUBBLE'];
|
|
document.addEventListener('click', function(e) {
|
|
console.log('[spa-diag] click CAPTURE on document: target=' + e.target.tagName +
|
|
' href=' + (e.target.getAttribute?.('href') || 'none') +
|
|
' defaultPrevented=' + e.defaultPrevented);
|
|
}, true);
|
|
|
|
document.addEventListener('click', function(e) {
|
|
console.log('[spa-diag] click BUBBLE on document: defaultPrevented=' + e.defaultPrevented +
|
|
' propagation=' + (e.cancelBubble ? 'stopped' : 'running'));
|
|
}, false);
|
|
|
|
// Monitor pushState
|
|
const origPush = history.pushState;
|
|
history.pushState = function() {
|
|
console.log('[spa-diag] pushState called: ' + JSON.stringify(arguments[2]));
|
|
return origPush.apply(this, arguments);
|
|
};
|
|
|
|
// Monitor replaceState
|
|
const origReplace = history.replaceState;
|
|
history.replaceState = function() {
|
|
console.log('[spa-diag] replaceState called: ' + JSON.stringify(arguments[2]));
|
|
return origReplace.apply(this, arguments);
|
|
};
|
|
});
|
|
|
|
// Detect full reload vs SPA by checking if a new page load happens
|
|
let fullReload = false;
|
|
let networkNav = false;
|
|
page.on('load', () => { fullReload = true; });
|
|
page.on('request', req => {
|
|
if (req.isNavigationRequest()) {
|
|
networkNav = true;
|
|
console.log(' [network] Navigation request:', req.url());
|
|
}
|
|
});
|
|
|
|
// Click the link
|
|
console.log(' Clicking /sx/(geography)...');
|
|
const urlBefore = page.url();
|
|
await page.click('a[href="/sx/(geography)"]');
|
|
await page.waitForTimeout(3000);
|
|
const urlAfter = page.url();
|
|
|
|
console.log(` URL: ${urlBefore.split('8013')[1]} → ${urlAfter.split('8013')[1]}`);
|
|
console.log(` Full reload: ${fullReload}`);
|
|
console.log(` Network navigation: ${networkNav}`);
|
|
|
|
// Check page content
|
|
const content = await page.evaluate(() => ({
|
|
title: document.title,
|
|
h1: document.querySelector('h1')?.textContent?.slice(0, 50) || 'none',
|
|
bodyLen: document.body.innerHTML.length,
|
|
}));
|
|
console.log(' Content:', JSON.stringify(content));
|
|
|
|
// ----------------------------------------------------------------
|
|
// 3. Check SX router state
|
|
// ----------------------------------------------------------------
|
|
console.log('\n--- 3. SX router state ---');
|
|
const routerState = await page.evaluate(() => {
|
|
const K = window.SxKernel;
|
|
if (!K) return { error: 'no kernel' };
|
|
const checks = {};
|
|
try { checks['_page-routes count'] = K.eval('(len _page-routes)'); } catch(e) { checks['_page-routes'] = e.message; }
|
|
try { checks['current-route'] = K.eval('(browser-location-pathname)'); } catch(e) { checks['current-route'] = e.message; }
|
|
return checks;
|
|
});
|
|
console.log(' Router:', JSON.stringify(routerState));
|
|
|
|
console.log('\n=== Done ===\n');
|
|
await browser.close();
|
|
})();
|