Compare commits

...

2 Commits

Author SHA1 Message Date
Kpa-clawbot
81f4beda43 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>
2026-03-30 18:55:26 -07:00
Kpa-clawbot
0b82ca791e Fix #249 BYOP dialog stacking and close behavior
- Prevent leaked document click handlers from stacking across packets page init

- Ensure BYOP dialog is singleton and close removes all overlay layers

- Add frontend regression tests for BYOP overlay and handler de-dup

- Bump public asset cache busters

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:42:04 -07:00
3 changed files with 260 additions and 35 deletions

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=1774920209">
<link rel="stylesheet" href="home.css?v=1774920209">
<link rel="stylesheet" href="live.css?v=1774920209">
<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=1774920209"></script>
<script src="customize.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774920209"></script>
<script src="hop-resolver.js?v=1774920209"></script>
<script src="hop-display.js?v=1774920209"></script>
<script src="app.js?v=1774920209"></script>
<script src="home.js?v=1774920209"></script>
<script src="packet-filter.js?v=1774920209"></script>
<script src="packets.js?v=1774920209"></script>
<script src="geo-filter-overlay.js?v=1774920209"></script>
<script src="map.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774920209" 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>

View File

@@ -157,9 +157,33 @@
let directPacketId = null;
let directPacketHash = null;
let initGeneration = 0;
let _docActionHandler = null;
let _docMenuCloseHandler = null;
let _docColMenuCloseHandler = null;
let _docEscHandler = null;
let directObsId = null;
function removeAllByopOverlays() {
document.querySelectorAll('.byop-overlay').forEach(function (el) { el.remove(); });
}
function bindDocumentHandler(kind, eventName, handler) {
const prev = kind === 'action'
? _docActionHandler
: kind === 'menu'
? _docMenuCloseHandler
: 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 if (kind === 'colmenu') _docColMenuCloseHandler = handler;
else _docEscHandler = handler;
}
function renderTimestampCell(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
@@ -237,7 +261,7 @@
}
// Event delegation for data-action buttons
document.addEventListener('click', function (e) {
bindDocumentHandler('action', 'click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.dataset.action === 'pkt-refresh') loadPackets();
@@ -376,6 +400,11 @@
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
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;
filtersBuilt = false;
@@ -705,7 +734,7 @@
});
// Close multi-select menus on outside click
document.addEventListener('click', (e) => {
bindDocumentHandler('menu', 'click', (e) => {
const obsWrap = document.getElementById('observerFilterWrap');
const typeWrap = document.getElementById('typeFilterWrap');
if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
@@ -820,7 +849,7 @@
e.stopPropagation();
colMenu.classList.toggle('open');
});
document.addEventListener('click', () => colMenu.classList.remove('open'));
bindDocumentHandler('colmenu', 'click', () => colMenu.classList.remove('open'));
applyColVisibility();
document.getElementById('hexHashToggle').addEventListener('click', function () {
@@ -943,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();
}
@@ -1548,9 +1577,10 @@
// BYOP modal — decode only, no DB injection
function showBYOP() {
removeAllByopOverlays();
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.className = 'modal-overlay byop-overlay';
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
@@ -1561,7 +1591,7 @@
document.body.appendChild(overlay);
const modal = overlay.querySelector('.byop-modal');
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };
overlay.querySelector('.byop-x').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
@@ -1597,7 +1627,7 @@
async function doDecode() {
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
const result = document.getElementById('byopResult');
const result = overlay.querySelector('#byopResult');
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
result.innerHTML = '<p class="text-muted">Decoding...</p>';

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
@@ -1318,6 +1321,198 @@ console.log('\n=== compare.js: comparePacketSets ===');
assert.ok(packetsSource.includes("classList.remove('detail-collapsed')"),
'selectPacket should remove detail-collapsed class');
});
test('BYOP uses dedicated overlay class and clears existing overlays before opening', () => {
assert.ok(packetsSource.includes("overlay.className = 'modal-overlay byop-overlay'"),
'BYOP overlay should have byop-overlay class');
assert.ok(/function showBYOP\(\)\s*\{\s*removeAllByopOverlays\(\);/m.test(packetsSource),
'showBYOP should clear existing overlays before creating a new one');
});
test('BYOP close removes all overlays in one click', () => {
assert.ok(packetsSource.includes("const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };"),
'close handler should remove all BYOP overlays');
});
test('packets page de-duplicates document click handlers', () => {
assert.ok(packetsSource.includes("bindDocumentHandler('action', 'click'"),
'action click handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('menu', 'click'"),
'menu close handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('colmenu', 'click'"),
'column menu close handler should be bound through bindDocumentHandler');
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 =====