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/env.js src/hg/utils/uiTest/lib/env.js new file mode 100644 index 00000000000..47f12aaeadd --- /dev/null +++ src/hg/utils/uiTest/lib/env.js @@ -0,0 +1,317 @@ +// env.js -- everything uiTest knows before a browser exists: the conf file, the +// target servers, the test accounts, and what this machine is allowed to do. +// +// This is the bottom layer. It must never require playwright, name a CGI, or +// make a network call. Everything above it asks this module where to point and +// what it is permitted to do, and gets an answer without a browser running. +// +// Precedence for every key, one rule: CLI flag > environment > conf > compiled +// default. The compiled defaults are enough on their own, so uiTest works with +// no conf file at all against genome-test. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Exit codes. One table, used at every layer, so "my password expired" never +// looks like "the feature broke". +const EXIT = { + OK: 0, // everything that ran passed (skips allowed) + FAIL: 1, // a check failed -- a bug in the thing under test + CONFIG: 2, // usage or configuration error + INFRA: 3, // no browser, server unreachable, login broken + SKIPPED: 4, // everything was skipped and --strict was given +}; + +function taggedError(msg, code) { + const e = new Error(msg); + e.exitCode = code; + return e; +} + +function configError(msg) { + return taggedError(msg, EXIT.CONFIG); +} + +function infraError(msg) { + return taggedError(msg, EXIT.INFRA); +} + +// The same servers docent names in its own SERVERS map. Duplicated on purpose: +// docent is not edited by this work, and making it require a file under uiTest +// would invert the dependency and leave it unrunnable if that file moved. +const SERVERS = { + 'rr': 'https://genome.ucsc.edu/cgi-bin', + 'genome-test': 'https://genome-test.gi.ucsc.edu/cgi-bin', + 'hgwdev': 'https://hgwdev.gi.ucsc.edu/cgi-bin', + 'hgwbeta': 'https://hgwbeta.soe.ucsc.edu/cgi-bin', +}; + +const DEFAULT_TARGET = 'genome-test'; +const DEFAULT_TIMEOUT = 30000; +const CONF_FILE = path.join(os.homedir(), '.hg.uiTest.conf'); + +function expandHome(p) { + if (!p) { + return p; + } + if (p === '~') { + return os.homedir(); + } + if (p.startsWith('~/')) { + return path.join(os.homedir(), p.slice(2)); + } + return p; +} + +function checkConfigPerms(file) { + // Port of checkConfigPerms() in hg/lib/hgConfig.c. A config file whose name + // starts with "." must not be readable by group or other, because it holds a + // password. Same wording as the C, so someone who has seen it once knows it. + if (path.basename(file)[0] !== '.') { + return; + } + let st; + try { + st = fs.statSync(file); + } catch (e) { + return; // does not exist, which is allowed + } + if ((st.mode & 0o077) !== 0) { + throw configError(`config file ${file} allows group or other access, ` + + `must only allow user access`); + } +} + +function parseConfFile(file, depth, into) { + // Kent name=value format: "#" comments, blank lines, "include <path>" and + // "delete <var> ...". Follows parseConfigFile() in hg/lib/hgConfig.c, + // including its behaviour of ignoring a file that is not there. + if (depth > 10) { + throw configError(`maximum config include depth exceeded: ${file}`); + } + checkConfigPerms(file); + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') { + return into; + } + throw configError(`cannot read ${file}: ${e.message}`); + } + into.files.push(file); + text.split('\n').forEach((raw, i) => { + const line = raw.trim(); + if (line === '' || line[0] === '#') { + return; + } + if (/^include\s/.test(line)) { + const rest = line.replace(/^include\s+/, '').trim(); + if (rest === '' || /\s/.test(rest)) { + throw configError( + `invalid format for config include: ${file}:${i + 1}: ${line}`); + } + const inc = rest[0] === '/' ? rest : path.join(path.dirname(file), rest); + parseConfFile(inc, depth + 1, into); + return; + } + if (/^delete\s/.test(line)) { + line.replace(/^delete\s+/, '').trim().split(/\s+/) + .forEach(v => delete into.vals[v]); + return; + } + const eq = line.indexOf('='); + if (eq < 0) { + throw configError(`invalid format in config file ${file}:${i + 1}: ${line}`); + } + into.vals[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + }); + return into; +} + +function readConf(file) { + return parseConfFile(file, 0, { vals: {}, files: [] }); +} + +function asBoolean(v, dflt) { + if (v == null) { + return dflt; + } + return /^(yes|on|true|1)$/i.test(String(v).trim()); +} + +function resolveTimeout(v) { + // Missing or unparseable falls back to DEFAULT_TIMEOUT. 0 is honoured as + // "no timeout", the meaning Playwright gives setDefaultTimeout(0). A + // negative number is never coherent, so it is rejected rather than + // silently substituted. + if (v == null || String(v).trim() === '') { + return DEFAULT_TIMEOUT; + } + const n = Number(v); + if (Number.isNaN(n)) { + return DEFAULT_TIMEOUT; + } + if (n < 0) { + throw configError(`timeout.default is "${v}", which is negative`); + } + return n; +} + +function onPath(prog) { + try { + execSync(`command -v ${prog}`, { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +function resolveTarget(name, vals) { + // A conf `target.<name>` line wins over the compiled table, so someone can + // add the docker QA instances without a code change. + const fromConf = vals[`target.${name}`]; + if (fromConf) { + if (!/^https?:\/\//i.test(fromConf)) { + throw configError(`target.${name} in your conf is "${fromConf}", which is not ` + + `a http(s) URL`); + } + return fromConf.replace(/\/$/, ''); + } + if (SERVERS[name]) { + return SERVERS[name]; + } + if (/^hgwdev-[a-z0-9._-]+$/i.test(name)) { + return `https://${name}.gi.ucsc.edu/cgi-bin`; // personal sandbox + } + if (/^https?:\/\//i.test(name)) { + return name.replace(/\/$/, ''); + } + throw configError(`unknown target "${name}". Known: ` + + `${Object.keys(SERVERS).join(', ')}, hgwdev-<user>, a full http(s) URL, ` + + `or a target.<name> line in your conf file.`); +} + +function sameHost(base, rrBase) { + // Compares hostnames rather than full URLs, so "rr" and every spelling of + // genome.ucsc.edu/cgi-bin match. A base that new URL() cannot parse is + // treated as production: failing open here would let credentials and + // writes reach the real site. + let host; + try { + host = new URL(base).hostname.toLowerCase().replace(/^www\./, ''); + } catch (e) { + return true; + } + const rrHost = new URL(rrBase).hostname.toLowerCase().replace(/^www\./, ''); + return host === rrHost; +} + +function load(opts) { + const o = opts || {}; + const confFile = expandHome(o.conf || process.env.UITEST_CONF || CONF_FILE); + const conf = readConf(confFile); + const vals = conf.vals; + + const target = o.target || process.env.UITEST_TARGET || + vals['default.target'] || DEFAULT_TARGET; + const base = resolveTarget(target, vals); + const isRr = target === 'rr' || sameHost(base, SERVERS['rr']); + + // Which account, if any. "(none)" is how a conf says "never log in here". + // rr defaults to no account whatever else is configured: credentials do not + // go to production unless someone deliberately says so in their conf. + let accountName = o.account || process.env.UITEST_ACCOUNT || + vals[`target.${target}.account`] || + (isRr ? '(none)' : vals['default.account']); + let account = null; + let accountReason = ''; + if (!accountName || accountName === '(none)') { + accountReason = conf.files.length + ? `no account configured for target ${target}` + : `no conf file at ${confFile}`; + accountName = null; + } else { + const user = vals[`account.${accountName}.user`]; + if (!user) { + accountReason = `account "${accountName}" has no ` + + `account.${accountName}.user line in ${confFile}`; + accountName = null; + } else { + account = { name: accountName, user: user }; + } + } + + const hgsqlWanted = asBoolean(vals['can.hgsql'], false); + const hgsqlThere = hgsqlWanted && onPath('hgsql'); + let hgsqlReason = ''; + if (!hgsqlWanted) { + hgsqlReason = `can.hgsql is not set in ${confFile}`; + } else if (!hgsqlThere) { + hgsqlReason = 'hgsql is not on PATH'; + } + + const env = { + EXIT: EXIT, + confFile: confFile, + confFiles: conf.files, + confPresent: conf.files.length > 0, + target: target, + base: base, + isRr: isRr, + account: account, + accountReason: accountReason, + canHgsql: hgsqlThere, + hgsqlReason: hgsqlReason, + canWrite: asBoolean(vals['can.write'], true), + artifacts: expandHome(o.artifacts || process.env.UITEST_ARTIFACTS || + vals['artifacts'] || path.join(os.homedir(), 'uiTest')), + pwPrefix: expandHome(o.pwPrefix || process.env.UITEST_PW_PREFIX || + vals['pw.prefix'] || null), + timeout: resolveTimeout(vals['timeout.default']), + headed: !!o.headed || asBoolean(process.env.UITEST_HEADED, false), + slowMo: Number(o.slowMo) || 0, + get: (key, dflt) => (vals[key] == null ? dflt : vals[key]), + }; + + // The password is resolved only when something actually logs in, so a run + // that needs no account never shells out to a password manager. + env.password = function () { + if (!account) { + return null; + } + const plain = vals[`account.${account.name}.password`]; + if (plain) { + return plain; + } + const cmd = vals[`account.${account.name}.passwordCmd`]; + if (!cmd) { + throw configError(`account "${account.name}" has neither ` + + `account.${account.name}.password nor ` + + `account.${account.name}.passwordCmd in ${confFile}`); + } + let out; + try { + out = execSync(cmd, { encoding: 'utf8' }); + } catch (e) { + throw configError(`account.${account.name}.passwordCmd failed: ${cmd}`); + } + const pw = out.split('\n')[0].trim(); + if (!pw) { + throw configError(`account.${account.name}.passwordCmd printed nothing: ${cmd}`); + } + return pw; + }; + + return env; +} + +module.exports = { + load, resolveTarget, readConf, checkConfigPerms, expandHome, asBoolean, + configError, infraError, + EXIT, SERVERS, CONF_FILE, DEFAULT_TARGET, DEFAULT_TIMEOUT, +};