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/public/app.js b/public/app.js
index 425e82e35..3bb1bdadb 100644
--- a/public/app.js
+++ b/public/app.js
@@ -2245,60 +2245,63 @@ 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';
-
- const containerW = table.parentElement.clientWidth;
- const saved = localStorage.getItem(storageKey);
- let widths;
-
- 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;
- }
+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; }
}
- widths = null; // Force remeasure
+ if (spans) continue;
+ out.push(row);
+ if (out.length >= limit) break;
}
+ return out;
+}
- 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) : [];
+/** 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;
+}
- // Temporarily set auto layout to measure
+// Max content width per column (header + rows), measured in auto layout without
+// 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) {
+ const tr = window.TableResponsive;
+ const measure = () => {
table.style.tableLayout = 'auto';
table.style.width = 'auto';
- // Remove nowrap temporarily so we get true content width
+ // 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'; });
-
- // Measure each column's max content width across header + rows
- widths = ths.map((th, i) => {
+ const widths = ths.map((th, i) => {
let maxW = th.scrollWidth;
rows.forEach(row => {
const td = row.children[i];
@@ -2306,11 +2309,16 @@ function makeColumnsResizable(tableSelector, storageKey) {
});
return maxW + 4; // small padding buffer
});
-
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();
+}
- // 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 +2356,72 @@ 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 authoredWidths = ths.map(th => th.style.width);
+ 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();
+ // 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 });
+ }
+
function addResizeHandles() {
// Add resize handles
ths.forEach((th, i) => {
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 ============
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-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..1a34ef741
--- /dev/null
+++ b/test-issue-258-column-widths-e2e.js
@@ -0,0 +1,272 @@
+/**
+ * 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%,
+ * 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).
+ * 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, 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();
+ 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' + (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(() => {
+ 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} 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);
+ 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); });
diff --git a/test-issue-258-column-widths.js b/test-issue-258-column-widths.js
new file mode 100644
index 000000000..df0f75758
--- /dev/null
+++ b/test-issue-258-column-widths.js
@@ -0,0 +1,343 @@
+/**
+ * #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 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
+ */
+'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: {} }; }
+// 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"); 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;
+ // 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; },
+ };
+ 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))
+ : 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)) };
+}
+
+// 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 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)));
+ 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', () => {
+ // 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) + ')');
+});
+
+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()));
+});
+
+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', () => {
+ 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);
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();
|