diff --git a/public/nodes.js b/public/nodes.js index ce1f17a8d..30d4aeeeb 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -747,9 +747,11 @@
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return ` -

Recent Adverts (${validPackets.length})

+

Recent Adverts (${validPackets.length})

- ${validPackets.length ? validPackets.map(p => { + ${window.groupRecentAdverts(validPackets).map(group => `
+
${group.label} (${group.adverts.length})
+ ${group.adverts.length ? group.adverts.map(p => { let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {} const typeLabel = p.payload_type === 4 ? ' Advert' : p.payload_type === 5 ? ' Channel' : p.payload_type === 2 ? ' DM' : ' Packet'; const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : ''; @@ -773,7 +775,8 @@ ${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi} Analyze →
`; - }).join('') : '
No recent packets
'} + }).join('') : '
None in this recent sample
'} +
`).join('')}
`; })()} @@ -1738,9 +1741,11 @@
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return ` -

Recent Adverts (${validPackets.length})

+

Recent Adverts (${validPackets.length})

- ${validPackets.length ? validPackets.map(a => { + ${window.groupRecentAdverts(validPackets).map(group => `
+
${group.label} (${group.adverts.length})
+ ${group.adverts.length ? group.adverts.map(a => { let decoded; try { decoded = JSON.parse(a.decoded_json); } catch {} const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet'; @@ -1757,7 +1762,8 @@
Analyze →
`; - }).join('') : '
No recent packets
'} + }).join('') : '
None in this recent sample
'} +
`).join('')} `; })()} diff --git a/public/roles.js b/public/roles.js index a816399e7..cf9e0c582 100644 --- a/public/roles.js +++ b/public/roles.js @@ -981,6 +981,29 @@ }; }; + // The API accumulates route evidence across observations of the same advert. + // A canonical frame or selected observation cannot prove an exclusive kind. + window.classifyRecentAdvert = function (advert) { + var kind = advert && advert.advert_kind; + if (kind === 'flood' || kind === 'mixed') return kind; + return kind === 'zero_hop' ? 'zero-hop' : 'other'; + }; + + // One pass over the API's bounded recent sample; keep input order and rows intact. + window.groupRecentAdverts = function (adverts) { + var groups = [ + { kind: 'flood', label: 'Flood adverts', adverts: [] }, + { kind: 'mixed', label: 'Mixed flood / zero-hop adverts', adverts: [] }, + { kind: 'zero-hop', label: 'Zero-hop adverts', adverts: [] }, + { kind: 'other', label: 'Other / unknown adverts', adverts: [] }, + ]; + adverts.forEach(function (advert) { + var kind = window.classifyRecentAdvert(advert); + groups[kind === 'flood' ? 0 : kind === 'mixed' ? 1 : kind === 'zero-hop' ? 2 : 3].adverts.push(advert); + }); + return groups.filter(function (group) { return group.kind === 'flood' || group.kind === 'zero-hop' || group.adverts.length; }); + }; + /** Render a skew sparkline SVG (inline, word-sized) */ window.renderSkewSparkline = function(samples, w, h) { w = w || 120; h = h || 24; diff --git a/public/style.css b/public/style.css index e91e1e711..903e6c322 100644 --- a/public/style.css +++ b/public/style.css @@ -2060,6 +2060,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .btn-primary:hover { background: var(--accent-hover); } /* Advert Timeline */ +.node-advert-group h5 { margin: 8px 0; font-size: 12px; color: var(--text-muted); } .advert-entry { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); diff --git a/tests/e2e/test-e2e-playwright.js b/tests/e2e/test-e2e-playwright.js index 1c53d079e..d6f35490b 100644 --- a/tests/e2e/test-e2e-playwright.js +++ b/tests/e2e/test-e2e-playwright.js @@ -65,6 +65,88 @@ async function run() { console.log(`\nRunning E2E tests against ${BASE}\n`); + // API contract fixtures exercise both real node renderers at desktop/mobile sizes. + for (const width of [1280, 375]) { + await test(`#2073 recent adverts grouped in both node views at ${width}px`, async () => { + const fixtureContext = await browser.newContext({ viewport: { width, height: 900 } }); + const fixturePage = await fixtureContext.newPage(); + const pubkey = 'a'.repeat(64); + const node = { public_key: pubkey, name: 'Advert fixture', role: 'repeater', + last_seen: new Date().toISOString(), advert_count: 900 }; + const routes = [ + { advert_kind: 'flood', route_type: 1, raw_hex: '110000' }, + { advert_kind: 'zero_hop', route_type: 2, raw_hex: '120000' }, + { advert_kind: 'flood', route_type: 0, raw_hex: '10010203040000' }, + { advert_kind: 'zero_hop', route_type: 3, raw_hex: '13010203040000' }, + { advert_kind: 'other', route_type: 2, raw_hex: '1201ab00', path_json: '[]' }, + { route_type: null, raw_hex: null }, + { route_type: 2, raw_hex: '1200zz' }, + { advert_kind: 'mixed', route_type: 1, raw_hex: '110000' }, + { advert_kind: 'mixed', route_type: 2, raw_hex: '120000' }, + { route_type: 1, raw_hex: '110000' }, + { advert_kind: 'future_kind', route_type: 2, raw_hex: '120000' }, + ]; + let adverts = routes.map((route, i) => ({ ...route, hash: String(i + 1).repeat(16), + timestamp: new Date(Date.now() - i * 60000).toISOString(), payload_type: 4, + observer_name: `Fixture observer ${i + 1}`, snr: 7 + i, rssi: -80 - i, observation_count: 2 })); + await fixturePage.route('**/api/nodes**', async route => { + const path = new URL(route.request().url()).pathname; + let body; + if (path === '/api/nodes') body = { nodes: [node], total: 1 }; + else if (path === '/api/nodes/' + pubkey) body = { node, recentAdverts: adverts }; + else if (path === '/api/nodes/' + pubkey + '/health') body = {}; + else return route.continue(); + await route.fulfill({ json: body }); + }); + try { + for (const empty of [false, true]) { + if (empty) adverts = []; + for (const full of [false, true]) { + await fixturePage.goto(`${BASE}/#/nodes${full ? '/' + pubkey : ''}`, { waitUntil: 'domcontentloaded' }); + await fixturePage.reload({ waitUntil: 'domcontentloaded' }); + if (!full) await fixturePage.locator(`tr[data-key="${pubkey}"]`).click(); + // On phones a list click opens the full page; only desktop has a side pane. + const fullView = full || width <= 640; + const root = fullView ? '#node-packets' : '#advertTimeline'; + await fixturePage.locator(root).waitFor(); + const groups = await fixturePage.locator(root + ' [data-advert-kind]').evaluateAll(els => els.map(el => ({ + kind: el.dataset.advertKind, + heading: el.querySelector('h5').textContent.trim(), + rows: Array.from(el.querySelectorAll('a.ch-analyze-link'), a => ({ + href: a.getAttribute('href'), + text: a.closest('.node-activity-item, .advert-entry').textContent, + })), + text: el.textContent, + overflow: el.scrollWidth > el.clientWidth + 1, + }))); + assert(groups.length === (empty ? 2 : 4), `Expected ${empty ? 2 : 4} advert groups, got ${groups.length}`); + const expected = empty ? [[], []] : [[0, 2], [7, 8], [1, 3], [4, 5, 6, 9, 10]]; + const labels = empty ? ['Flood adverts', 'Zero-hop adverts'] : ['Flood adverts', 'Mixed flood / zero-hop adverts', 'Zero-hop adverts', 'Other / unknown adverts']; + assert(groups.reduce((count, group) => count + group.rows.length, 0) === adverts.length, 'Each advert must appear exactly once'); + expected.forEach((indices, i) => { + assert(groups[i].heading === `${labels[i]} (${indices.length})`, `Wrong sample count: ${groups[i].heading}`); + assert(JSON.stringify(groups[i].rows.map(row => row.href)) === JSON.stringify(indices.map(j => '#/packets/' + adverts[j].hash)), 'Advert order or analyze links changed'); + assert(!groups[i].overflow, `${labels[i]} overflows at ${width}px`); + if (empty) assert(groups[i].text.includes('None in this recent sample'), `${labels[i]} empty-state message missing`); + indices.forEach((j, rowIndex) => { + const advert = adverts[j]; + const row = groups[i].rows[rowIndex]; + assert(row.text.includes(advert.observer_name) && row.text.includes(`SNR ${advert.snr}dB`) && row.text.includes(`RSSI ${advert.rssi}dBm`), `RF/observer metadata changed for ${row.href}`); + }); + }); + const heading = fullView ? fixturePage.locator('#node-packets h4') : fixturePage.locator('#advertTimeline').locator('..').locator('h4'); + assert(await heading.textContent() === `Recent Adverts (${adverts.length})`, 'Recent Adverts count must reflect sample, not lifetime'); + assert((await heading.getAttribute('title')).includes('originated'), 'Existing origin tooltip lost'); + const explanation = await heading.getAttribute('title'); + assert(explanation.includes('available observations') && explanation.includes('older history may be incomplete'), 'Grouping must explain the available-evidence limit'); + } + } + } finally { + await fixtureContext.close(); + } + }); + } + // --- Group: Home page (tests 1, 6, 7) --- // Test 1: Home page loads diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index ad54cb4f2..dca13eb50 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -6937,6 +6937,115 @@ console.log('\n=== map.js: hash size fallback ==='); }); } +// ===== Recent advert routing (#2073) ===== +console.log('\n=== roles.js: recent advert groups (#2073) ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + function classify(packet) { + assert.strictEqual(typeof ctx.classifyRecentAdvert, 'function', 'shared recent-advert classifier must exist'); + return ctx.classifyRecentAdvert(packet); + } + test('accumulated mixed evidence wins for the same hash in either arrival order', () => { + for (const canonical of [{ route_type: 1, raw_hex: '110000' }, { route_type: 2, raw_hex: '120000' }]) { + const advert = { ...canonical, hash: 'same-advert', advert_kind: 'mixed', path_json: '[]' }; + assert.strictEqual(classify(advert), 'mixed', 'first received frame must not override accumulated evidence'); + const groups = ctx.groupRecentAdverts([advert]); + assert.strictEqual(groups.reduce((n, group) => n + group.adverts.length, 0), 1, 'mixed advert must not be counted twice'); + const mixed = groups.find(group => group.kind === 'mixed'); + assert.ok(mixed, 'mixed group must exist'); + assert.strictEqual(mixed.adverts[0], advert, 'row metadata must be retained'); + assert.deepStrictEqual(Array.from(groups, group => group.kind), ['flood', 'mixed', 'zero-hop']); + assert.strictEqual(mixed.label, 'Mixed flood / zero-hop adverts'); + } + }); + test('authoritative advert kind does not depend on canonical route or path', () => { + for (const [advert_kind, expected] of [['flood', 'flood'], ['zero_hop', 'zero-hop'], ['mixed', 'mixed'], ['other', 'other']]) { + assert.strictEqual(classify({ advert_kind }), expected); + assert.strictEqual(classify({ advert_kind, route_type: 1, raw_hex: '110000', path_json: '[]' }), expected); + assert.strictEqual(classify({ advert_kind, route_type: 2, raw_hex: '120000', path_json: '["ab"]' }), expected); + } + }); + test('legacy and unrecognized advert evidence stays unknown despite known-looking frames', () => { + for (const advert_kind of [undefined, null, '', 'zero-hop', 'future_kind', 1, false, ['flood']]) { + for (const canonical of [{ route_type: 1, raw_hex: '110000' }, { route_type: 2, raw_hex: '120000' }]) { + assert.strictEqual(classify({ ...canonical, advert_kind, path_json: '[]', observations: [{ raw_hex: '110000' }, { raw_hex: '120000' }] }), 'other'); + } + } + }); + test('mixed groups preserve per-group order and hide only empty optional groups', () => { + const adverts = ['mixed', 'zero_hop', 'flood', 'mixed', 'other'].map((advert_kind, i) => ({ advert_kind, hash: String(i) })); + const before = JSON.stringify(adverts); + const groups = ctx.groupRecentAdverts(adverts); + assert.deepStrictEqual(JSON.parse(JSON.stringify(groups.map(group => [group.kind, group.adverts.map(advert => advert.hash)]))), [ + ['flood', ['2']], ['mixed', ['0', '3']], ['zero-hop', ['1']], ['other', ['4']], + ]); + assert.strictEqual(groups.reduce((n, group) => n + group.adverts.length, 0), adverts.length); + assert.strictEqual(JSON.stringify(adverts), before); + assert.deepStrictEqual(Array.from(ctx.groupRecentAdverts([]), group => group.kind), ['flood', 'zero-hop']); + }); + test('legacy flood routes without accumulated evidence stay unknown', () => { + for (const route of [0, 1, '0', '1']) { + assert.strictEqual(classify({ route_type: route, path_json: '[]' }), 'other'); + } + assert.strictEqual(classify({ raw_hex: '110000' }), 'other'); + assert.strictEqual(classify({ raw_hex: '10010203040000' }), 'other'); + }); + test('legacy direct routes cannot prove zero-hop from a selected empty path', () => { + for (const route of [2, 3, '2', '3']) { + assert.strictEqual(classify({ route_type: route, path_json: '[]' }), 'other'); + assert.strictEqual(classify({ route_type: route, path_json: [] }), 'other'); + assert.strictEqual(classify({ route_type: route }), 'other'); + assert.strictEqual(classify({ route_type: route, path_json: '["ab"]' }), 'other'); + } + assert.strictEqual(classify({ route_type: null, raw_hex: '120000' }), 'other'); + assert.strictEqual(classify({ raw_hex: '13010203040000' }), 'other'); + }); + test('neither canonical transmission nor selected observation path fills missing evidence', () => { + assert.strictEqual(classify({ route_type: 2, raw_hex: '1201ab00', path_json: '[]' }), 'other'); + assert.strictEqual(classify({ route_type: 2, raw_hex: '120000', path_json: '["ab"]' }), 'other'); + assert.strictEqual(classify({ route_type: 3, raw_hex: '130002030401ab00', path_json: '[]' }), 'other'); + assert.strictEqual(classify({ route_type: 1, raw_hex: '114000' }), 'other'); + assert.strictEqual(classify({ route_type: 2, raw_hex: '124000' }), 'other'); + }); + test('unknown, malformed and contradictory routing is never guessed', () => { + for (const packet of [null, {}, { route_type: null }, { route_type: '' }, + { route_type: false }, { route_type: [] }, { route_type: [2], path_json: '[]' }, + { route_type: {} }, { route_type: 4 }, { route_type: -1 }, { route_type: 2.5 }, + { route_type: 'flood' }, { route_type: 2, path_json: null }, + { route_type: 2, path_json: '' }, { route_type: 2, path_json: 'null' }, + { route_type: 2, path_json: '{}' }, { route_type: 2, path_json: '[' }, + { route_type: 1, raw_hex: '120000' }, { route_type: 2, raw_hex: '110000' }, + { route_type: 2, raw_hex: '12' }, { route_type: 2, raw_hex: '120' }, + { route_type: 2, raw_hex: '1200zz' }, { route_type: 2, raw_hex: '12c000' }, + { route_type: 2, raw_hex: '1202ab' }, { route_type: 3, raw_hex: '130000' }]) { + assert.strictEqual(classify(packet), 'other', JSON.stringify(packet)); + } + }); + test('grouping preserves every row, per-group order and sample counts without mutation', () => { + assert.strictEqual(typeof ctx.groupRecentAdverts, 'function', 'shared recent-advert grouping must exist'); + const packets = [ + { hash: 'a', advert_kind: 'zero_hop', route_type: 2, path_json: '[]' }, + { hash: 'b', advert_kind: 'flood', route_type: 1 }, + { hash: 'c', route_type: null }, + { hash: 'd', advert_kind: 'flood', route_type: 0 }, + { hash: 'e', advert_kind: 'zero_hop', route_type: 3, path_json: '[]' }, + { hash: 'f', route_type: 2, path_json: '["ab"]' }, + ]; + const before = JSON.stringify(packets); + const groups = ctx.groupRecentAdverts(packets); + assert.deepStrictEqual(JSON.parse(JSON.stringify(groups.map(g => [g.kind, g.label, g.adverts.map(p => p.hash)]))), [ + ['flood', 'Flood adverts', ['b', 'd']], + ['zero-hop', 'Zero-hop adverts', ['a', 'e']], + ['other', 'Other / unknown adverts', ['c', 'f']], + ]); + assert.strictEqual(groups.reduce((n, g) => n + g.adverts.length, 0), packets.length); + assert.strictEqual(groups[0].adverts[0], packets[1]); + assert.strictEqual(JSON.stringify(packets), before); + assert.strictEqual(ctx.groupRecentAdverts([]).reduce((n, g) => n + g.adverts.length, 0), 0); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);