ce8cbfc3d2ef6853f94bf80b0f8b5f389c60025b braney Sat Sep 5 09:21:32 2026 -0700 docent: two more expect: checks, a positional click:, and three fixes found writing tests Everything here was needed by a regression script that could not otherwise be written, or by one that failed for a reason that was not a bug. expect: {url:, noUrl:} is a substring check on the current address. Some things are visible nowhere else: which CGI a click reached, and what a form put in a query string. #36387's fix strips zero-width characters out of a search term before the position box submits it, and the character is invisible in the rendered page, so whether %E2%80%8B survives into the URL is the only evidence either way. expect: {has:, noHas:} takes a CSS selector, for a bug whose whole signature is WHERE something sits. #37785 attached a squishyPack track's center label to the wrong row: same rows drawn, same image height, same pixels, and only the row the label's image map hangs off changed, so rows:, height: and text: are all blind to it. Documented as a last resort, since an assertion on hgTracks' own ids breaks easily for reasons that are not bugs. click: now takes the positional forms mouseover: already had (at:/frac:/x:) and follows the item box nearest that point. Some tracks have no item that can be named at all: every GIAB Problematic Regions subtrack is type bigBed 3, so hgTracks writes an EMPTY i= into the hgc href and gives every box the title "Start of Exon (1/1)". Neither item: nor title: can pick one, and a raw mouse click on the data area is swallowed by the drag-select handler. The "item not found" message now picks the row's map boxes by MAP NAME instead of by a y-band, reports how many boxes are in the row, and falls back to a box's title when it has no name. On a quickLift target the band test dropped every item box, so the message said the GIAB row held three things when it held twenty-six, and the three it named were a density control and two exon arrows. goShow now scrolls to the top and takes the Login/Share links out of the way before clicking Search. Those links sit in an absolutely-positioned container at the top right of the header bar and on a wide page land on top of #goButton; Playwright then retries for the full 30s and fails with " subtree intercepts pointer events", which reads like a broken Search button rather than a covered one. Pressing Enter in the position box is not a substitute: by the time a 30s click timeout has been caught, the navigation wait armed before it has already expired. tests/pagechecks and tests/pagechecks.xfail cover all of it, per the rule in tests/README.txt that a verb we touch and find untested belongs on its list. The xfail aims all four new checks the wrong way at once, because a check that cannot fail is not a check. refs #38252 diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index faa72a30d3b..3646de3269b 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -748,59 +748,130 @@ } const span = await page.evaluate(({ keys, want }) => { const md = window.mapData; if (!md || !md.spans) return null; for (const k of keys) { const arr = md.spans[k]; if (!arr) continue; const s = arr.find(r => String(r.value || '').includes(want)); if (s) return { x1: s.x1, x2: s.x2 }; } return null; }, { keys: [key, t], want: String(want) }); if (!span) { // Say WHAT is there instead. An item name that has gone missing is usually a track // whose items depend on the pixel width -- a print render (SCALE) draws a wider image, // so features hgTracks merged into one box at screen width come apart into several // with names of their own -- and the fix is to pick from the names that do exist. - const near = await page.evaluate(({ band }) => { + // Pick the row's areas by MAP NAME, not by geometry. hgTracks names each map after + // the track it belongs to (map_data_, map_center_), so this is exact -- + // whereas the y-band test this used to do dropped every item box on a quickLift + // target and left the message saying the row held three things when it held + // twenty-eight. The band is still the tie-break when a map cannot be attributed. + const near = await page.evaluate(({ key }) => { const out = []; - for (const a of document.querySelectorAll('map[name^="map_"] area')) { - const c = (a.getAttribute('coords') || '').split(',').map(Number); - if (c.length < 4) continue; - const m = a.closest('map'), nm = m && m.getAttribute('name'); - const im = nm && document.querySelector(`img[usemap="#${nm}"]`); - if (!im) continue; - const r = im.getBoundingClientRect(); - const s = im.naturalWidth ? r.width / im.naturalWidth : 1; - const cy = r.top + s * (c[1] + c[3]) / 2; - if (cy < band.top - 1 || cy > band.bot + 1) continue; + const mine = m => { + const nm = m.getAttribute('name') || ''; + return nm === `map_${key}` || nm.endsWith(`_${key}`); + }; + const all = [...document.querySelectorAll('map[name^="map_"]')]; + // Fall back to every map when none can be attributed to this key, so the message + // is never empty just because hgTracks named the map something unexpected. + const named = all.filter(mine); + const maps = named.length ? named : all; + for (const m of maps) for (const a of m.querySelectorAll('area')) { const i = (a.getAttribute('href') || '').match(/[?&]i=([^&]+)/); + // A bigBed with no name field (type bigBed 3, e.g. every GIAB Problematic + // Regions subtrack) gets map boxes with no &i= at all, so an `i=`-only list comes + // back empty and the error then says nothing about what the row does hold. Fall + // back to what the box can be addressed BY instead: its title, which is what + // `title:` matches. if (i) out.push(decodeURIComponent(i[1])); + else { + const tmp = document.createElement('div'); + tmp.innerHTML = a.getAttribute('title') || a.getAttribute('data-tooltip') || ''; + const tip = (tmp.textContent || '').replace(/\s+/g, ' ').trim(); + if (tip) out.push(`title: ${JSON.stringify(tip)}`); } - return out; - }, { band }).catch(() => []); + } + const boxes = maps.reduce((k, m) => k + m.querySelectorAll('area').length, 0); + return { names: [...new Set(out)], boxes }; + }, { key }).catch(() => ({ names: [], boxes: 0 })); const win = await page.evaluate(() => { try { return `${hgTracks.chromName}:${hgTracks.winStart}-${hgTracks.winEnd}`; } catch (_) { return '?'; } }).catch(() => '?'); - const show = process.env.DOCENT_ROWS ? near : near.slice(0, 12); + const names = near.names; + const show = process.env.DOCENT_ROWS ? names : names.slice(0, 12); + // The BOX count matters as much as the names. A type-bigBed-3 row has plenty of + // items and no way to name any of them -- 26 boxes collapsing to two distinct + // titles -- and the next thing to try there is a positional `click: {frac: ...}`, + // not a different name. Saying only "in that row: two titles" hides that. throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + ` - + `mouseOver spans). Window ${win}.${near.length ? ` In that row: ${show.join(', ')}` - + `${show.length < near.length ? `, ... (${near.length} total; DOCENT_ROWS=1 for all)` : ''}` - : ''}`); + + `mouseOver spans). Window ${win}. ${near.boxes} map box(es) in that row` + + `${names.length ? `, addressable as: ${show.join(', ')}` + + `${show.length < names.length ? `, ... (${names.length} distinct; DOCENT_ROWS=1 for all)` : ''}` + : ' and none of them carries a name or a title'}`); } return { x: img.x + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null }; } + // A POSITIONAL point plus the hgc link of the map box nearest it. `click:` needs this + // because some tracks have no item that can be named at all: every subtrack of GIAB + // Problematic Regions is `type bigBed 3`, so its hgc hrefs carry an EMPTY `i=` and every + // box's title is "Start of Exon (1/1)" -- neither `item:` nor `title:` can pick one, and + // a raw mouse click on the data area is swallowed by hgTracks' drag-select handler. So + // place the point the way posXY does and follow the box under (or nearest) it. + // + // Nearest rather than strictly containing, because y is the row's middle and a packed + // row stacks its items above and below that line. + async function areaXY(t, o) { + const { key } = await trackBox(t); + const { x, y } = await posXY(t, o); + const hit = await page.evaluate(({ x, y, key }) => { + const maps = [...document.querySelectorAll('map[name^="map_"]')].filter(m => { + const nm = m.getAttribute('name') || ''; + return nm === `map_data_${key}` || nm === `map_${key}`; + }); + let best = null; + for (const m of maps) { + const nm = m.getAttribute('name'); + const im = document.querySelector(`img[usemap="#${nm}"]`); + if (!im) continue; + const r = im.getBoundingClientRect(); + const sc = im.naturalWidth ? r.width / im.naturalWidth : 1; + for (const a of m.querySelectorAll('area')) { + const href = a.getAttribute('href') || ''; + if (!/hgc\?|hgc$/.test(href)) continue; // item links only + const c = (a.getAttribute('coords') || '').split(',').map(Number); + if (c.length < 4) continue; + const x1 = r.left + sc * Math.min(c[0], c[2]), x2 = r.left + sc * Math.max(c[0], c[2]); + const y1 = r.top + sc * Math.min(c[1], c[3]), y2 = r.top + sc * Math.max(c[1], c[3]); + const dx = Math.max(x1 - x, 0, x - x2), dy = Math.max(y1 - y, 0, y - y2); + const d = Math.hypot(dx, dy); + if (!best || d < best.d) + best = { href, cx: (x1 + x2) / 2, cy: (y1 + y2) / 2, d }; + } + } + const count = maps.reduce((k, m) => k + m.querySelectorAll('area').length, 0); + return best && { ...best, n: count }; + }, { x, y, key }); + if (!hit) throw new Error(`click: no item link near that point in track "${t}" ` + + `(no map box under map_data_${key} carries an hgc href)`); + if (process.env.DOCENT_ROWS) + console.log(` point in ${t}: (${Math.round(x)},${Math.round(y)}) -> box at ` + + `(${Math.round(hit.cx)},${Math.round(hit.cy)}), ${Math.round(hit.d)}px away, ` + + `${hit.n} box(es) in the row`); + return { x: hit.cx, y: hit.cy, href: hit.href }; + } // POSITIONAL point: at:/frac:/x: -> x, y forced to the track row's middle. The grey // side-label strip (insideX) is baked into the image's left, so a fraction/coord maps // across [img.x+insideX, img.x+img.width], not the whole image width. async function posXY(t, o) { const { img, row, imgPx } = await trackBox(t); const insideX = imgPx * await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } }); let x; if (o.x != null) x = img.x + insideX + imgPx * Number(o.x); else { const frac = (o.frac != null) ? Number(o.frac) : (o.at != null) ? await page.evaluate(at => { try { const s = hgTracks.winStart, e = hgTracks.winEnd; const c = +String(at).replace(/.*:/, '').replace(/,/g, ''); return Math.max(0, Math.min(1, (c - s) / (e - s))); } catch (_) { return 0.5; } }, o.at) @@ -1175,35 +1246,53 @@ } // The only verb that can fail a run. Every other verb renders happily whatever it is // handed: a superTrack that came up whole and made an image 7,581 px tall, a subtrack // that never hid, a pinned tooltip that grabbed the neighbouring item, an Apache 414 // page where the view should be. All of those shipped once and all were caught by eye. // Stating the expectation instead stops the run, non-zero, at the step that broke it -- // `make` then fails rather than writing a wrong figure over a right one. // // expect: {rows: [ruler, mane]} these rows were drawn // expect: {rows: [ruler, mane], exact: true} ... and nothing else // expect: {rows: [ruler, mane], ordered: true} ... in that order, top to bottom // expect: {noRows: [clinvarCnv]} this row was not // expect: {height: 2000} the still is no taller than this ("<1200" etc.) // expect: {tip: "mismatch A->C"} the tooltip now up says this // expect: {text: "...", noText: "..."} the page does / does not contain this + // expect: {url: "hgSearch", noUrl: "%E2%80%8B"} the address bar does / does not + // expect: {has: "#td_data_mane map[name=map_center_mane]"} this selector matches + // expect: {noHas: "#td_data_knownGene map[name=map_center_mane]"} ... does not + // + // `url:`/`noUrl:` are a substring check on the CURRENT address, which is the only place + // some things are visible at all: which CGI a click actually reached, and what the page + // put in a query string. #36387's fix strips zero-width characters out of a search term + // before the position box submits it, and the term is invisible in the rendered page -- + // the only evidence either way is whether `%E2%80%8B` survives into the URL. + // + // `has:`/`noHas:` are for a bug whose whole signature is WHERE something sits in the + // page. #37785 attached a squishyPack track's center label to the wrong row: same rows + // drawn, same total height, same pixels -- only the row the label hangs off changed, so + // rows:, height: and text: are all blind to it. Both take a CSS selector, or a list of + // them, and each may name several elements. Reach for these last: an assertion on + // hgTracks' own ids and classes is the most likely thing here to break for a reason + // that is not a bug. // // `warn: true` downgrades a failure to a warning, for a check worth logging but not worth // stopping a build over. async function expectState(arg) { const o = (arg && typeof arg === 'object') ? arg : { text: arg }; + const url = page.url(); const seen = await page.evaluate(() => { const im = document.getElementById('imgTbl'); const tip = document.getElementById('mouseoverContainer'); const up = tip && tip.offsetWidth > 0 && getComputedStyle(tip).display !== 'none' && getComputedStyle(tip).visibility !== 'hidden'; return { rows: [...document.querySelectorAll('[id^="img_data_"]')].map(e => e.id.replace('img_data_', '')), cssHeight: im ? im.getBoundingClientRect().height : 0, tip: up ? tip.innerText.trim() : '', text: document.body ? document.body.innerText : '', }; }); // The still is a screenshot of #imgTbl, so its height in PIXELS is the CSS height times // the device pixel ratio -- which is what someone means by "7,581 px tall", and what a // print run makes k times bigger. @@ -1244,30 +1333,44 @@ if (!m) bad.push(`height: cannot read "${o.height}"`); else { const n = Number(m[2]), op = m[1] || '<='; const ok = op === '<' ? height < n : op === '>' ? height > n : op === '>=' ? height >= n : op === '=' ? height === n : height <= n; if (!ok) bad.push(`image is ${height}px, wanted ${op}${n}`); } } if (o.tip != null && !seen.tip.includes(String(o.tip))) bad.push(seen.tip ? `tooltip says "${seen.tip}", wanted "${o.tip}"` : `no tooltip is up, wanted "${o.tip}"`); if (o.text != null && !seen.text.includes(String(o.text))) bad.push(`page does not contain "${o.text}"`); if (o.noText != null && seen.text.includes(String(o.noText))) bad.push(`page contains "${o.noText}"`); + if (o.url != null && !url.includes(String(o.url))) + bad.push(`url is "${url}", wanted it to contain "${o.url}"`); + if (o.noUrl != null && url.includes(String(o.noUrl))) + bad.push(`url contains "${o.noUrl}": ${url}`); + for (const sel of list(o.has)) { + const n = await page.locator(sel).count().catch(() => -1); + if (n === 0) bad.push(`nothing matches "${sel}"`); + else if (n < 0) bad.push(`has: cannot read the selector "${sel}"`); + } + for (const sel of list(o.noHas)) { + const n = await page.locator(sel).count().catch(() => -1); + if (n > 0) bad.push(`${n} element(s) match "${sel}", wanted none`); + else if (n < 0) bad.push(`noHas: cannot read the selector "${sel}"`); + } if (!bad.length) { console.log(`EXPECT ok -- ${seen.rows.length} row(s), ${height}px`); return; } const msg = bad.join('; ') + `\n drawn: ${seen.rows.join(', ') || '(none)'}`; if (o.warn) console.warn('EXPECT (warning only):', msg); else throw new Error(msg); } // Shift+drag across the track image to open the browser's own drag-select dialog // ("Zoom In / Single Highlight / ..."), then act on it. The usual form gives one // genomic region and zooms: drag: chr7:155,806,100-155,806,557 // Any other action needs the map form, which is also how you pass shot:/track: // drag: {range: "chr7:155,806,100-155,806,557", then: highlight} // Endpoints that are not genomic coords use a fraction // across the view (fromFrac:/toFrac:) or a raw pixel (fromX:/toX:) instead. @@ -1621,30 +1724,40 @@ // the term is ambiguous (substring of the menu row); default is an exact symbol // match, else the first row. // // goShow: BRCA1 // goShow: {gene: SHH, shot: source} // goShow: {position: "chr7:155,799,529-155,812,871", shot: source} // goShow: {gene: BRCA1, pick: "NM_007294", shot: source} const o = (typeof arg === 'string') ? { position: arg } : (arg || {}); const pos = [o.position, o.pos, o.gene, o.search].find(v => v != null && v !== ''); if (pos == null) { console.warn('goShow: no position or gene given'); break; } const term = String(pos).trim(); // A coordinate has no suggestions to wait on; anything else is a search term. const isPos = /^[\w.|-]+:[\d,]+(-[\d,]+)?$/.test(term); if (!await page.locator('#positionInput:visible').count()) throw new Error('goShow: no position box on this page (need hgTracks or hgGateway) -- ' + page.url()); + // Scroll to the top first. The position box and the Search button live in the page + // header, and the site's top bar is FIXED -- so on a tall page that is already + // scrolled down (a lifted view 2,000px high, say) Playwright's own minimal + // scroll-into-view leaves the button underneath that bar and the click is + // intercepted: " from
subtree + // intercepts pointer events", retried for 30s and then a timeout. Typing into the + // box still worked, so the failure came at the Search click and looked like a + // broken button rather than a covered one. + await page.evaluate(() => window.scrollTo(0, 0)); + await sleep(120); await glideTo('#positionInput'); await page.click('#positionInput'); await dwell(160); await page.fill('#positionInput', ''); // clear the old position await dwell(200); await typeIn(page, '#positionInput', term); // visible typing await dwell(450); pinnedTips.length = 0; // new view, old tips don't apply let done = null; if (!isPos) { const hit = await pickSuggest(term, o.pick != null ? o.pick : o.match); if (hit) { console.log(`goShow ${term}: suggestion "${hit.text}"` + (hit.timedOut ? ` (no strong match, waited ${hit.ms}ms)` : '')); // Address the row by index at click time (a late re-render replaces the
  • // elements, so a handle or a marker attribute taken earlier goes stale). const li = page.locator(SUGGEST_ROW).nth(hit.index); @@ -1655,30 +1768,47 @@ if (b) await glide(b.x + b.width / 2, b.y + b.height / 2); await sleep(220); await li.click(); done = await committed; if (done) await page.waitForLoadState('load').catch(() => {}); } else { console.warn(`goShow ${term}: no suggestion matched, submitting the term as typed`); } } // Coordinate, no suggestions, or hgGateway (where picking a suggestion only fills // the box): click the page's own go button. Arm the nav wait BEFORE the click -- on // hgTracks the OLD page already has an #imgTbl, so waiting on the selector alone // returns instantly and a following shot races the reload ("Cannot find context // with specified id"). if (!done) { + // Take the Login/Share links out of the way of the Search button. They sit in an + // absolutely-positioned container at the top right of the header bar (see the + // comment lib/web.c writes into the page), and on a wide page -- a session with a + // large pix, a quickLift target 2,000px tall -- that container lands ON TOP of + // #goButton. Playwright then retries the click for the full 30s and fails with + // " subtree intercepts pointer events", which reads like a + // broken Search button rather than a covered one. Turning off pointer events on + // those links is enough, costs nothing, and is safe because goShow never wants + // them: they are replaced on the next page load anyway. Pressing Enter in the box + // is NOT a working substitute -- by the time a 30s click timeout has been caught, + // the navigation wait armed before it has already expired. + await page.evaluate(() => { + for (const el of document.querySelectorAll('#loginLink, .topRightLink')) { + el.style.pointerEvents = 'none'; + if (el.parentElement) el.parentElement.style.pointerEvents = 'none'; + } + }).catch(() => {}); const navDone = page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {}); await clickGlide((await page.locator('#goButton').count()) ? '#goButton' : '.jwGoButtonContainer'); await navDone; } // A unique hit lands on the track image; a term with no suggestion and several // matches lands on the search-results page instead, which has no #imgTbl -- that's // legal, the script can `click` a result from there. await page.waitForSelector('#imgTbl', { timeout: 15000 }).catch(() => {}); await captureState(); await page.mouse.move(cur.x, cur.y); // re-show the cursor overlay if (o.shot) { await shot(o.shot); return; } break; } case 'hide': if (arg === 'all' || arg === true) { await clickGlide('#hgt\\.hideAll'); await page.waitForSelector('#imgTbl'); } break; case 'track': { @@ -1828,33 +1958,39 @@ .catch(() => console.warn(`zoom ${arg}: window still ${was} after 15s`)); await captureState(); break; } case 'shot': await shot(arg); return; // shot supplies its own dwell case 'pinShot': await pinShot(arg); break; // combined figure, off the mp4 timeline case 'montage': await montage(arg); break; // stills -> one multi-panel PNG case 'session': await session(arg); break; // the cart itself, as a loadable file case 'loadSession': await loadSession(arg); break; // ... and back in again case 'expect': await expectState(arg); break; // the one verb that can fail a run case 'mouseover': await mouseover(arg); return; // supplies its own dwell (o.hold) // escape hatches case 'goto': await nav(arg); break; case 'click': if (arg && typeof arg === 'object' && arg.track) { - // Click a NAMED track item -> follow its map-box link (e.g. the hgc detail page). - const it = await itemXY(arg.track, arg.item ?? arg.title ?? arg.value, - arg.title != null && arg.item == null && arg.value == null); + // Click a track item -> follow its map-box link (e.g. the hgc detail page). + // Named (item:/title:/value:) picks the item by identity; positional + // (at:/frac:/x:) takes the box nearest that point, for a track whose items + // cannot be named at all. + const named = arg.item ?? arg.title ?? arg.value; + const it = (named != null) + ? await itemXY(arg.track, named, + arg.title != null && arg.item == null && arg.value == null) + : await areaXY(arg.track, arg); await glide(it.x, it.y); await sleep(200); // A raw click on the data area is swallowed by hgTracks' drag-select handler, so // follow the item's own map-box link (the hgc detail page) directly. if (it.href) await nav(it.href); else { await page.mouse.click(it.x, it.y); await page.waitForLoadState('load').catch(() => {}); await captureState(); } if (arg.shot) { await shot(arg.shot); return; } } else { // Plain selector click. Strip target=_blank first so an external link (e.g. a // dbSNP id -> NIH) navigates in THIS tab instead of a popup we can't screenshot, // then wait out the navigation so a following shot captures the destination page // (a no-op if the click didn't navigate). await page.evaluate(s => document.querySelectorAll(s).forEach(e => e.removeAttribute('target')), arg).catch(() => {}); await clickGlide(arg); await page.waitForLoadState('load').catch(() => {}); await captureState();