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();