Fix BYOP keydown handler cleanup and add regressions

- bind packet Escape key listener via bindDocumentHandler and clean it in destroy

- add VM-based regression tests for BYOP overlay singleton/cleanup and handler dedupe across re-init

- bump public cache-buster versions in index.html

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-30 18:55:26 -07:00
co-authored by Copilot
parent 0b82ca791e
commit 81f4beda43
3 changed files with 208 additions and 31 deletions
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774920715">
<link rel="stylesheet" href="home.css?v=1774920715">
<link rel="stylesheet" href="live.css?v=1774920715">
<link rel="stylesheet" href="style.css?v=1774923001">
<link rel="stylesheet" href="home.css?v=1774923001">
<link rel="stylesheet" href="live.css?v=1774923001">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,30 +81,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774920715"></script>
<script src="customize.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774920715"></script>
<script src="hop-resolver.js?v=1774920715"></script>
<script src="hop-display.js?v=1774920715"></script>
<script src="app.js?v=1774920715"></script>
<script src="home.js?v=1774920715"></script>
<script src="packet-filter.js?v=1774920715"></script>
<script src="packets.js?v=1774920715"></script>
<script src="geo-filter-overlay.js?v=1774920715"></script>
<script src="map.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774923001"></script>
<script src="customize.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774923001"></script>
<script src="hop-resolver.js?v=1774923001"></script>
<script src="hop-display.js?v=1774923001"></script>
<script src="app.js?v=1774923001"></script>
<script src="home.js?v=1774923001"></script>
<script src="packet-filter.js?v=1774923001"></script>
<script src="packets.js?v=1774923001"></script>
<script src="geo-filter-overlay.js?v=1774923001"></script>
<script src="map.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+8 -3
View File
@@ -160,6 +160,7 @@
let _docActionHandler = null;
let _docMenuCloseHandler = null;
let _docColMenuCloseHandler = null;
let _docEscHandler = null;
let directObsId = null;
@@ -172,12 +173,15 @@
? _docActionHandler
: kind === 'menu'
? _docMenuCloseHandler
: _docColMenuCloseHandler;
: kind === 'colmenu'
? _docColMenuCloseHandler
: _docEscHandler;
if (prev) document.removeEventListener(eventName, prev);
document.addEventListener(eventName, handler);
if (kind === 'action') _docActionHandler = handler;
else if (kind === 'menu') _docMenuCloseHandler = handler;
else _docColMenuCloseHandler = handler;
else if (kind === 'colmenu') _docColMenuCloseHandler = handler;
else _docEscHandler = handler;
}
function renderTimestampCell(isoString) {
@@ -399,6 +403,7 @@
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
if (_docEscHandler) { document.removeEventListener('keydown', _docEscHandler); _docEscHandler = null; }
removeAllByopOverlays();
packets = [];
hashIndex = new Map(); selectedId = null;
@@ -967,7 +972,7 @@
}
// Escape to close packet detail panel
document.addEventListener('keydown', function pktEsc(e) {
bindDocumentHandler('esc', 'keydown', function pktEsc(e) {
if (e.key === 'Escape') {
closeDetailPanel();
}
+172
View File
@@ -1300,6 +1300,9 @@ console.log('\n=== compare.js: comparePacketSets ===');
{
console.log('\nPackets page — detail pane initial state:');
const packetsSource = fs.readFileSync('public/packets.js', 'utf8');
const removeAllByopOverlaysSource = extractFunctionSourceFromText(packetsSource, 'removeAllByopOverlays');
const showBYOPSource = extractFunctionSourceFromText(packetsSource, 'showBYOP');
const bindDocumentHandlerSource = extractFunctionSourceFromText(packetsSource, 'bindDocumentHandler');
test('split-layout starts with detail-collapsed class', () => {
// The template literal that creates the split-layout must include detail-collapsed
@@ -1341,6 +1344,175 @@ console.log('\n=== compare.js: comparePacketSets ===');
assert.ok(packetsSource.includes("if (prev) document.removeEventListener(eventName, prev);"),
'bindDocumentHandler should remove previous handler before re-binding');
});
test('BYOP repeated opens keep exactly one overlay', () => {
assert.ok(removeAllByopOverlaysSource, 'removeAllByopOverlays source should be present');
assert.ok(showBYOPSource, 'showBYOP source should be present');
const ctx = vm.createContext({
document: createPacketsTestDocument(),
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
renderDecodedPacket: () => '',
routeTypeName: () => 'UNKNOWN',
payloadTypeName: () => 'UNKNOWN',
});
vm.runInContext(`${removeAllByopOverlaysSource}\n${showBYOPSource}`, ctx);
ctx.showBYOP();
ctx.showBYOP();
assert.strictEqual(ctx.document.getOverlayCount(), 1, 'repeated opens should leave one overlay');
});
test('BYOP close removes all overlays', () => {
assert.ok(removeAllByopOverlaysSource, 'removeAllByopOverlays source should be present');
assert.ok(showBYOPSource, 'showBYOP source should be present');
const ctx = vm.createContext({
document: createPacketsTestDocument(),
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
renderDecodedPacket: () => '',
routeTypeName: () => 'UNKNOWN',
payloadTypeName: () => 'UNKNOWN',
});
vm.runInContext(`${removeAllByopOverlaysSource}\n${showBYOPSource}`, ctx);
ctx.showBYOP();
// Simulate stale stacked overlay from prior bad state; close should clear all.
ctx.document.appendOverlay();
assert.strictEqual(ctx.document.getOverlayCount(), 2, 'setup should contain two overlays');
ctx.document.getFirstOverlay().querySelector('.byop-x').onclick();
assert.strictEqual(ctx.document.getOverlayCount(), 0, 'close should remove all BYOP overlays');
});
test('bindDocumentHandler removes previous handlers across SPA re-init', () => {
assert.ok(bindDocumentHandlerSource, 'bindDocumentHandler source should be present');
const doc = createBindingTestDocument();
const ctx = vm.createContext({
document: doc,
_docActionHandler: null,
_docMenuCloseHandler: null,
_docColMenuCloseHandler: null,
_docEscHandler: null,
});
vm.runInContext(bindDocumentHandlerSource, ctx);
let clicks = 0;
const clickHandlerV1 = () => { clicks += 1; };
const clickHandlerV2 = () => { clicks += 1; };
ctx.bindDocumentHandler('action', 'click', clickHandlerV1);
ctx.bindDocumentHandler('action', 'click', clickHandlerV2);
doc.dispatch('click', { type: 'click' });
assert.strictEqual(clicks, 1, 'only latest click handler should fire once');
assert.strictEqual(doc.getRemoveCount('click'), 1, 'rebind should remove prior click handler');
let esc = 0;
const escHandlerV1 = () => { esc += 1; };
const escHandlerV2 = () => { esc += 1; };
ctx.bindDocumentHandler('esc', 'keydown', escHandlerV1);
ctx.bindDocumentHandler('esc', 'keydown', escHandlerV2);
doc.dispatch('keydown', { key: 'Escape' });
assert.strictEqual(esc, 1, 'only latest esc handler should fire once');
assert.strictEqual(doc.getRemoveCount('keydown'), 1, 'rebind should remove prior keydown handler');
});
}
function extractFunctionSourceFromText(source, functionName) {
const start = source.indexOf(`function ${functionName}(`);
if (start === -1) return null;
let braceStart = source.indexOf('{', start);
if (braceStart === -1) return null;
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth += 1;
else if (ch === '}') depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
return null;
}
function createPacketsTestDocument() {
let overlays = [];
let activeElement = null;
function createFocusable() {
const focusable = {
onclick: null,
addEventListener: () => {},
focus: () => { activeElement = focusable; }
};
return focusable;
}
function createOverlay() {
let removed = false;
const closeBtn = createFocusable();
const decodeBtn = createFocusable();
const textarea = createFocusable();
textarea.value = '';
const result = { innerHTML: '' };
const modal = {
querySelectorAll: () => [textarea, decodeBtn, closeBtn],
};
const overlayObj = {
className: '',
innerHTML: '',
addEventListener: () => {},
querySelector: (sel) => {
if (sel === '.byop-modal') return modal;
if (sel === '.byop-x') return closeBtn;
if (sel === '#byopHex') return textarea;
if (sel === '#byopDecode') return decodeBtn;
if (sel === '#byopResult') return result;
return null;
},
remove: () => {
removed = true;
overlays = overlays.filter(o => o !== overlayObj);
},
__removed: () => removed,
};
return overlayObj;
}
const triggerBtn = { focus: () => { activeElement = triggerBtn; } };
return {
body: {
appendChild: (el) => { overlays.push(el); }
},
querySelector: (sel) => (sel === '[data-action="pkt-byop"]' ? triggerBtn : null),
querySelectorAll: (sel) => {
if (sel !== '.byop-overlay') return [];
return overlays.filter(o => !o.__removed || !o.__removed());
},
createElement: () => createOverlay(),
appendOverlay: function () {
const extra = this.createElement('div');
extra.className = 'modal-overlay byop-overlay';
this.body.appendChild(extra);
return extra;
},
getFirstOverlay: () => overlays[0],
getOverlayCount: () => overlays.length,
getLastOverlay: () => overlays[overlays.length - 1],
get activeElement() { return activeElement; },
};
}
function createBindingTestDocument() {
const listeners = new Map();
const removeCounts = new Map();
return {
addEventListener: (event, handler) => {
listeners.set(event, handler);
},
removeEventListener: (event, handler) => {
if (listeners.get(event) === handler) listeners.delete(event);
removeCounts.set(event, (removeCounts.get(event) || 0) + 1);
},
dispatch: (event, payload) => {
const handler = listeners.get(event);
if (handler) handler(payload || {});
},
getRemoveCount: (event) => removeCounts.get(event) || 0,
};
}
// ===== APP.JS: formatEngineBadge =====