From 0e8f65696b573966ae5e866394a552a425e301d5 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 09:49:57 +0000 Subject: [PATCH] test(packets): make the Details advert-link E2E independent of fixture age (#244) The #1122 step "advert links in Details stay visible and clickable" failed in CI whenever it ran ~13+ min after freshen-fixture.sh. Cause: makeColumnsResizable() (app.js) sizes the packets columns once, from the rows of the first render. The test opened /packets with the default 15-min window, which empties as the fixture ages; the measured layout then gave the expand column ~43% and Details ~5% (64px at 1200px). Advert names wrapped onto the clamped-away 2nd line, and the test probed the centre of the link's bounding box, which is on that hidden line (the hit was the next row's Details cell, or the clip itself at 900px). - Open /packets with the widest selectable window (24 h, 3 h at <=1024px), so the one-shot measurement sees the same rows for the whole e2e job. - Check every on-screen advert link: the visible part of its first line fragment must be >= 12px and hit-test to the link. Pin a fixture row with a 21-char name and require that it is cut by the clip. - Failure messages name the element that was hit and the fragments. Co-Authored-By: Claude Opus 5.5 --- test-issue-1122-details-row-clamp-e2e.js | 85 ++++++++++++++++++++---- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/test-issue-1122-details-row-clamp-e2e.js b/test-issue-1122-details-row-clamp-e2e.js index 95882e4c4..a0ac6fef4 100644 --- a/test-issue-1122-details-row-clamp-e2e.js +++ b/test-issue-1122-details-row-clamp-e2e.js @@ -42,8 +42,26 @@ const VIEWPORTS = [ { w: 375, h: 812, name: 'mobile-375' }, // repo mobile convention (#1668 M6) ]; -async function gotoPackets(page) { - await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); +// #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. +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. +const PINNED_ADVERT_ROW = 'e8b09a35ac87fa5c'; +// Enough of a name to read and click: about two characters. +const MIN_VISIBLE_LINK_PX = 12; + +async function gotoPackets(page, vp) { + await page.goto(BASE + '/#/packets?timeWindow=' + pinnedWindowMin(vp), { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#packetFilterInput', { state: 'attached', timeout: 8000 }); await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 }); // All time, so the fixture's long channel messages are rendered (same as #1122). @@ -109,7 +127,7 @@ function measureRows() { let rows = []; await step(`[${vp.name}] navigate to /packets`, async () => { - await gotoPackets(page); + await gotoPackets(page, vp); rows = await page.evaluate(measureRows); assert(rows.length > 0, 'no packet rows rendered'); }); @@ -136,20 +154,59 @@ function measureRows() { }); await step(`[${vp.name}] advert links in Details stay visible and clickable`, async () => { + // Every advert link in a Details summary that is on screen: the part of + // the link inside the one-line clip (its first line fragment) must be + // visible and be what a click there hits. A long name that does not fit + // wraps; the clamp hides its 2nd+ lines, so the link's bounding box (the + // union of its fragments) reaches into hidden lines and the next row -- + // probing the box centre (#244) tested a hidden spot, not the link. const res = await page.evaluate(() => { - const links = [...document.querySelectorAll('#pktBody td.col-details .col-details-clip a.hop-link')]; - for (const a of links) { - const r = a.getBoundingClientRect(); - if (r.width < 2 || r.height < 2 || r.top < 0 || r.bottom > innerHeight) continue; - const td = a.closest('td').getBoundingClientRect(); - const x = Math.min(r.left + 4, td.right - 2), y = r.top + r.height / 2; - const hit = document.elementFromPoint(x, y); - return { found: true, hitIsLink: !!(hit && (hit === a || a.contains(hit))), text: a.textContent }; + const desc = (el) => el ? { + tag: el.tagName, + cls: String(el.className && el.className.baseVal != null ? el.className.baseVal : el.className).slice(0, 60), + text: (el.textContent || '').trim().slice(0, 40), + row: el.closest('tr') ? el.closest('tr').getAttribute('data-hash') : undefined, + } : null; + const round = (r) => ({ left: Math.round(r.left), top: Math.round(r.top), right: Math.round(r.right), bottom: Math.round(r.bottom) }); + const checked = []; + for (const a of document.querySelectorAll('#pktBody td.col-details .col-details-clip a.hop-link')) { + const clip = a.closest('.col-details-clip').getBoundingClientRect(); + // Only rows fully on screen (elementFromPoint needs the viewport). + if (clip.height < 2 || clip.top < 0 || clip.bottom > innerHeight) continue; + const frags = [...a.getClientRects()].filter(r => r.width >= 1 && r.height >= 1); + const out = (r) => r.bottom > clip.bottom + 0.5 || r.right > clip.right + 0.5; + const item = { row: a.closest('tr').getAttribute('data-hash'), text: a.textContent, clip: round(clip), + frags: frags.map(round), truncated: frags.some(out) }; + // Visible part of the first fragment that intersects the clip. + for (const r of frags) { + const vis = { left: Math.max(r.left, clip.left), right: Math.min(r.right, clip.right), + top: Math.max(r.top, clip.top), bottom: Math.min(r.bottom, clip.bottom) }; + if (vis.right - vis.left < 1 || vis.bottom - vis.top < 1) continue; + item.visibleW = Math.round(vis.right - vis.left); + item.point = { x: vis.left + Math.min(4, (vis.right - vis.left) / 2), y: (vis.top + vis.bottom) / 2 }; + const hit = document.elementFromPoint(item.point.x, item.point.y); + item.hitIsLink = !!(hit && (hit === a || a.contains(hit))); + if (!item.hitIsLink) item.hit = desc(hit); + break; + } + checked.push(item); } - return { found: false, count: links.length }; + return checked; }); - assert(res.found, 'no visible advert link in Details to check (' + JSON.stringify(res) + ')'); - assert(res.hitIsLink, 'advert link in Details is not hit-testable: ' + JSON.stringify(res)); + 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. + 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)); + 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))); + const missed = res.filter(r => !r.hitIsLink); + assert(missed.length === 0, `${missed.length}/${res.length} advert links in Details are not hit-testable ` + + '(hit = element under the visible part of the link): ' + JSON.stringify(missed.slice(0, 3))); }); await step(`[${vp.name}] full message is shown when a long row is selected`, async () => {