Compare commits

...
2 Commits
Author SHA1 Message Date
clawbot 115368d02d fix(#1849): render — for TRACE col-hashsize (path bytes are SNR, not hop hashes)
At the 3 render sites in public/packets.js (buildGroupRowHtml header,
group child rows, buildFlatRowHtml), when payload_type === 9 (TRACE)
skip the (>>6)+1 derivation and render — in col-hashsize with a title
tooltip explaining path bytes are per-hop SNR readings, not truncated
hop hashes.

See internal/packetpath/route.go PathBytesAreHops(TRACE)=false.
Non-TRACE behavior unchanged.

Fixes #1849
2026-07-16 07:10:45 +00:00
clawbot dd7a4e7850 test(#1849): TRACE col-hashsize must render — not misleading integer
Red commit: asserts col-hashsize cell equals '—' with title tooltip
for payload_type=9 (TRACE) in both buildFlatRowHtml and buildGroupRowHtml.
Non-TRACE cases continue to render numeric hashBytes.
2026-07-16 07:09:07 +00:00
3 changed files with 173 additions and 8 deletions
+21 -8
View File
@@ -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 @@
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-hashsize mono">${groupHashBytes}</td>
<td class="col-hashsize mono"${_grpHashSizeTitle}>${groupHashBytes}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(headerObserverId) || '')}">${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
@@ -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 @@
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${childHashBytes}</td>
<td class="col-hashsize mono"${_cHashSizeTitle}>${childHashBytes}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(c.observer_id) || '')}">${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
@@ -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 @@
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${hashBytes}</td>
<td class="col-hashsize mono"${_flatHashSizeTitle}>${hashBytes}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(p.observer_id) || '')}">${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
+1
View File
@@ -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 "═══════════════════════════════════════"
+151
View File
@@ -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 '<span>' + h + '</span>'; },
_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 class="col-hashsize[^"]*"([^>]*)>([^<]*)<\/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 <td> 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 class="col-hashsize[^"]*"[^>]*>([^<]*)<\/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 class="col-hashsize[^"]*"([^>]*)>([^<]*)<\/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 <td> 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 class="col-hashsize[^"]*"[^>]*>([^<]*)<\/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`);