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) diff --git src/hg/utils/uiTest/WRITING-TESTS.md src/hg/utils/uiTest/WRITING-TESTS.md new file mode 100644 index 00000000000..7abcdc89c40 --- /dev/null +++ src/hg/utils/uiTest/WRITING-TESTS.md @@ -0,0 +1,183 @@ +# Writing a uiTest + +Read `README.md` first for what this is and how to run it. This is how to add a +check. + +## The shape + +A test file exports two things: + +```js +'use strict'; + +const site = require('../../utils/uiTest/lib/site'); +const hgTracks = require('./pages/hgTracks'); + +module.exports.needs = { login: false, hgsql: false, write: false }; + +module.exports.main = async function (p, t) { + const env = t.env; + + await hgTracks.reset(p, env, { db: 'hg38', position: 'chr7:155799529-155812871' }); + + await t.check('hide all leaves only the ruler', async () => { + await hgTracks.hideAll(p); + const extra = (await hgTracks.rows(p)).filter(r => r !== 'ruler'); + return extra.length ? `also drew: ${extra.join(', ')}` : null; + }); +}; +``` + +`p` is a Playwright page. `t` is the runner: `t.check`, `t.xcheck`, `t.skipped`, +`t.heading`, `t.log`, and `t.env`. + +That is the whole interface. A test file is a plain module, so a test can be run +straight from node with no environment set: + + node src/hg/hgTracks/tests/t01-render.js # nothing happens: this is a module + cd src/hg/hgTracks/tests && make uiTest T=t01 + +## A check returns a message, not a boolean + +```js +return extra.length ? `also drew: ${extra.join(', ')}` : null; +``` + +Falsy means it held. A string means it did not, and that string is what somebody +reads when the run goes red. Write it for them: `also drew: clinvarCnv` says what +went wrong; `expected 0 to equal 1` does not. This is the one real advantage over +`assert`, so spend the extra ten seconds on the message. + +A check that throws is a failure too, so a stale selector shows up as a failed +check rather than killing the run. + +`t.xcheck` is for something known to be broken that you want pinned: it fails the +run if it ever starts passing, so nobody has to remember to come back. + +## Declare what the test needs + +```js +module.exports.needs = { login: true, hgsql: false, write: true }; +``` + +Resolved **before any browser launches**: + +| | | +|---|---| +| `login` | skipped, with a reason, when no account is configured for the target | +| `hgsql` | skipped when `can.hgsql` is unset or `hgsql` is not on PATH | +| `write` | skipped when `can.write` is off, and **refused outright** when the target is production | + +A skip is never a failure. A developer with no QA password must not see a red +run over it. + +`needs.write` aimed at `rr` is exit 2 and is not skippable. That is deliberate: +skipping it would mean a green run that quietly did nothing, which is how someone +eventually uploads a `qa123456` hub to genome.ucsc.edu. + +A test with `needs.login` is handed a page that is already logged in. There is +nothing to call. + +## Selectors go in the page object, never in the test + +`src/hg//tests/pages/.js` holds every selector for that CGI. It exports + +- **verbs** -- do a thing, wait for its own completion condition, return nothing +- **readers** -- return data, never assert + +If a check needs a raw selector, the page object is missing a reader. Add the +reader. + +Keeping the assertions out of the page object is what makes it reusable, and it +is why a check reads like a sentence about the browser rather than a sentence +about the DOM. + +To borrow another CGI's page object: + +```js +const { page } = require('../../utils/uiTest/lib/pages'); +const hgTables = page('hgTables'); +``` + +## Never sleep + +`page.waitForTimeout` is banned in `lib/` and `pages/`. In a test it needs a +`// flake:` comment naming what it waits for and why no condition exists. +`make lint` enforces it. + +A sleep is a guess about how long something takes on a machine you are not +sitting at. Every helper in `lib/wait.js` waits for a condition instead: + +| | | +|---|---| +| `until(page, fn, msg)` | `waitForFunction` whose failure message is `msg`, not `Timeout 30000ms exceeded`. The workhorse | +| `eventually(fn, msg)` | poll a check-shaped function until it holds. Replaces "sleep, then assert" | +| `noPending(page)` | jQuery has nothing in flight. The right default wait after a click | +| `navigated(page, fn, post)` | run `fn`, wait for the page to settle, then apply a post-condition | +| `stable(page, sel)` | it has stopped moving, not just started existing | + +Almost every sleep you are tempted to write is really `until(p, () => ..., 'the +row never appeared')`. + +## Navigation is already checked + +`site.goto` asserts, after every navigation in every CGI's tests, that the page +is not a kent error page. It reads `#warnBox` and `#warnList` -- what +`htmlWarnBoxSetup()` in `src/lib/htmshell.c` actually produces -- plus Apache's +own 414 and 500 pages, a "Very Early Error" title, and a body that ends in +`-- ERROR --`. + +Nobody writes that check. Everybody gets it. What a suite should carry instead is +a proof the detector still works: something that **must** produce the banner. +`t01-render.js` has two, one for each branch. + +When a check wants the error rather than the absence of one, navigate with plain +`p.goto` and ask `site.errorOnPage(p)`. + +## Assert on what will be sent, not only on what was drawn + +```js +const at = await hgTracks.position(p); // reads hgTracks.chromName +``` + +`site.pageGlobal(p, expr)` reads the page's own JavaScript. An interactive zoom +stores the new window in the cart, not in the position box, so the box can be +stale while the page knows exactly where it is. The same trick reaches +`window.uppy.getFiles()` on the hubSpace page, and anywhere else the truth lives +in a variable rather than in the DOM. + +`site.fetchText(p, url)` fetches through the page's own request context, so the +session cookies come along and udc's cache of a `hub.txt` is bypassed. + +## Setting up a new CGI + + mkdir -p src/hg//tests/pages + +`src/hg//tests/makefile`, two lines: + +```make +UITEST ?= $(CURDIR)/../../utils/uiTest/uiTest +include $(dir $(UITEST))uiTest.mk +``` + +The path is relative because a CGI's `tests/` directory always sits the same two +directories from the harness, so a git worktree needs no override. + +That gives `make uiTest`, `make headed`, `make selfcheck`, `make lint`, +`make clean`, and an inert `test:` that keeps these out of the tree-wide +`make test`. Do not add the directory to `TEST_DIRS`, `APPS`, `UTIL_DIRS` or +`UTILS_APPLIST`. + +Then write `pages/.js` and `t01-.js`, and copy the shape of +`src/hg/hgTracks/tests/` while you do it. + +## Setting yourself up for a login + +1. Make an hgLogin account named `Qa` on the server you test against. +2. `cp sample.hg.uiTest.conf ~/.hg.uiTest.conf && chmod 600 ~/.hg.uiTest.conf` +3. Fill in `default.target`, `default.account` and the `account.qa.*` lines. +4. `make selfcheck` in a tests directory, then `uiTest --selfcheck --login` to do + one real login round trip. + +One account per person. A run logs in as you, so it cannot reach a hub or a +session that belongs to somebody else's account.