4265f36510c9498df6d7b0a8ff9e5251350c133c braney Sun Sep 20 14:34:09 2026 -0700 docent: read a graph's tooltip, and noTip: for a bare number, refs #37892 hgTracks has two tooltips and their ids differ only in the case of one letter. hg/js/utils.js hangs an ITEM's tooltip off `#mouseoverContainer`: the text of a map box's title, an rsID or a gene name. A WIGGLE reports itself the other way. The `mouseOver` module in hg/js/hgTracks.js writes the value under the cursor into `#mouseOverText`, read from the per-pixel spans hgTracks leaves in a trash .json. A track drawn as a coverage graph has no map boxes at all, so its numbers exist only in the second one, and `tip:` on such a track read an empty string and then waited out its timeout. Every reader of the live tooltip now takes whichever is up: `tip:`, the `mouseover:` waits, `pinShot:` and the shot clip. Two more from the same mistake. `mouseover: {value: ...}` is documented for wiggles and had never matched anything on any track: it looked in `window.mapData.spans[...]` for a member called `value`, which is hg/js/mouseOver.js, an older copy of the module the page does not load, and the member there is `v` as well. It now reads `mouseOver.items[...]` and places the cursor from `td_data_`, which is what hgTracks.js itself compares the cursor against. And asking by value now skips the map boxes entirely, because a track's own center label is titled "Click to alter the display density of ": a probe for the value 3 hovered the center label of a track called rm38253stairs before any span was looked at. `noTip:` is new. `tip:` is a substring test, which is right for an item, whose tooltip is markup and whose tail can render differently from the title it came from. It is wrong for a graph, whose tooltip is the number and nothing else: `tip: "1"` is also satisfied by "1.5" and by "13", and a mean computed with the wrong divisor is exactly the fraction that would slip through. Naming the other digits and the decimal point leaves one number. This is the prefix trap #38279 hit with two messages sharing a head, in a place with no closing paren to carry the delimiter. diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index 819389c47e5..bfd84537ada 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -281,30 +281,57 @@ // is written as an inline style, so the rule has to be !important to win. `.__pinnedTip` is // a recorded tooltip re-injected by pinShot() (which strips the id, keeps the class). const SCALE_INIT = ({ k, tipPx }) => { const add = () => { if (document.getElementById('__scale')) return; const s = document.createElement('style'); s.id = '__scale'; s.textContent = `#imgTbl, #chromIdeoImg, img[src*="hgtIdeo"] { zoom: ${1 / k} !important; }` + `\n#mouseoverContainer, #mouseOverText, .tooltip, .__pinnedTip { font-size: ${tipPx}px !important; }`; (document.head || document.documentElement).appendChild(s); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add(); }; +// hgTracks has TWO tooltips and their ids differ only in the case of one letter, so a check +// that reads one is blind to the other. hg/js/utils.js hangs an ITEM's tooltip off +// `#mouseoverContainer`: the text of a map box's title, an rsID or a gene name. A WIGGLE +// reports itself the other way -- the `mouseOver` module in hg/js/hgTracks.js writes the +// value under the cursor into `#mouseOverText`, read out of the per-pixel spans hgTracks +// leaves in a trash .json (`div.mouseOverData[jsonUrl=...]`). A track drawn as a coverage +// graph has no map boxes at all, so its numbers exist ONLY in the second one, and a `tip:` +// that looked at the first read an empty string and then waited out its timeout. +// +// hg/js/mouseOver.js is an older copy of that module and is NOT what the page runs: it +// wants a `#mouseOverContainer` and a `.wigMouseOver` that hgTracks no longer writes. Do +// not take it for the live code, as this did. +// +// Item tooltip first, so a page showing both answers the way it always did. +const TIP_INIT = () => { + window.__docentTip = () => { + for (const id of ['mouseoverContainer', 'mouseOverText']) { + const e = document.getElementById(id); + if (!e || !e.offsetWidth) continue; + const st = getComputedStyle(e); + if (st.display === 'none' || st.visibility === 'hidden') continue; + return e; + } + return null; + }; +}; + // ---------- animated cursor (same technique as the walkthrough-video skill's record.js) ---------- // The glyph and its box are shared with pinShot(), which draws a STATIC copy at every // pinned mouseover so a combined figure shows where each tooltip was raised from. Keep // them one definition: a pinned cursor that did not match the animated one would read as // a different pointer rather than as the same tour paused. const CURSOR_BOX = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;' + 'width:24px;height:24px;margin-left:-3px;margin-top:-2px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));'; const CURSOR_SVG = ''; const CURSOR_INIT = ({ box, svg }) => { const add = () => { if (document.getElementById('__cur')) return; const c = document.createElement('div'); c.id = '__cur'; c.style.cssText = box; c.innerHTML = svg; @@ -357,30 +384,31 @@ // makes them cheap to look at, and cheap to diff when the derivation is changed. const DERIVE = !!process.env.DOCENT_DERIVE; const T_START = Date.now(); (async () => { // Before the browser: nothing in the derivation touches the page, and the point is to // not pay for one. (The helpers below are function declarations, so they are hoisted.) if (DERIVE) { await deriveMain(); return; } fs.mkdirSync(STILLDIR, { recursive: true }); const browser = await chromium.launch({ headless: true, args: ['--force-color-profile=srgb'] }); const ctx = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE, ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }), }); if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS); + await ctx.addInitScript(TIP_INIT); await ctx.addInitScript(CURSOR_INIT, { box: CURSOR_BOX, svg: CURSOR_SVG }); await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} }); const page = await ctx.newPage(); const T_REC = Date.now(); // the recorder starts with the page; see FLASH below const shotSecs = []; // when each shot: was taken, seconds into the recording const cur = { x: 120, y: 120 }; const pinnedTips = []; // recorded mouseover tooltips for the next pinShot (per view) async function captureState() { try { const u = new URL(page.url()); const h = u.searchParams.get('hgsid'); if (h) state.hgsid = h; const db = u.searchParams.get('db'); if (db) state.db = db; const p = u.searchParams.get('position'); if (p) state.position = p; } catch (e) {} @@ -649,33 +677,32 @@ return null; } async function shot(name) { const p = path.join(STILLDIR, name + '.png'); // Drop any click ripple still fading: it reads as a red blob over whatever was just // clicked. It belongs to the video, not to a figure. await page.evaluate(() => document.querySelectorAll('.__ripple').forEach(e => e.remove())).catch(() => {}); // If a mouseover tooltip is currently up, capture the image + tooltip together // (the tooltip is appended to , so an #imgTbl element shot would clip it). const clip = await page.evaluate(() => { const im = document.getElementById('imgTbl'); if (!im) return null; // Floating overlays to capture together with the image: the mouseover tooltip, // and any visible jQuery-UI dialog (e.g. the drag-select "Zoom In / Highlight" box). const overlays = []; - const tip = document.getElementById('mouseoverContainer'); - if (tip && tip.offsetWidth > 0 && - getComputedStyle(tip).display !== 'none' && getComputedStyle(tip).visibility !== 'hidden') overlays.push(tip); + const tip = window.__docentTip && window.__docentTip(); + if (tip) overlays.push(tip); for (const d of document.querySelectorAll('.ui-dialog')) if (d.offsetWidth > 0) overlays.push(d); if (!overlays.length) return null; const a = im.getBoundingClientRect(); let x = a.left, y = a.top, x2 = a.right, y2 = a.bottom; for (const o of overlays) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); } return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 }; }); if (clip) { await page.screenshot({ path: p, clip }); } else if (await page.locator('#imgTbl').count()) { await page.locator('#imgTbl').screenshot({ path: p }); } else { // Not a tracks page (hgc detail page, an external page a link led to, ...). An element // shot of
would run the whole scrolling page -- thousands of pixels tall and // unusable as a figure. Capture the viewport only, i.e. the top of the page. @@ -725,34 +752,41 @@ // message below decide it. hgTracks names each map after the track it draws // (map_data_, map_center_), so the test is exact. A y-band test is not: a // packed row stacks items above and below its middle, and on a quickLift target the band // drops boxes that really are in the row. The band is kept only to choose between // several boxes OF THIS TRACK that carry the same name. // // A box belonging to another track is never picked, however well it matches. That // fallback used to be here -- `inBand[0] || cands[0]` -- and it answered with a // neighbouring track's item, silently, whenever this track had none of its own. A hub // track that declares more bigBed fields than its file has draws no items at all // (#38310), and a probe for one reported the item of a native track that happens to use // the same item names, so three tracks that draw nothing were recorded as drawing. // rm35920 read hg38's native `ultras` for years the same way. An answer that is wrong // but reads as a pass is worse than a failure, so this now returns nothing and lets the // error below say where the name really was. - async function itemXY(t, want, titleOnly) { + // + // `spanOnly` says the caller asked by VALUE, which only a wiggle has, so the map-box scan + // is skipped entirely. It has to be: a track's own center label carries the title "Click + // to alter the display density of ", and a track whose name contains the wanted + // digits matches that box before any span is looked at. A probe for the value 3 on a + // track called rm38253stairs hovered the center label and was told so only because the + // tooltip that came up disagreed with the one the box promised. + async function itemXY(t, want, titleOnly, spanOnly) { const { key, img, row, imgPx } = await trackBox(t); const band = { top: row.y, bot: row.y + row.height }; - const area = await page.evaluate(({ want, titleOnly, band, imgBox, key }) => { + const area = spanOnly ? null : await page.evaluate(({ want, titleOnly, band, imgBox, key }) => { // Does this map hold the pixels of the track we were asked about? const isMine = m => { const nm = (m && m.getAttribute('name')) || ''; return nm === `map_${key}` || nm.endsWith(`_${key}`); }; // When NOTHING on the page can be attributed to this key, hgTracks named the map // something we do not recognise. Only then does geometry get to decide, which is // what this did for every track before. const anyMine = [...document.querySelectorAll('map[name^="map_"]')].some(isMine); const areas = [...document.querySelectorAll('map[name^="map_"] area')]; const cands = []; for (const a of areas) { const href = a.getAttribute('href') || ''; const title = a.getAttribute('title') || a.getAttribute('data-tooltip') || a.getAttribute('mouseoverText') || ''; @@ -786,36 +820,46 @@ const elsewhere = [...new Set(cands.filter(h => !h.mine) .map(h => h.map.replace(/^map_(data_|center_)?/, '')) .filter(Boolean))]; return { pick, n: cands.length, nPool: pool.length, anyMine, elsewhere, all: pool.map(c => Math.round(c.cx)) }; }, { want: String(want), titleOnly: !!titleOnly, band, imgBox: img, key }); if (area && area.pick) { const p = area.pick; if (process.env.DOCENT_ROWS) console.log(` item "${want}" in ${t}: ${area.n} map box(es) match, ${area.nPool} of them ` + `${area.anyMine ? "this track's" : 'in the row (no map names this track)'}, ` + `centers x=[${area.all}] -> hovering (${Math.round(p.cx)},${Math.round(p.cy)})` + (p.tip ? `, expecting tip "${p.tip.slice(0, 40)}"` : '')); return { x: p.cx, y: p.cy, href: p.href, tip: p.tip }; } + // A WIGGLE has no per-item map box at all: its values live in the spans the `mouseOver` + // module in hg/js/hgTracks.js fetches from a trash .json, one run of pixels per value. + // They are `mouseOver.items[]`, each `{x1, x2, v}`, and x1/x2 are offsets from + // the left edge of `td_data_` in the pixels the SERVER drew -- which is what + // hgTracks.js itself compares the cursor against. This read used to go to + // `window.mapData.spans[...]` for a member called `value`; that is hg/js/mouseOver.js, + // an older copy of the module the page does not load, and the member there is `v` as + // well, so `value:` had never matched anything on any track. const span = await page.evaluate(({ keys, want }) => { - const md = window.mapData; if (!md || !md.spans) return null; + const mo = window.mouseOver; if (!mo || !mo.items) 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 }; + const arr = mo.items[k]; if (!arr) continue; + const s = arr.find(r => String(r.v).includes(want)); + if (!s) continue; + const td = document.getElementById('td_data_' + k); + return { x1: s.x1, x2: s.x2, tdLeft: td ? td.getBoundingClientRect().left : null }; } 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. // 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 = []; @@ -860,31 +904,32 @@ const alsoIn = other.length ? `. That name IS on this page, in ${other.slice(0, 6).join(', ')}` + `${other.length > 6 ? `, ... (${other.length} tracks)` : ''}` + ` -- another track's box is never used for this one` : ''; // 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.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'}${alsoIn}`); } - return { x: img.x + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null }; + const left = (span.tdLeft != null) ? span.tdLeft : img.x; + return { x: left + 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 => { @@ -1143,95 +1188,83 @@ if (m) return [1, 2, 3].map(i => parseInt(m[i], 16)); m = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/.exec(s); if (m) { const v3 = [1, 2, 3].map(i => Number(m[i])); return v3.every(x => x <= 255) ? v3 : null; } return null; } // Hover an item to raise its mouseover tooltip (real mousemove -> the browser's own // tooltip). Two ways to place the cursor: // IDENTITY `item:` / `title:` / `value:` -> name the item (lands on the right ROW). // POSITION `at:` (genomic coord) / `frac:` (0..1) / `x:` (raw px) -> a point. async function mouseover(o) { if (typeof o === 'string') o = { track: o }; o = o || {}; const t = o.track; if (!t) throw new Error('mouseover: needs a track'); const want = o.item ?? o.title ?? o.value; // identity mode if any is set const spot = (want != null) - ? await itemXY(t, want, o.title != null && o.item == null && o.value == null) + ? await itemXY(t, want, o.title != null && o.item == null && o.value == null, + o.value != null && o.item == null && o.title == null) : await posXY(t, o); const { x, y } = spot; // Raise a FRESH tooltip for THIS item, and be sure it IS this item's. The browser shows // a tooltip on MOUSEENTER after a 500ms delay and hides it 500ms after mouseleave // (hg/js/utils.js addMouseover), so while the cursor glides in it crosses other items // and any of THEIR tooltips can still be on screen when we arrive -- an Alignment // Differences item recorded as "identical" (the neighbouring aligned block, lingering in // its grace period) when the item under the cursor reads "mismatch C->T". Waiting for // "some tooltip is visible" is therefore not enough: when we know the item's own text // (from its map box) we wait for exactly that. const tipHtml = () => page.evaluate(() => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return null; - const st = getComputedStyle(c); - return (st.display === 'none' || st.visibility === 'hidden') ? null : c.innerHTML; + const c = window.__docentTip(); + return c ? c.innerHTML : null; }); const prevTip = await tipHtml(); await page.mouse.move(2, y); cur.x = 2; cur.y = y; - if (prevTip) await page.waitForFunction(() => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return true; - const st = getComputedStyle(c); - return st.display === 'none' || st.visibility === 'hidden'; - }, null, { timeout: 2000 }).catch(() => {}); + if (prevTip) await page.waitForFunction(() => !window.__docentTip(), + null, { timeout: 2000 }).catch(() => {}); await sleep(60); await glide(x, y); // small jiggle so the mousemove handler definitely fires and positions the tooltip await page.mouse.move(x + 1, y); await sleep(60); await page.mouse.move(x, y); const shown = () => page.evaluate(() => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return null; - const st = getComputedStyle(c); - if (st.display === 'none' || st.visibility === 'hidden') return null; - return (c.textContent || '').replace(/\s+/g, ' ').trim(); + const c = window.__docentTip(); + return c ? (c.textContent || '').replace(/\s+/g, ' ').trim() : null; }); const wantTip = spot.tip || null; if (wantTip) { // The item's own tooltip, or nothing. A neighbour's tooltip lingering from the glide // fails this test, so we keep waiting until the 500ms show timer fires for OUR item. // Compared with ALL whitespace removed: the title's markup (`rsID: ...`, `
`) // leaves no whitespace at all in textContent, so any tag-to-space normalisation would // never match and the wait would just burn its timeout on a tooltip that was right. await page.waitForFunction(w => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return false; - const st = getComputedStyle(c); - if (st.display === 'none' || st.visibility === 'hidden') return false; + const c = window.__docentTip(); + if (!c) return false; const flat = z => z.replace(/\s+/g, ''); // A distinctive PREFIX, not the whole string: the head of a mouseOver carries the // item's identity (its name/HGVS), while the tail can render differently from the // title it came from (entities, stars, a max-width span). Short tips match whole. return flat(c.textContent || '').includes(flat(w).slice(0, 60)); }, wantTip, { timeout: 4000 }).catch(() => {}); const flat = z => (z || '').replace(/\s+/g, ''); if (process.env.DOCENT_ROWS && !flat(await shown()).includes(flat(wantTip).slice(0, 60))) console.warn(` WARNING: mouseover ${t} "${want}": tooltip never showed its own text\n` + ` want: ${JSON.stringify(flat(wantTip).slice(0, 70))}\n` + ` got : ${JSON.stringify(flat(await shown()).slice(0, 70))}`); } else { await page.waitForFunction(prev => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return false; - const st = getComputedStyle(c); - if (st.display === 'none' || st.visibility === 'hidden') return false; + const c = window.__docentTip(); + if (!c) return false; return prev == null || c.innerHTML !== prev; }, prevTip, { timeout: 3000 }).catch(() => {}); } // A POSITIONAL hover has no expected text to wait for, so the best it can do is let the // tooltip settle: the content stops changing once the cursor is parked, so sample until // two reads agree. (A pinned positional hover therefore still records whatever is under // the point -- `frac: 0.5` can land between two features and report the block they sit // in. Name the item when the figure depends on which tooltip it is.) if (!wantTip) { let settled = await tipHtml(); for (let i = 0; i < 15; i++) { await sleep(80); const now = await tipHtml(); if (now && now === settled) break; settled = now; @@ -1247,32 +1280,32 @@ // `pinMouseovers:` default. Records accumulate within a view and are cleared on nav. const pin = (o.pin != null) ? o.pin : (doc.pinMouseovers === true); if (pin) await recordTip(x, y); if (!FAST) await sleep(o.hold != null ? Number(o.hold) * 1000 : SHOTHOLD); if (o.shot) await shot(o.shot); } // Grab the live mouseover tooltip's HTML and anchor it at the ITEM's coordinate (x,y // that mouseover just hovered), expressed RELATIVE TO the track image (#imgTbl). The // browser parks its own tooltip at a near-fixed spot, so two tips would stack; anchoring // to the item keeps each pinned tooltip on its own feature (and robust to the throwaway // page's image sitting at a different offset). // cx/cy is the HOVER POINT itself (also image-relative), kept alongside the tooltip's // own offset so pinShot() can draw a cursor exactly where the tip was raised from. async function recordTip(x, y) { const t = await page.evaluate(({ x, y }) => { - const c = document.getElementById('mouseoverContainer'); - if (!c || !c.offsetWidth) return null; + const c = window.__docentTip(); + if (!c) return null; const im = document.getElementById('imgTbl'); const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 }; return { cx: x - ir.left, cy: y - ir.top, dx: x - ir.left + 8, dy: y - ir.top + 8, html: c.outerHTML }; }, { x, y }); if (t) pinnedTips.push(t); } // Render every recorded tooltip open at once in a still, WITHOUT touching the recorded // page (so the mp4 is unaffected): reload the current view on a throwaway page that // shares the session cookie (cart), inject the recorded tooltips, screenshot, discard. // Bare string is the still's name; the map form adds `cursors:` (default true) to draw // a static pointer at every pinned hover point, so a combined figure says which feature // each tooltip came off rather than leaving the reader to infer it from the anchor. async function pinShot(arg) { const o = (arg && typeof arg === 'object') ? arg : { name: arg }; @@ -1561,33 +1594,32 @@ // #38251's icon left the blue bar and slid across the menu items at narrow widths with // every selector still matching. See boxCheck(). // // `color:` is the one check that reads the IMAGE rather than the page, because a bug about // color changes nothing else: same rows, same height, same items, same tooltips. It names // the color the row is mostly drawn in (`is:`) or the one it must not be (`not:`), and // `part: label` asks about the center label instead of the items. See rowColors(). // // `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'; + const tip = window.__docentTip && window.__docentTip(); + const up = !!tip; 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. const height = Math.round(seen.cssHeight * SCALE); // A hub track's row id carries a per-run hub__ prefix (a quickLift target's rows all // do), so match the plain name by suffix, the way `mouseover:` resolves a track. const drawn = w => seen.rows.some(r => r === w || r.endsWith('_' + w)); const list = v => v == null ? [] : (Array.isArray(v) ? v : [v]).map(String); @@ -1612,30 +1644,40 @@ for (let i = 1; i < seq.length; i++) if (seq[i].at < seq[i - 1].at) { bad.push(`rows out of order: ${seq[i - 1].w} should be drawn above ${seq[i].w}`); break; } } const banned = list(o.noRows).filter(w => drawn(w)); if (banned.length) bad.push(`rows that should not be drawn: ${banned.join(', ')}`); if (o.height != null) { const why = cmpSize(height, o.height, 'image'); if (why) bad.push(why); } 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}"`); + // `tip:` is a SUBSTRING test, because an item's tooltip is markup and its tail renders + // differently from the title it came from. That is right for a name and wrong for a + // number: a coverage graph's tooltip is the value and nothing else, so `tip: "1"` is + // also satisfied by "1.5" and by "13" -- the prefix trap that #38279 hit with two + // messages sharing a head. `noTip:` is the delimiter this check otherwise has no way to + // carry: state the digits and the decimal point the value must NOT contain and only one + // number is left. One string or a LIST, like every other no-check here. + for (const no of list(o.noTip)) + if (seen.tip.includes(String(no))) + bad.push(`tooltip says "${seen.tip}", which must not contain "${no}"`); // text:/noText: take one string or a LIST of them, the way has:/noHas: do. They have to: // a list handed to a check that stringifies its argument fails OPEN -- ["a", "b"] becomes // "a,b", which no page contains, so the check passes on anything and passes silently. // Six scripts in one batch were written that way and all six looked green. for (const want of list(o.text)) if (!seen.text.includes(want)) bad.push(`page does not contain "${want}"`); for (const want of list(o.noText)) if (seen.text.includes(want)) bad.push(`page contains "${want}"`); 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}"`); @@ -2340,31 +2382,32 @@ // 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. // // `raw: true` presses the mouse where a user would press it and lets the page do // whatever it does, instead of following the link. That is a different gesture, // not a slower way to reach the same page: an item click on most tracks is // answered by an ajax DIALOG, and the dialog is where a whole class of bug lives // (hgTracks hanging on the SECOND click of the same item, #36805). Following the // href never opens a dialog, so it can never see one. With raw: and no item name // it is a bare point on the row -- the ruler carries no hgc map boxes at all, and // a click on it is the gesture in #27113. 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) + arg.title != null && arg.item == null && arg.value == null, + arg.value != null && arg.item == null && arg.title == null) : (arg.raw ? await posXY(arg.track, arg) : 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 // by default follow the item's own map-box link (the hgc detail page) directly. if (it.href && !arg.raw) await nav(it.href); else { const was = page.url(); await page.mouse.click(it.x, it.y); // Answered in one of three ways depending on what was clicked: a navigation, an // ajax dialog, or a new image swapped in place. Wait for whichever arrives // rather than picking one, and never on a fixed sleep, which would flake on a // slow hgc and waste the time on a fast one. await Promise.race([ page.waitForSelector('.ui-dialog:visible', { timeout: 20000 }), page.waitForFunction(u => location.href !== u, was, { timeout: 20000 }),