756582322f8a53fef19e8c24ba902e353ba3623f
chmalee
  Thu Aug 27 14:38:34 2026 -0700
uiTest: shared browser UI test harness, plus an hgTracks example, refs #38188

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git src/hg/hgTracks/tests/pages/hgTracks.js src/hg/hgTracks/tests/pages/hgTracks.js
new file mode 100644
index 00000000000..384ec741ff5
--- /dev/null
+++ src/hg/hgTracks/tests/pages/hgTracks.js
@@ -0,0 +1,190 @@
+// The hgTracks page object: every selector these tests need, in one place.
+//
+// Verbs do a thing and wait for their own completion condition, and return
+// nothing. Readers return data and never assert. Keeping the assertions out of
+// here is what lets a check say what it wanted in its own words -- and if a test
+// ever needs a raw selector, this file is missing a reader.
+//
+// The row readers are the same four lines docent's expectState() uses. They are
+// re-typed rather than shared on purpose: making hg/utils/docent require a file
+// under hg/hgTracks/tests/ would invert the dependency and leave docent
+// unrunnable the day that file moved.
+
+'use strict';
+
+const site = require('../../../utils/uiTest/lib/site');
+const wait = require('../../../utils/uiTest/lib/wait');
+
+// ---------------------------------------------------------------- verbs
+
+async function waitForImgTbl(p) {
+    // #imgTbl is the track image table. Every verb that lands on hgTracks
+    // waits for it, so a render that never finishes fails right where it
+    // happened instead of as an empty result somewhere downstream.
+    try {
+        await p.waitForSelector('#imgTbl');
+    } catch (e) {
+        throw new Error(`hgTracks never drew #imgTbl: ${e.message.split('\n')[0]}`);
+    }
+}
+
+async function open(p, env, params) {
+    // Every navigation goes through site.goto, which asserts the page is not an
+    // error page. So no test has to check for a CGI error; they all get it.
+    await site.open(p, env, 'hgTracks', params);
+    await waitForImgTbl(p);
+    return p;
+}
+
+async function reset(p, env, params) {
+    // hgt.reset drops the cart back to the assembly's default track set, so a
+    // run does not inherit whatever the last one left behind.
+    return open(p, env, { 'hgt.reset': 1, ...params });
+}
+
+async function hideAll(p) {
+    // The Hide all button, hButtonWithMsg("hgt.hideAll", ...) in hgTracks.c.
+    await wait.navigated(p, async () => {
+        await Promise.all([
+            p.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+            p.click('#hgt\\.hideAll'),
+        ]);
+    }, site.assertNoCgiError);
+    await waitForImgTbl(p);
+}
+
+async function setVis(p, env, vis, extra) {
+    // Visibility through the cart, the way the browser's own links do it. A
+    // subtrack needs its <track>_sel checkbox as well as its visibility, which
+    // is the pair hui.c reads.
+    return open(p, env, { ...vis, ...(extra || {}) });
+}
+
+async function search(p, term) {
+    await p.fill('#positionInput', term);
+    await wait.navigated(p, async () => {
+        await Promise.all([
+            p.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+            p.press('#positionInput', 'Enter'),
+        ]);
+    }, site.assertNoCgiError);
+    await waitForImgTbl(p);
+}
+
+async function hover(p, x, y) {
+    await p.mouse.move(x, y);
+}
+
+async function moveAway(p) {
+    // Park the mouse somewhere with nothing under it, and wait for any tooltip
+    // to actually go away. Without this a neighbour's leftover tooltip reads as
+    // the one this check was about to raise.
+    await p.mouse.move(2, 2);
+    await wait.until(p, () => {
+        const c = document.getElementById('mouseoverContainer');
+        return !c || getComputedStyle(c).visibility === 'hidden';
+    }, 'a tooltip was still up after moving the mouse off everything',
+    { timeout: 5000 });
+}
+
+// ---------------------------------------------------------------- readers
+
+async function rows(p) {
+    // The track rows hgTracks drew, scoped to the image table. Document-wide
+    // would also sweep up page chrome: the ideogram IMG carries id="chrom".
+    return p.evaluate(() =>
+        [...document.querySelectorAll('#imgTbl [id^="img_data_"]')]
+            .map(e => e.id.replace('img_data_', '')));
+}
+
+function drawn(rowList, name) {
+    // A hub track's row id carries a per-run hub_<n>_ prefix, so match a plain
+    // name by suffix, the way docent resolves a track.
+    return rowList.some(r => r === name || r.endsWith('_' + name));
+}
+
+async function height(p) {
+    return p.evaluate(() => {
+        const im = document.getElementById('imgTbl');
+        return im ? Math.round(im.getBoundingClientRect().height) : 0;
+    });
+}
+
+async function rowHeight(p, track) {
+    // How tall one track's row is. A visibility that the cart accepted but
+    // hgTracks did not apply shows up here and nowhere else.
+    return p.evaluate((t) => {
+        const el = [...document.querySelectorAll('#imgTbl [id^="img_data_"]')]
+            .find(e => e.id === 'img_data_' + t || e.id.endsWith('_' + t));
+        return el ? Math.round(el.getBoundingClientRect().height) : 0;
+    }, track);
+}
+
+async function position(p) {
+    // Straight from the page's own JavaScript rather than the position box: an
+    // interactive zoom stores the new window in the cart, so the box can be
+    // stale while hgTracks knows exactly where it is.
+    return site.pageGlobal(p,
+        '({chrom: hgTracks.chromName, start: hgTracks.winStart, end: hgTracks.winEnd})');
+}
+
+async function tooltipItems(p) {
+    // Every item in the image that has a tooltip, with the point to hover to
+    // raise it. convertTitleTagsToMouseovers() in utils.js copies each area's
+    // title into a mouseoverText attribute on load, so this is what the page
+    // will actually show.
+    return p.evaluate(() => {
+        const out = [];
+        for (const a of document.querySelectorAll('#imgTbl area[mouseoverText]')) {
+            const text = a.getAttribute('mouseoverText');
+            const map = a.closest('map');
+            if (!text || !map || !map.name) {
+                continue;
+            }
+            const img = document.querySelector(`img[usemap="#${map.name}"]`);
+            if (!img) {
+                continue;
+            }
+            const c = (a.getAttribute('coords') || '').split(',').map(Number);
+            if (c.length < 4) {
+                continue;
+            }
+            const box = img.getBoundingClientRect();
+            out.push({
+                text: text,
+                x: Math.round(box.left + (c[0] + c[2]) / 2),
+                y: Math.round(box.top + (c[1] + c[3]) / 2),
+            });
+        }
+        return out;
+    });
+}
+
+async function tip(p) {
+    return p.evaluate(() => {
+        const c = document.getElementById('mouseoverContainer');
+        if (!c) {
+            return '';
+        }
+        const up = c.offsetWidth > 0 && getComputedStyle(c).visibility !== 'hidden' &&
+            getComputedStyle(c).display !== 'none';
+        return up ? c.innerText.trim() : '';
+    });
+}
+
+async function waitForTip(p, wanted) {
+    // The tooltip appears 500ms after mouseenter (addMouseover in utils.js), so
+    // this waits for the text rather than for the clock.
+    await wait.until(p, (want) => {
+        const c = document.getElementById('mouseoverContainer');
+        if (!c || getComputedStyle(c).visibility === 'hidden') {
+            return false;
+        }
+        return c.innerText.trim().includes(want);
+    }, `no tooltip saying "${wanted}" came up`, { arg: wanted, timeout: 8000 });
+}
+
+module.exports = {
+    open, reset, hideAll, setVis, search, hover, moveAway,
+    rows, drawn, height, rowHeight, position, tooltipItems, tip, waitForTip,
+};