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_<track>`, 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 <track>": 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 = '<svg width="24" height="24" viewBox="0 0 24 24"><path d="M3 2 L3 19 L7.5 14.5 L10.5 21.5 L13.5 20.2 L10.6 13.5 L17 13.5 Z" fill="#111" stroke="#fff" stroke-width="1.3"/></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 <body>, 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 <main> 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_<key>, map_center_<key>), 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 <track>", 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[<track>]`, each `{x1, x2, v}`, and x1/x2 are offsets from
+ // the left edge of `td_data_<track>` 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_<key>, map_center_<key>), 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 (`<b>rsID</b>: ...`, `<br>`)
// 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_<n>_ 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 }),