diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e672815..b82491b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -265,6 +265,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/packets.js b/public/packets.js index b222e290..5ad0c636 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2841,15 +2841,18 @@ } } - // Location: from ADVERT lat/lon, or from known node via pubkey/sender name - let locationHtml = 'โ€”'; + // Location: from ADVERT lat/lon, or from known node via pubkey/sender name. + // Issue #1281: only render the row when we actually have transmitter GPS. + // Non-ADVERT packets don't carry GPS in the unencrypted payload, so the row + // would otherwise render as "โ€”" and waste a slot on ~90% of packet types. + let locationHtml = ''; let locationNodeKey = null; if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) { locationNodeKey = decoded.pubKey || decoded.srcPubKey || ''; const nodeName = decoded.name || ''; locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`; if (nodeName) locationHtml = `${escapeHtml(nodeName)} โ€” ${locationHtml}`; - if (locationNodeKey) locationHtml += ` ๐Ÿ“map`; + if (locationNodeKey) locationHtml += ` ๐Ÿ“map`; } else { // Try to resolve sender node location from nodes list const senderKey = decoded.pubKey || decoded.srcPubKey; @@ -2861,7 +2864,7 @@ locationNodeKey = nodeData.node.public_key; locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`; if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)} โ€” ${locationHtml}`; - locationHtml += ` ๐Ÿ“map`; + locationHtml += ` ๐Ÿ“map`; } else if (senderName && !senderKey) { // Search by name const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null); @@ -2870,7 +2873,7 @@ locationNodeKey = match.public_key; locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`; locationHtml = `${escapeHtml(match.name)} โ€” ${locationHtml}`; - locationHtml += ` ๐Ÿ“map`; + locationHtml += ` ๐Ÿ“map`; } } } catch {} @@ -2896,7 +2899,7 @@ ${messageHtml}
Observer
${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}
-
Location
${locationHtml}
+ ${locationHtml ? `
Location
${locationHtml}
` : ''}
SNR / RSSI
${snr != null ? snr + ' dB' : 'โ€”'} / ${rssi != null ? rssi + ' dBm' : 'โ€”'}
Route Type
${routeTypeName(pkt.route_type)}
Payload Type
${typeName}
diff --git a/public/style.css b/public/style.css index 7b356b23..8bc92257 100644 --- a/public/style.css +++ b/public/style.css @@ -917,6 +917,10 @@ body.scroll-locked { overflow: hidden; } } .detail-meta dt { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; } .detail-meta dd { font-weight: 500; margin-bottom: 4px; } +/* #1281: ๐Ÿ“map link inside detail-meta โ€” UA-default blue is unreadable on + * dark backgrounds. Force theme-aware color via --accent. */ +.loc-map-link { color: var(--accent); font-size: 0.85em; text-decoration: none; } +.loc-map-link:hover { text-decoration: underline; } .observation-current { background: var(--accent-bg, rgba(0,122,255,0.1)); font-weight: 600; } .detail-obs-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); } .detail-obs-table th { font-size: 0.8em; text-transform: uppercase; color: var(--text-muted); } diff --git a/test-issue-1281-location-row-e2e.js b/test-issue-1281-location-row-e2e.js new file mode 100644 index 00000000..006d13aa --- /dev/null +++ b/test-issue-1281-location-row-e2e.js @@ -0,0 +1,155 @@ +/** + * #1281 โ€” Packet detail Location row + ๐Ÿ“map link contrast. + * + * Bug: + * A)
Location
โ€”
renders unconditionally on every packet, + * wasting a row on ~90% of packet types (only ADVERT carries unencrypted + * transmitter GPS). + * B) The trailing `๐Ÿ“map` link has no class/color โ†’ inherits UA-default + * blue โ†’ unreadable in dark mode. + * + * Asserts: + * 1. Some non-ADVERT packet detail does NOT contain
Location
. + * 2. Some ADVERT packet detail DOES contain
Location
with coords. + * 3. The ๐Ÿ“map link uses class="loc-map-link" with color = --accent + * (NOT the default UA blue rgb(0,0,238)). + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' โœ“ ' + name); } + catch (e) { failed++; console.error(' โœ— ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +function normRgb(s) { + const m = s && s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return null; + return `rgb(${m[1]}, ${m[2]}, ${m[3]})`; +} + +async function gotoPackets(page) { + await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.removeItem('meshcore-groupbyhash'); + localStorage.setItem('meshcore-time-window', '525600'); + }); + await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 }); +} + +// Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert" +// or any non-"Advert"). Returns true on hit, false if exhausted. +async function findPacketDetailByType(page, predicate, maxRows = 40) { + await page.waitForTimeout(400); + const rows = await page.$$('table tbody tr[data-hash][data-action]'); + for (let i = 0; i < Math.min(rows.length, maxRows); i++) { + await rows[i].click({ timeout: 3000 }).catch(() => null); + await page.waitForTimeout(350); + const meta = await page.evaluate(() => { + const dts = document.querySelectorAll('dl.detail-meta dt'); + let typeName = null; + let hasLocation = false; + let locationText = ''; + for (const dt of dts) { + const label = dt.textContent.trim(); + const dd = dt.nextElementSibling; + if (label === 'Payload Type') typeName = dd ? dd.textContent.trim() : null; + if (label === 'Location') { hasLocation = true; locationText = dd ? dd.textContent.trim() : ''; } + } + return { typeName, hasLocation, locationText }; + }); + if (predicate(meta)) return meta; + } + return null; +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + console.log(`\n=== #1281 Location row + map link contrast E2E against ${BASE} ===`); + + await step('Non-ADVERT packet detail does NOT render
Location
', async () => { + await gotoPackets(page); + // Filter to a non-ADVERT type to make the search efficient. + const meta = await findPacketDetailByType( + page, + (m) => m.typeName && m.typeName !== 'Advert', + 40 + ); + assert(meta, 'No non-ADVERT packet found in first 40 rows'); + assert(!meta.hasLocation, + `Expected NO
Location
for type "${meta.typeName}", but found one with text "${meta.locationText}"`); + }); + + await step('ADVERT packet detail STILL renders
Location
with GPS coords', async () => { + await gotoPackets(page); + // Filter UI to ADVERTs to guarantee we find one. + const fInput = await page.$('#packetFilterInput'); + if (fInput) { + await fInput.fill('type == ADVERT'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(600); + } + const meta = await findPacketDetailByType( + page, + (m) => m.typeName === 'Advert' && m.hasLocation, + 40 + ); + assert(meta, 'No ADVERT packet with Location row found in first 40 ADVERT rows'); + assert(/-?\d+\.\d+\s*,\s*-?\d+\.\d+/.test(meta.locationText), + `ADVERT Location should contain GPS coords, got: "${meta.locationText}"`); + }); + + await step('๐Ÿ“map link uses class="loc-map-link" with color = var(--accent)', async () => { + // Reuse the ADVERT detail pane left open from the previous step. + const result = await page.evaluate(() => { + const link = document.querySelector('dl.detail-meta a.loc-map-link'); + if (!link) return { missing: true }; + const cs = getComputedStyle(link); + const accentRaw = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); + // Resolve --accent value to its computed rgb() via a probe element. + const probe = document.createElement('span'); + probe.style.color = `var(--accent)`; + document.body.appendChild(probe); + const accentRgb = getComputedStyle(probe).color; + probe.remove(); + return { + linkColor: cs.color, + accentRgb, + accentRaw, + href: link.getAttribute('href'), + text: link.textContent.trim(), + }; + }); + assert(!result.missing, + '
not found in detail pane โ€” implementation must apply the class'); + const link = normRgb(result.linkColor); + const accent = normRgb(result.accentRgb); + console.log(` link.color=${result.linkColor} --accentโ†’${result.accentRgb} (raw "${result.accentRaw}")`); + assert(link === accent, + `๐Ÿ“map link color ${result.linkColor} must equal --accent (${result.accentRgb}); ` + + `default UA blue (rgb(0, 0, 238)) is not acceptable`); + assert(link !== 'rgb(0, 0, 238)', + 'Link color is UA-default blue โ€” class is missing or CSS rule does not match'); + }); + + await browser.close(); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(1); });