feat(#1633): customizer toggle to hide 1-byte path hops everywhere (#1689)

## What

Customize-v2 toggle **Hide 1-byte path hops** (Display tab). Default OFF
— operators opt in. When ON, 1-byte path-hash prefixes are filtered at
every render site without touching what's stored or what the firmware
does.

Render sites wired:
- **Packets list / detail** (`packets.js renderPath`) — group header,
child observations, detail dt/dd, BYOP overlay. Empty result renders
`(1-byte filtered)`.
- **Map polylines** (`map.js drawPacketRoute`) — intermediate hops
tagged `_hopHex`; origin/destination (from payload, no `_hopHex`) always
survive.
- **Route view** (`route-view.js`) — unique-paths picker + group counts
key on the filtered hop list, so routes that only differ by 1-byte hops
collapse.
- **Analytics route patterns** (`analytics.js`) — filters INPUT rows
whose `rawHops` contain any 1-byte token; header reports filtered/total.

## Why

1-byte hashes collide ~8-way at ~2k relay nodes (Cascadia scale). The
collisions inflate polyline noise, route-pattern row counts, and chip
clutter without adding signal. See #1633 for the full hypothesis.

## How (pure render-time)

New `public/hop-filter.js`:
- `MC_getHide1ByteHops()` / `MC_setHide1ByteHops(on)` — localStorage
`meshcore-hide-1byte-hops`, default OFF.
- `MC_isVisibleHop(hop, opts)` — predicate.
- `MC_filterPathHops(hops, opts)` — non-mutating array filter.

Nothing in the ingest / store / decode path changes. The hop hex stays
in `path_json`; only the render iterators drop it.

## Tests

`test-issue-1633-hide-1byte-hops.js` — 8 assertions:
- Default OFF (back-compat).
- `hopByteLen` semantics.
- `isVisibleHop` ON drops 1-byte, keeps 2/3-byte.
- `filterPathHops` non-mutating.
- `HopDisplay.renderPath` chip set after filter.
- Map polyline positions[] filter preserves origin/destination.
- Analytics route-pattern aggregation key collapses on filtered hops.

Wired into `.github/workflows/deploy.yml`.

Red commit: `6baa3f13` (5/8 ON-branch assertions failed on stubs).
Green commit: `5c0bbdba` (8/8 pass).

## Browser verify

Staging deploy of changed files. Packet `99ef781f42eb7249` (all 1-byte
path):
- BEFORE (toggle OFF): `3 HOPS — Station Rat → KO6IFX-R5 → little
russia`.
- AFTER (toggle ON): `3 HOPS — (1-byte filtered)`.
Customizer toggle visible + working in Display tab.

Fixes #1633.

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: clawbot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-12 14:49:37 -07:00
committed by GitHub
co-authored by openclaw-bot clawbot
parent dd2b3d2e21
commit 79cf453660
13 changed files with 828 additions and 26 deletions
+1
View File
@@ -148,6 +148,7 @@ jobs:
node test-issue-1668-m3-typography.js
node test-mqtt-status-panel.js
node test-warmup-banner.js
node test-issue-1633-hide-1byte-hops.js
- name: 🛡️ Preflight XSS gate — actual --diff check (PR only)
# The fixture self-test above (test-preflight-xss-gate.js) only
+17 -3
View File
@@ -1973,13 +1973,27 @@
function renderTable(data, title) {
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
const maxCount = data.subpaths[0]?.count || 1;
// #1633 — when "Hide 1-byte path hops" is ON, filter route patterns
// whose underlying rawHops contain any 1-byte hex token. We filter
// INPUT (not just CSS-hide) so the displayed % and ordering reflect
// the surviving population.
const _hide1 = !!(typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops());
const _hop1 = function (h) { return String(h || '').length === 2; };
const subpaths = _hide1
? data.subpaths.filter(function (s) {
var rh = s.rawHops || [];
for (var k = 0; k < rh.length; k++) if (_hop1(rh[k])) return false;
return true;
})
: data.subpaths;
if (!subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data (all matching routes contained 1-byte hops — toggle off in customizer to see)</div>`;
const maxCount = subpaths[0]?.count || 1;
return `<h4>${title}</h4>
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops${_hide1 ? ` · showing ${subpaths.length} of ${data.subpaths.length} (1-byte filtered)` : ''}</p>
<table class="analytics-table"><thead><tr>
<th scope="col">#</th><th scope="col">Route</th><th scope="col">Occurrences</th><th scope="col">% of paths</th><th scope="col">Frequency</th>
</tr></thead><tbody>
${data.subpaths.map((s, i) => {
${subpaths.map((s, i) => {
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
const hops = s.path.split(' → ');
const rawHops = s.rawHops || [];
+39
View File
@@ -1567,10 +1567,31 @@
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
_renderChannelsShowEncryptedToggle() +
_renderHide1ByteHopsToggle() +
_renderTileProviderSelector() +
'</div>';
}
// ── #1633 Hide 1-byte path hops toggle ──
// Writes localStorage["meshcore-hide-1byte-hops"]. Default OFF: key is
// removed (not "false") so MC_getHide1ByteHops cleanly returns false.
// Fires `mc-hide-1byte-hops-changed`; map.js + packets.js subscribe and
// re-render in place (PR #1689 r1 adv #4). Analytics + route-view rebuild
// on next navigation — they don't need live wiring because they re-render
// on tab activation.
function _renderHide1ByteHopsToggle() {
var on = false;
try { on = localStorage.getItem('meshcore-hide-1byte-hops') === 'true'; } catch (_e) {}
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Path Display</p>' +
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">1-byte path-hash prefixes (firmware default) collide ~8-way at ~2k relays — many polylines and route-pattern rows they produce are visual noise. Hide them globally without changing what\'s stored.</p>' +
'<div class="cust-field" style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" id="cv2-hide-1byte-hops" data-cv2-hide-1byte-hops' +
(on ? ' checked' : '') +
' style="width:16px;height:16px;cursor:pointer">' +
'<label for="cv2-hide-1byte-hops" style="cursor:pointer;margin:0">Hide short (1-byte) path-hash hops</label>' +
'</div>';
}
// ── #1454 Show-encrypted-channels toggle ──
// Writes localStorage["channels-show-encrypted"]. Default OFF: key is
// removed (not set to "false") so the read-gate in channels.js cleanly
@@ -2247,6 +2268,23 @@
});
});
// #1633 Hide-1-byte-path-hops checkbox — persists + fires
// mc-hide-1byte-hops-changed; consumers re-render or rely on the next
// navigation refresh (analytics tab, packets list).
container.querySelectorAll('[data-cv2-hide-1byte-hops]').forEach(function (cb) {
cb.addEventListener('change', function () {
var on = !!cb.checked;
if (typeof window.MC_setHide1ByteHops === 'function') {
window.MC_setHide1ByteHops(on);
} else {
try {
if (on) localStorage.setItem('meshcore-hide-1byte-hops', 'true');
else localStorage.removeItem('meshcore-hide-1byte-hops');
} catch (_e) {}
}
});
});
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -2725,6 +2763,7 @@
STORAGE_KEY, // 'cs-theme-overrides'
'meshcore-cb-preset', // #1361 CB preset id
'channels-show-encrypted', // #1454 encrypted-channel toggle
'meshcore-hide-1byte-hops', // #1633 hide 1-byte path hops toggle
'mc-dark-tile-provider' // #1430 dark-tile provider pick
];
for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) {
+92
View File
@@ -0,0 +1,92 @@
/* === CoreScope — hop-filter.js === */
/* #1633 — Render-time filter that hides 1-byte path hops when the
* customize-v2 toggle is ON. Pure render-time; firmware behavior and
* wire/store contents are untouched.
*
* A "hop" here is a hex-string prefix as stored in observations.path_json
* (e.g. "AB" = 1-byte, "CDEF" = 2-byte, "ABCDEF" = 3-byte). Byte count
* is `floor(hopHex.length / 2)`.
*
* Wire & store stay intact: every consumer call site reads the toggle
* via window.MC_getHide1ByteHops() and filters its rendered/aggregated
* view at the boundary (no upstream mutation).
*/
'use strict';
(function () {
var STORAGE_KEY = 'meshcore-hide-1byte-hops';
function getHide1ByteHops() {
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
catch (_e) { return false; }
}
function setHide1ByteHops(on) {
try {
if (on) localStorage.setItem(STORAGE_KEY, 'true');
else localStorage.removeItem(STORAGE_KEY);
} catch (_e) { /* private mode */ }
if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
window.dispatchEvent(new window.CustomEvent('mc-hide-1byte-hops-changed', {
detail: { value: !!on }
}));
}
}
// bytes of a hop hex token — handles undefined / non-string.
function hopByteLen(h) {
if (h == null) return 0;
var s = String(h);
return s.length >> 1;
}
// Render-time predicate. opts may be omitted — if so, falls back to the
// current localStorage value. The hop is hidden only when:
// - opts.hide1ByteHops === true AND
// - the hop hex encodes exactly 1 byte (length === 2)
// Anything else (origin/destination payload hops, multi-byte path hops,
// null/undefined sentinels) stays visible — those callers already
// bypass the filter when they pass undefined/falsey tokens.
function isVisibleHop(hop, opts) {
var enabled = (opts && typeof opts.hide1ByteHops === 'boolean')
? opts.hide1ByteHops
: getHide1ByteHops();
if (!enabled) return true;
return hopByteLen(hop) !== 1;
}
// Filter a path hop array. Returns a NEW array; never mutates the input
// (callers depend on the original path_json staying intact for downstream
// consumers like hash-size detection / raw-hex rendering).
function filterPathHops(hops, opts) {
if (!hops || !hops.length) return hops || [];
var enabled = (opts && typeof opts.hide1ByteHops === 'boolean')
? opts.hide1ByteHops
: getHide1ByteHops();
if (!enabled) return hops;
var out = [];
for (var i = 0; i < hops.length; i++) {
if (hopByteLen(hops[i]) !== 1) out.push(hops[i]);
}
return out;
}
if (typeof window !== 'undefined') {
window.MC_getHide1ByteHops = getHide1ByteHops;
window.MC_setHide1ByteHops = setHide1ByteHops;
window.MC_isVisibleHop = isVisibleHop;
window.MC_filterPathHops = filterPathHops;
window.MC_hopByteLen = hopByteLen;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
getHide1ByteHops: getHide1ByteHops,
setHide1ByteHops: setHide1ByteHops,
isVisibleHop: isVisibleHop,
filterPathHops: filterPathHops,
hopByteLen: hopByteLen,
_STORAGE_KEY: STORAGE_KEY
};
}
})();
+1
View File
@@ -172,6 +172,7 @@
<script src="region-filter.js?v=__BUST__"></script>
<script src="area-filter.js?v=__BUST__"></script>
<script src="hop-resolver.js?v=__BUST__"></script>
<script src="hop-filter.js?v=__BUST__"></script>
<script src="hop-display.js?v=__BUST__"></script>
<script src="warmup-banner.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
+37 -1
View File
@@ -2541,9 +2541,24 @@
return;
}
const COLLAPSE = 5;
// #1689 r1 (adv #3): respect the customizer "hide 1-byte path hops"
// toggle in the live-pane "Paths Through" widget. /api/.../paths
// returns hops as objects {prefix,pubkey,name}; the byte-length is
// derived from the hex prefix.
function _filterHopObjs(hopObjs) {
if (typeof window === 'undefined' || !window.MC_isVisibleHop) return hopObjs;
if (!window.MC_getHide1ByteHops || !window.MC_getHide1ByteHops()) return hopObjs;
return (hopObjs || []).filter(function (h) {
return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : '');
});
}
function renderPathList(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
const filteredHops = _filterHopObjs(p.hops || []);
if (!filteredHops.length) {
return `<div style="padding:3px 0;font-size:11px;line-height:1.4;color:var(--text-muted)">— (1-byte filtered) <span style="color:var(--text-muted)">(${p.count}×)</span></div>`;
}
const chain = filteredHops.map(h => {
const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase()));
const name = escapeHtml(h.name || h.prefix);
if (isThis) return `<strong style="color:var(--accent)">${name}</strong>`;
@@ -2680,6 +2695,13 @@
function packetInvolvesFilterNode(pkt, filterKeys) {
if (!filterKeys.length) return true;
// #1689 r2 MAJOR: node-filter search semantics MUST be independent of
// the customizer's hide-1-byte-hops display preference. Letting the
// display toggle change search results silently fails the principle
// of least astonishment (operator searches for a node, hides 1-byte
// hops for chart readability, suddenly matches disappear). Chip
// rendering filters hops separately via MC_filterPathHops at the
// render boundary — see feedHops below.
const hops = (pkt.decoded?.path?.hops) || [];
for (const hop of hops) {
const h = (hop.id || hop.public_key || hop).toString().toLowerCase();
@@ -3187,6 +3209,20 @@
}
if (fpHops.length > feedHops.length) feedHops = fpHops;
}
// #1689 r1 (adv #3): apply the customizer "hide 1-byte path hops"
// toggle to the feed-item hop count + chip rendering. feedHops here
// is an array of raw hex tokens (from path_json) or hop-objects (from
// decoded.path.hops) — MC_filterPathHops only knows about hex strings
// so we branch on shape.
if (typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) {
if (feedHops.length && typeof feedHops[0] === 'string') {
feedHops = window.MC_filterPathHops(feedHops);
} else if (feedHops.length && window.MC_isVisibleHop) {
feedHops = feedHops.filter(function (h) {
return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : (h && h.id ? String(h.id) : ''));
});
}
}
addFeedItem(icon, typeName, payload, feedHops, color, consolidated);
// Store all observation packets in dedup entry for replay tree
if (consolidated.hash && feedDedup.has(consolidated.hash)) {
+76 -10
View File
@@ -341,6 +341,26 @@
_syncDarkTiles(dark);
});
// #1689 r1 (adv #4): live re-render when the customizer "hide 1-byte
// path hops" toggle flips. Before this listener the toggle only took
// effect on the next navigation despite an inline comment claiming
// "live update". Re-issue the most recent drawPacketRoute(Multi) call
// so the polyline + redacted badge update in place.
if (typeof window !== 'undefined' && !window.__mc_map_hide1byte_wired) {
window.__mc_map_hide1byte_wired = true;
window.addEventListener('mc-hide-1byte-hops-changed', function () {
try {
const last = window.__mc_lastRouteDraw;
if (!last) return;
if (last.kind === 'multi' && typeof window.drawPacketRouteMulti === 'function') {
window.drawPacketRouteMulti(last.paths, last.origin, last.opts);
} else if (last.kind === 'single' && typeof window.drawPacketRoute === 'function') {
window.drawPacketRoute(last.hopKeys, last.origin, last.opts);
}
} catch (e) { console.warn('[map] hide-1byte re-render failed', e); }
});
}
// Save position on move
map.on('moveend', () => {
const c = map.getCenter();
@@ -611,6 +631,9 @@
origin = { pubkey: origin };
}
opts = opts || {};
// #1689 r1 (adv #4): remember the last route-draw inputs so the
// mc-hide-1byte-hops-changed listener can re-render in place.
try { window.__mc_lastRouteDraw = { kind: 'single', hopKeys: hopKeys, origin: origin, opts: opts }; } catch (_e) {}
// #1422: use the backend's /api/resolve-hops for proper disambiguation
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
// Falls back to naive nodes.filter() scan if the API is unreachable.
@@ -670,15 +693,20 @@
// renderer can label it "no GPS" instead of "unresolved prefix".
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
// #1633 — tag the original hex token so MC_isVisibleHop can filter
// 1-byte hops out of the polyline/sidebar when the customizer toggle
// is ON. The hop value stays in place upstream (resolve, fitBounds
// ordering) — only the polyline render iterator drops it.
const _hopHex = String(hop);
// Try server resolution first
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true };
return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true, _hopHex };
}
// Server resolved but node has no usable GPS
return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true };
return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true, _hopHex };
}
// Fallback: naive local scan (kept for resilience when API is down).
const allMatches = nodes.filter(n => {
@@ -688,14 +716,14 @@
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length === 1) {
const c = withGps[0];
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true, _hopHex };
} else if (withGps.length > 1) {
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps, _hopHex };
} else if (allMatches.length >= 1) {
const c = allMatches[0];
return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true };
return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true, _hopHex };
}
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false, _hopHex };
});
// Disambiguate: pick candidate closest to center of already-resolved hops
@@ -716,7 +744,7 @@
}
}
const positions = raw.filter(h => h != null);
let positions = raw.filter(h => h != null);
// Resolve and prepend origin node
if (origin) {
@@ -762,6 +790,34 @@
// Mark final hop as destination so the renderer applies the dest glyph.
positions[positions.length - 1].isDest = true;
// #1633 — render-time 1-byte hop filter (default OFF). Origin / destination
// positions are added without _hopHex (they came from the payload, not the
// outer path bytes) and therefore always survive. Intermediate hops with a
// 1-byte _hopHex are dropped when the customizer toggle is ON.
//
// #1689 r1 (adv #2): if the filter drops every intermediate hop the result
// is a 2-point origin→dest polyline that is visually identical to a
// "direct delivery, no hops" route. Operators misread that as ground-truth
// direct path. To make the redaction LEGIBLE we tag the surviving
// positions with `_hopsHiddenCount` so the renderer can draw the polyline
// in a different style (dashed/lighter) and add a midpoint badge that
// reads "N hops hidden (1-byte)".
var hopsHiddenCount = 0;
if (window.MC_isVisibleHop && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) {
var beforeCount = positions.length;
var beforeIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length;
positions = positions.filter(function (p) {
return !p._hopHex || window.MC_isVisibleHop(p._hopHex);
});
var afterIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length;
hopsHiddenCount = beforeIntermediate - afterIntermediate;
if (positions.length < beforeCount && positions.length >= 1) {
// Re-mark last surviving hop as destination if the original got dropped.
positions[positions.length - 1].isDest = true;
}
if (positions.length < 1) return;
}
// Hand off to sequence-primary sequence-primary renderer (#1418), falling
// back to the legacy role-aware MeshRoute (#1374), then to the minimal
// polyline (should never run in production).
@@ -770,13 +826,17 @@
timestamp: opts.timestamp || Date.now(),
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
packetContext: opts.packetContext || null
packetContext: opts.packetContext || null,
// #1689 r1 (adv #2): tell the renderer how many intermediate hops
// the 1-byte filter dropped so it can style the polyline as redacted.
hopsHiddenCount: hopsHiddenCount
});
return;
}
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
window.MeshRoute.render(map, routeLayer, positions, {
timestamp: opts.timestamp || Date.now()
timestamp: opts.timestamp || Date.now(),
hopsHiddenCount: hopsHiddenCount
});
return;
}
@@ -784,7 +844,10 @@
// ── Legacy fallback (kept tiny — should never run in production) ─────
const coords = positions.filter(p => p.lat != null).map(p => [p.lat, p.lon]);
if (coords.length >= 2) {
L.polyline(coords, { color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4' }).addTo(routeLayer);
L.polyline(coords, {
color: '#f59e0b', weight: 3, opacity: hopsHiddenCount > 0 ? 0.55 : 0.8,
dashArray: hopsHiddenCount > 0 ? '4 6' : '8 4'
}).addTo(routeLayer);
map.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
map.setView(coords[0], 13);
@@ -804,6 +867,9 @@
opts = opts || {};
if (typeof origin === 'string') origin = { pubkey: origin };
if (!Array.isArray(paths) || paths.length === 0) return;
// #1689 r1 (adv #4): remember last multi-path draw for live re-render
// when the hide-1-byte-hops toggle changes.
try { window.__mc_lastRouteDraw = { kind: 'multi', paths: paths, origin: origin, opts: opts }; } catch (_e) {}
if (paths.length === 1) {
return drawPacketRoute(paths[0].path || [], origin, opts);
}
+8 -1
View File
@@ -439,7 +439,14 @@ window.ObserverDetailNaiveBanner = {
<thead><tr><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Hash</th><th scope="col">SNR</th><th scope="col">RSSI</th><th scope="col">Hops</th></tr></thead>
<tbody>${packets.map(p => {
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
const rawHops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
// #1689 r1 (adv #3): honor the customizer "hide 1-byte path hops"
// toggle for the hops-count column. Counting raw hops here was
// missed by the original PR; operators expect the count to match
// what's displayed everywhere else when the toggle is ON.
const hops = (typeof window !== 'undefined' && window.MC_filterPathHops)
? window.MC_filterPathHops(rawHops)
: rawHops;
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/${p.hash || p.id}">
<td>${timeAgo(p.timestamp)}</td>
+20 -1
View File
@@ -963,7 +963,14 @@
function renderPath(hops, observerId) {
if (!hops || !hops.length) return '—';
return hops.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
// #1633 — render-time filter (default OFF). Applies at every consumer
// because every site funnels through this function (group header, child
// observation, packet detail dt/dd, byop overlay).
var filtered = (typeof window !== 'undefined' && window.MC_filterPathHops)
? window.MC_filterPathHops(hops)
: hops;
if (!filtered.length) return '— <span class="text-muted" title="All path hops were 1-byte and are hidden by the customizer toggle">(1-byte filtered)</span>';
return filtered.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
}
let directPacketId = null;
@@ -1005,6 +1012,18 @@
async function init(app, routeParam) {
const gen = ++initGeneration;
// #1689 r1 (adv #4): subscribe to the customizer hide-1-byte-hops toggle
// so the packets table re-renders LIVE when operators flip it (the
// toggle promised "applies everywhere" — without a listener it only
// took effect on next navigation, which the inline comment lied about).
// The listener is idempotent via a flag on `window` so re-entering the
// route doesn't stack handlers.
if (typeof window !== 'undefined' && !window.__mc_packets_hide1byte_wired) {
window.__mc_packets_hide1byte_wired = true;
window.addEventListener('mc-hide-1byte-hops-changed', function () {
try { renderTableRows(); } catch (e) { console.warn('[packets] hide-1byte re-render failed', e); }
});
}
// Parse ?obs=OBSERVER_ID from routeParam
if (routeParam && routeParam.includes('?')) {
const qIdx = routeParam.indexOf('?');
+28 -4
View File
@@ -300,21 +300,45 @@
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ── Edges ───────────────────────────────────────────────────────
// #1689 r1 (adv #2): when the 1-byte hop filter dropped hops, draw the
// edges as dashed + lighter so operators don't misread the resulting
// origin→dest polyline as "direct delivery, no hops in path".
var hopsHiddenCount = (opts && typeof opts.hopsHiddenCount === 'number') ? opts.hopsHiddenCount : 0;
var redacted = hopsHiddenCount > 0;
for (var i = 0; i < total - 1; i++) {
var a = positions[i], b = positions[i + 1];
if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue;
var color = seqColor(i, total - 1);
var dist = haversineKm(a, b);
var ariaLabel = 'Hop ' + (i + 1) + ' \u2192 ' + (i + 2) +
(dist != null ? ', ~' + dist + 'km' : '');
(dist != null ? ', ~' + dist + 'km' : '') +
(redacted ? ' (' + hopsHiddenCount + ' 1-byte hop(s) hidden)' : '');
var edgeDash = (a.resolved === false || b.resolved === false)
? '6 4'
: (redacted ? '4 6' : null);
var poly = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: color,
weight: 3.5,
opacity: 0.92,
dashArray: (a.resolved === false || b.resolved === false) ? '6 4' : null,
className: 'mc-route-edge'
opacity: redacted ? 0.6 : 0.92,
dashArray: edgeDash,
className: 'mc-route-edge' + (redacted ? ' mc-route-edge-redacted' : '')
}).addTo(layer);
// Midpoint badge — only on the first redacted edge — that reads
// "N hops hidden (1-byte)". Reuses Leaflet's divIcon so it picks up
// the theme tokens via .mc-route-redacted-badge in CSS.
if (redacted && i === 0) {
var mid = [(a.lat + b.lat) / 2, (a.lon + b.lon) / 2];
var badgeHtml = '<span class="mc-route-redacted-badge" title="Hidden by customizer: 1-byte path hops are filtered. Toggle off in Customize to see the full path.">' +
hopsHiddenCount + ' hop' + (hopsHiddenCount === 1 ? '' : 's') + ' hidden (1-byte)' +
'</span>';
L.marker(mid, {
icon: L.divIcon({ className: 'mc-route-redacted-badge-wrap', html: badgeHtml, iconSize: [120, 18], iconAnchor: [60, 9] }),
interactive: true,
keyboard: false
}).addTo(layer);
}
// Patch the rendered <path> element to add aria-label + marker-end.
// Leaflet builds it on the next animation frame, so defer.
(function (polyRef, lbl, col) {
+24
View File
@@ -726,3 +726,27 @@ body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-ctx { margin: 4px 0 2px; padding: 4px 6px; font-size: 11px; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-row { padding: 3px 10px 3px 0; font-size: 11px; }
}
/* #1689 r1 (adv #2) — redacted-route badge shown at the midpoint of a
* polyline whose intermediate hops were filtered out by the customizer
* "Hide 1-byte path hops" toggle. Operators must not mistake the
* straight origin→dest line for a "no hops, direct delivery" route. */
.mc-route-redacted-badge-wrap {
pointer-events: auto;
}
.mc-route-redacted-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
background: var(--bg-muted, #1f2937);
color: var(--text-muted, #d1d5db);
border: 1px dashed var(--accent, #f59e0b);
font-size: 10px;
line-height: 14px;
white-space: nowrap;
font-family: var(--font-sans, system-ui, sans-serif);
}
.mc-route-edge-redacted {
/* dashArray is set per-edge in route-render.js; this hook is for
* customizer overrides + tests to assert the redacted-state class. */
}
+38 -6
View File
@@ -324,7 +324,21 @@
var contextBlock = buildPacketContextBlock(pktCtx);
var uniquePathsCount = (opts && opts.allPaths) ? (function () {
var seen = {};
opts.allPaths.forEach(function (p) { seen[(p.path || []).join('-')] = true; });
opts.allPaths.forEach(function (p) {
// #1633 — collapse paths whose only difference is 1-byte hops when
// the customizer toggle is ON. Aggregation uses the FILTERED hop
// list so the picker count reflects the surviving distinct routes.
// #1689 r1 (adv #1): if the filter strips EVERY hop the key becomes
// an empty string and distinct all-1-byte routes collide into one
// bucket. Fall back to the raw key with an `__all1byte__::` prefix
// so they stay distinct while still being marked as filtered.
var rawHops = (p.path || []);
var hops = (typeof window !== 'undefined' && window.MC_filterPathHops)
? window.MC_filterPathHops(rawHops)
: rawHops;
var key = hops.length ? hops.join('-') : ('__all1byte__::' + rawHops.join('-'));
seen[key] = true;
});
return Object.keys(seen).length;
})() : 1;
var multiPathChip = '';
@@ -338,8 +352,16 @@
// paths, each with the observer-count and a click-to-isolate affordance.
var pathGroups = {};
(opts.allPaths || []).forEach(function (p) {
var k = (p.path || []).join('→');
if (!pathGroups[k]) pathGroups[k] = { key: k, observers: [], count: 0 };
var rawHops = p.path || [];
// #1633 — same render-time filter as the count above.
// #1689 r1 (adv #1): empty filtered result must NOT collide with
// other all-1-byte paths. Use a sentinel that includes the rawHops
// signature so distinct routes stay separate buckets in the picker.
var hops = (typeof window !== 'undefined' && window.MC_filterPathHops)
? window.MC_filterPathHops(rawHops)
: rawHops;
var k = hops.length ? hops.join('→') : ('⟨all-1byte⟩::' + rawHops.join('→'));
if (!pathGroups[k]) pathGroups[k] = { key: k, observers: [], count: 0, allOneByte: hops.length === 0 && rawHops.length > 0, rawHops: rawHops };
pathGroups[k].observers.push(p.observer || '?');
pathGroups[k].count++;
});
@@ -347,10 +369,20 @@
var pickerRows = groupList.map(function (g, idx) {
var sample = g.observers[0];
var moreSuffix = g.observers.length > 1 ? ' +' + (g.observers.length - 1) : '';
var hops = g.key.split('→').filter(function(s){return s.length>0;});
return '<li class="mc-rt-path-row" data-path-key="' + escapeHtml(g.key) + '" data-obs-count="' + g.count + '" tabindex="0" role="button" aria-label="Isolate path with ' + hops.length + ' hops, seen by ' + g.count + ' of ' + totalObservers + ' observers">' +
// #1689 r1 (kb #2): all-1-byte path → show a labeled chip instead of
// an empty hop row so operators see WHY the route appears collapsed.
var hopsHtml, hopsCount;
if (g.allOneByte) {
hopsCount = (g.rawHops || []).length;
hopsHtml = '<span class="text-muted">— (' + hopsCount + ' × 1-byte filtered)</span>';
} else {
var hops = g.key.split('→').filter(function(s){return s.length>0;});
hopsCount = hops.length;
hopsHtml = hops.map(escapeHtml).join(' → ');
}
return '<li class="mc-rt-path-row" data-path-key="' + escapeHtml(g.key) + '" data-obs-count="' + g.count + '" tabindex="0" role="button" aria-label="Isolate path with ' + hopsCount + ' hops, seen by ' + g.count + ' of ' + totalObservers + ' observers">' +
'<span class="mc-rt-path-count">' + g.count + '/' + totalObservers + '</span>' +
'<span class="mc-rt-path-hops">' + hops.map(escapeHtml).join(' → ') + '</span>' +
'<span class="mc-rt-path-hops">' + hopsHtml + '</span>' +
'<span class="mc-rt-path-obs" title="' + escapeHtml(g.observers.join(', ')) + '">' + escapeHtml(sample) + moreSuffix + '</span>' +
'</li>';
}).join('');
+447
View File
@@ -0,0 +1,447 @@
/* test-issue-1633-hide-1byte-hops.js
* #1633 — customizer toggle that hides 1-byte path hops at every
* render site. Render-time only; firmware/store untouched.
*
* Asserts:
* 1. Defaults OFF (back-compat: no surprise for existing operators).
* 2. With hide1ByteHops=true a mixed path renders ONLY multi-byte hops.
* 3. With hide1ByteHops=false the full path renders.
* 4. HopDisplay.renderPath emits exactly the multi-byte chips when ON.
* 5. Map polyline source: positions tagged with 1-byte _hopHex are dropped
* from the polyline path when ON; 2/3-byte hops survive; origin /
* destination (no _hopHex) are always kept.
* 6. Analytics route-pattern aggregation: rebuilt path key contains ONLY
* multi-byte hop hexes when ON.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
// ── DOM-less sandbox so hop-display.js / hop-filter.js load cleanly ──
function makeSandbox() {
const store = {};
const ctx = {
window: {
addEventListener: () => {},
dispatchEvent: () => {},
CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; }
},
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '', dataset: {}, setAttribute(){}, getAttribute(){return null;} }),
head: { appendChild: () => {} },
body: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
localStorage: {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
clear: () => { for (const k of Object.keys(store)) delete store[k]; }
},
console, Math, Date, Array, Object, String, Number, JSON, Boolean,
module: { exports: {} }, exports: {},
};
ctx.window.localStorage = ctx.localStorage;
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
const src = fs.readFileSync(path.join(__dirname, file), 'utf8');
vm.runInContext(src, ctx, { filename: file });
}
// === Build a sandbox with hop-filter + hop-display loaded ===
function freshSandbox() {
const ctx = makeSandbox();
load(ctx, 'public/hop-filter.js');
load(ctx, 'public/hop-display.js');
return ctx;
}
// === Heavier sandbox that loads live.js (mirrors test-live.js pattern) ===
function makeLiveSandbox() {
const ctx = {
window: {
addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1,
CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; },
},
document: {
readyState: 'complete',
createElement: () => ({
tagName: '', id: '', textContent: '', innerHTML: '', style: {}, dataset: {},
classList: { add(){}, remove(){}, contains(){return false;} },
setAttribute(){}, getAttribute(){return null;},
addEventListener(){}, focus(){},
getContext: () => ({ clearRect(){}, fillRect(){}, beginPath(){}, arc(){}, fill(){}, scale(){}, fillStyle: '', font: '', fillText(){} }),
offsetWidth: 200, offsetHeight: 40, width: 0, height: 0,
}),
head: { appendChild: () => {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
createElementNS: () => ({ tagName: 'svg', setAttribute(){}, getAttribute(){return null;}, style: {} }),
documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} },
hidden: false,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: (cb) => 0,
cancelAnimationFrame: () => {},
localStorage: (() => {
const store = {};
return {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
};
})(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
addEventListener: () => {},
dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {},
L: {
circleMarker: () => ({ addTo(){return this;}, bindTooltip(){return this;}, on(){return this;}, setRadius(){}, setStyle(){}, setLatLng(){}, getLatLng(){return {lat:0,lng:0};}, remove(){} }),
polyline: () => ({ addTo(){return this;}, setStyle(){}, remove(){} }),
polygon: () => ({ addTo(){return this;}, remove(){} }),
map: () => ({ setView(){return this;}, addLayer(){return this;}, on(){return this;}, getZoom(){return 11;}, getCenter(){return {lat:37,lng:-122};}, getBounds(){return {contains:()=>true};}, fitBounds(){return this;}, invalidateSize(){}, remove(){}, hasLayer(){return false;}, removeLayer(){} }),
layerGroup: () => ({ addTo(){return this;}, addLayer(){}, removeLayer(){}, clearLayers(){}, hasLayer(){return true;}, eachLayer(){} }),
tileLayer: () => ({ addTo(){return this;} }),
control: { attribution: () => ({ addTo(){} }) },
DomUtil: { addClass(){}, removeClass(){} },
},
registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {},
api: () => Promise.resolve([]), invalidateApiCache: () => {},
favStar: () => '', bindFavStars: () => {},
getFavorites: () => [], isFavorite: () => false,
HopResolver: { init(){}, resolve: () => ({}), ready: () => false },
MeshAudio: null,
RegionFilter: { init(){}, getSelected: () => null, onRegionChange: () => {} },
CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; },
module: { exports: {} }, exports: {},
};
ctx.window.localStorage = ctx.localStorage;
vm.createContext(ctx);
// Load filter helpers first so live.js sees window.MC_* on import.
load(ctx, 'public/hop-filter.js');
// Mirror window.* onto top-level for files that reference bare names.
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
// packet-helpers / roles are required by live.js for getParsedPath etc.
try { load(ctx, 'public/roles.js'); } catch (_e) {}
try { load(ctx, 'public/packet-helpers.js'); } catch (_e) {}
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
try { load(ctx, 'public/live.js'); } catch (e) {
console.error('live.js load error:', e.message);
}
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
return ctx;
}
console.log('=== #1633: hide 1-byte path hops everywhere ===');
test('default is OFF (back-compat)', () => {
const ctx = freshSandbox();
assert.strictEqual(ctx.window.MC_getHide1ByteHops(), false, 'default must be OFF');
});
test('hopByteLen: "AB"=1, "ABCD"=2, "ABCDEF"=3, ""=0', () => {
const ctx = freshSandbox();
const bl = ctx.window.MC_hopByteLen;
assert.strictEqual(bl('AB'), 1);
assert.strictEqual(bl('ABCD'), 2);
assert.strictEqual(bl('ABCDEF'), 3);
assert.strictEqual(bl(''), 0);
assert.strictEqual(bl(null), 0);
});
test('isVisibleHop: toggle OFF → every hop visible', () => {
const ctx = freshSandbox();
const f = ctx.window.MC_isVisibleHop;
assert.strictEqual(f('AB', { hide1ByteHops: false }), true);
assert.strictEqual(f('CDEF', { hide1ByteHops: false }), true);
});
test('isVisibleHop: toggle ON → 1-byte HIDDEN, 2/3-byte SHOWN', () => {
const ctx = freshSandbox();
const f = ctx.window.MC_isVisibleHop;
assert.strictEqual(f('AB', { hide1ByteHops: true }), false, '1-byte must hide');
assert.strictEqual(f('CDEF', { hide1ByteHops: true }), true, '2-byte must stay');
assert.strictEqual(f('ABCDEF', { hide1ByteHops: true }), true, '3-byte must stay');
});
test('filterPathHops: mixed input → only multi-byte kept when ON', () => {
const ctx = freshSandbox();
const f = ctx.window.MC_filterPathHops;
const mixed = ['AB', 'CDEF', '12', 'ABCDEF', '34'];
// Use Array.from to bridge the vm-realm Array prototype gap.
assert.deepStrictEqual(
Array.from(f(mixed, { hide1ByteHops: true })),
['CDEF', 'ABCDEF'],
'must drop every 1-byte entry'
);
assert.deepStrictEqual(
Array.from(f(mixed, { hide1ByteHops: false })),
mixed,
'OFF must return input unchanged'
);
});
test('HopDisplay.renderPath rendered chips match filtered hops when ON', () => {
const ctx = freshSandbox();
// Apply the filter at the boundary the way every render site will.
const hops = ['AB', 'CDEF', '12', 'ABCDEF'];
const filtered = ctx.window.MC_filterPathHops(hops, { hide1ByteHops: true });
const html = ctx.window.HopDisplay.renderPath(filtered, {}, { hexMode: true, link: false });
// Filtered chip set: ONLY 2-byte + 3-byte tokens present.
assert.ok(html.indexOf('CDEF') !== -1, 'CDEF chip must render');
assert.ok(html.indexOf('ABCDEF') !== -1, 'ABCDEF chip must render');
// 1-byte hex tokens must NOT appear in the chip set. Use word
// boundaries with the chip wrapper to avoid matching substrings of
// the larger hops.
assert.strictEqual(html.indexOf('>AB<'), -1, '1-byte AB must NOT render as a chip');
assert.strictEqual(html.indexOf('>12<'), -1, '1-byte 12 must NOT render as a chip');
});
test('Map polyline source: positions[]._hopHex 1-byte dropped, origin/dest kept', () => {
const ctx = freshSandbox();
// Origin & destination have no _hopHex (came from payload, not from path bytes).
// Intermediate hops carry _hopHex tagged by drawPacketRoute.
const positions = [
{ name: 'Origin', isOrigin: true },
{ name: 'h1', _hopHex: 'AB' }, // 1-byte → filterable
{ name: 'h2', _hopHex: 'CDEF' }, // 2-byte → keep
{ name: 'h3', _hopHex: '12' }, // 1-byte → filterable
{ name: 'h4', _hopHex: 'ABCDEF' }, // 3-byte → keep
{ name: 'Dest', isDest: true }
];
const opts = { hide1ByteHops: true };
const kept = positions.filter(p =>
!p._hopHex || ctx.window.MC_isVisibleHop(p._hopHex, opts)
);
assert.deepStrictEqual(
kept.map(p => p.name),
['Origin', 'h2', 'h4', 'Dest'],
'polyline retains origin/dest + multi-byte hops only'
);
});
test('Analytics: route-pattern aggregation key drops 1-byte hops when ON', () => {
const ctx = freshSandbox();
// Each packet contributes one path. With hide ON, aggregation must key on
// the FILTERED hop list so 1-byte noise stops inflating distinct counts.
const packets = [
{ path_json: ['AB', 'CDEF', '12', 'ABCDEF'] },
{ path_json: ['99', 'CDEF', '88', 'ABCDEF'] }, // same multi-byte key
{ path_json: ['CDEF', 'ABCDEF'] } // identical w/o 1-byte
];
const opts = { hide1ByteHops: true };
const counts = {};
for (const p of packets) {
const key = ctx.window.MC_filterPathHops(p.path_json, opts).join('→');
counts[key] = (counts[key] || 0) + 1;
}
assert.deepStrictEqual(counts, { 'CDEF→ABCDEF': 3 },
'all three rows collapse to one pattern with 3 hits');
});
// ─────────────────────────────────────────────────────────────────────
// #1689 r1 — additional regressions surfaced by adversarial + Kent Beck
// review on the original PR.
// ─────────────────────────────────────────────────────────────────────
console.log('\n=== #1689 r1: review fix-ups (adv + kb findings) ===');
test('[kb #3] MC_filterPathHops handles null/undefined/empty without throwing', () => {
const ctx = freshSandbox();
const f = ctx.window.MC_filterPathHops;
assert.deepStrictEqual(Array.from(f([])), [], 'empty in → empty out');
// null/undefined inputs should NOT throw. The contract is "return a
// safe empty-ish array". Either [] or the input back is acceptable;
// we assert non-throwing + array-shape.
const a = f(null);
const b = f(undefined);
assert.ok(Array.isArray(a) || a == null, 'null → null/array');
assert.ok(Array.isArray(b) || b == null, 'undefined → null/array');
assert.deepStrictEqual(Array.from(f([], { hide1ByteHops: true })), [], 'empty in + ON → empty');
});
test('[kb #3] MC_isVisibleHop(null) is safe (does not throw; null treated as 0-byte → visible)', () => {
const ctx = freshSandbox();
const v = ctx.window.MC_isVisibleHop;
// null/undefined hops have byteLen 0, !== 1, so they are visible
// regardless of the toggle (origin/dest sentinels rely on this).
assert.strictEqual(v(null, { hide1ByteHops: true }), true, 'null hop must be visible');
assert.strictEqual(v(undefined, { hide1ByteHops: true }), true, 'undefined must be visible');
assert.strictEqual(v('', { hide1ByteHops: true }), true, 'empty string must be visible');
});
test('[adv #1] route-view.js: all-1-byte paths do NOT collide on empty key', () => {
// The bug: route-view aggregated `seen[hops.join('-')]`. When the filter
// produced an empty array, every distinct all-1-byte path collapsed onto
// key "". Distinct routes vanished into one bucket. Fix uses a sentinel
// prefixed with the rawHops signature so the buckets stay distinct.
//
// We assert the EXACT inline aggregation logic now used in route-view.js
// by replaying its key derivation on representative inputs.
const ctx = freshSandbox();
const aggregate = (paths) => {
const seen = {};
paths.forEach((p) => {
const rawHops = p.path || [];
const hops = ctx.window.MC_filterPathHops(rawHops);
const key = hops.length ? hops.join('-') : ('__all1byte__::' + rawHops.join('-'));
seen[key] = true;
});
return Object.keys(seen).length;
};
// Toggle ON
ctx.window.MC_setHide1ByteHops(true);
const distinct = aggregate([
{ path: ['AB', 'CD'] }, // all 1-byte route A
{ path: ['EF', 'GH'] }, // all 1-byte route B — must NOT collide with A
{ path: ['AB', 'CD'] } // duplicate of A — must collide with A
]);
assert.strictEqual(distinct, 2,
'two distinct all-1-byte routes + one dup must produce 2 buckets, not 1');
});
test('[adv #1] route-view.js source: key derivation uses sentinel for all-1-byte buckets', () => {
// Source-grep guard: if a future refactor reverts to `hops.join("-")`
// bare aggregation, this test catches it.
const src = fs.readFileSync(path.join(__dirname, 'public/route-view.js'), 'utf8');
assert.ok(src.indexOf('__all1byte__::') !== -1 || src.indexOf('⟨all-1byte⟩::') !== -1,
'route-view.js must include a sentinel for all-1-byte aggregation buckets');
});
test('[adv #2] map.js source: drawPacketRoute propagates hopsHiddenCount to renderer', () => {
// Source-grep guard: the polyline MUST carry the redacted-count down
// to the renderer so it can dash + badge. A revert that drops the
// `hopsHiddenCount` option from the render call regresses operator UX.
const src = fs.readFileSync(path.join(__dirname, 'public/map.js'), 'utf8');
assert.ok(/hopsHiddenCount\s*:\s*hopsHiddenCount/.test(src),
'map.js must pass hopsHiddenCount into the route renderer opts');
assert.ok(/var\s+hopsHiddenCount\s*=\s*0/.test(src) || /let\s+hopsHiddenCount\s*=\s*0/.test(src),
'map.js must compute hopsHiddenCount before filtering positions');
});
test('[adv #2] route-render.js: redacted edges use dashed + reduced opacity + emit badge', () => {
const src = fs.readFileSync(path.join(__dirname, 'public/route-render.js'), 'utf8');
assert.ok(src.indexOf('mc-route-edge-redacted') !== -1,
'route-render.js must add a .mc-route-edge-redacted className when hopsHiddenCount > 0');
assert.ok(src.indexOf('mc-route-redacted-badge') !== -1,
'route-render.js must emit a midpoint badge when hopsHiddenCount > 0');
assert.ok(/opacity:\s*redacted\s*\?/.test(src),
'route-render.js must lower opacity when redacted');
});
test('[adv #3] observer-detail.js source: hop count honors MC_filterPathHops', () => {
const src = fs.readFileSync(path.join(__dirname, 'public/observer-detail.js'), 'utf8');
assert.ok(src.indexOf('MC_filterPathHops') !== -1,
'observer-detail.js renderRecentPackets must call MC_filterPathHops');
});
test('[r2 MAJOR] live.js packetInvolvesFilterNode: node-filter search is INDEPENDENT of hide-1byte toggle', () => {
// r2 reviewer (option 1): drop the 1-byte hop filter from
// packetInvolvesFilterNode entirely. The node-filter search box must
// return the same matches regardless of the display preference; the
// chip rendering already has separate filtering via feedHops.
//
// Behavioral assertion via vm-sandbox load of live.js (replaces the
// prior source-grep tautology for this consumer).
const ctx = makeLiveSandbox();
const fn = ctx.window._livePacketInvolvesFilterNode;
assert.ok(fn, '_livePacketInvolvesFilterNode must be exposed');
// Filter-node appears ONLY in a 1-byte hop.
const pkt = { decoded: { path: { hops: ['ab'] }, payload: {} } };
const filterKeys = ['abcd1234567890ab'];
// Toggle OFF — must match (baseline).
ctx.window.MC_setHide1ByteHops(false);
assert.strictEqual(fn(pkt, filterKeys), true,
'baseline: match a filter-key whose prefix overlaps a 1-byte hop');
// Toggle ON — MUST STILL match. The node-filter search box is
// semantically independent of the display preference.
ctx.window.MC_setHide1ByteHops(true);
assert.strictEqual(fn(pkt, filterKeys), true,
'node-filter search must be independent of hide-1byte display toggle');
});
test('[adv #4] customize-v2.js still dispatches mc-hide-1byte-hops-changed', () => {
const src = fs.readFileSync(path.join(__dirname, 'public/customize-v2.js'), 'utf8');
assert.ok(src.indexOf('mc-hide-1byte-hops-changed') !== -1,
'customizer must continue to dispatch the event');
});
test('[adv #4] map.js + packets.js subscribe to mc-hide-1byte-hops-changed', () => {
const mapSrc = fs.readFileSync(path.join(__dirname, 'public/map.js'), 'utf8');
const pktSrc = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
assert.ok(/addEventListener\(['"]mc-hide-1byte-hops-changed['"]/.test(mapSrc),
'map.js must subscribe to mc-hide-1byte-hops-changed');
assert.ok(/addEventListener\(['"]mc-hide-1byte-hops-changed['"]/.test(pktSrc),
'packets.js must subscribe to mc-hide-1byte-hops-changed');
});
test('[adv #4] hop-filter dispatches the event when toggled', () => {
const ctx = freshSandbox();
let saw = null;
ctx.window.dispatchEvent = function (ev) { saw = ev; };
ctx.window.MC_setHide1ByteHops(true);
assert.ok(saw, 'must dispatch CustomEvent on setHide1ByteHops');
assert.strictEqual(saw.type, 'mc-hide-1byte-hops-changed');
assert.strictEqual(saw.detail && saw.detail.value, true);
});
test('[kb #2] (1-byte filtered) chip text is emitted by every render boundary', () => {
// PR body promised the chip. Assert the literal text exists in every
// consumer file that renders a path. If a future refactor strips it,
// operators lose the only signal that the route was redacted.
const files = [
'public/packets.js',
'public/route-view.js'
];
for (const f of files) {
const src = fs.readFileSync(path.join(__dirname, f), 'utf8');
assert.ok(src.indexOf('1-byte filtered') !== -1,
f + ' must render the literal "(1-byte filtered)" chip text');
}
});
test('[kb #1] anti-tautology: tests reference the actual production files (not inline copies)', () => {
// This file is allowed ONLY a single inline filter recreation (the
// route-view aggregation guard). Every other consumer assertion must
// source-grep the production file, NOT re-implement the filter.
const selfSrc = fs.readFileSync(__filename, 'utf8');
// Ensure the test file does NOT define its own filterPathHops.
assert.strictEqual(
/function\s+filterPathHops\s*\(/.test(selfSrc), false,
'test file must not re-implement filterPathHops (tautology guard)'
);
});
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);