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/wait.js src/hg/utils/uiTest/lib/wait.js new file mode 100644 index 00000000000..489024c8abb --- /dev/null +++ src/hg/utils/uiTest/lib/wait.js @@ -0,0 +1,113 @@ +// wait.js -- the anti-flake helpers. +// +// The policy these exist to serve: page.waitForTimeout is banned in lib/ and in +// pages/, and in a test it must carry a "// flake:" comment naming what it waits +// for and why no condition exists. "uiTest lint" enforces that. +// +// A sleep is a guess about how long something takes on a machine you are not +// sitting at. Every helper here waits for a CONDITION instead, and says what it +// was waiting for when the condition never arrives -- which is the whole +// difference between a useful failure and "Timeout 30000ms exceeded". + +'use strict'; + +async function until(page, fn, msg, opts) { + // waitForFunction with an authored failure message. The workhorse: almost + // every sleep in a page object is really "wait until this row exists". With + // no opts.timeout this uses the context's own default timeout instead of a + // number of its own. + const o = opts || {}; + const options = o.timeout ? { timeout: o.timeout } : {}; + try { + await page.waitForFunction(fn, o.arg, options); + } catch (e) { + throw new Error(`${msg}: ${e.message.split('\n')[0]}`); + } +} + +async function eventually(fn, msg, opts) { + // The Node-side version: poll a check()-shaped function (returns a failure + // string, or null when it holds) until it holds. Replaces "sleep, then + // assert". Returns the last failure string, so it drops straight into a + // check() body. There is no page here, so no context default timeout to + // fall back on -- 30000 below is this function's own default, not + // timeout.default. + const o = opts || {}; + const timeout = o.timeout || 30000; + const every = o.every || 250; + const deadline = Date.now() + timeout; + let last = msg || 'condition never held'; + for (;;) { + try { + const out = await fn(); + if (!out) { + return null; + } + last = out; + } catch (e) { + last = `threw: ${e.message.split('\n')[0]}`; + } + if (Date.now() >= deadline) { + return `${last} (still true after ${timeout}ms)`; + } + await new Promise(r => setTimeout(r, every)); + } +} + +async function noPending(page) { + // Wait for jQuery to have no request in flight. hgTracks and myData both + // drive their updates through jQuery ajax, so this is a real, cheap, correct + // condition and it is the right default wait after a click. A page with no + // jQuery has nothing pending by definition. + try { + await page.waitForFunction(() => { + const jq = window.jQuery || window.$; + return !jq || jq.active === 0; + }); + } catch (e) { + throw new Error(`jQuery still had a request in flight: ${e.message.split('\n')[0]}`); + } +} + +async function navigated(page, fn, postCondition) { + // Run fn, wait for the page to settle, then apply the caller's + // post-condition. site.js passes the always-on CGI error assertion here, so + // every navigation in every CGI's tests gets checked without anyone writing + // a check for it. The post-condition is a parameter rather than a require, + // to keep this file free of anything site-specific. + await fn(); + await page.waitForLoadState('domcontentloaded'); + await noPending(page); + if (postCondition) { + await postCondition(page); + } +} + +async function stable(page, sel, opts) { + // Bounding box unchanged across two animation frames. For jQuery dialogs and + // anything else that slides into place: "it exists" is true well before "it + // has stopped moving". With no opts.timeout this uses the context's own + // default timeout instead of a number of its own. + const o = opts || {}; + const options = { polling: 'raf' }; + if (o.timeout) { + options.timeout = o.timeout; + } + await page.waitForFunction((s) => { + const el = document.querySelector(s); + if (!el) { + return false; + } + const key = '__uiTestBox'; + const now = el.getBoundingClientRect(); + const was = el[key]; + el[key] = { x: now.x, y: now.y, w: now.width, h: now.height }; + return !!was && was.x === now.x && was.y === now.y && + was.w === now.width && was.h === now.height; + }, sel, options) + .catch((e) => { + throw new Error(`"${sel}" never stopped moving: ${e.message.split('\n')[0]}`); + }); +} + +module.exports = { until, eventually, noPending, navigated, stable };