Files
meshcore-analyzer/tests/unit/test-issue-1189-composed-cell.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

119 lines
5.2 KiB
JavaScript

/**
* Composed-cell test (#1189 R2 item 3): the grouped row's Observer cell must
* NOT render two adjacent unlabeled "+N +M" tokens.
*
* Before R2: `Name SJC SFO +1 +5` — `+1` was distinct-IATA overflow,
* `+5` was observer-count overflow. On mobile the two unlabeled tokens
* wrap/clip and operators can't tell what either number means. The
* eyeball-count badge `👁 N` in the col-rpt cell already conveys multi-
* reception, so we drop the observer-count `+N` from this composed cell
* and keep `+N` semantics SINGLE — distinct-IATA overflow only.
*
* Strategy: extract just the col-observer template-literal expression
* from buildGroupRowHtml in public/packets.js, evaluate it in a Node
* sandbox with mocked helpers, and assert the output contains exactly
* ONE `+N` token (or zero if all IATAs fit). Mutation test: revert the
* fix → this test must turn red.
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' \u2705 ' + msg); }
else { failed++; console.error(' \u274c ' + msg); }
}
const src = fs.readFileSync(path.join(REPO_ROOT, 'public/packets.js'), 'utf8');
// ── Extract the composed col-observer cell expression. ──────────────────
// The cell is built inline in buildGroupRowHtml's template literal:
// <td class="col-observer" ...>${ ... composed expression ... }</td>
// We capture the expression between the `>` and the closing `</td>` of the
// col-observer cell when the row is a GROUP HEADER (isSingle false branch).
// The expression matches the pattern:
// isSingle ? <single-branch> : <multi-branch>
// We want to verify the multi-branch.
const cellRe = /class="col-observer"[^<]*?>\$\{(isSingle\s*\?[\s\S]*?)\}<\/td>/m;
const m = src.match(cellRe);
assert(m != null,
'extracted composed col-observer cell expression from buildGroupRowHtml');
if (m) {
// The matched expression is `isSingle ? <single> : <multi>`. Force the
// multi branch by binding isSingle=false in the sandbox and evaluating.
const expr = m[1];
// Sandbox mocks. `groupedObserverIataBadgesHtml` returns a deterministic
// multi-IATA overflow string so the composed cell would historically have
// shown `<helper output> +<observer_count - 1>` adjacent to it.
const ctx = {
p: {
observer_count: 6, // would have rendered ' +5'
observer_id: 'obsA',
_children: [], // collapsed view — no children
distinct_iatas: ['SJC', 'SFO', 'OAK'],
},
headerObserverId: 'obsA',
isSingle: false,
truncate(s, n) { return String(s || '').slice(0, n); },
obsNameOnly(_id) { return 'NameA'; },
obsCellName(_id, n) { return 'NameA'.slice(0, n); },
obsIataBadge(_p) { return ''; },
groupedObserverIataBadgesHtml(_p) {
return '<span class="badge-iata">SJC</span><span class="badge-iata">SFO</span> +1';
},
escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
},
};
vm.createContext(ctx);
// Wrap in template literal so all ${} substitutions resolve.
ctx.__result__ = vm.runInContext('`${' + expr + '}`', ctx);
const cell = ctx.__result__;
// Cell must include the IATA badges from the helper.
assert(cell.includes('SJC') && cell.includes('SFO'),
'composed cell contains the distinct-IATA badges, got: ' + cell);
// CORE assertion: exactly ONE `+N` token in the cell. Before the fix the
// cell would carry `+1 +5` (distinct-IATA overflow + observer-count
// overflow); after the fix only `+1` remains.
const plusTokens = cell.match(/\+\d+/g) || [];
assert(plusTokens.length === 1,
'composed cell carries exactly ONE `+N` token (distinct-IATA only); got ' +
plusTokens.length + ' tokens: ' + JSON.stringify(plusTokens) +
'\n cell=' + cell);
// The `+5` (observer_count - 1 = 5) MUST NOT appear — that would mean
// the observer-count overflow was re-introduced.
assert(!cell.includes('+5'),
'composed cell must NOT contain observer-count overflow `+5`; got: ' + cell);
// And the cell must not contain ` +` adjacent twice (the failure mode is
// `+N +M` with whitespace between two `+` tokens).
assert(!/\+\d+\s+\+\d+/.test(cell),
'composed cell must NOT contain adjacent `+N +M` tokens; got: ' + cell);
// Edge case: even when the helper returns no overflow at all, the cell
// must still NOT append an observer-count `+N`.
const ctx2 = Object.assign({}, ctx);
ctx2.groupedObserverIataBadgesHtml = function () {
return '<span class="badge-iata">SJC</span>';
};
vm.createContext(ctx2);
ctx2.__result__ = vm.runInContext('`${' + expr + '}`', ctx2);
const cell2 = ctx2.__result__;
const plusTokens2 = cell2.match(/\+\d+/g) || [];
assert(plusTokens2.length === 0,
'composed cell with single-IATA helper output has ZERO `+N` tokens; got: ' +
JSON.stringify(plusTokens2) + '\n cell=' + cell2);
}
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);