Files
meshcore-analyzer/tests/e2e/test-analytics-fluid-charts.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

215 lines
9.0 KiB
JavaScript

/**
* E2E (#1058): Analytics chart containers — fluid + auto-stacking via
* container queries.
*
* Boots Chromium with a minimal HTML harness that links public/style.css
* and renders the .analytics-charts grid at 768/1080/1440 viewports.
*
* Asserts:
* - No horizontal overflow of the chart grid (scrollWidth <= clientWidth).
* - Cards STACK (single column) when the .analytics-charts container is
* narrower than 800px.
* - Cards are SIDE-BY-SIDE (≥2 columns) when the container is at least
* 1200px wide.
* - The .analytics-charts element opts in to container queries via
* `container-type: inline-size`.
*
* Pure file:// harness — does not require the Go server.
*
* Usage: node test-analytics-fluid-charts.js
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const os = require('os');
const CSS_PATH = path.join(REPO_ROOT, 'public', 'style.css');
const cssHref = 'file://' + CSS_PATH;
// Minimal harness: a sized wrapper that defines the available width
// for the .analytics-charts container, plus a handful of chart cards
// matching the production markup.
function harnessHTML(wrapperWidth) {
const card = (full) =>
`<div class="analytics-chart-card${full ? ' full' : ''}">` +
`<h4>Card</h4>` +
`<div class="analytics-chart-desc">Desc</div>` +
`<svg viewBox="0 0 800 200" style="width:100%;max-height:160px"><rect width="800" height="200" fill="#888"/></svg>` +
`</div>`;
return `<!doctype html><html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="${cssHref}">
<style>
/* Sized wrapper simulates the page's content column width — the
.analytics-charts inside MUST stay fluid relative to this. */
#wrap { width: ${wrapperWidth}px; box-sizing: border-box; padding: 0; margin: 0; }
body { margin: 0; }
</style>
</head><body>
<div id="wrap">
<div class="analytics-charts" id="grid">
${card(false)}${card(false)}${card(false)}${card(false)}
</div>
</div>
</body></html>`;
}
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(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();
const page = await ctx.newPage();
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1058 Analytics fluid charts E2E ===');
async function load(wrapperWidth, viewportWidth) {
await page.setViewportSize({ width: viewportWidth, height: 900 });
const tmp = path.join(os.tmpdir(),
`1058-harness-${wrapperWidth}-${viewportWidth}.html`);
fs.writeFileSync(tmp, harnessHTML(wrapperWidth));
// Count only once style.css applies. With a <link rel="stylesheet"> and
// no scripts, DOMContentLoaded does not wait for the stylesheet; CI once
// counted 1 column at 1300px, which only unstyled (block-flow) cards
// give. Wait for 'load', then for the grid rule itself: a real CSS
// regression still fails, with a message that says so.
await page.goto('file://' + tmp, { waitUntil: 'load' });
await page.waitForFunction(() =>
getComputedStyle(document.getElementById('grid')).display === 'grid', null, { timeout: 5000 })
.catch(() => { throw new Error('.analytics-charts never became display:grid: style.css not applied'); });
}
// Helper: count distinct column-x-positions of chart cards.
async function colCount() {
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll(
'.analytics-charts > .analytics-chart-card'));
const xs = new Set(cards.map(c =>
Math.round(c.getBoundingClientRect().left)));
return xs.size;
});
}
async function overflow() {
return page.evaluate(() => {
const g = document.getElementById('grid');
return { scrollW: g.scrollWidth, clientW: g.clientWidth };
});
}
// --- Container-query opt-in -------------------------------------------
await step('analytics-charts opts in to container queries', async () => {
await load(1200, 1440);
const ct = await page.evaluate(() => {
const g = document.getElementById('grid');
return getComputedStyle(g).containerType;
});
assert(/inline-size|size/.test(ct),
`expected container-type to be inline-size; got "${ct}"`);
});
// --- Viewport 1440: container ≥1200 → side-by-side --------------------
await step('viewport 1440 / wrapper 1300px → side-by-side (≥2 cols)', async () => {
await load(1300, 1440);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1300px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1080: medium width — must not overflow ------------------
await step('viewport 1080 / wrapper 1040px → no horizontal overflow', async () => {
await load(1040, 1080);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 768: container <800 → must stack vertically -------------
await step('viewport 768 / wrapper 760px → cards stack (1 col)', async () => {
await load(760, 768);
const cols = await colCount();
assert(cols === 1, `expected 1 column at wrapper 760px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- THE bug: wide viewport + narrow container — must stack ----------
// Today's @media (max-width:768px) is keyed off viewport, not container.
// A narrow wrapper inside a wide viewport (e.g., side pane on a 1440
// screen) should still stack the charts via container queries.
await step('viewport 1440 / wrapper 600px → cards stack via container query', async () => {
await load(600, 1440);
const cols = await colCount();
assert(cols === 1,
`expected 1 column when container <800px regardless of viewport; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at wide-viewport/narrow-container: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1920: large desktop → side-by-side, no overflow --------
await step('viewport 1920 / wrapper 1880px → side-by-side (≥2 cols), no overflow', async () => {
await load(1880, 1920);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1880px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 1920: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 2560: ultra-wide → side-by-side, no overflow -----------
await step('viewport 2560 / wrapper 2520px → side-by-side (≥2 cols), no overflow', async () => {
await load(2520, 2560);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 2520px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 2560: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- AC3: charts must redraw/relayout on viewport resize -------------
// Open at 1440 wide (side-by-side), then shrink the wrapper to 760
// (sub-800 container) and assert the layout actually flips to a
// single column. This guards against any future regression where
// the grid is computed once and stuck.
await step('AC3: layout reflows on resize (1440 side-by-side → 768 stacked)', async () => {
await load(1300, 1440);
const colsWide = await colCount();
assert(colsWide >= 2,
`precondition failed: expected ≥2 cols at 1300px; got ${colsWide}`);
// Shrink only the wrapper (no full reload) — proves the layout
// recomputes from the current container width, not a one-shot value.
await page.evaluate(() => {
document.getElementById('wrap').style.width = '760px';
});
await page.setViewportSize({ width: 768, height: 900 });
// Give the browser a frame to recompute layout.
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const colsNarrow = await colCount();
assert(colsNarrow === 1,
`expected layout to reflow to 1 column after shrink; got ${colsNarrow}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow after resize: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();