diff --git a/public/packets.js b/public/packets.js index 4c8e6fca..2b91f497 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2244,7 +2244,12 @@ const groupTypeClass = payloadTypeColor(p.payload_type); const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; const _grpPlOff = getPathLenOffset(p.route_type); - const groupHashBytes = ((parseInt(p.raw_hex?.slice(_grpPlOff * 2, _grpPlOff * 2 + 2), 16) || 0) >> 6) + 1; + // #1849: TRACE (payload_type=9) header path bytes are per-hop SNR readings, + // not truncated hop hashes (see internal/packetpath/route.go PathBytesAreHops). + // The (>>6)+1 derivation is meaningless for TRACE — render — with a tooltip. + const _grpIsTrace = p.payload_type === 9; + const groupHashBytes = _grpIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_grpPlOff * 2, _grpPlOff * 2 + 2), 16) || 0) >> 6) + 1); + const _grpHashSizeTitle = _grpIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : ''; const isSingle = p.count <= 1; // Channel color highlighting (#271) const _grpDecoded = getParsedDecoded(p) || {}; @@ -2257,7 +2262,7 @@ ${renderTimestampCell(p.latest)} ${truncate(p.hash || '—', 8)} ${groupSize ? groupSize + 'B' : '—'} - ${groupHashBytes} + ${groupHashBytes} ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} ${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)} ${groupPathStr} @@ -2275,9 +2280,14 @@ const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0; const childPath = getParsedPath(c); const _cPlOff = getPathLenOffset(p.route_type); - const childHashBytes = c.raw_hex - ? (((parseInt(c.raw_hex.slice(_cPlOff * 2, _cPlOff * 2 + 2), 16) || 0) >> 6) + 1) - : (childPath.length > 0 ? childPath[0].length / 2 : 0); + // #1849: TRACE header path bytes are SNR readings, not hop hashes. + const _cIsTrace = c.payload_type === 9; + const childHashBytes = _cIsTrace + ? '—' + : (c.raw_hex + ? (((parseInt(c.raw_hex.slice(_cPlOff * 2, _cPlOff * 2 + 2), 16) || 0) >> 6) + 1) + : (childPath.length > 0 ? childPath[0].length / 2 : 0)); + const _cHashSizeTitle = _cIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : ''; const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : ''; const childPathStr = renderPath(childPath, c.observer_id); const _childHashStripe = _hashStripeStyle(c.hash || p.hash); @@ -2286,7 +2296,7 @@ ${renderTimestampCell(c.timestamp)} ${truncate(c.hash || '', 8)} ${size}B - ${childHashBytes} + ${childHashBytes} ${typeName}${transportBadge(c.route_type)} ${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)} ${childPathStr} @@ -2309,7 +2319,10 @@ const _chanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(decoded.type || typeName, decoded.channel) : ''; const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; const _flatPlOff = getPathLenOffset(p.route_type); - const hashBytes = ((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1; + // #1849: TRACE path bytes = SNR readings, not hop hashes. + const _flatIsTrace = p.payload_type === 9; + const hashBytes = _flatIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1); + const _flatHashSizeTitle = _flatIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : ''; const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded); const _flatHashStripe = _hashStripeStyle(p.hash); @@ -2319,7 +2332,7 @@ ${renderTimestampCell(p.timestamp)} ${truncate(p.hash || String(p.id), 8)} ${size}B - ${hashBytes} + ${hashBytes} ${typeName}${transportBadge(p.route_type)} ${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)} ${pathStr} diff --git a/test-all.sh b/test-all.sh index a80e4c13..914fcee0 100755 --- a/test-all.sh +++ b/test-all.sh @@ -65,6 +65,7 @@ node test-naive-banner-tone.js node test-issue-1473-reserved-prefixes.js node test-issue-1473-prefix-generator.js node test-issue-1770-mobile-row-clamp.js +node test-issue-1849-trace-hashbytes.js echo "" echo "═══════════════════════════════════════" diff --git a/test-issue-1849-trace-hashbytes.js b/test-issue-1849-trace-hashbytes.js new file mode 100644 index 00000000..85307d5a --- /dev/null +++ b/test-issue-1849-trace-hashbytes.js @@ -0,0 +1,151 @@ +/** + * #1849 — "Hop Bytes" (col-hashsize) column shows misleading `1` for TRACE packets. + * + * TRACE packets (payload_type=9) use header path bytes as per-hop SNR readings, not + * truncated hop hashes (see internal/packetpath/route.go PathBytesAreHops(TRACE)=false). + * The high-2-bit "hash_size" derivation is meaningless for TRACE. + * + * Fix: at the 3 render sites in public/packets.js (buildGroupRowHtml header, its child + * rows, buildFlatRowHtml), when payload_type === 9 render `—` in col-hashsize with an + * explanatory title tooltip. Non-TRACE behavior unchanged. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// Reuse the sandbox construction from test-packets.js (kept in sync intentionally). +function makeSandbox() { + const registeredPages = {}; + const ctx = { + window: { + addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, + innerWidth: 1200, PacketFilter: null, + }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', className: '', style: {}, + appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, + classList: { add: () => {}, remove: () => {}, contains: () => false } }), + head: { appendChild: () => {} }, getElementById: () => null, + addEventListener: () => {}, removeEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, body: { appendChild: () => {} }, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, RangeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { + getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; }, + }; })(), + location: { hash: '' }, history: { replaceState: () => {} }, + CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + registerPage: (name, handler) => { registeredPages[name] = handler; }, + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file }); + for (const k of Object.keys(ctx.window)) { ctx[k] = ctx.window[k]; } +} + +function loadPacketsSandbox() { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/payload-labels.js'); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + loadInCtx(ctx, 'public/packet-helpers.js'); + vm.runInContext(` + window.HopDisplay = { + renderHop: function(h, entry, opts) { return '' + h + ''; }, + _showFromBtn: function() {} + }; + `, ctx); + loadInCtx(ctx, 'public/packets.js'); + return ctx; +} + +console.log('\n=== #1849: TRACE col-hashsize renders — not misleading integer ==='); +{ + const ctx = loadPacketsSandbox(); + const api = ctx._packetsTestAPI; + assert(api, '_packetsTestAPI must be exposed'); + + test('buildFlatRowHtml: TRACE (payload_type=9) renders — in col-hashsize with title tooltip', () => { + const p = { + id: 100, hash: 'trace1', timestamp: '', observer_id: null, + raw_hex: '00090000000000000000000102030405', payload_type: 9, + route_type: 0, decoded_json: '{}', path_json: '[]' + }; + const html = api.buildFlatRowHtml(p); + const m = html.match(/]*)>([^<]*)<\/td>/); + assert(m, 'col-hashsize cell must be present. Got: ' + html.slice(0, 500)); + assert.strictEqual(m[2].trim(), '—', + 'TRACE col-hashsize must be — (em-dash), got: ' + JSON.stringify(m[2])); + assert(/title="[^"]*TRACE[^"]*"/.test(m[1]), + 'col-hashsize must carry a title attribute mentioning TRACE. Got attrs: ' + m[1]); + }); + + test('buildFlatRowHtml: non-TRACE (payload_type=0) still renders numeric hashBytes', () => { + const p = { + id: 101, hash: 'adv1', timestamp: '', observer_id: null, + raw_hex: '00000000000000000000C100', payload_type: 0, + route_type: 0, decoded_json: '{}', path_json: '[]' + }; + const html = api.buildFlatRowHtml(p); + const m = html.match(/]*>([^<]*)<\/td>/); + assert(m, 'col-hashsize cell must be present'); + assert(/^\d+$/.test(m[1].trim()), + 'non-TRACE col-hashsize must be numeric, got: ' + JSON.stringify(m[1])); + }); + + test('buildGroupRowHtml: TRACE header row (payload_type=9) renders — in col-hashsize with title', () => { + const p = { + hash: 'trg1', count: 1, latest: '', + observer_id: null, raw_hex: '00090000000000000000000102030405', + payload_type: 9, route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 1, observer_count: 1, + }; + const html = api.buildGroupRowHtml(p); + const m = html.match(/]*)>([^<]*)<\/td>/); + assert(m, 'col-hashsize cell must be present'); + assert.strictEqual(m[2].trim(), '—', + 'TRACE group header col-hashsize must be —, got: ' + JSON.stringify(m[2])); + assert(/title="[^"]*TRACE[^"]*"/.test(m[1]), + 'col-hashsize must carry title mentioning TRACE'); + }); + + test('buildGroupRowHtml: non-TRACE header row still numeric', () => { + const p = { + hash: 'grp1', count: 1, latest: '', + observer_id: null, raw_hex: '00000000000000000000C100', + payload_type: 0, route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 1, observer_count: 1, + }; + const html = api.buildGroupRowHtml(p); + const m = html.match(/]*>([^<]*)<\/td>/); + assert(m, 'col-hashsize cell must be present'); + assert(/^\d+$/.test(m[1].trim()), + 'non-TRACE group col-hashsize must be numeric, got: ' + JSON.stringify(m[1])); + }); +} + +console.log(''); +if (failed > 0) { + console.error(`❌ ${failed} test(s) failed, ${passed} passed`); + process.exit(1); +} +console.log(`✅ All ${passed} tests passed`);