c4bcca06ef1a06c434c9136a79459f1512cd0606 braney Tue Sep 8 07:41:02 2026 -0700 docent: expect: can assert the color a track's row was drawn in, and a test for #36212 A bug about color leaves the page identical -- same rows, same height, same item names, same tooltips -- so every check expect: had was blind to it. `color:` reads the pixels instead: it names the color a row is mostly drawn in (`is:`), or one it must not be (`not:`), with `part: label` for the center label rather than the items, `at:`/`frac:`/`x:` for one item rather than the whole row, and a list form so one step can state a whole color matrix and a failure name every row that came out wrong. hgTracks draws the whole view into one png and shows each row as a CSS-offset slice of it, so a row's pixels are that slice drawn into a canvas at its offset. The clipping box is the img's own div.sliceDiv, not the table cell: the center label and the data are two slices inside one td_data_<key>, and measuring the cell runs the canvas past the end of this row and into the next track's, which reads that track's color as part of this one. The side labels are a separate png and are never included, since "what color is this row" must not be answered by the label text. White is background; everything else counts, black included, because a track with no color of its own draws black items. is:/not: take r,g,b or #rrggbb and no CSS color names, because trackDb's `color 0,255,0` is not CSS green. tests/colorchecks covers all of it and tests/colorchecks.xfail aims all six forms wrong in one step, so the failure has to name all six. tests/regress/rm36212.xfail is the bug it was written for: a stanza that sets both `itemRgb on` and `color` draws its items in the color setting instead of in the file's own RGB column, because bedItemRgb() (hg/cgilib/bedCart.c) tests for the presence of `color` before it tests for an explicit `itemRgb on`, so the explicit setting is never reached. It is an xfail because the bug is live on the RR, on beta and on genome-test. It is also the first script in that directory that has been watched both to fail on a build with the bug and to pass on a build with the fix -- the three-line reorder built into parked #36212 and the same script pointed at that port. The fixture is ~/public_html/docentFixtures/itemRgbHub/, four tracks over four copies of one bed9 file whose items all carry a pure blue itemRgb column. refs #36212, refs #37892 diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index 9f5bc294467..b5037a27a19 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -868,30 +868,123 @@ 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) : 0.5; x = img.x + insideX + frac * (img.width - insideX); } return { x, y: row.y + row.height / 2 }; } + // The colors hgTracks actually DREW in a track's row, most pixels first. + // + // Every other check in `expect:` reads the DOM, and a bug about color leaves the DOM + // untouched: the same rows, the same height, the same item names, the same tooltips. + // #36212 is the case -- a track that sets both `itemRgb on` and `color` draws its items + // in the color setting instead of in the file's own RGB column -- and the pixels are the + // only evidence either way. + // + // hgTracks renders the whole view into ONE png and shows each row as a CSS-offset slice + // of it: `#img_data_<key>` for the items, `#img_center_<key>` for the center label, both + // inside `#tr_<key>`. So a row's own pixels are that slice -- the image drawn into a + // canvas at its offset and clipped to the cell it sits in. The png is served from the + // same host as the page, so the canvas is readable rather than tainted. + // + // The SIDE labels are a different png (`#img_side_`) and are deliberately left out: + // "what color is this row" must not be answered by the label text. The side-label strip + // is also baked into the left of the data png, which the slice offset hides -- x0 below + // is where the data actually starts inside the slice, and everything left of it is + // skipped for the same reason. + // + // White is background and is not counted. Everything else is, black included, since a + // track with no color of its own draws black items. + async function rowColors(o) { + const { key } = await trackBox(o.track); + const part = String(o.part || 'items'); + const id = ((part === 'label' || part === 'center') ? 'img_center_' : 'img_data_') + key; + // at: is a genomic coordinate, so it needs the window; frac: and x: do not. + let frac = (o.frac != null) ? Number(o.frac) : null; + if (frac == null && o.at != null) + frac = 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); + await page.waitForFunction(i => { + const im = document.getElementById(i); + return !!(im && im.complete && im.naturalWidth > 0); + }, id, { timeout: 8000 }).catch(() => {}); + return await page.evaluate(({ id, frac, xpx, wide }) => { + const im = document.getElementById(id); + if (!im) return { err: `no #${id} on the page` }; + if (!im.complete || !im.naturalWidth) return { err: `#${id} has not loaded` }; + // The clipping box is the img's own DIV, not the cell: hgTracks puts the center + // label and the data in two `div.sliceDiv` of their own inside one `td_data_<key>`, + // each with the explicit height of its slice. Measuring the cell instead runs the + // canvas off the end of this row's slice and into the next track's -- which reads + // that track's color as if it were part of this one. + const cell = im.parentElement.classList.contains('sliceDiv') + ? im.parentElement : (im.closest('td') || im.parentElement); + const cs = getComputedStyle(im); + const dx = parseFloat(cs.left) || 0, dy = parseFloat(cs.top) || 0; + const w = Math.max(1, Math.round(cell.clientWidth)); + const h = Math.max(1, Math.round(cell.clientHeight)); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = h; + const g = cv.getContext('2d', { willReadFrequently: true }); + g.drawImage(im, dx, dy); + let insideX = 0; + try { insideX = hgTracks.insideX || 0; } catch (_) {} + const x0 = Math.max(0, Math.min(w - 1, Math.round(insideX + dx))); + let xa = x0, xb = w; + if (xpx != null || frac != null) { + const c = (xpx != null) ? x0 + xpx : x0 + frac * (w - x0); + xa = Math.max(x0, Math.round(c - wide / 2)); + xb = Math.min(w, xa + wide); + } + const d = g.getImageData(xa, 0, Math.max(1, xb - xa), h).data; + const n = new Map(); + let total = 0; + for (let i = 0; i < d.length; i += 4) { + const r = d[i], gg = d[i + 1], b = d[i + 2], a = d[i + 3]; + if (a < 8) continue; // nothing drawn here + if (r >= 250 && gg >= 250 && b >= 250) continue; // background + const k = r + ',' + gg + ',' + b; + n.set(k, (n.get(k) || 0) + 1); + total++; + } + const top = [...n.entries()].sort((p, q) => q[1] - p[1]).slice(0, 6) + .map(([k, v]) => ({ c: k.split(',').map(Number), n: v })); + return { top, total, box: [xa, xb, w, h] }; + }, { id, frac, xpx: (o.x != null) ? Number(o.x) : null, wide: Number(o.wide || 5) }); + } + // "r,g,b" or "#rrggbb" -> [r,g,b]. No color NAMES on purpose: trackDb's `color 0,255,0` + // is not CSS `green` (#008000), and a script that says one and means the other would be + // wrong in a way nobody would look for. + function parseRgb(v) { + const s = String(v).trim(); + let m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(s); + 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 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 @@ -1249,45 +1342,52 @@ // 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 + // expect: {color: {track: crm4, is: "0,0,255"}} the items in that row are drawn blue + // expect: {color: {track: crm4, part: label, is: "0,255,0"}} ... its center label green // // `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. // + // `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'; 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 : '', @@ -1347,30 +1447,69 @@ 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}"`); } + // color: the pixels hgTracks drew in a row, which no other check here can see. A list + // is allowed, and every entry is checked, so one step can state the whole of a color + // matrix and a failure names every row that came out wrong rather than only the first. + for (const one of (o.color == null ? [] : (Array.isArray(o.color) ? o.color : [o.color]))) { + const c = (typeof one === 'object') ? one : { is: one }; + const where = `${c.track}${(c.part === 'label' || c.part === 'center') ? "'s center label" : ''}`; + if (!c.track) bad.push('color: needs a track'); + else if (c.is == null && c.not == null) bad.push('color: needs is: or not:'); + else { + const got = await rowColors(c).catch(e => ({ err: e.message })); + const show = g => g.top.slice(0, 3) + .map(t => `${t.c.join(',')} (${Math.round(100 * t.n / g.total)}%)`).join(', '); + if (got.err) bad.push(`color: ${got.err}`); + // An empty row is the failure mode to name explicitly. A track that drew nothing + // has no color at all, and a check that quietly passed on it -- or failed saying + // the color was wrong -- would send the reader after the wrong thing. + else if (!got.top.length) + bad.push(`color: nothing is drawn in ${where}`); + else { + const tol = (c.tolerance != null) ? Number(c.tolerance) : 8; + const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= tol); + const dom = got.top[0].c; + for (const [k, want] of [['is', c.is], ['not', c.not]]) { + if (want == null) continue; + const rgb = parseRgb(want); + if (!rgb) { bad.push(`color: cannot read the color "${want}"`); continue; } + if (k === 'is' && !near(dom, rgb)) + bad.push(`${where} is drawn ${dom.join(',')}, wanted ${rgb.join(',')}` + + ` -- the row holds ${show(got)}`); + if (k === 'not' && near(dom, rgb)) + bad.push(`${where} is drawn ${dom.join(',')}, which is the color it should not be` + + ` -- the row holds ${show(got)}`); + } + if (process.env.DOCENT_ROWS) + console.log(` color ${where}: ${show(got)}` + + ` [x ${got.box[0]}-${got.box[1]} of ${got.box[2]}, ${got.box[3]}px tall]`); + } + } + } 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.