From 1d75b01ca4ef072b36f2e1993431ec815fb1383f Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:12:56 +0200 Subject: [PATCH 1/7] test: reproduce column widths measured from colspan rows and empty first renders (#258) makeColumnsResizable() credits full-width colspan rows (vscroll spacers, "No packets found") to column 0 and locks the widths from an empty or near-empty first body. The new vm-sandbox unit test runs the real app.js against a minimal fake table: colspan and short rows must not change any width, an (almost) empty body must be measured again once when rows arrive (and only then), saved widths must be applied as before. Red on master: 3 passed, 9 failed. Co-Authored-By: Claude Opus 5.5 --- test-all.sh | 1 + test-issue-258-column-widths.js | 280 ++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 test-issue-258-column-widths.js diff --git a/test-all.sh b/test-all.sh index 9ce3bdbcc..5bd3901e5 100755 --- a/test-all.sh +++ b/test-all.sh @@ -58,6 +58,7 @@ run test-important-links-byte-filter.js run test-url-state.js run test-node-adverts.js run test-issue-254-affinity-debug-toggle.js +run test-issue-258-column-widths.js run test-perf-go-runtime.js run test-channel-psk-ux.js run test-channel-sidebar-layout.js diff --git a/test-issue-258-column-widths.js b/test-issue-258-column-widths.js new file mode 100644 index 000000000..d8ff914ec --- /dev/null +++ b/test-issue-258-column-widths.js @@ -0,0 +1,280 @@ +/** + * #258: makeColumnsResizable() (public/app.js) sizes a table's columns from its + * header and its first body rows. + * + * 1. Rows that do not have one cell per column -- a full-width colspan cell (the + * packets vscroll spacers, "No packets found") or a different cell count -- + * are not measured. They used to be credited to column 0 by index. + * 2. Widths are not locked from an (almost) empty body. With fewer than + * COL_MEASURE_MIN_ROWS usable rows the table gets provisional widths and the + * columns are measured again ONCE, when enough rows arrive -- unless the user + * has saved widths by then. A table measured from a full body gets no + * observer at all, so there is no per-render work. + * 3. Saved widths (localStorage) are applied as before, without measuring. + * + * Runs the real app.js in a vm sandbox against a minimal fake table DOM. A + * cell's scrollWidth is its content width; like a real auto-layout table, a + * header whose th still carries a width reports at least that width, so a + * re-measure that forgets to clear the old widths is caught. + * + * Usage: node test-issue-258-column-widths.js + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// --- sandbox ----------------------------------------------------------------- + +const observers = []; +class FakeMutationObserver { + constructor(cb) { this.cb = cb; this.target = null; this.options = null; this.connected = false; observers.push(this); } + observe(target, options) { this.target = target; this.options = options; this.connected = true; } + disconnect() { this.connected = false; } + // A real observer only calls back while it observes. + fire() { if (this.connected) this.cb([], this); } +} + +const tables = {}; +const store = {}; +const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ className: '', classList: { add() {}, remove() {} }, addEventListener() {}, style: {} }), + head: { appendChild: () => {} }, + body: { style: {} }, + getElementById: () => null, + addEventListener: () => {}, + removeEventListener: () => {}, + querySelectorAll: () => [], + querySelector: (sel) => tables[sel] || null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, + parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: (k) => { delete store[k]; }, + }, + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, dispatchEvent: () => {}, + requestAnimationFrame: () => {}, + MutationObserver: FakeMutationObserver, +}; +ctx.getHashParams = () => new URLSearchParams(''); +vm.createContext(ctx); +vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); +vm.runInContext(fs.readFileSync('public/app.js', 'utf8'), ctx, { filename: 'public/app.js' }); +const makeColumnsResizable = ctx.makeColumnsResizable; + +// --- fake table ---------------------------------------------------------------- + +const CONTAINER_W = 1000; +const pct = (s) => parseFloat(s); + +function cell(w, colSpan) { return { scrollWidth: w, colSpan: colSpan || 1, style: {}, dataset: {} }; } +function row(widths) { return { children: widths.map((w) => cell(w)) }; } +function spanRow(w, span) { return { children: [cell(w, span)] }; } + +// headers: content width of each header cell. +function makeTable(id, headers, rows) { + const ths = headers.map((w) => { + const th = { + style: {}, dataset: {}, handles: [], + appendChild(h) { this.handles.push(h); }, + get scrollWidth() { + // An auto-layout cell is at least as wide as the width it was given. + const given = /%$/.test(this.style.width || '') ? pct(this.style.width) / 100 * CONTAINER_W : 0; + return Math.max(w, given); + }, + get offsetWidth() { return pct(this.style.width || '0') / 100 * CONTAINER_W; }, + }; + return th; + }); + const tbody = { rows: rows.slice(), querySelectorAll(sel) { return sel === 'tr' ? this.rows : []; } }; + Object.defineProperty(tbody, 'children', { get() { return this.rows; } }); + const thead = { querySelectorAll: (sel) => (sel === 'tr:first-child th' ? ths : []) }; + const table = { + dataset: {}, style: {}, isConnected: true, + parentElement: { clientWidth: CONTAINER_W }, + get offsetWidth() { return CONTAINER_W; }, + querySelector: (sel) => (sel === 'thead' ? thead : sel === 'tbody' ? tbody : null), + querySelectorAll: (sel) => (sel === 'td, th' + ? ths.concat(...tbody.rows.map((r) => r.children)) + : []), + }; + tables['#' + id] = table; + return { table, ths, tbody, widths: () => ths.map((th) => pct(th.style.width)) }; +} + +// Columns: expand, time, path, details. +const HEADERS = [10, 30, 30, 50]; +const DATA = [20, 80, 100, 300]; +const dataRows = (n, w) => Array.from({ length: n }, () => row(w || DATA)); +const observersOf = (t) => observers.filter((o) => o.target === t.tbody); +const close = (a, b) => a.length === b.length && a.every((x, i) => Math.abs(x - b[i]) < 1e-6); + +// Reference: a table measured from 10 plain data rows. +const ref = makeTable('ref', HEADERS, dataRows(10)); +makeColumnsResizable('#ref', 'k-ref'); +const REF = ref.widths(); +// Header-only widths (what an empty body yields). +const hdr = makeTable('hdr', HEADERS, []); +makeColumnsResizable('#hdr', 'k-hdr'); +const HDR = hdr.widths(); + +console.log('\n=== #258 makeColumnsResizable: rows that span columns ==='); + +test('reference widths come from the data rows (Details is the widest column)', () => { + assert.strictEqual(typeof makeColumnsResizable, 'function'); + assert(REF[3] > REF[0] * 5, 'Details must be far wider than expand: ' + JSON.stringify(REF)); + assert(Math.abs(REF.reduce((s, w) => s + w, 0) - 100) < 1e-6, 'percentages sum to 100'); +}); + +test('colspan spacer / empty-state rows are not credited to column 0', () => { + const t = makeTable('span', HEADERS, [spanRow(1200, 4)].concat(dataRows(10), [spanRow(1200, 4)])); + makeColumnsResizable('#span', 'k-span'); + assert(close(t.widths(), REF), 'widths with spacer rows ' + JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); + assert(t.widths()[0] < 5, 'expand stays narrow: ' + t.widths()[0] + '%'); +}); + +test('a row whose cell count differs from the header is not measured', () => { + const short = { children: [cell(900), cell(900, 3)] }; + const fewer = { children: [cell(900), cell(900)] }; + const t = makeTable('short', HEADERS, [short, fewer].concat(dataRows(10))); + makeColumnsResizable('#short', 'k-short'); + assert(close(t.widths(), REF), JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); +}); + +test('a full first body is measured once and gets no observer (no per-render work)', () => { + assert.strictEqual(observersOf(ref).length, 0, 'no MutationObserver for a table measured from enough rows'); + const t = makeTable('full', HEADERS, [spanRow(1200, 4)].concat(dataRows(5))); + makeColumnsResizable('#full', 'k-full'); + assert.strictEqual(observersOf(t).length, 0, '5 usable rows are enough'); +}); + +console.log('\n=== #258 makeColumnsResizable: empty or near-empty first body ==='); + +test('an empty first body (only "No packets found") gets provisional header widths and one childList observer', () => { + const t = makeTable('empty', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#empty', 'k-empty'); + assert(close(t.widths(), HDR), 'provisional = header widths: ' + JSON.stringify(t.widths())); + const obs = observersOf(t); + assert.strictEqual(obs.length, 1, 'one observer on the tbody'); + assert(obs[0].connected && obs[0].options && obs[0].options.childList === true, JSON.stringify(obs[0].options)); + assert(!obs[0].options.subtree && !obs[0].options.attributes && !obs[0].options.characterData, + 'childList only: ' + JSON.stringify(obs[0].options)); + assert.strictEqual(t.ths.slice(0, -1).every((th) => th.handles.length === 1), true, 'resize handles added right away'); +}); + +test('when real rows arrive the columns are measured again, once, and the observer disconnects', () => { + const t = makeTable('later', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#later', 'k-later'); + const [mo] = observersOf(t); + t.tbody.rows = [spanRow(0, 4)].concat(dataRows(20), [spanRow(0, 4)]); + mo.fire(); + assert(close(t.widths(), REF), 're-measured from the rows: ' + JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); + assert.strictEqual(mo.connected, false, 'observer disconnected after the re-measure'); + assert(t.ths.slice(0, -1).every((th) => th.handles.length === 1), 'no second set of handles'); + // Later renders do not move the widths. + t.tbody.rows = dataRows(20, [20, 80, 400, 30]); + mo.fire(); + assert(close(t.widths(), REF), 'widths stay after later renders: ' + JSON.stringify(t.widths())); + assert.strictEqual(observersOf(t).length, 1, 'no new observer'); +}); + +test('a near-empty first body (2 rows) is provisional; 4 rows are not enough, 6 rows re-measure', () => { + const t = makeTable('few', HEADERS, dataRows(2, [20, 80, 100, 60])); + makeColumnsResizable('#few', 'k-few'); + const provisional = t.widths(); + const [mo] = observersOf(t); + assert(mo && mo.connected, 'near-empty body is observed'); + t.tbody.rows = dataRows(4); + mo.fire(); + assert(close(t.widths(), provisional), 'below the threshold nothing is measured'); + assert(mo.connected, 'still waiting'); + t.tbody.rows = [spanRow(1200, 4), spanRow(1200, 4), spanRow(1200, 4), spanRow(1200, 4), spanRow(1200, 4), spanRow(1200, 4)]; + mo.fire(); + assert(close(t.widths(), provisional), 'six colspan rows are not "real rows"'); + assert(mo.connected, 'still waiting'); + t.tbody.rows = dataRows(6); + mo.fire(); + assert(close(t.widths(), REF), 're-measured: ' + JSON.stringify(t.widths())); + assert.strictEqual(mo.connected, false); +}); + +test('a re-measure is not fed by the provisional widths (th widths are cleared first)', () => { + // Provisional widths from a header-only measure give "time" ~22%; the rows + // need less. A re-measure that keeps the old th widths would keep ~22%. + const t = makeTable('feedback', [10, 200, 30, 50], [spanRow(1200, 4)]); + makeColumnsResizable('#feedback', 'k-feedback'); + const before = t.widths(); + const [mo] = observersOf(t); + t.tbody.rows = dataRows(10, [20, 80, 100, 600]); + mo.fire(); + const after = t.widths(); + const r = makeTable('feedback-ref', [10, 200, 30, 50], dataRows(10, [20, 80, 100, 600])); + makeColumnsResizable('#feedback-ref', 'k-feedback-ref'); + assert(close(after, r.widths()), 'same as a first measure of those rows: ' + JSON.stringify(after) + ' vs ' + JSON.stringify(r.widths()) + ' (provisional ' + JSON.stringify(before) + ')'); +}); + +console.log('\n=== #258 makeColumnsResizable: saved widths ==='); + +test('valid saved widths are applied as is: fixed layout, no measure, no observer', () => { + store['k-saved'] = JSON.stringify([5, 20, 25, 50]); + const t = makeTable('saved', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#saved', 'k-saved'); + assert.deepStrictEqual(t.widths(), [5, 20, 25, 50]); + assert.strictEqual(t.table.style.tableLayout, 'fixed'); + assert.strictEqual(observersOf(t).length, 0, 'saved widths need no re-measure'); + assert(t.ths.slice(0, -1).every((th) => th.handles.length === 1), 'handles still added'); +}); + +test('widths saved while the table is provisional (user dragged a handle) are not re-measured', () => { + const t = makeTable('dragged', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#dragged', 'k-dragged'); + const [mo] = observersOf(t); + t.ths[0].style.width = '7%'; t.ths[1].style.width = '23%'; t.ths[2].style.width = '30%'; t.ths[3].style.width = '40%'; + store['k-dragged'] = JSON.stringify([7, 23, 30, 40]); + t.tbody.rows = dataRows(20); + mo.fire(); + assert.deepStrictEqual(t.widths(), [7, 23, 30, 40], 'the user\'s widths stay'); + assert.strictEqual(mo.connected, false, 'nothing left to wait for'); +}); + +console.log('\n=== #258 makeColumnsResizable: lifecycle ==='); + +test('calling again on the same table is a no-op (no second measure, no second observer)', () => { + const t = makeTable('again', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#again', 'k-again'); + makeColumnsResizable('#again', 'k-again'); + assert.strictEqual(observersOf(t).length, 1); + assert(t.ths.slice(0, -1).every((th) => th.handles.length === 1)); +}); + +test('a table removed from the page stops waiting without measuring', () => { + const t = makeTable('gone', HEADERS, [spanRow(1200, 4)]); + makeColumnsResizable('#gone', 'k-gone'); + const provisional = t.widths(); + const [mo] = observersOf(t); + t.table.isConnected = false; + t.tbody.rows = dataRows(20); + mo.fire(); + assert(close(t.widths(), provisional), 'not measured'); + assert.strictEqual(mo.connected, false, 'disconnected'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); From 4de7afe4f4e7ba20f739929b1ba1de5a536bb576 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:12:56 +0200 Subject: [PATCH 2/7] test(e2e): packets column widths with an aged fixture (#258) New test-issue-258-column-widths-e2e.js, registered after the #1122 Details clamp E2E. It ages the fixture by moving the browser clock (20 and 120 min effective, offset from the newest packet), checks that the default window renders empty, then widens the window at 1200 and 900 px: Details >= 15% and expand <= 10% of the table, advert names show text (the long pinned name at least half), widths stay through later renders, saved widths apply unchanged and a resize handle still resizes and saves. Red on master: 9 passed, 9 failed. test-issue-1122-details-row-clamp-e2e.js: the pinned long advert name must be cut by its clip only on mobile (375 px). At 900/1200 px Details is sized from the real rows after the fix, so the name must show at least half of itself instead (finding 1 of the #252 review). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-issue-1122-details-row-clamp-e2e.js | 40 ++-- test-issue-258-column-widths-e2e.js | 257 +++++++++++++++++++++++ 3 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 test-issue-258-column-widths-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 954d29a3a..0cd4d703b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -442,6 +442,7 @@ jobs: BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-258-column-widths-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-189-group-caret-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-254-affinity-toggle-mobile-aria-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-issue-1122-details-row-clamp-e2e.js b/test-issue-1122-details-row-clamp-e2e.js index a0ac6fef4..57e75b3ad 100644 --- a/test-issue-1122-details-row-clamp-e2e.js +++ b/test-issue-1122-details-row-clamp-e2e.js @@ -43,19 +43,18 @@ const VIEWPORTS = [ ]; // #244: open with the widest selectable time window, not the default 15 min. -// makeColumnsResizable() (app.js) sizes the columns ONCE, from the rows of the -// first render, and later time-window changes keep those widths. With the -// default window those rows depend on the fixture age: in CI the window holds -// only a few packets ~13 min after freshen-fixture.sh, and none after 15 min; -// the expand column then gets ~43% of the table and Details ~5% (64px at -// 1200px), and advert names wrap onto the clamped-away 2nd line. The newest -// rows of a 24 h window (3 h at <=1024px, where longer windows are disabled) -// are the same ones for the whole e2e job (150 min timeout), so the measured -// layout no longer depends on the fixture age. +// makeColumnsResizable() (app.js) sizes the columns from the rows of the first +// render; since #258 a (nearly) empty first render is measured again once real +// rows arrive (test-issue-258-column-widths-e2e.js covers that). With the +// default window the first rows depend on the fixture age (a few packets ~13 +// min after freshen-fixture.sh, none after 15 min). The newest rows of a 24 h +// window (3 h at <=1024px, where longer windows are disabled) are the same ones +// for the whole e2e job (150 min timeout), so this test measures one layout +// regardless of the fixture age. const pinnedWindowMin = (vp) => (vp.w > 1024 ? 1440 : 180); -// A fixture advert with a long name ("KN6PLV-BrkOxfLA-Yebes", 21 chars) that -// is wider than its Details clip at every viewport here; it renders near the -// top of the list. +// A fixture advert with a long name ("KN6PLV-BrkOxfLA-Yebes", 21 chars); it +// renders near the top of the list. It is wider than its Details clip on +// mobile; at 900/1200px Details is wide enough to show it (#258). const PINNED_ADVERT_ROW = 'e8b09a35ac87fa5c'; // Enough of a name to read and click: about two characters. const MIN_VISIBLE_LINK_PX = 12; @@ -194,13 +193,22 @@ function measureRows() { return checked; }); assert(res.length > 0, 'no on-screen advert link in Details to check'); - // The pinned long-name row must be among them and be cut by the clip, - // so the test cannot pass without exercising a long advert name. + // The pinned long-name row must be among them, so the test cannot pass + // without exercising a long advert name. On mobile its clip is narrow and + // the name must be cut by it (the clamped-link case). Wider, Details is + // sized from the real rows (#258), so the name may fit; it must then show + // at least half of itself, not just the icon. const pinned = res.find(r => r.row === PINNED_ADVERT_ROW); assert(pinned, `pinned advert row ${PINNED_ADVERT_ROW} is not on screen; checked: ` + JSON.stringify(res.map(r => [r.row, r.text]))); - assert(pinned.truncated, - 'pinned advert name fits its Details clip -- long names are not exercised: ' + JSON.stringify(pinned)); + if (vp.w <= 640) { + assert(pinned.truncated, + 'pinned advert name fits its mobile Details clip -- the clamped long name is not exercised: ' + JSON.stringify(pinned)); + } else { + const nameW = pinned.frags.reduce((s, r) => s + (r.right - r.left), 0); + assert(pinned.visibleW >= nameW / 2, + `pinned long advert name shows ${pinned.visibleW}px of ${Math.round(nameW)}px in Details: ` + JSON.stringify(pinned)); + } const hidden = res.filter(r => !(r.visibleW >= MIN_VISIBLE_LINK_PX)); assert(hidden.length === 0, `${hidden.length}/${res.length} advert links show < ${MIN_VISIBLE_LINK_PX}px ` + 'of their name in the one-line Details clip: ' + JSON.stringify(hidden.slice(0, 3))); diff --git a/test-issue-258-column-widths-e2e.js b/test-issue-258-column-widths-e2e.js new file mode 100644 index 000000000..830dac172 --- /dev/null +++ b/test-issue-258-column-widths-e2e.js @@ -0,0 +1,257 @@ +/** + * E2E (#258): packets column widths do not depend on what the first render had. + * + * makeColumnsResizable() (app.js) used to size the columns once, from the first + * render, and credited full-width colspan rows (vscroll spacers, "No packets + * found") to column 0. With an aged fixture the default 15-min window is empty, + * so the expand column got ~43% of the table and Details ~5% (65 px at 1200 px): + * long advert names sat entirely on the line the #67 clamp hides, and only the + * icon showed. Now an (almost) empty first render only gives provisional widths + * and the columns are measured once more when real rows arrive. + * + * The fixture is aged by moving the browser clock forward (packets.js computes + * the window's `since` from Date.now()). The offset is taken from the newest + * packet, so the effective age is AGE_MIN regardless of when the e2e job runs. + * + * At 1200 px (window then 24 h) and 900 px (3 h, the widest at <=1024 px), for + * an effective age of 20 and 120 min: + * - the default window renders no usable rows (so the deferred path is tested); + * - after widening the window Details is >= 15% of the table, expand <= 10%; + * - every on-screen advert link in Details shows >= 12 px of its name, and the + * pinned long name ("KN6PLV-BrkOxfLA-Yebes") shows at least half of it; + * - later window changes keep the widths (one re-measure, no per-render work). + * Also: saved widths (meshcore-pkt-col-widths) are applied unchanged, and a + * resize handle still resizes and saves (Details is the last column, so the + * Path handle is dragged left, which gives the room to the columns after it). + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-258-column-widths-e2e.js + * SCREENSHOT_DIR= also saves screenshots. + */ +'use strict'; +const path = require('path'); +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOTS = process.env.SCREENSHOT_DIR || ''; +const PINNED_ADVERT_ROW = 'e8b09a35ac87fa5c'; // "KN6PLV-BrkOxfLA-Yebes", 21 chars (#252) +const MIN_VISIBLE_LINK_PX = 12; +const MIN_DETAILS_SHARE = 0.15; +const MAX_EXPAND_SHARE = 0.10; +const AGES_MIN = [20, 120]; +const VIEWPORTS = [ + { w: 1200, h: 900, wide: '1440' }, + { w: 900, h: 1024, wide: '180' }, +]; + +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'); } + +// Column widths and Details advert links, as rendered. +function measure(pinned) { + const table = document.getElementById('pktTable'); + const ths = [...table.querySelectorAll('thead tr:first-child th')]; + const tw = table.getBoundingClientRect().width; + const px = (cls) => { const th = ths.find((t) => t.classList.contains(cls)); return th ? th.getBoundingClientRect().width : 0; }; + const usableRows = [...document.querySelectorAll('#pktBody tr')] + .filter((r) => r.children.length === ths.length && ![...r.children].some((c) => c.colSpan > 1)).length; + const links = []; + for (const a of document.querySelectorAll('#pktBody td.col-details .col-details-clip a.hop-link')) { + const clip = a.closest('.col-details-clip').getBoundingClientRect(); + if (clip.height < 2 || clip.top < 0 || clip.bottom > innerHeight) continue; + const frags = [...a.getClientRects()].filter((r) => r.width >= 1 && r.height >= 1); + let visibleW = 0; + for (const r of frags) { + const w = Math.min(r.right, clip.right) - Math.max(r.left, clip.left); + const h = Math.min(r.bottom, clip.bottom) - Math.max(r.top, clip.top); + if (w >= 1 && h >= 1) { visibleW = Math.round(w); break; } + } + // The name's width on one line, in the link's own font. + const cs = getComputedStyle(a); + const c2d = document.createElement('canvas').getContext('2d'); + c2d.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + links.push({ row: a.closest('tr').getAttribute('data-hash'), text: a.textContent.trim(), visibleW, + textW: Math.round(c2d.measureText(a.textContent.trim()).width) }); + } + return { + tableW: Math.round(tw), expandW: Math.round(px('col-expand')), detailsW: Math.round(px('col-details')), + thWidths: ths.map((t) => t.style.width), usableRows, links, pinned: links.find((l) => l.row === pinned) || null, + }; +} + +// Select a time window; wait for > 20 rows, or with `empty` for the empty state. +async function setWindow(page, value, empty) { + await page.evaluate((v) => { + const sel = document.getElementById('fTimeWindow'); + sel.value = v; + sel.dispatchEvent(new Event('change', { bubbles: true })); + }, value); + if (empty) { + await page.waitForFunction(() => /No packets/.test(document.getElementById('pktBody').textContent) && + !document.querySelector('#pktBody tr[data-hash]'), null, { timeout: 10000 }); + } else { + await page.waitForFunction(() => document.querySelectorAll('#pktBody tr[data-hash]').length > 20, null, { timeout: 10000 }); + } + await page.waitForTimeout(400); +} + +// Newest packet in the fixture, as the server reports it. +async function fixtureAgeMin(request) { + const res = await request.get(BASE + '/api/packets?limit=1&groupByHash=true'); + const p = ((await res.json()).packets || [])[0]; + const t = p && Date.parse(p.latest || p.first_seen); + assert(Number.isFinite(t), 'the fixture has a newest packet'); + return (Date.now() - t) / 60000; +} + +async function openAged(browser, vp, ageMin, init) { + const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); + if (init) await ctx.addInitScript(init.fn, init.arg); + const page = await ctx.newPage(); + page.setDefaultTimeout(10000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + const offsetMin = Math.max(0, ageMin - await fixtureAgeMin(page.request)); + await page.clock.install({ time: Date.now() + offsetMin * 60000 }); + await page.clock.resume(); + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#pktTable[data-resizable]', { state: 'attached', timeout: 12000 }); + // The first load has finished once the body shows rows or the empty state. + await page.waitForFunction(() => { + const b = document.getElementById('pktBody'); + return !!b && (b.querySelector('tr[data-hash]') || /No packets/.test(b.textContent)); + }, null, { timeout: 12000 }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(300); + return { ctx, page }; +} + +async function shot(page, name) { + if (!SHOTS) return; + await page.screenshot({ path: path.join(SHOTS, '258-' + name + '.png'), fullPage: false }); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== #258 packets column widths E2E against ${BASE} ===`); + + for (const vp of VIEWPORTS) { + for (const age of AGES_MIN) { + const tag = `[${vp.w}px, fixture ${age} min]`; + const { ctx, page } = await openAged(browser, vp, age); + let first = null, after = null; + + await step(`${tag} the default 15-min window renders no usable rows`, async () => { + first = await page.evaluate(measure, PINNED_ADVERT_ROW); + assert(first.usableRows === 0, 'the first render must be empty to test the deferred measure, got ' + first.usableRows + ' rows'); + }); + + await step(`${tag} after widening the window Details is >= ${MIN_DETAILS_SHARE * 100}% and expand <= ${MAX_EXPAND_SHARE * 100}% of the table`, async () => { + await setWindow(page, vp.wide); + after = await page.evaluate(measure, PINNED_ADVERT_ROW); + const d = after.detailsW / after.tableW, x = after.expandW / after.tableW; + assert(d >= MIN_DETAILS_SHARE, `Details is ${after.detailsW}px of ${after.tableW}px (${(d * 100).toFixed(1)}%); first render had ${first.detailsW}px`); + assert(x <= MAX_EXPAND_SHARE, `expand is ${after.expandW}px of ${after.tableW}px (${(x * 100).toFixed(1)}%)`); + await shot(page, `${vp.w}-age${age}`); + }); + + await step(`${tag} advert names in Details show text, not only the icon`, async () => { + assert(after && after.links.length > 0, 'no on-screen advert link in Details'); + const hidden = after.links.filter((l) => l.visibleW < MIN_VISIBLE_LINK_PX); + assert(hidden.length === 0, `${hidden.length}/${after.links.length} advert links show < ${MIN_VISIBLE_LINK_PX}px: ` + JSON.stringify(hidden)); + const p = after.pinned; + assert(p, `pinned long-name row ${PINNED_ADVERT_ROW} is not on screen: ` + JSON.stringify(after.links.map((l) => l.row))); + assert(p.visibleW >= p.textW / 2, `the long name shows ${p.visibleW}px of ${p.textW}px: ` + JSON.stringify(p)); + }); + + await step(`${tag} later renders (adverts only, empty window) keep the widths: measured again only once`, async () => { + const same = async (when) => { + const now = await page.evaluate(measure, PINNED_ADVERT_ROW); + assert(JSON.stringify(now.thWidths) === JSON.stringify(after.thWidths), + when + ': th widths changed: ' + JSON.stringify(after.thWidths) + ' -> ' + JSON.stringify(now.thWidths)); + }; + // Different rows (adverts only), which a second measure would size differently. + await page.fill('#packetFilterInput', 'type == ADVERT'); + await page.press('#packetFilterInput', 'Enter'); + await page.waitForFunction(() => { + const rows = [...document.querySelectorAll('#pktBody tr[data-hash]')]; + return rows.length >= 5 && rows.every((r) => /advert/i.test(r.textContent)); + }, null, { timeout: 10000 }); + await same('adverts only'); + await page.fill('#packetFilterInput', ''); + await page.press('#packetFilterInput', 'Enter'); + await page.waitForTimeout(600); + // Back to the (empty) default window and out again. + await setWindow(page, '15', true); + await same('empty window'); + await setWindow(page, vp.wide); + await same('wide window again'); + }); + await ctx.close(); + } + } + + // Saved widths: applied as saved, also when the first render is empty. + { + const vp = VIEWPORTS[0]; + let cols = 0; + { + const { ctx, page } = await openAged(browser, vp, 20); + cols = await page.evaluate(() => document.querySelectorAll('#pktTable thead tr:first-child th').length); + await ctx.close(); + } + const savedW = Array.from({ length: cols }, (_, i) => (i === 0 ? 4 : 96 / (cols - 1))); + const { ctx, page } = await openAged(browser, vp, 20, { + fn: (w) => { localStorage.setItem('meshcore-pkt-col-widths', JSON.stringify(w)); }, + arg: savedW, + }); + await step('[1200px] saved widths are applied unchanged and survive the rows arriving', async () => { + const same = (got) => got.length === savedW.length && got.every((w, i) => /%$/.test(w) && Math.abs(parseFloat(w) - savedW[i]) < 0.001); + const atFirst = await page.evaluate(measure, PINNED_ADVERT_ROW); + assert(same(atFirst.thWidths), 'first render: ' + JSON.stringify(atFirst.thWidths) + ' vs ' + JSON.stringify(savedW)); + await setWindow(page, vp.wide); + const filled = await page.evaluate(measure, PINNED_ADVERT_ROW); + assert(same(filled.thWidths), 'after rows: ' + JSON.stringify(filled.thWidths) + ' vs ' + JSON.stringify(savedW)); + }); + await ctx.close(); + } + + // Resize handle: dragging still resizes and saves. The handle straddles the + // column edge and the next header cell paints over its right half, so it is + // grabbed at its left part, inside its own column. + { + const vp = VIEWPORTS[0]; + const { ctx, page } = await openAged(browser, vp, 20); + await step('[1200px] dragging the Time resize handle widens Time and saves the widths', async () => { + await setWindow(page, vp.wide); + const colW = (cls) => page.evaluate((c) => document.querySelector('#pktTable th.' + c).getBoundingClientRect().width, cls); + const before = await colW('col-time'); + const box = await page.evaluate(() => { + const h = document.querySelector('#pktTable th.col-time .col-resize-handle'); + const r = h && h.getBoundingClientRect(); + return r ? { x: r.left + 2, y: r.top + r.height / 2 } : null; + }); + assert(box, 'Time has a resize handle'); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + await page.mouse.move(box.x + 30, box.y, { steps: 5 }); + await page.mouse.move(box.x + 60, box.y, { steps: 5 }); + await page.mouse.up(); + const after = await colW('col-time'); + const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('meshcore-pkt-col-widths') || 'null')); + assert(after > before + 10, `Time ${Math.round(before)}px -> ${Math.round(after)}px`); + assert(Array.isArray(saved) && saved.length > 1, 'widths saved: ' + JSON.stringify(saved)); + }); + await ctx.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); }); From 4ed0c60839b72b86e25eaf891251f5cec5adbea8 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:12:56 +0200 Subject: [PATCH 3/7] fix(ui): column widths ignore colspan rows and empty first renders (#258) makeColumnsResizable() measured each column by row index, so a full-width colspan row (vscroll spacer, "No packets found") was credited to column 0, and it measured only once, from whatever the first render had. On packets with an aged fixture the default 15-min window is empty: expand got ~43% of the table and Details 65 px at 1200 px, hiding long advert names behind the #67 clamp. - columnMeasureRows() keeps only rows with one cell per header cell and no colspan. - With fewer than 5 usable rows the widths are provisional; one MutationObserver (childList on the tbody) measures again once real rows arrive, unless widths have been saved by then, and disconnects. A table measured from a full body gets no observer. - Saved widths are read by readSavedColumnWidths() and applied as before. - The measure and fit steps are extracted unchanged (measureColumnWidths, fitColumnWidths, applyMeasuredColumnWidths); the re-measure clears the old th widths first. Co-Authored-By: Claude Opus 5.5 --- public/app.js | 169 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 111 insertions(+), 58 deletions(-) diff --git a/public/app.js b/public/app.js index 425e82e35..583f0813f 100644 --- a/public/app.js +++ b/public/app.js @@ -2245,72 +2245,70 @@ function initTabBar(container, onChange) { }); } +// #258: columns are measured from at most COL_MEASURE_MAX_ROWS body rows. A body +// with fewer than COL_MEASURE_MIN_ROWS usable rows only gives provisional widths. +const COL_MEASURE_MAX_ROWS = 30; +const COL_MEASURE_MIN_ROWS = 5; + /** - * Make table columns resizable with drag handles. Widths saved to localStorage. - * Call after table is in DOM. Re-call safe (idempotent per table). - * @param {string} tableSelector - CSS selector for the table - * @param {string} storageKey - localStorage key for persisted widths + * #258: the body rows a column measurement can use, i.e. rows with exactly one + * cell per header cell. A row that spans columns (a virtual-scroll spacer, "No + * packets found", a group-detail row) belongs to no single column; measured by + * index, it used to inflate column 0. */ -function makeColumnsResizable(tableSelector, storageKey) { - const table = document.querySelector(tableSelector); - if (!table) return; - const thead = table.querySelector('thead'); - if (!thead) return; - const ths = Array.from(thead.querySelectorAll('tr:first-child th')); - if (ths.length < 2) return; - - if (table.dataset.resizable) return; - table.dataset.resizable = '1'; - table.style.tableLayout = 'fixed'; +function columnMeasureRows(tbody, colCount, limit) { + const out = []; + if (!tbody) return out; + for (const row of tbody.rows) { + const cells = row.children; + if (cells.length !== colCount) continue; + let spans = false; + for (let i = 0; i < cells.length; i++) { + if (cells[i].colSpan > 1) { spans = true; break; } + } + if (spans) continue; + out.push(row); + if (out.length >= limit) break; + } + return out; +} - const containerW = table.parentElement.clientWidth; +/** Saved column widths (percentages) for storageKey, or null if none are valid. */ +function readSavedColumnWidths(storageKey, colCount) { const saved = localStorage.getItem(storageKey); + if (!saved) return null; let widths; + try { widths = JSON.parse(saved); } catch { return null; } + if (!Array.isArray(widths) || widths.length !== colCount) return null; + const sum = widths.reduce((s, w) => s + w, 0); + return sum > 90 && sum < 110 ? widths : null; +} - if (saved) { - try { widths = JSON.parse(saved); } catch { widths = null; } - // Validate: must be array of correct length with values summing to ~100 (percentages) - if (widths && Array.isArray(widths) && widths.length === ths.length) { - const sum = widths.reduce((s, w) => s + w, 0); - if (sum > 90 && sum < 110) { - // Saved percentages — apply directly - table.style.tableLayout = 'fixed'; - table.style.width = '100%'; - ths.forEach((th, i) => { th.style.width = widths[i] + '%'; }); - // Skip measurement, jump to adding handles - addResizeHandles(); - return; - } - } - widths = null; // Force remeasure - } - - if (!widths) { - // Measure actual max content width per column by scanning visible rows - const tbody = table.querySelector('tbody'); - const rows = tbody ? Array.from(tbody.querySelectorAll('tr')).slice(0, 30) : []; - - // Temporarily set auto layout to measure - table.style.tableLayout = 'auto'; - table.style.width = 'auto'; - // Remove nowrap temporarily so we get true content width - const cells = table.querySelectorAll('td, th'); - cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; }); - - // Measure each column's max content width across header + rows - widths = ths.map((th, i) => { - let maxW = th.scrollWidth; - rows.forEach(row => { - const td = row.children[i]; - if (td) maxW = Math.max(maxW, td.scrollWidth); - }); - return maxW + 4; // small padding buffer +// Max content width per column (header + rows), measured in auto layout without +// wrapping. Clears the widths the columns already have, so a re-measure is not +// fed by the previous one. +function measureColumnWidths(table, ths, rows) { + ths.forEach(th => { th.style.width = ''; }); + table.style.tableLayout = 'auto'; + table.style.width = 'auto'; + // Remove wrapping temporarily so we get true content width + const cells = table.querySelectorAll('td, th'); + cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; }); + const widths = ths.map((th, i) => { + let maxW = th.scrollWidth; + rows.forEach(row => { + const td = row.children[i]; + if (td) maxW = Math.max(maxW, td.scrollWidth); }); + return maxW + 4; // small padding buffer + }); + cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; }); + return widths; +} - cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; }); - } - - // Now fit to container: if total > container, squish widest first +// Fit measured widths to the container: if the total is too wide, squish the +// widest columns first; if there is room left, give it to the 2 widest. +function fitColumnWidths(widths, containerW) { const totalNeeded = widths.reduce((s, w) => s + w, 0); const finalWidths = [...widths]; @@ -2348,13 +2346,68 @@ function makeColumnsResizable(tableSelector, storageKey) { const topTotal = topN.reduce((s, x) => s + x.w, 0); topN.forEach(x => { finalWidths[x.i] += Math.round(surplus * (x.w / topTotal)); }); } + return finalWidths; +} +// Measure the columns from `rows` and set them as percentages of the table. +function applyMeasuredColumnWidths(table, ths, rows) { + const containerW = table.parentElement.clientWidth; + const finalWidths = fitColumnWidths(measureColumnWidths(table, ths, rows), containerW); table.style.width = '100%'; const totalFinal = finalWidths.reduce((s, w) => s + w, 0); ths.forEach((th, i) => { th.style.width = (finalWidths[i] / totalFinal * 100) + '%'; }); +} + +/** + * Make table columns resizable with drag handles. Widths saved to localStorage. + * Call after table is in DOM. Re-call safe (idempotent per table). + * Without saved widths the columns are measured from the header and the first + * body rows; if the body has too few rows for that, once more when it fills (#258). + * @param {string} tableSelector - CSS selector for the table + * @param {string} storageKey - localStorage key for persisted widths + */ +function makeColumnsResizable(tableSelector, storageKey) { + const table = document.querySelector(tableSelector); + if (!table) return; + const thead = table.querySelector('thead'); + if (!thead) return; + const ths = Array.from(thead.querySelectorAll('tr:first-child th')); + if (ths.length < 2) return; + + if (table.dataset.resizable) return; + table.dataset.resizable = '1'; + table.style.tableLayout = 'fixed'; + const saved = readSavedColumnWidths(storageKey, ths.length); + if (saved) { + // Saved percentages — apply directly, no measurement + table.style.width = '100%'; + ths.forEach((th, i) => { th.style.width = saved[i] + '%'; }); + addResizeHandles(); + return; + } + + const tbody = table.querySelector('tbody'); + const rows = columnMeasureRows(tbody, ths.length, COL_MEASURE_MAX_ROWS); + applyMeasuredColumnWidths(table, ths, rows); addResizeHandles(); + // #258: an (almost) empty first render, e.g. a quiet packets time window, + // only gives provisional widths. Measure once more when real rows arrive, + // unless the user has saved widths by then. Until then each body render costs + // a cheap guard; after the re-measure the observer is gone. + if (tbody && rows.length < COL_MEASURE_MIN_ROWS && typeof MutationObserver === 'function') { + const filled = new MutationObserver(() => { + if (!table.isConnected || readSavedColumnWidths(storageKey, ths.length)) { filled.disconnect(); return; } + if (tbody.rows.length < COL_MEASURE_MIN_ROWS) return; + const rowsNow = columnMeasureRows(tbody, ths.length, COL_MEASURE_MAX_ROWS); + if (rowsNow.length < COL_MEASURE_MIN_ROWS) return; + filled.disconnect(); + applyMeasuredColumnWidths(table, ths, rowsNow); + }); + filled.observe(tbody, { childList: true }); + } + function addResizeHandles() { // Add resize handles ths.forEach((th, i) => { From 02847b5255753fead6d355bc08338ac6e4fef039 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:32:13 +0200 Subject: [PATCH 4/7] test: cover a same-length colspan row and th widths set by the page (#258) - A row with as many cells as the header, one of them a colspan, is not measured either. - A width the page's markup puts on a th (observers' compare column has style="width:32px") counts in the first measure, as on master, and again in the re-measure. Red on the previous commit, which cleared it before measuring and changed the observers table's widths slightly. Co-Authored-By: Claude Opus 5.5 --- test-issue-258-column-widths.js | 39 ++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/test-issue-258-column-widths.js b/test-issue-258-column-widths.js index d8ff914ec..65d23cd1e 100644 --- a/test-issue-258-column-widths.js +++ b/test-issue-258-column-widths.js @@ -14,8 +14,9 @@ * * Runs the real app.js in a vm sandbox against a minimal fake table DOM. A * cell's scrollWidth is its content width; like a real auto-layout table, a - * header whose th still carries a width reports at least that width, so a - * re-measure that forgets to clear the old widths is caught. + * header whose th carries a width reports at least that width. So a re-measure + * that keeps the provisional widths, or drops a width the page's markup set on + * a th, is caught. * * Usage: node test-issue-258-column-widths.js */ @@ -88,15 +89,17 @@ function cell(w, colSpan) { return { scrollWidth: w, colSpan: colSpan || 1, styl function row(widths) { return { children: widths.map((w) => cell(w)) }; } function spanRow(w, span) { return { children: [cell(w, span)] }; } -// headers: content width of each header cell. -function makeTable(id, headers, rows) { - const ths = headers.map((w) => { +// headers: content width of each header cell; authored: inline th widths the +// page's own markup sets (e.g. observers' style="width:32px"). +function makeTable(id, headers, rows, authored) { + const ths = headers.map((w, i) => { const th = { - style: {}, dataset: {}, handles: [], + style: { width: (authored && authored[i]) || '' }, dataset: {}, handles: [], appendChild(h) { this.handles.push(h); }, get scrollWidth() { // An auto-layout cell is at least as wide as the width it was given. - const given = /%$/.test(this.style.width || '') ? pct(this.style.width) / 100 * CONTAINER_W : 0; + const sw = this.style.width || ''; + const given = /%$/.test(sw) ? pct(sw) / 100 * CONTAINER_W : /px$/.test(sw) ? parseFloat(sw) : 0; return Math.max(w, given); }, get offsetWidth() { return pct(this.style.width || '0') / 100 * CONTAINER_W; }, @@ -158,6 +161,13 @@ test('a row whose cell count differs from the header is not measured', () => { assert(close(t.widths(), REF), JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); }); +test('a row with a colspan cell is not measured even when its cell count matches the header', () => { + const odd = { children: [cell(900, 2), cell(900), cell(900), cell(900)] }; + const t = makeTable('oddspan', HEADERS, [odd].concat(dataRows(10))); + makeColumnsResizable('#oddspan', 'k-oddspan'); + assert(close(t.widths(), REF), JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); +}); + test('a full first body is measured once and gets no observer (no per-render work)', () => { assert.strictEqual(observersOf(ref).length, 0, 'no MutationObserver for a table measured from enough rows'); const t = makeTable('full', HEADERS, [spanRow(1200, 4)].concat(dataRows(5))); @@ -215,7 +225,7 @@ test('a near-empty first body (2 rows) is provisional; 4 rows are not enough, 6 assert.strictEqual(mo.connected, false); }); -test('a re-measure is not fed by the provisional widths (th widths are cleared first)', () => { +test('a re-measure is not fed by the provisional widths', () => { // Provisional widths from a header-only measure give "time" ~22%; the rows // need less. A re-measure that keeps the old th widths would keep ~22%. const t = makeTable('feedback', [10, 200, 30, 50], [spanRow(1200, 4)]); @@ -230,6 +240,19 @@ test('a re-measure is not fed by the provisional widths (th widths are cleared f assert(close(after, r.widths()), 'same as a first measure of those rows: ' + JSON.stringify(after) + ' vs ' + JSON.stringify(r.widths()) + ' (provisional ' + JSON.stringify(before) + ')'); }); +test('a th width set by the page markup counts in the first measure and again in the re-measure', () => { + const authored = ['60px', '', '', '']; + const r = makeTable('authored-ref', HEADERS, dataRows(10), authored); + makeColumnsResizable('#authored-ref', 'k-authored-ref'); + assert(r.widths()[0] > REF[0] + 2, 'the 60px header widens column 0 as before: ' + JSON.stringify(r.widths())); + const t = makeTable('authored', HEADERS, [spanRow(1200, 4)], authored); + makeColumnsResizable('#authored', 'k-authored'); + const [mo] = observersOf(t); + t.tbody.rows = dataRows(10); + mo.fire(); + assert(close(t.widths(), r.widths()), 're-measure with the authored width: ' + JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(r.widths())); +}); + console.log('\n=== #258 makeColumnsResizable: saved widths ==='); test('valid saved widths are applied as is: fixed layout, no measure, no observer', () => { From 885e5b410f8e6563a34f89be1076bd520fc3b7e9 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:32:13 +0200 Subject: [PATCH 5/7] fix(ui): keep th widths set by the page when measuring columns (#258) The first measure no longer clears the header cells' inline widths, so tables measured from a full body (nodes, observers, analytics, new-nodes) get exactly master's widths. The re-measure restores the widths the page's markup set before measuring, instead of the provisional percentages. Co-Authored-By: Claude Opus 5.5 --- public/app.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/app.js b/public/app.js index 583f0813f..df0ee4a16 100644 --- a/public/app.js +++ b/public/app.js @@ -2285,10 +2285,8 @@ function readSavedColumnWidths(storageKey, colCount) { } // Max content width per column (header + rows), measured in auto layout without -// wrapping. Clears the widths the columns already have, so a re-measure is not -// fed by the previous one. +// wrapping. function measureColumnWidths(table, ths, rows) { - ths.forEach(th => { th.style.width = ''; }); table.style.tableLayout = 'auto'; table.style.width = 'auto'; // Remove wrapping temporarily so we get true content width @@ -2388,6 +2386,7 @@ function makeColumnsResizable(tableSelector, storageKey) { } const tbody = table.querySelector('tbody'); + const authoredWidths = ths.map(th => th.style.width); const rows = columnMeasureRows(tbody, ths.length, COL_MEASURE_MAX_ROWS); applyMeasuredColumnWidths(table, ths, rows); addResizeHandles(); @@ -2403,6 +2402,9 @@ function makeColumnsResizable(tableSelector, storageKey) { const rowsNow = columnMeasureRows(tbody, ths.length, COL_MEASURE_MAX_ROWS); if (rowsNow.length < COL_MEASURE_MIN_ROWS) return; filled.disconnect(); + // Measure as the first time: the widths the page itself gave the header + // cells, not the provisional ones. + ths.forEach((th, i) => { th.style.width = authoredWidths[i]; }); applyMeasuredColumnWidths(table, ths, rowsNow); }); filled.observe(tbody, { childList: true }); From b544f6631a8d805a43ce2171bf2422c80e5bf3ee Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:42:43 +0200 Subject: [PATCH 6/7] test: a re-measure must match a direct first measure (#258) - E2E: after the deferred re-measure, every column's share must equal (within 1 point) that of a page opened with the wide window right away. Red on the previous commit: at 900 px TableResponsive had hidden five header cells but not yet the new rows' cells, so the re-measure saw a skewed table (HB 12.2% instead of 3.8%); at 1200 px the resize handles, which stick out 4px past their th, widened the narrow columns. - Unit: columns TableResponsive hid are measured inside TableResponsive.unhidden (stubbed here), and a shown handle's 4px overhang does not count. - test-packets.js: the real TableResponsive.unhidden lifts col-hidden and the pills only while its callback runs, also when it throws. Co-Authored-By: Claude Opus 5.5 --- test-issue-258-column-widths-e2e.js | 21 +++++++++-- test-issue-258-column-widths.js | 54 +++++++++++++++++++++++++---- test-packets.js | 39 +++++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/test-issue-258-column-widths-e2e.js b/test-issue-258-column-widths-e2e.js index 830dac172..1a34ef741 100644 --- a/test-issue-258-column-widths-e2e.js +++ b/test-issue-258-column-widths-e2e.js @@ -16,7 +16,8 @@ * At 1200 px (window then 24 h) and 900 px (3 h, the widest at <=1024 px), for * an effective age of 20 and 120 min: * - the default window renders no usable rows (so the deferred path is tested); - * - after widening the window Details is >= 15% of the table, expand <= 10%; + * - after widening the window Details is >= 15% of the table, expand <= 10%, + * and every column has the share a page opened with that window gets; * - every on-screen advert link in Details shows >= 12 px of its name, and the * pinned long name ("KN6PLV-BrkOxfLA-Yebes") shows at least half of it; * - later window changes keep the widths (one re-measure, no per-render work). @@ -107,7 +108,7 @@ async function fixtureAgeMin(request) { return (Date.now() - t) / 60000; } -async function openAged(browser, vp, ageMin, init) { +async function openAged(browser, vp, ageMin, init, route) { const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); if (init) await ctx.addInitScript(init.fn, init.arg); const page = await ctx.newPage(); @@ -116,7 +117,7 @@ async function openAged(browser, vp, ageMin, init) { const offsetMin = Math.max(0, ageMin - await fixtureAgeMin(page.request)); await page.clock.install({ time: Date.now() + offsetMin * 60000 }); await page.clock.resume(); - await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.goto(BASE + '/#/packets' + (route || ''), { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#pktTable[data-resizable]', { state: 'attached', timeout: 12000 }); // The first load has finished once the body shows rows or the empty state. await page.waitForFunction(() => { @@ -161,6 +162,20 @@ async function shot(page, name) { await shot(page, `${vp.w}-age${age}`); }); + await step(`${tag} the widths equal those of a page opened with that window right away`, async () => { + // The columns must not depend on what the first render had: measured + // later from the same rows, they come out as a direct first measure. + const direct = await openAged(browser, vp, age, null, '?timeWindow=' + vp.wide); + try { + await direct.page.waitForFunction(() => document.querySelectorAll('#pktBody tr[data-hash]').length > 20, null, { timeout: 10000 }); + const d = await direct.page.evaluate(measure, PINNED_ADVERT_ROW); + const pctOf = (ws) => ws.map((w) => Math.round(parseFloat(w) * 10) / 10); + const a = pctOf(after.thWidths), b = pctOf(d.thWidths); + const off = a.map((x, i) => Math.abs(x - b[i])).reduce((m, x) => Math.max(m, x), 0); + assert(off <= 1, `column % differ by up to ${off}: deferred ${JSON.stringify(a)} vs direct ${JSON.stringify(b)}`); + } finally { await direct.ctx.close(); } + }); + await step(`${tag} advert names in Details show text, not only the icon`, async () => { assert(after && after.links.length > 0, 'no on-screen advert link in Details'); const hidden = after.links.filter((l) => l.visibleW < MIN_VISIBLE_LINK_PX); diff --git a/test-issue-258-column-widths.js b/test-issue-258-column-widths.js index 65d23cd1e..df0f75758 100644 --- a/test-issue-258-column-widths.js +++ b/test-issue-258-column-widths.js @@ -14,9 +14,10 @@ * * Runs the real app.js in a vm sandbox against a minimal fake table DOM. A * cell's scrollWidth is its content width; like a real auto-layout table, a - * header whose th carries a width reports at least that width. So a re-measure - * that keeps the provisional widths, or drops a width the page's markup set on - * a th, is caught. + * header whose th carries a width reports at least that width, and a shown + * resize handle adds the 4px it sticks out. So a re-measure that keeps the + * provisional widths, drops a width the page's markup set on a th, or counts + * the handles that the first measure did not have, is caught. * * Usage: node test-issue-258-column-widths.js */ @@ -86,21 +87,41 @@ const CONTAINER_W = 1000; const pct = (s) => parseFloat(s); function cell(w, colSpan) { return { scrollWidth: w, colSpan: colSpan || 1, style: {}, dataset: {} }; } +// A cell TableResponsive hid (class col-hidden, display:none): it has no width +// unless its hiding is lifted (TableResponsive.unhidden) while measuring. +function hiddenCell(w) { + return { colSpan: 1, style: {}, dataset: {}, table: null, + get scrollWidth() { return this.table && this.table.__unhidden ? w : 0; } }; +} +// Stand-in for packets.js's TableResponsive.unhidden(table, fn); the real one +// is tested in test-packets.js. +const unhiddenCalls = []; +ctx.window.TableResponsive = { + unhidden(table, fn) { + unhiddenCalls.push(table); + table.__unhidden = true; + try { return fn(); } finally { table.__unhidden = false; } + }, +}; function row(widths) { return { children: widths.map((w) => cell(w)) }; } function spanRow(w, span) { return { children: [cell(w, span)] }; } // headers: content width of each header cell; authored: inline th widths the -// page's own markup sets (e.g. observers' style="width:32px"). -function makeTable(id, headers, rows, authored) { +// page's own markup sets (e.g. observers' style="width:32px"); hidden: header +// cells TableResponsive hid. +function makeTable(id, headers, rows, authored, hidden) { const ths = headers.map((w, i) => { const th = { style: { width: (authored && authored[i]) || '' }, dataset: {}, handles: [], appendChild(h) { this.handles.push(h); }, get scrollWidth() { + if (hidden && hidden[i] && !table.__unhidden) return 0; // An auto-layout cell is at least as wide as the width it was given. const sw = this.style.width || ''; const given = /%$/.test(sw) ? pct(sw) / 100 * CONTAINER_W : /px$/.test(sw) ? parseFloat(sw) : 0; - return Math.max(w, given); + // A shown resize handle sticks out 4px past the th (right: -4px). + const handleOut = this.handles.some((h) => h.style.display !== 'none') ? 4 : 0; + return Math.max(w, given) + handleOut; }, get offsetWidth() { return pct(this.style.width || '0') / 100 * CONTAINER_W; }, }; @@ -116,8 +137,9 @@ function makeTable(id, headers, rows, authored) { querySelector: (sel) => (sel === 'thead' ? thead : sel === 'tbody' ? tbody : null), querySelectorAll: (sel) => (sel === 'td, th' ? ths.concat(...tbody.rows.map((r) => r.children)) - : []), + : sel === '.col-resize-handle' ? [].concat(...ths.map((th) => th.handles)) : []), }; + rows.forEach((r) => r.children.forEach((c) => { if ('table' in c) c.table = table; })); tables['#' + id] = table; return { table, ths, tbody, widths: () => ths.map((th) => pct(th.style.width)) }; } @@ -253,6 +275,24 @@ test('a th width set by the page markup counts in the first measure and again in assert(close(t.widths(), r.widths()), 're-measure with the authored width: ' + JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(r.widths())); }); +test('columns TableResponsive hid are measured as if shown, as at the first measure', () => { + // A re-measure runs before TableResponsive.register()'s own observer has + // marked the new cells; the measure lifts the hiding so header and rows agree. + const rows = Array.from({ length: 10 }, () => ({ children: [cell(20), hiddenCell(80), cell(100), cell(300)] })); + const t = makeTable('responsive', HEADERS, rows, null, [false, true, false, false]); + makeColumnsResizable('#responsive', 'k-responsive'); + assert(close(t.widths(), REF), 'hidden column measured like a shown one: ' + JSON.stringify(t.widths()) + ' vs ' + JSON.stringify(REF)); + assert(unhiddenCalls.includes(t.table), 'measured inside TableResponsive.unhidden'); + // The re-measure too. + const e = makeTable('responsive-later', HEADERS, [spanRow(1200, 4)], null, [false, true, false, false]); + makeColumnsResizable('#responsive-later', 'k-responsive-later'); + const [mo] = observersOf(e); + e.tbody.rows = Array.from({ length: 10 }, () => ({ children: [cell(20), hiddenCell(80), cell(100), cell(300)] })); + e.tbody.rows.forEach((r) => { r.children[1].table = e.table; }); + mo.fire(); + assert(close(e.widths(), REF), 're-measured with the hiding lifted: ' + JSON.stringify(e.widths())); +}); + console.log('\n=== #258 makeColumnsResizable: saved widths ==='); test('valid saved widths are applied as is: fixed layout, no measure, no observer', () => { diff --git a/test-packets.js b/test-packets.js index e117c58a0..d49a4e125 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1124,6 +1124,45 @@ console.log('\n=== packets.js: buildFlatRowHtml ==='); }); } +// #258: makeColumnsResizable() (app.js) measures inside TableResponsive.unhidden, +// so a re-measure sees the columns as the first measure did, before register(). +console.log('\n=== packets.js: TableResponsive.unhidden (#258) ==='); +{ + const ctx = loadPacketsSandbox(); + const TR = ctx.window.TableResponsive; + const el = (classes) => { + const set = new Set(classes); + return { style: { display: '' }, classList: { add: (c) => set.add(c), remove: (c) => set.delete(c), contains: (c) => set.has(c) } }; + }; + const makeTable = () => { + const els = [el(['col-observer', 'col-hidden']), el(['col-observer', 'col-hidden']), el(['col-time']), el(['col-hidden-pill']), el(['col-hidden-pill', 'col-rehide-pill'])]; + els[3].style.display = 'inline-block'; + return { + els, + querySelectorAll: (sel) => els.filter((e) => e.classList.contains(sel.replace(/^\./, ''))), + }; + }; + const state = (t) => t.els.map((e) => ['col-hidden', 'col-hidden-pill'].filter((c) => e.classList.contains(c)).join('+') + ':' + e.style.display); + + test('#258: unhidden lifts col-hidden and hides the pills only while fn runs', () => { + assert.strictEqual(typeof TR.unhidden, 'function', 'TableResponsive.unhidden is exported'); + const t = makeTable(); + const before = state(t); + let during = null; + const out = TR.unhidden(t, () => { during = state(t); return 42; }); + assert.strictEqual(out, 42, 'returns what fn returns'); + assert.deepStrictEqual(during, [':', ':', ':', 'col-hidden-pill:none', 'col-hidden-pill:none'], 'during: ' + JSON.stringify(during)); + assert.deepStrictEqual(state(t), before, 'restored: ' + JSON.stringify(state(t))); + }); + + test('#258: unhidden restores the hiding when fn throws', () => { + const t = makeTable(); + const before = state(t); + assert.throws(() => TR.unhidden(t, () => { throw new Error('boom'); }), /boom/); + assert.deepStrictEqual(state(t), before); + }); +} + console.log('\n=== packets.js: buildGroupRowHtml ==='); { const ctx = loadPacketsSandbox(); From da436cd6ccf38f4c9d1e62afc39d71c292240478 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 16:42:43 +0200 Subject: [PATCH 7/7] fix(ui): re-measure columns as the first measure saw them (#258) - TableResponsive.unhidden(table, fn) (packets.js) runs fn with its own column hiding lifted (no col-hidden, no pill) and restores it. measureColumnWidths() measures inside it, so the re-measure, which runs before TableResponsive has marked the new rows' cells, sees the same columns as the first measure (before register()). Other CSS that hides a column still applies. - The resize handles are hidden while measuring; they did not exist at the first measure and their overhang inflated th.scrollWidth. Co-Authored-By: Claude Opus 5.5 --- public/app.js | 42 +++++++++++++++++++++++++++--------------- public/packets.js | 20 +++++++++++++++++++- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/public/app.js b/public/app.js index df0ee4a16..3bb1bdadb 100644 --- a/public/app.js +++ b/public/app.js @@ -2285,23 +2285,35 @@ function readSavedColumnWidths(storageKey, colCount) { } // Max content width per column (header + rows), measured in auto layout without -// wrapping. +// wrapping. TableResponsive's column hiding is lifted while measuring: the first +// measure runs before TableResponsive.register(), and a re-measure (#258) runs +// before it has marked the newly rendered cells, so without that the header and +// the rows would disagree on which columns exist. function measureColumnWidths(table, ths, rows) { - table.style.tableLayout = 'auto'; - table.style.width = 'auto'; - // Remove wrapping temporarily so we get true content width - const cells = table.querySelectorAll('td, th'); - cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; }); - const widths = ths.map((th, i) => { - let maxW = th.scrollWidth; - rows.forEach(row => { - const td = row.children[i]; - if (td) maxW = Math.max(maxW, td.scrollWidth); + const tr = window.TableResponsive; + const measure = () => { + table.style.tableLayout = 'auto'; + table.style.width = 'auto'; + // The resize handles stick out of their th (right: -4px), which would add + // to its scrollWidth; the first measure runs before they exist. + const handles = table.querySelectorAll('.col-resize-handle'); + handles.forEach(h => { h.style.display = 'none'; }); + // Remove wrapping temporarily so we get true content width + const cells = table.querySelectorAll('td, th'); + cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; }); + const widths = ths.map((th, i) => { + let maxW = th.scrollWidth; + rows.forEach(row => { + const td = row.children[i]; + if (td) maxW = Math.max(maxW, td.scrollWidth); + }); + return maxW + 4; // small padding buffer }); - return maxW + 4; // small padding buffer - }); - cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; }); - return widths; + cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; }); + handles.forEach(h => { h.style.display = ''; }); + return widths; + }; + return tr && typeof tr.unhidden === 'function' ? tr.unhidden(table, measure) : measure(); } // Fit measured widths to the container: if the total is too wide, squish the diff --git a/public/packets.js b/public/packets.js index 826c3cd65..af6933528 100644 --- a/public/packets.js +++ b/public/packets.js @@ -32,6 +32,24 @@ if (pill) pill.remove(); } + // #258: run fn with this module's column hiding lifted -- no col-hidden on + // any cell and no pill -- and put it back afterwards. makeColumnsResizable() + // (app.js) measures inside it, so a re-measure sees the columns as the first + // measure did, before register(); other CSS that hides a column still applies. + function unhidden(table, fn) { + const hidden = Array.from(table.querySelectorAll('.' + HIDDEN_CLASS)); + const pills = Array.from(table.querySelectorAll('.' + PILL_CLASS)); + const pillDisplay = pills.map(p => p.style.display); + hidden.forEach(el => el.classList.remove(HIDDEN_CLASS)); + pills.forEach(p => { p.style.display = 'none'; }); + try { + return fn(); + } finally { + hidden.forEach(el => el.classList.add(HIDDEN_CLASS)); + pills.forEach((p, i) => { p.style.display = pillDisplay[i]; }); + } + } + function colIndexCells(table, idx) { // Return the at column index `idx` for every body row. const out = []; @@ -221,7 +239,7 @@ }, 120); }); - window.TableResponsive = { apply, register, sweep: sweepDetached }; + window.TableResponsive = { apply, register, sweep: sweepDetached, unhidden }; })(); /* === #1056 AC#4: SlideOver — narrow-viewport row-detail overlay ============