Files
meshcore-analyzer/tests/e2e/test-packets-compact-columns-e2e.js
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

499 lines
26 KiB
JavaScript

/**
* Playwright E2E — packets table column packing and the Full Names toggle.
*
* The packets table used to store every column width as a percentage of the
* table, measured once on load. Percentages scale with the screen, so on a
* wide monitor "17s ago" sat in a 180px column; and the measurement read the
* virtual-scroll spacer row (one colspan cell as wide as the table) as
* column 0, inflating the expand column too. fitColumnsToContent (app.js)
* now sizes short columns to their content in px and gives the rest to Path
* and Details.
*
* Usage: BASE_URL=http://localhost:13581 node tests/e2e/test-packets-compact-columns-e2e.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(` ✅ ${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(` ❌ ${name}: ${err.message}`);
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
// Upper bounds for the short columns at any desktop width. Content needs
// ~30-70px; the old percentage layout gave them 100-180px at 1920px.
const MAX_WIDTH = {
'col-expand': 40,
'col-time': 100,
'col-size': 70,
'col-hashsize': 50,
'col-scope': 80,
'col-rpt': 70,
};
async function gotoPackets(page, prefs) {
// The window goes in the URL: mobile clamps a stored window above 180min
// back to 15min, and the fixture is freshened at the start of the CI job.
await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' });
await page.evaluate((extra) => {
localStorage.removeItem('packets-visible-cols');
localStorage.removeItem('packets-known-cols');
localStorage.removeItem('meshcore-pkt-col-px');
localStorage.removeItem('meshcore-full-names');
for (const [k, v] of Object.entries(extra || {})) localStorage.setItem(k, v);
}, prefs);
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type',
{ state: 'attached', timeout: 30000 });
await settle(page);
}
// Column layout runs synchronously after a render, and container resizes are
// coalesced to the next animation frame: two frames cover both.
function settle(page) {
return page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
}
// The "+N" pills are recomputed once hop names settle (up to ~1s after a
// render). Wait for that pass to finish rather than for a fixed delay.
function pillsReady(page) {
return page.waitForFunction(() => {
const tbody = document.getElementById('pktBody');
return tbody && !tbody._rePathOverflowObserver &&
document.querySelector('#pktTable .path-overflow-pill');
}, null, { timeout: 10000 });
}
async function setColumnVisible(page, key, visible) {
// A checkbox click bubbles to the document handler that closes the menu,
// so each toggle needs its own open.
await page.click('#colToggleBtn');
const box = await page.waitForSelector(`#colToggleMenu input[data-col="${key}"]`);
if ((await box.isChecked()) !== visible) await box.click();
await page.waitForFunction(([k, v]) =>
document.getElementById('pktTable').classList.contains('hide-col-' + k) === !v, [key, visible]);
await settle(page);
}
function layout(page) {
return page.evaluate(() => {
const t = document.getElementById('pktTable');
const wrap = t.parentElement;
const widths = {};
t.querySelectorAll('thead th').forEach(th => {
if (th.offsetParent === null) return;
const key = Array.from(th.classList).find(c => c.startsWith('col-'));
widths[key] = th.offsetWidth;
});
const row = Array.from(t.querySelectorAll('tbody tr'))
.find(r => r.children.length === t.querySelectorAll('thead th').length);
const cells = row ? Array.from(row.children).filter(td => td.offsetParent !== null) : [];
const lastCell = cells[cells.length - 1];
const wr = wrap.getBoundingClientRect();
return {
widths,
wrapRight: wr.left + wrap.clientLeft + wrap.clientWidth,
tableRight: t.getBoundingClientRect().right,
lastCellRight: lastCell ? lastCell.getBoundingClientRect().right : null,
hScroll: wrap.scrollWidth - wrap.clientWidth,
};
});
}
(async () => {
console.log(`\nPackets compact columns — ${BASE}\n`);
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } });
await gotoPackets(page);
await test('short columns stay short on a wide screen', async () => {
const { widths } = await layout(page);
for (const [col, max] of Object.entries(MAX_WIDTH)) {
if (widths[col] == null) continue; // hidden by a preference
assert(widths[col] <= max, `${col} is ${widths[col]}px, expected <= ${max}px (all: ${JSON.stringify(widths)})`);
}
});
await test('Path and Details take the freed width', async () => {
const { widths } = await layout(page);
assert(widths['col-path'] >= 400, `col-path only ${widths['col-path']}px`);
assert(widths['col-details'] >= 400, `col-details only ${widths['col-details']}px`);
});
await test('no blank strip after the last column, no horizontal scroll', async () => {
const l = await layout(page);
assert(l.lastCellRight != null, 'no data row to measure');
assert(Math.abs(l.tableRight - l.lastCellRight) <= 2,
`last cell ends at ${l.lastCellRight}, table at ${l.tableRight}: a hidden column's slot is taking width`);
assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`);
assert(Math.abs(l.wrapRight - l.tableRight) <= 2,
`table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}`);
});
await test('hiding both Path and Details keeps the table at the wrapper width', async () => {
// Review on #2090: with no flex column the table shrank to the sum of
// the short columns (~400px in a 1900px wrapper). The gap was between
// the table and its wrapper, which the check above did not measure.
let l;
try {
await setColumnVisible(page, 'path', false);
await setColumnVisible(page, 'details', false);
l = await layout(page);
} finally {
// Restore before asserting, so a failure here does not leak two
// hidden columns into every later case.
await setColumnVisible(page, 'path', true);
await setColumnVisible(page, 'details', true);
}
assert(l.widths['col-path'] == null && l.widths['col-details'] == null, 'Path/Details still visible');
assert(Math.abs(l.wrapRight - l.tableRight) <= 2,
`table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}: ${Math.round(l.wrapRight - l.tableRight)}px empty`);
assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`);
const back = await layout(page);
assert(back.widths['col-details'] >= 400, `Details did not take the width back: ${back.widths['col-details']}px`);
});
await test('opening the detail panel re-fits the table without scrolling', async () => {
await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time');
await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 })
.catch(() => { throw new Error('clicking a row did not open the detail panel'); });
await settle(page);
const l = await layout(page);
assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px with the panel open`);
await page.keyboard.press('Escape');
await page.waitForSelector('#pktRight.empty', { state: 'attached', timeout: 10000 })
.catch(() => { throw new Error('Escape did not close the detail panel'); });
await settle(page);
const after = await layout(page);
assert(after.widths['col-details'] >= 400, `Details did not grow back after closing the panel: ${after.widths['col-details']}px`);
});
await test('stale percentage widths from the old layout are dropped', async () => {
const stale = JSON.stringify([12.6, 0.4, 8.7, 9.1, 5, 3.4, 12.6, 6.2, 12.6, 12.6, 4, 12.8]);
await gotoPackets(page, { 'meshcore-pkt-col-widths': stale });
const gone = await page.evaluate(() => localStorage.getItem('meshcore-pkt-col-widths') === null);
assert(gone, 'meshcore-pkt-col-widths still present');
const { widths } = await layout(page);
assert(widths['col-time'] <= MAX_WIDTH['col-time'], `col-time ${widths['col-time']}px after a stale save`);
});
await test('Full Names shows observer names untruncated and lifts the chip cap', async () => {
await gotoPackets(page);
const before = await page.evaluate(() => ({
cls: document.getElementById('pktTable').classList.contains('pkt-full-names'),
chipMax: (() => { const c = document.querySelector('#pktTable .path-hops .hop-named'); return c ? getComputedStyle(c).maxWidth : null; })(),
}));
assert(!before.cls, 'pkt-full-names set before toggling');
if (before.chipMax) assert(before.chipMax === '120px', `default chip cap is ${before.chipMax}`);
await page.click('#fullNamesToggle');
await page.waitForFunction(() => document.getElementById('pktTable').classList.contains('pkt-full-names'));
await pillsReady(page);
await settle(page);
const after = await page.evaluate(() => {
const t = document.getElementById('pktTable');
const obs = Array.from(t.querySelectorAll('td.col-observer')).map(td => td.textContent);
const chip = t.querySelector('.path-hops .hop-named');
return {
cls: t.classList.contains('pkt-full-names'),
active: document.getElementById('fullNamesToggle').classList.contains('active'),
ellipsised: obs.filter(s => s.includes('…')).length,
chipMax: chip ? getComputedStyle(chip).maxWidth : null,
hScroll: t.parentElement.scrollWidth - t.parentElement.clientWidth,
// The virtual scroller positions rows at a fixed height: a path that
// wrapped onto a second line would break it.
tallPaths: Array.from(t.querySelectorAll('.path-hops')).filter(h => h.offsetHeight > 24).length,
// Hops past the edge are reachable through the +N pill, which must
// sit inside the clipped path rather than past its edge.
offscreenPills: Array.from(t.querySelectorAll('.path-overflow-pill')).filter(p => {
const host = p.closest('.path-hops').getBoundingClientRect();
const r = p.getBoundingClientRect();
return r.right > host.right + 1 || r.left < host.left;
}).length,
};
});
assert(after.cls && after.active, 'toggle did not switch the table to full names');
assert(after.ellipsised === 0, `${after.ellipsised} observer cells still end in "…"`);
if (after.chipMax) assert(after.chipMax !== '120px', 'path chips still capped at 120px');
assert(after.hScroll <= 0, `table overflows by ${after.hScroll}px in full-name mode`);
assert(after.tallPaths === 0, `${after.tallPaths} paths wrapped onto a second line`);
assert(after.offscreenPills === 0, `${after.offscreenPills} "+N" overflow pills are outside the visible path`);
});
await test('Full Names persists across reloads', async () => {
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type',
{ state: 'attached', timeout: 30000 });
const on = await page.evaluate(() =>
document.getElementById('pktTable').classList.contains('pkt-full-names') &&
document.getElementById('fullNamesToggle').classList.contains('active'));
assert(on, 'full-name mode lost on reload');
await page.click('#fullNamesToggle');
});
await test('hovering the +N pill lists the full path vertically; click pins it', async () => {
await gotoPackets(page, { 'meshcore-full-names': 'true' });
// At 1920px with full names the fixture's multi-hop paths always overflow.
await pillsReady(page).catch(() => null);
const pillSel = await page.evaluate(() => {
const pills = Array.from(document.querySelectorAll('#pktTable .path-overflow-pill'));
if (!pills.length) return null;
pills[0].closest('tr').setAttribute('data-e2e-pill-row', '1');
return '#pktTable tr[data-e2e-pill-row] .path-overflow-pill';
});
// At 1920px with full names the fixture's multi-hop paths always overflow;
// no pill means the pill logic broke, not that there is nothing to test.
assert(pillSel, 'no "+N" overflow pill rendered under Full Names');
const hopsInRow = await page.$eval(pillSel, p =>
p.closest('.path-hops').querySelectorAll('.arrow').length + 1);
await page.hover(pillSel);
await page.waitForSelector('#pathPopover', { timeout: 3000 });
// The pill sits over the last chip: a translucent hover background let
// the chip show through it.
const hoverBg = await page.$eval(pillSel, p => getComputedStyle(p).backgroundColor);
const alpha = /rgba\([^)]*,\s*([\d.]+)\)/.exec(hoverBg);
assert(hoverBg !== 'transparent' && (!alpha || parseFloat(alpha[1]) === 1),
`hovered pill background is not opaque: ${hoverBg}`);
const pop = await page.evaluate(() => {
const el = document.getElementById('pathPopover');
const lis = Array.from(el.querySelectorAll('li'));
return {
items: lis.length,
vertical: lis.every((li, i) => i === 0 || li.getBoundingClientRect().top > lis[i - 1].getBoundingClientRect().top),
truncated: Array.from(el.querySelectorAll('.hop-named')).filter(c => c.scrollWidth > c.clientWidth + 1).length,
title: el.querySelector('.path-popover-title').textContent,
};
});
assert(pop.items === hopsInRow, `popover lists ${pop.items} hops, row has ${hopsInRow}`);
assert(pop.vertical, 'hops are not stacked one per line');
assert(pop.truncated === 0, `${pop.truncated} names truncated in the popover`);
assert(pop.title.includes(hopsInRow + ' hop'), `title "${pop.title}" should count hops, not arrows`);
// Moving away closes an unpinned popover (after a 150ms grace period).
await page.mouse.move(5, 5);
await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 })
.catch(() => { throw new Error('popover still open after the pointer left'); });
// Clicking pins it, and must not select the row underneath.
const panelEmptyBefore = await page.$eval('#pktRight', el => el.classList.contains('empty'));
await page.click(pillSel);
await page.waitForSelector('#pathPopover', { timeout: 3000 });
await page.mouse.move(5, 5);
// Deliberately fixed: proving it stays open means outlasting the 150ms
// close grace an unpinned popover would get.
await page.waitForTimeout(400);
assert(await page.$('#pathPopover'), 'pinned popover closed when the pointer left');
const panelEmptyAfter = await page.$eval('#pktRight', el => el.classList.contains('empty'));
assert(panelEmptyBefore === panelEmptyAfter, 'clicking the pill also selected the row');
await page.keyboard.press('Escape');
await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 })
.catch(() => { throw new Error('Escape did not close the pinned popover'); });
// Keyboard: focusing a pill shows the popover too. Escape above also
// closes the detail panel, which re-renders the rows: query afresh.
await pillsReady(page);
await page.focus('#pktTable .path-overflow-pill');
await page.waitForSelector('#pathPopover', { timeout: 3000 });
await page.keyboard.press('Escape');
// The popover lives on <body>: leaving the page must take it along.
await page.click('#pktTable .path-overflow-pill');
await page.waitForSelector('#pathPopover', { timeout: 3000 });
await page.evaluate(() => { location.hash = '#/home'; });
await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 5000 })
.catch(() => { throw new Error('pinned path popover survived navigating away from Packets'); });
await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); });
});
await test('Full Names is carried in the URL', async () => {
await gotoPackets(page);
// Don't assume the starting state: the previous test turned Full Names on,
// and the in-page setting (not just localStorage) can reach this page's
// URL before gotoPackets reloads it. Check both directions instead.
const state = () => page.evaluate(() => ({
on: document.getElementById('pktTable').classList.contains('pkt-full-names'),
inUrl: /[?&]fullNames=1\b/.test(location.hash),
hash: location.hash,
}));
for (let n = 0; n < 2; n++) {
const was = (await state()).on;
await page.click('#fullNamesToggle');
await page.waitForFunction(w => document.getElementById('pktTable').classList.contains('pkt-full-names') !== w, was);
const s = await state();
assert(s.on === s.inUrl, `Full Names is ${s.on ? 'on' : 'off'} but the URL says otherwise: ${s.hash}`);
}
// A shared link switches the mode on for a visitor whose own pref is off.
await page.evaluate(() => { localStorage.setItem('meshcore-full-names', 'false'); });
await page.goto(BASE + '/#/packets?timeWindow=10080&fullNames=1', { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 });
const on = await page.$eval('#pktTable', t => t.classList.contains('pkt-full-names'));
assert(on, 'fullNames=1 in the URL did not turn Full Names on');
// ...for that page only: the visitor's own saved preference is untouched.
const saved = await page.evaluate(() => localStorage.getItem('meshcore-full-names'));
assert(saved === 'false', `opening a fullNames=1 link overwrote the saved preference with ${saved}`);
await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 });
const offAgain = await page.$eval('#pktTable', t => !t.classList.contains('pkt-full-names'));
assert(offAgain, 'without the parameter the page did not return to the saved preference (off)');
await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); });
});
await test('Observer column shrinks back once observer names arrive late', async () => {
// Rows can render before /api/observers resolves (#1692), showing raw
// 64-char pubkeys — ~600px under Full Names. grow() alone would keep the
// column that wide after the names replace them.
await page.route('**/api/observers*', async route => {
await new Promise(r => setTimeout(r, 3000));
await route.continue();
});
try {
await gotoPackets(page, { 'meshcore-full-names': 'true' });
await page.waitForFunction(() =>
!Array.from(document.querySelectorAll('#pktTable td.col-observer'))
.some(td => /[0-9A-F]{40}/.test(td.textContent)), null, { timeout: 15000 });
await settle(page);
const { th, widest } = await page.evaluate(() => {
const cells = Array.from(document.querySelectorAll('#pktTable td.col-observer'))
.filter(td => td.offsetParent !== null);
return {
th: document.querySelector('#pktTable th.col-observer').offsetWidth,
widest: Math.max(...cells.map(td => cellContentWidth(td))),
};
});
assert(th <= widest + 8, `Observer column ${th}px for ${widest}px of content: stuck at the pubkey width`);
} finally {
await page.unroute('**/api/observers*');
}
});
await test('dragging a handle resizes the column; double-click fits it back', async () => {
await gotoPackets(page);
const flexHandles = await page.$$eval('#pktTable th.col-path .col-resize-handle, #pktTable th.col-details .col-resize-handle', h => h.length);
assert(flexHandles === 0, 'Path/Details must not have drag handles: they share the remaining width');
const before = await page.$eval('#pktTable th.col-hash', th => th.offsetWidth);
// Grab the handle 2px inside the header: its outer half overhangs the
// next header, which paints over it.
const box = await page.$eval('#pktTable th.col-hash', th => {
const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 };
});
await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(box.x + 30, box.y, { steps: 3 });
await page.mouse.move(box.x + 60, box.y, { steps: 3 });
await page.mouse.up();
await settle(page);
const dragged = await page.evaluate(() => ({
w: document.querySelector('#pktTable th.col-hash').offsetWidth,
saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'],
}));
// The edge follows the pointer: no lag from fixed layout spreading the delta.
assert(Math.abs(dragged.w - (before + 60)) <= 5, `dragged +60px from ${before}px, column is ${dragged.w}px`);
assert(Math.abs(dragged.saved - dragged.w) <= 1, `saved ${dragged.saved}px, column ${dragged.w}px`);
const edge = await page.$eval('#pktTable th.col-hash', th => {
const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 };
});
await page.mouse.dblclick(edge.x, edge.y);
await settle(page);
const reset = await page.evaluate(() => ({
w: document.querySelector('#pktTable th.col-hash').offsetWidth,
saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'],
}));
assert(Math.abs(reset.w - before) <= 1, `double-click left the column at ${reset.w}px, expected ${before}px`);
assert(reset.saved === undefined, 'double-click did not forget the saved width');
});
await test('expanding a group leaves the column widths alone', async () => {
// CI seeds one grouped transmission (fae0c9e6d357a814, 3 observations).
await page.goto(BASE + '/#/packets?hash=fae0c9e6d357a814&timeWindow=0', { waitUntil: 'networkidle' });
// Fail, don't skip: without the seeded row this case would silently stop
// asserting (review on #2090).
await page.waitForSelector('#pktTable tr.group-header', { timeout: 10000 })
.catch(() => { throw new Error('seeded grouped row fae0c9e6d357a814 not found: check the CI fixture seeding step'); });
await settle(page);
const widths = () => page.$$eval('#pktTable thead th', ths => Object.fromEntries(ths
.filter(th => th.offsetParent !== null && !th.matches('.col-path, .col-details'))
.map(th => [th.className.split(' ')[0], th.offsetWidth])));
const before = await widths();
await page.click('#pktTable tr.group-header td.col-expand');
await page.waitForSelector('#pktTable tr.group-child', { timeout: 10000 });
await settle(page);
const after = await widths();
// Child rows name other observers and may legitimately need a few px
// more; the bug was every column jumping ~14px from the row indent.
const grew = Object.keys(before).filter(k => k !== 'col-time' && after[k] - before[k] > 8);
assert(grew.length === 0, `columns widened on expand: ${grew.map(k => `${k} ${before[k]}→${after[k]}`).join(', ')}`);
await page.keyboard.press('Escape');
});
await test('1100px with the detail panel open: the table still fits', async () => {
// All columns are visible above 1024px, and the panel leaves ~650px.
await page.setViewportSize({ width: 1100, height: 900 });
await gotoPackets(page);
await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time');
await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 });
await settle(page);
const { hScroll } = await layout(page);
assert(hScroll <= 0, `table overflows its wrapper by ${hScroll}px`);
await page.keyboard.press('Escape');
});
await test('revealing and re-hiding responsive columns re-fits the table', async () => {
// At <=1024px TableResponsive hides Hash, Observer and Rpt behind "+N hidden".
await page.setViewportSize({ width: 1000, height: 900 });
await gotoPackets(page);
const pill = await page.$('#pktTable .col-hidden-pill:not(.col-rehide-pill)');
assert(pill, 'no "+N hidden" pill at 1000px');
const check = async (label) => {
const l = await layout(page);
assert(l.hScroll <= 0, `${label}: table overflows by ${l.hScroll}px`);
assert(Math.abs(l.tableRight - l.lastCellRight) <= 2,
`${label}: last cell ends at ${l.lastCellRight}, table at ${l.tableRight} (blank strip)`);
};
await pill.click();
await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent !== null, null, { timeout: 5000 })
.catch(() => { throw new Error('Observer not revealed'); });
await settle(page);
await check('after reveal');
await page.click('#pktTable .col-rehide-pill');
await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent === null, null, { timeout: 5000 });
await settle(page);
await check('after re-hide');
});
await test('mobile (375px): table fits without horizontal scroll', async () => {
await page.setViewportSize({ width: 375, height: 800 });
await gotoPackets(page);
const { hScroll } = await layout(page);
assert(hScroll <= 0, `table overflows by ${hScroll}px at 375px`);
});
await browser.close();
const passed = results.filter(r => r.pass).length;
const failed = results.length - passed;
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})().catch(err => {
console.error(err);
process.exit(1);
});