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/lib/run.js src/hg/utils/uiTest/lib/run.js new file mode 100644 index 00000000000..cbe58530ee7 --- /dev/null +++ src/hg/utils/uiTest/lib/run.js @@ -0,0 +1,296 @@ +// run.js -- checks, skips, xfail, and the results a run leaves behind. +// +// A check returns a FAILURE STRING, or anything falsy when it holds: +// +// 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; +// }); +// +// The message is authored by whoever wrote the check, which is why this is +// better here than assert: "also drew: clinvarCnv" tells you what went wrong, +// where "expected 0 to equal 1" does not. A check that throws is a failure too, +// so a stale selector shows up as a failed check rather than killing the run. +// +// Skipped never folds into passed. A suite that quietly skipped everything must +// not read as green, so every status is counted separately and printed. +// +// This file must not name a CGI. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const browser = require('./browser'); +const site = require('./site'); +const { EXIT, configError } = require('./env'); + +const SCHEMA = 1; + +function slug(s) { + return s.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 60).toLowerCase(); +} + +function safeUrl(u) { + // The URL that produced a check, fit to be written down. The hgsid goes, + // because a results.json often lands under public_html and an hgsid is a + // live session anyone who reads it could then load. Long ones are cut, + // because a check can deliberately send a 9000-character request line. + if (!u) { + return ''; + } + let out = u; + try { + const parsed = new URL(u); + parsed.searchParams.delete('hgsid'); + out = parsed.toString(); + } catch (e) { + out = u.replace(/([?&])hgsid=[^&]*/g, '$1'); + } + return out.length > 300 ? `${out.slice(0, 300)}... [${out.length} characters]` : out; +} + +function resolveNeeds(needs, env) { + // Decided before any browser launches. Returns null to run, or a reason to + // skip. Throws a config error for the one case that must never be skippable. + const n = needs || {}; + if (n.write && env.isRr) { + throw configError( + `this test declares needs.write and the target is ${env.target}, which is ` + + `production. Refusing to run. Point --target somewhere else.`); + } + if (n.write && !env.canWrite) { + return `can.write is not set in ${env.confFile}`; + } + if (n.login && !env.account) { + return env.accountReason; + } + if (n.hgsql && !env.canHgsql) { + return env.hgsqlReason; + } + return null; +} + +function createRun(env, dir) { + fs.mkdirSync(dir, { recursive: true }); + const logFile = path.join(dir, 'run.log'); + + const run = { + env: env, + dir: dir, + checks: [], + grep: null, + strict: false, + filtered: 0, + page: null, // the page a failing check is photographed from + failedHere: false, // did the test file being run right now fail? + }; + + run.log = function (msg) { + const line = msg == null ? '' : String(msg); + console.log(line); + fs.appendFileSync(logFile, line + '\n'); + }; + + run.heading = function (s) { + run.log(`\n--- ${s} ---`); + }; + + run.record = function (entry) { + run.checks.push(entry); + if (entry.status === 'fail' || entry.status === 'xpass') { + run.failedHere = true; + } + return entry; + }; + + async function one(name, fn, expectFail) { + if (run.grep && !name.toLowerCase().includes(run.grep.toLowerCase())) { + run.filtered++; + return true; + } + const t0 = Date.now(); + let out; + try { + out = await fn(); + } catch (e) { + out = `threw: ${String(e.message || e).split('\n')[0]}`; + } + const held = !out; + let status; + if (expectFail) { + status = held ? 'xpass' : 'xfail'; + } else { + status = held ? 'pass' : 'fail'; + } + const bad = status === 'fail' || status === 'xpass'; + const entry = { + name: name, + status: status, + ms: Date.now() - t0, + detail: out ? String(out) : '', + url: run.page ? safeUrl(run.page.url()) : '', + shot: null, + }; + if (status === 'xpass') { + entry.detail = 'this was supposed to fail, and it passed'; + } + if (bad && run.page) { + const f = path.join(dir, 'shots', + `${String(run.checks.length + 1).padStart(2, '0')}-${slug(name)}.png`); + entry.shot = await browser.shot(run.page, f); + } + const label = { pass: 'PASS', fail: 'FAIL', xfail: 'XFAIL', xpass: 'XPASS' }[status]; + run.log(`${label} ${name}${entry.detail ? '\n ' + entry.detail : ''}`); + if (entry.shot) { + run.log(` screenshot: ${entry.shot}`); + } + run.record(entry); + return !bad; + } + + run.check = (name, fn) => one(name, fn, false); + run.xcheck = (name, fn) => one(name, fn, true); + + run.skipped = function (name, reason) { + run.log(`SKIP ${name}\n ${reason}`); + return run.record({ name, status: 'skip', ms: 0, detail: reason, url: '', shot: null }); + }; + + run.runFile = async function (file) { + // Load the test, decide whether it may run, then give it a page and get + // out of the way. + const name = path.basename(file).replace(/\.js$/, ''); + run.heading(name); + let mod; + try { + mod = require(path.resolve(file)); + } catch (e) { + const detail = `cannot load ${file}: ${e.message}`.split('\n')[0]; + run.record({ + name: `${name} aborted`, status: 'fail', ms: 0, + detail: detail, url: '', shot: null, + }); + run.log(`\nRUN ABORTED: ${detail}`); + return; + } + if (typeof mod.main !== 'function') { + const detail = `${file} exports no main function`; + run.record({ + name: `${name} aborted`, status: 'fail', ms: 0, + detail: detail, url: '', shot: null, + }); + run.log(`\nRUN ABORTED: ${detail}`); + return; + } + const skip = resolveNeeds(mod.needs, env); + if (skip) { + run.skipped(name, skip); + return; + } + + run.failedHere = false; + const b = await browser.launch(env); + try { + const ctx = await browser.context(b, env, { + initScripts: site.INIT_SCRIPTS, + storageState: (mod.needs && mod.needs.login) ? site.storageStateFor(env) : undefined, + }); + const p = await ctx.newPage(); + p.on('pageerror', e => run.log(` [page error] ${e.message}`)); + run.page = p; + let traceFile = null; + try { + if (mod.needs && mod.needs.login) { + await site.ensureLoggedIn(ctx, p, env); + } + await mod.main(p, run); + } catch (e) { + if (e.exitCode) { + throw e; // config or infrastructure, not a failed check + } + run.record({ + name: `${name} aborted`, status: 'fail', ms: 0, + detail: String(e.message || e).split('\n')[0], + url: safeUrl(p.url()), + shot: await browser.shot(p, path.join(dir, 'shots', `${slug(name)}-abort.png`)), + }); + run.log(`\nRUN ABORTED: ${e.message}`); + } finally { + // A trace is written only when this file recorded a failure, so + // tracing costs nothing on a green run. + if (run.failedHere) { + traceFile = path.join(dir, 'trace', `${name}.zip`); + } + await browser.stopTracing(ctx, traceFile); + run.page = null; + } + if (traceFile) { + run.log(` trace: npx playwright show-trace ${traceFile}`); + } + } finally { + // The browser is closed on every path out of this block, launched + // or not, so a failure while setting up the context or the page + // never leaves a headless Chromium process behind. + await b.close().catch(() => {}); + } + }; + + run.totals = function () { + const t = { pass: 0, fail: 0, skip: 0, xfail: 0, xpass: 0 }; + run.checks.forEach(c => { t[c.status]++; }); + return t; + }; + + run.summary = function () { + const t = run.totals(); + const parts = [`${t.pass} passed`, `${t.fail} failed`, `${t.skip} skipped`]; + if (t.xfail) { + parts.push(`${t.xfail} xfail`); + } + if (t.xpass) { + parts.push(`${t.xpass} xpass`); + } + run.log(`\n${parts.join(', ')}`); + if (run.filtered) { + run.log(`(${run.filtered} check(s) not run, filtered by --grep)`); + } + const bad = run.checks.filter(c => c.status === 'fail' || c.status === 'xpass'); + if (bad.length) { + run.log('failed:'); + bad.forEach(c => run.log(` - ${c.name}: ${c.detail}`)); + } + run.log(`\nresults: ${path.join(dir, 'results.json')}`); + return t; + }; + + run.write = function (meta) { + const out = { + schema: SCHEMA, + ...meta, + target: env.target, + base: env.base, + account: env.account ? env.account.user : null, + totals: run.totals(), + checks: run.checks, + }; + fs.writeFileSync(path.join(dir, 'results.json'), JSON.stringify(out, null, 2) + '\n'); + return out; + }; + + run.exitCode = function () { + const t = run.totals(); + if (t.fail || t.xpass) { + return EXIT.FAIL; + } + if (run.strict && t.pass === 0 && t.xfail === 0 && (t.skip > 0 || run.filtered > 0)) { + return EXIT.SKIPPED; + } + return EXIT.OK; + }; + + return run; +} + +module.exports = { createRun, resolveNeeds, slug, safeUrl, SCHEMA };