sx-tools: WASM kernel updates, TW/CSSX rework, content refresh, new debugging tools
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>
This commit is contained in:
105
hosts/ocaml/browser/bisect_sxbc.sh
Executable file
105
hosts/ocaml/browser/bisect_sxbc.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
# bisect_sxbc.sh — Binary search for which .sxbc file breaks reactive rendering.
|
||||
# Runs test_wasm.sh with SX_TEST_BYTECODE=1, toggling individual files between
|
||||
# bytecode and source to find the culprit.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../../.."
|
||||
|
||||
SXBC_DIR="shared/static/wasm/sx"
|
||||
BACKUP_DIR="/tmp/sxbc-bisect-backup"
|
||||
|
||||
# All .sxbc files in load order
|
||||
FILES=(
|
||||
render core-signals signals deps router page-helpers freeze
|
||||
bytecode compiler vm dom browser
|
||||
adapter-html adapter-sx adapter-dom
|
||||
cssx boot-helpers hypersx
|
||||
harness harness-reactive harness-web
|
||||
engine orchestration boot
|
||||
)
|
||||
|
||||
# Backup all sxbc files
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
for f in "${FILES[@]}"; do
|
||||
cp "$SXBC_DIR/$f.sxbc" "$BACKUP_DIR/$f.sxbc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Test function: returns 0 if the reactive scoped test passes
|
||||
test_passes() {
|
||||
local result
|
||||
result=$(SX_TEST_BYTECODE=1 bash hosts/ocaml/browser/test_wasm.sh 2>&1) || true
|
||||
if echo "$result" | grep -q "scoped static class"; then
|
||||
# Test mentioned = it failed
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Restore all bytecodes
|
||||
restore_all() {
|
||||
for f in "${FILES[@]}"; do
|
||||
cp "$BACKUP_DIR/$f.sxbc" "$SXBC_DIR/$f.sxbc" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
# Remove specific bytecodes (force source loading for those)
|
||||
remove_sxbc() {
|
||||
for f in "$@"; do
|
||||
rm -f "$SXBC_DIR/$f.sxbc"
|
||||
done
|
||||
}
|
||||
|
||||
echo "=== Bytecode bisect: finding which .sxbc breaks reactive rendering ==="
|
||||
echo " ${#FILES[@]} files to search"
|
||||
echo ""
|
||||
|
||||
# First: verify all-bytecode fails
|
||||
restore_all
|
||||
echo "--- All bytecode (should fail) ---"
|
||||
if test_passes; then
|
||||
echo "UNEXPECTED: all-bytecode passes! Nothing to bisect."
|
||||
exit 0
|
||||
fi
|
||||
echo " Confirmed: fails with all bytecode"
|
||||
|
||||
# Second: verify all-source passes
|
||||
for f in "${FILES[@]}"; do rm -f "$SXBC_DIR/$f.sxbc"; done
|
||||
echo "--- All source (should pass) ---"
|
||||
if ! test_passes; then
|
||||
echo "UNEXPECTED: all-source also fails! Bug is not bytecode-specific."
|
||||
restore_all
|
||||
exit 1
|
||||
fi
|
||||
echo " Confirmed: passes with all source"
|
||||
|
||||
# Binary search: find minimal set of bytecode files that causes failure
|
||||
# Strategy: start with all source, add bytecode files one at a time
|
||||
echo ""
|
||||
echo "=== Individual file test ==="
|
||||
culprits=()
|
||||
for f in "${FILES[@]}"; do
|
||||
# Start from all-source, add just this one file as bytecode
|
||||
for g in "${FILES[@]}"; do rm -f "$SXBC_DIR/$g.sxbc"; done
|
||||
cp "$BACKUP_DIR/$f.sxbc" "$SXBC_DIR/$f.sxbc"
|
||||
|
||||
if test_passes; then
|
||||
printf " %-20s bytecode OK\n" "$f"
|
||||
else
|
||||
printf " %-20s *** BREAKS ***\n" "$f"
|
||||
culprits+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore
|
||||
restore_all
|
||||
|
||||
echo ""
|
||||
if [ ${#culprits[@]} -eq 0 ]; then
|
||||
echo "No single file causes the failure — it's a combination."
|
||||
echo "Run with groups to narrow down."
|
||||
else
|
||||
echo "=== CULPRIT FILE(S): ${culprits[*]} ==="
|
||||
echo "These .sxbc files individually cause the reactive rendering to break."
|
||||
fi
|
||||
@@ -66,8 +66,11 @@ cp "$ROOT/web/engine.sx" "$DIST/sx/"
|
||||
cp "$ROOT/web/orchestration.sx" "$DIST/sx/"
|
||||
cp "$ROOT/web/boot.sx" "$DIST/sx/"
|
||||
|
||||
# 9. CSSX (stylesheet language — runtime with tw, ~cssx/tw, cssx-process-token etc.)
|
||||
cp "$ROOT/shared/sx/templates/cssx.sx" "$DIST/sx/"
|
||||
# 9. Styling (tw token engine + legacy cssx)
|
||||
cp "$ROOT/shared/sx/templates/cssx.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw-layout.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw-type.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw.sx" "$DIST/sx/"
|
||||
|
||||
# Summary
|
||||
WASM_SIZE=$(du -sh "$DIST/sx_browser.bc.wasm.assets" | cut -f1)
|
||||
|
||||
@@ -37,7 +37,7 @@ const FILES = [
|
||||
'render.sx', 'core-signals.sx', 'signals.sx', 'deps.sx', 'router.sx',
|
||||
'page-helpers.sx', 'freeze.sx', 'bytecode.sx', 'compiler.sx', 'vm.sx',
|
||||
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
|
||||
'cssx.sx',
|
||||
'cssx.sx', 'tw-layout.sx', 'tw-type.sx', 'tw.sx',
|
||||
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
|
||||
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
|
||||
];
|
||||
|
||||
226
hosts/ocaml/browser/test-spa.js
Normal file
226
hosts/ocaml/browser/test-spa.js
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/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();
|
||||
})();
|
||||
Reference in New Issue
Block a user