Files
meshcore-analyzer/tests/e2e/test-issue-1281-location-row-e2e.js
T
Sylvain Rabot a4a447acea fix(packets): pack short columns, one expand arrow, Full Names toggle (#2090)
The packets table wastes most of its width on short columns, draws two
arrows on grouped rows, and cuts every observer and hop name short. This
fixes all three and adds a way to read the full path when it does not
fit.

## Why the columns were wide

`makeColumnsResizable` measured each column once on load and stored the
widths as **percentages** of the table. Percentages grow with the
screen: on a 2000px display "17s ago" sat in a 180px column. The
measurement also read the virtual-scroll spacer row — a single `colspan`
cell as wide as the table — as column 0, so the expand column came out
160px wide locally and about 950px on a live instance with grouped rows.

Measured on the fixture at 2000px:

| Column | Before | After |
|---|---|---|
| Expand | 162–230px | 32px |
| Time | 111px | 67px |
| Size | 64px | 46px |
| HB | 44px | 29px |
| Scope | 80px | 48px |
| Path / Details | ~180px each | ~650px each |

## The change

**Column packing.** A new `fitColumnsToContent` (`app.js`) sizes every
column except Path and Details to its widest cell, in px; those two
split what is left. Every visible column gets an explicit width and the
table gets their sum: a spacer row keeps a slot for each hidden column,
and leaving any column `auto` hands those slots a share of the slack as
a blank strip at the right edge.

When space is short (detail panel open, narrow window) the widest fixed
columns give way first, then Path and Details shrink to 60px, and only
then does the wrapper scroll. The split itself is a pure function,
`distributeColumnWidths`, with unit tests.

It re-lays out when the container resizes (detail panel), when
TableResponsive hides or reveals columns (a new `table-columns-changed`
event), and after sorting or a column toggle. After each render `grow()`
widens columns for the rows just inserted and never narrows them, so
nothing jitters while scrolling. Drag handles store px; double-clicking
one resets it. Path and Details have no handle.

`makeColumnsResizable`, still used by the nodes, observers and analytics
tables, now skips `colspan` rows when measuring; other rows are measured
as before.

**One expand arrow.** A CSS `::before` "▶" sat next to the SVG caret,
which pointed up when collapsed. The glyph is gone and the caret points
right, then down.

**Full Names.** A toolbar toggle next to Hex Paths shows observer and
hop names untruncated. It is saved per browser; a link can carry
`fullNames=1`, which applies to that page without overwriting the
visitor's saved preference. Paths stay on one line: the virtual scroller
positions rows at one fixed height, so wrapping would make scrolling
jump.

**The "+N" path pill, and a full-path popover.** Hops past the column
edge were meant to go behind a "+N" pill (#1124), but the pill was
appended after the overflowing chips and so sat past the clipped edge:
it never showed. It is now sticky at the right edge, opaque on hover,
and counts hop chips only (it used to count warning icons too). Hovering
or focusing it shows the full path, one hop per line and numbered;
click, Enter or Space pins it until an outside click or Escape, and it
closes when the page changes or its row scrolls away. Clicking the pill
no longer selects the row underneath.

**Two related fixes.**
- Rows drawn before `/api/observers` resolves show raw 64-char pubkeys.
`loadObservers` now re-renders and re-measures, instead of leaving
Observer at the pubkey's width (~600px under Full Names).
- Group child rows indented every cell by 20px, which misaligned them
with the headers and widened every column on expand. Only the Time cell
is indented now.

Anyone who had dragged packet columns gets the new defaults once: the
old percentage key is dropped.

## Performance

`grow()` runs after every `renderVisibleRows`, so it is on the hot path.
Measured in Chromium on the fixture, 300 iterations, three runs:

| Case | Cost |
|---|---|
| Full render, 63 rows measured | +0.88–0.93ms over the forced reflow
alone |
| Scroll step (incremental render) | ~73 `Range` rect reads, only the
inserted rows |
| Large jump / re-render | ~569 rect reads |

Before narrowing `grow()` to inserted rows, one scroll step with 92
rendered rows made 5,472 rect reads. The DOM row count is bounded by the
virtual-scroll window, so the cost does not grow with the packet count.
`distributeColumnWidths` is a binary search over at most ~10 columns.

## Tests

- `tests/e2e/test-packets-compact-columns-e2e.js`, 16 cases: short
columns stay under fixed px bounds at 1920px; Path and Details take the
freed width; hiding both still fills the wrapper; no blank strip and no
horizontal scroll, with and without the detail panel, at 1920, 1100,
1000 and 375px; responsive reveal and re-hide refit; stale percentage
widths dropped; drag follows the pointer and double-click resets;
expanding a group leaves widths alone; Full Names untruncates, keeps
paths on one line, persists and round-trips through the URL; the
Observer column shrinks back when observer names arrive late; the pill
popover lists every hop vertically, closes when the pointer leaves, pins
on click without selecting the row, closes on Escape and on navigation,
and opens on keyboard focus. Registered in `scripts/non-unit-tests.json`
and `deploy.yml`.
- On `master` the suite fails where it should, and restoring each fix's
old behaviour (child-row indent, the late-observer refit) turns its case
red.
- `tests/unit/test-packets.js`: `distributeColumnWidths` (room to spare,
rounding, widest-first capping, floors, dragged columns, the 60px
minimum, overflow), the single chevron and no `::before` glyph, Full
Names on flat and grouped observer cells, and the Full Names CSS.
- `tests/unit/test-xss-escape-sinks.js` now builds the real
`obsCellName` from source: escaping moved into it. Removing its
`escapeHtml` turns both observer-cell tests red.
- `test-issue-1189-composed-cell.js` and
`test-issue-2012-clear-filters-selection.js` evaluate packets.js
fragments with injected helpers and needed `obsCellName` /
`showFullNames` added.
- Existing packets E2E suites pass on this branch and on master against
a CI-prepared fixture: scope column, #1128 (layout and multi-viewport),
#1122, #1188, #1648, table sort, #1692, #1486.
- Unit: 182 of 183 suites pass locally;
`test-issue-1956-release-routing.js` could not create a temp directory
in my sandbox and is unrelated.

## Browser validation

Checked in Chromium against the fixture at 2000, 1920, 1280 (panel open
and closed), 1100 and 375px, and with the seeded grouped row expanded
and collapsed. Screenshots taken at each step; the table fills its
container exactly, grouped rows show one arrow, and the pill popover
lists full names.

## Not done, deliberately

- **No width cap on Observer under Full Names.** A cap brings back the
truncation the toggle removes, and Observer is already the first column
to give way when space is short.
- **Customizer.** The floors (60px flex minimum, 64px shrink floor, 32px
expand, 58px Rpt) are hardcoded, per AGENTS rule 8; exposing them in the
customizer can follow.
- **Wrapping paths.** Rejected because of the fixed row height above;
the pill popover is the way to read a long path.
2026-10-03 12:15:52 +02:00

258 lines
12 KiB
JavaScript

/**
* #1281 — Packet detail Location row + 📍map link contrast.
*
* Bug:
* A) <dt>Location</dt><dd>—</dd> 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 <a>
* blue → unreadable in dark mode.
*
* Asserts:
* 1. Some non-ADVERT packet detail does NOT contain <dt>Location</dt>.
* 2. Some ADVERT packet detail DOES contain <dt>Location</dt> with coords.
* 3. The 📍map link uses class="loc-map-link" with a themed link color
* (was --accent pre-M5; now --link-color after #1668/PR #1696 AA-contrast
* fix). The hard guarantee is: NOT the default UA blue rgb(0,0,238).
* 4. #1149: node side/full stats omit aggregate SNR, retaining Heard By
* readings on desktop and mobile. Node API fixtures keep SNR non-null.
*
* 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 the i-th packet row on its Time cell. A row's centre can land on a
// hop link (which opens the node page) or on the "+N" path pill (which opens
// the path popover), and neither selects the row; the Time cell is never
// interactive. Rows are re-queried on every call because selecting one
// re-renders the table and detaches handles taken before the click.
async function clickPacketRow(page, rowSelector, i) {
const rows = await page.$$(rowSelector);
if (!rows[i]) return false;
const cell = await rows[i].$('td.col-time');
await (cell || rows[i]).click({ timeout: 3000 }).catch(() => null);
return true;
}
// 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 = 'table tbody tr[data-hash][data-action]';
const count = (await page.$$(ROWS)).length;
for (let i = 0; i < Math.min(count, maxRows); i++) {
if (!(await clickPacketRow(page, ROWS, i))) break;
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 <dt>Location</dt>', 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 <dt>Location</dt> for type "${meta.typeName}", but found one with text "${meta.locationText}"`);
});
await step('ADVERT packet detail STILL renders <dt>Location</dt> 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 themed link color (not UA default blue)', async () => {
// Reuse the ADVERT detail pane left open from the previous step.
//
// History: original #1281 asserted color === var(--accent) (rgb(74,158,255)).
// M5 axe/WCAG-AA fixes (#1668, PR #1696) rewired link text away from --accent
// because --accent (#4a9eff) fails AA contrast for body-text links on most
// surfaces. .loc-map-link now resolves via --link-color (= --palette-blue-600
// = #2563eb), which passes AA. Test author intent ("links use a token, not
// raw UA blue") is satisfied by either token; we now track --link-color to
// match the CSS rule and preserve the "not UA default blue" guard.
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 linkColorRaw = getComputedStyle(document.documentElement).getPropertyValue('--link-color').trim();
// Resolve --link-color value to its computed rgb() via a probe element.
const probe = document.createElement('span');
probe.style.color = `var(--link-color)`;
document.body.appendChild(probe);
const linkColorRgb = getComputedStyle(probe).color;
probe.remove();
return {
linkColor: cs.color,
tokenRgb: linkColorRgb,
tokenRaw: linkColorRaw,
href: link.getAttribute('href'),
text: link.textContent.trim(),
};
});
assert(!result.missing,
'<a class="loc-map-link"> not found in detail pane — implementation must apply the class');
const link = normRgb(result.linkColor);
const token = normRgb(result.tokenRgb);
console.log(` link.color=${result.linkColor} --link-color→${result.tokenRgb} (raw "${result.tokenRaw}")`);
assert(link === token,
`📍map link color ${result.linkColor} must equal var(--link-color) (${result.tokenRgb}); ` +
`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');
});
const pubkey = 'ab'.repeat(32);
const now = new Date().toISOString();
const node = {
public_key: pubkey, name: 'SNR fixture node', role: 'repeater',
first_seen: now, last_seen: now, advert_count: 1005,
};
const health = {
stats: { totalPackets: 1005, packetsToday: 7, avgSnr: 6.3, avgHops: 2, lastHeard: now },
observers: [
{ observer_id: 'observer-a', observer_name: 'Observer A', packetCount: 1000, avgSnr: 12.5, avgRssi: -45 },
{ observer_id: 'observer-b', observer_name: 'Observer B', packetCount: 5, avgSnr: -8.5, avgRssi: -110 },
],
recentPackets: [],
};
for (const view of [
{ name: 'desktop side panel', width: 1280, full: false },
{ name: 'desktop full detail', width: 1280, full: true },
{ name: 'mobile full detail', width: 390, full: true },
]) {
await step('#1149 ' + view.name + ' retains observer SNR without an aggregate headline', async () => {
const nodePage = await browser.newPage({ viewport: { width: view.width, height: 900 } });
try {
await nodePage.route('**/api/nodes**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
const fixtures = {
'/api/nodes': { nodes: [node], total: 1, counts: { repeaters: 1 } },
'/api/nodes/clock-skew': [],
['/api/nodes/' + pubkey]: { node, recentAdverts: [] },
['/api/nodes/' + pubkey + '/health']: health,
['/api/nodes/' + pubkey + '/neighbors']: { neighbors: [] },
['/api/nodes/' + pubkey + '/paths']: { paths: [] },
['/api/nodes/' + pubkey + '/clock-skew']: {},
};
if (Object.prototype.hasOwnProperty.call(fixtures, pathname)) {
await route.fulfill({ json: fixtures[pathname] });
} else {
await route.continue();
}
});
await nodePage.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await nodePage.locator('#nodesBody tr[data-key="' + pubkey + '"]').click();
if (view.full && view.width > 640) {
await nodePage.locator('#nodesRight .node-detail-btn').click();
}
const stats = nodePage.locator(view.full ? '#node-stats' : '#nodesRight .detail-meta');
const readings = nodePage.locator(view.full ? '#node-observers tbody tr' : '#nodesRight .observer-row');
await readings.first().waitFor({ state: 'visible' });
assert(await readings.count() === 2, 'Heard By must retain both observers');
for (const observer of health.observers) {
const row = readings.filter({ hasText: observer.observer_name });
const text = (await row.innerText()).replace(/\s+/g, ' ');
const snr = observer.avgSnr.toFixed(1) + (view.full ? ' dB' : 'dB');
assert(text.includes(snr), observer.observer_name + ' must retain SNR ' + snr + ', got: ' + text);
}
for (const [label, value] of [['Total Packets', '1005'], ['Packets Today', '7'], ['Avg Hops', '2']]) {
const metric = stats.getByText(label, { exact: true });
const actual = await metric.evaluate(el => el.nextElementSibling.textContent.trim());
assert(actual === value, label + ' must remain ' + value + ', got: ' + actual);
}
const headlineCount = await stats.getByText('Avg SNR', { exact: true }).count();
assert(headlineCount === 0,
'Expected no aggregate Avg SNR headline with stats.avgSnr=6.3, got ' + headlineCount);
if (process.env.SCREENSHOT_DIR) {
await stats.scrollIntoViewIfNeeded();
await nodePage.screenshot({
path: require('path').join(process.env.SCREENSHOT_DIR, 'issue-1149-' + view.name.replace(/ /g, '-') + '.png'),
fullPage: true,
});
}
} finally {
await nodePage.close();
}
});
}
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });