#!/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(); })();