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/utils/uiTest/lib/site.js src/hg/utils/uiTest/lib/site.js
new file mode 100644
index 00000000000..3b30f4649dd
--- /dev/null
+++ src/hg/utils/uiTest/lib/site.js
@@ -0,0 +1,227 @@
+// site.js -- what every Genome Browser page has in common: CGI URLs, hgLogin,
+// the cached login state, reading the page's own JavaScript, and the always-on
+// assertion that a navigation did not land on an error page.
+//
+// Site chrome only. The two selectors allowed here are hgLogin's own fields and
+// the menubar's login link. A selector belonging to the BODY of one CGI belongs
+// in that CGI's page object, not here.
+
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { infraError, configError } = require('./env');
+const wait = require('./wait');
+
+const STATE_DIR = path.join(os.homedir(), '.cache', 'uiTest');
+
+// Applied to every context, so no test has to remember it: the hgTracks tutorial
+// pops up on a first visit and would sit on top of whatever is being looked at.
+const INIT_SCRIPTS = [
+    () => {
+        try {
+            localStorage.setItem('hgTracks_hideTutorial', '1');
+        } catch (e) {
+            // a context with storage disabled has no tutorial state to set
+        }
+    },
+];
+
+function url(env, cgi, params) {
+    const qs = new URLSearchParams(params || {}).toString();
+    return `${env.base}/${cgi}${qs ? '?' + qs : ''}`;
+}
+
+async function readErrorState(page) {
+    // What a kent CGI actually produces when it aborts, read from
+    // htmlWarnBoxSetup() and htmlVaWarn() in src/lib/htmshell.c, plus the two
+    // pages Apache itself serves when the CGI never got to run.
+    return page.evaluate(() => {
+        const box = document.getElementById('warnBox');
+        const list = document.getElementById('warnList');
+        const shown = box && box.offsetWidth > 0 &&
+            getComputedStyle(box).display !== 'none' &&
+            getComputedStyle(box).visibility !== 'hidden';
+        const body = document.body ? document.body.innerText : '';
+        const html = document.body ? document.body.innerHTML : '';
+        return {
+            warn: shown && list ? list.innerText.trim() : '',
+            title: document.title || '',
+            serverError: /Request-URI Too Long|Internal Server Error|Service Temporarily Unavailable/
+                .exec(body),
+            tail: html.slice(-200),
+        };
+    });
+}
+
+async function errorOnPage(page) {
+    // Returns a description of the error on this page, or null. This is the
+    // detector; assertNoCgiError is the assertion built on it, and a test that
+    // WANTS an error (proving the detector is alive) calls this one. A page
+    // that cannot even be read is reported, not treated as clean -- a page
+    // closed or navigated away mid-check must not read as "no error".
+    let s;
+    try {
+        s = await readErrorState(page);
+    } catch (e) {
+        return `could not check this page for an error: ${e.message}`;
+    }
+    if (s.warn) {
+        return s.warn.replace(/\s+/g, ' ').slice(0, 300);
+    }
+    if (s.serverError) {
+        return s.serverError[0];
+    }
+    if (/Very Early Error/.test(s.title)) {
+        return 'Very Early Error';
+    }
+    if (s.tail.includes('-- ERROR --')) {
+        return 'the page ends in -- ERROR --';
+    }
+    return null;
+}
+
+async function assertNoCgiError(page) {
+    const err = await errorOnPage(page);
+    if (err) {
+        throw new Error(`${page.url()} is an error page: ${err}`);
+    }
+}
+
+async function goto(page, target) {
+    // Every navigation goes through here, so every navigation is checked.
+    await wait.navigated(page, () => page.goto(target, { waitUntil: 'domcontentloaded' }),
+        assertNoCgiError);
+    return page;
+}
+
+async function open(page, env, cgi, params) {
+    return goto(page, url(env, cgi, params));
+}
+
+async function pageGlobal(page, expr) {
+    // Read one of the page's own JavaScript globals -- hgTracks.chromName,
+    // window.uppy.getFiles(). An assertion about what the page WILL SEND is
+    // usually stronger than one about what it happens to have drawn.
+    return page.evaluate((e) => {
+        try {
+            // eslint-disable-next-line no-eval
+            return eval(e);
+        } catch (err) {
+            return undefined;
+        }
+    }, expr);
+}
+
+async function fetchText(page, target) {
+    // Fetch through the page's own request context, so the session cookies come
+    // along and udc's cache of a hub.txt is bypassed. Returns {status, body}.
+    const res = await page.request.get(target);
+    return { status: res.status(), body: await res.text() };
+}
+
+function stateFileFor(base, user) {
+    const host = new URL(base).host.replace(/[^a-zA-Z0-9.-]/g, '_');
+    const safeUser = user.replace(/[^a-zA-Z0-9.-]/g, '_');
+    return path.join(STATE_DIR, `authState.${host}.${safeUser}.json`);
+}
+
+async function loggedInUser(page, env) {
+    await page.goto(url(env, 'hgGateway'), { waitUntil: 'domcontentloaded' });
+    return page.getAttribute('a#loginLink', 'data-username').catch(() => null);
+}
+
+async function login(page, env) {
+    const account = env.account;
+    if (!account) {
+        throw configError('login was asked for with no account configured');
+    }
+    const password = env.password();
+    await page.goto(url(env, 'hgLogin', { 'hgLogin.do.displayLoginPage': 1 }),
+        { waitUntil: 'domcontentloaded' });
+    // The warning box is in every page but hidden unless there is something to
+    // warn about. On the login page it can be a stale-session notice standing
+    // between us and the form.
+    const ok = await page.$('#warnOK');
+    if (ok && await ok.isVisible()) {
+        await ok.click();
+        await page.waitForLoadState('domcontentloaded');
+    }
+    await page.fill('#userName', account.user);
+    await page.fill('#password', password);
+    await Promise.all([
+        page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+        page.click('input[name="hgLogin.do.displayLogin"]'),
+    ]);
+    // The success page sets its cookies from inline JS and then redirects, so
+    // the session is not usable until that has run.
+    await page.waitForLoadState('load');
+    await page.waitForURL(/hgSession|hgGateway|hgTracks|hgHubConnect/, { timeout: env.timeout })
+        .catch(() => {});
+}
+
+async function ensureLoggedIn(ctx, page, env) {
+    // Log in if the cached cookies are gone or stale, then re-save them. One
+    // cached session per host and account, so switching between a sandbox and
+    // hgwdev never hands over the wrong cookies.
+    const account = env.account;
+    if (!account) {
+        throw configError('ensureLoggedIn was called with no account configured');
+    }
+    if (await loggedInUser(page, env) !== account.user) {
+        await login(page, env);
+        if (await loggedInUser(page, env) !== account.user) {
+            throw infraError(`login to ${env.base} as ${account.user} did not take -- ` +
+                `check the password, and whether hgLogin is showing a warning page`);
+        }
+    }
+    const stateFile = stateFileFor(env.base, account.user);
+    fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
+    fs.chmodSync(STATE_DIR, 0o700);
+    await ctx.storageState({ path: stateFile });
+    fs.chmodSync(stateFile, 0o600);   // it holds live session cookies
+    return page;
+}
+
+function storageStateFor(env) {
+    // The cached cookies to hand a new context, or undefined if there are none.
+    if (!env.account) {
+        return undefined;
+    }
+    const f = stateFileFor(env.base, env.account.user);
+    return fs.existsSync(f) ? f : undefined;
+}
+
+function hgsql(env, db, sql) {
+    // Read-only: rows from a database on this machine, so a test can compare
+    // what the page shows against what was stored. Only reachable when
+    // env.canHgsql is true.
+    if (!env.canHgsql) {
+        throw configError(`hgsql is not available here: ${env.hgsqlReason}`);
+    }
+    const first = sql.trim().split(/\s+/)[0].toUpperCase();
+    if (!/^(SELECT|SHOW|DESCRIBE|EXPLAIN)$/.test(first)) {
+        throw configError(`hgsql is read-only in uiTest, refusing to run: ${sql}`);
+    }
+    if (/;\s*\S/.test(sql)) {
+        throw configError(`hgsql takes one statement, refusing to run: ${sql}`);
+    }
+    const out = execFileSync('hgsql', [db, '-e', sql], { encoding: 'utf8' }).trim();
+    const lines = out.split('\n');
+    if (lines.length < 2) {
+        return [];
+    }
+    const cols = lines[0].split('\t');
+    return lines.slice(1).map(l => {
+        const v = l.split('\t');
+        return Object.fromEntries(cols.map((c, i) => [c, v[i]]));
+    });
+}
+
+module.exports = {
+    url, open, goto, assertNoCgiError, errorOnPage, pageGlobal, fetchText,
+    login, ensureLoggedIn, loggedInUser, storageStateFor, hgsql,
+    INIT_SCRIPTS, STATE_DIR, stateFileFor,
+};