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/tests/t01-env.js src/hg/utils/uiTest/tests/t01-env.js new file mode 100644 index 00000000000..21a9142e1f2 --- /dev/null +++ src/hg/utils/uiTest/tests/t01-env.js @@ -0,0 +1,124 @@ +// The conf file and the target table: parsing, include, delete, permissions, +// and which of CLI, environment and conf wins. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const t = require('./assert'); +const env = require('../lib/env'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uiTestSelf-')); +const write = (name, text, mode) => { + const f = path.join(tmp, name); + fs.writeFileSync(f, text); + fs.chmodSync(f, mode == null ? 0o600 : mode); + return f; +}; + +// --- name=value, comments, blank lines --- +const basic = write('.hg.uiTest.conf', [ + '# a comment', + '', + 'default.target = hgwbeta', + 'account.qa.user = someoneQa ', + 'can.hgsql = yes', +].join('\n')); +const c1 = env.readConf(basic); +t.is(c1.vals['default.target'], 'hgwbeta', 'reads a value'); +t.is(c1.vals['account.qa.user'], 'someoneQa', 'trims space around name and value'); +t.is(c1.vals['# a comment'], undefined, 'ignores a comment'); + +// --- include, relative to the including file, and delete --- +write('.shared.conf', 'target.tip = http://127.0.0.1:8081/cgi-bin\ncan.write = no\n'); +const withInc = write('.inc.conf', [ + 'include .shared.conf', + 'delete can.write', + 'default.target = tip', +].join('\n')); +const c2 = env.readConf(withInc); +t.is(c2.vals['target.tip'], 'http://127.0.0.1:8081/cgi-bin', 'include pulls in the other file'); +t.is(c2.vals['can.write'], undefined, 'delete removes a variable the include set'); +t.is(c2.files.length, 2, 'both files are recorded'); + +// A missing include is ignored, the same as hgConfig.c does. +const missing = write('.missing.conf', 'include .noSuchFile.conf\nk = v\n'); +t.is(env.readConf(missing).vals['k'], 'v', 'a missing include is not an error'); + +// --- a file that is not there at all is not an error either --- +t.is(env.readConf(path.join(tmp, '.absent.conf')).files.length, 0, + 'an absent conf file reads as empty'); + +// --- permissions: the port of checkConfigPerms() --- +const loose = write('.loose.conf', 'k = v\n', 0o640); +t.throws(() => env.readConf(loose), /allows group or other access/, + 'a group-readable dot file is rejected'); +const notDot = write('shared.conf', 'k = v\n', 0o644); +t.is(env.readConf(notDot).vals['k'], 'v', + 'a file whose name does not start with a dot is not permission-checked'); + +// --- bad syntax --- +const noEq = write('.bad.conf', 'this line has no equals sign\n'); +t.throws(() => env.readConf(noEq), /invalid format in config file/, 'a line with no = is rejected'); +const badInc = write('.badinc.conf', 'include one two\n'); +t.throws(() => env.readConf(badInc), /invalid format for config include/, + 'an include with two arguments is rejected'); + +// --- target resolution --- +t.is(env.resolveTarget('genome-test', {}), 'https://genome-test.gi.ucsc.edu/cgi-bin', + 'a compiled shorthand resolves'); +t.is(env.resolveTarget('hgwdev-someone', {}), 'https://hgwdev-someone.gi.ucsc.edu/cgi-bin', + 'hgwdev-<user> expands to a sandbox'); +t.is(env.resolveTarget('http://127.0.0.1:8081/cgi-bin/', {}), 'http://127.0.0.1:8081/cgi-bin', + 'a full URL passes through, with any trailing slash trimmed'); +t.is(env.resolveTarget('tip', { 'target.tip': 'http://127.0.0.1:8081/cgi-bin' }), + 'http://127.0.0.1:8081/cgi-bin', 'a conf target.<name> line resolves'); +t.is(env.resolveTarget('hgwdev', { 'target.hgwdev': 'https://elsewhere/cgi-bin' }), + 'https://elsewhere/cgi-bin', 'the conf wins over the compiled table'); +t.throws(() => env.resolveTarget('nonsense', {}), /unknown target/, + 'an unknown target is a config error, not a silent URL'); + +// --- precedence: CLI > env > conf > compiled --- +const prec = write('.prec.conf', 'default.target = hgwbeta\n'); +t.is(env.load({ conf: prec }).target, 'hgwbeta', 'the conf beats the compiled default'); +process.env.UITEST_TARGET = 'hgwdev'; +t.is(env.load({ conf: prec }).target, 'hgwdev', 'the environment beats the conf'); +t.is(env.load({ conf: prec, target: 'genome-test' }).target, 'genome-test', + 'a CLI flag beats the environment'); +delete process.env.UITEST_TARGET; +t.is(env.load({ conf: path.join(tmp, '.absent.conf') }).target, 'genome-test', + 'with no conf at all the compiled default is genome-test'); + +// --- accounts --- +const acct = write('.acct.conf', [ + 'default.account = qa', + 'account.qa.user = someoneQa', +].join('\n')); +const withAcct = env.load({ conf: acct, target: 'hgwdev' }); +t.is(withAcct.account.user, 'someoneQa', 'an account resolves for an ordinary target'); +const onRr = env.load({ conf: acct, target: 'rr' }); +t.is(onRr.account, null, 'rr gets no account even when one is configured'); +t.ok(/no account configured for target rr/.test(onRr.accountReason), + 'and says why'); +const noUser = write('.nouser.conf', 'default.account = qa\n'); +const bad = env.load({ conf: noUser, target: 'hgwdev' }); +t.is(bad.account, null, 'an account block with no user line yields no account'); +t.ok(/account\.qa\.user/.test(bad.accountReason), 'and names the missing line'); +const none = write('.none.conf', [ + 'default.account = qa', + 'account.qa.user = someoneQa', + 'target.hgwbeta.account = (none)', +].join('\n')); +t.is(env.load({ conf: none, target: 'hgwbeta' }).account, null, + '(none) turns off login for one target'); + +// --- booleans --- +['yes', 'on', 'true', '1', 'YES'].forEach(v => + t.ok(env.asBoolean(v, false), `"${v}" is true`)); +['no', 'off', 'false', '0', ''].forEach(v => + t.ok(!env.asBoolean(v, true), `"${v}" is false`)); +t.ok(env.asBoolean(undefined, true), 'an unset value takes the default'); + +fs.rmSync(tmp, { recursive: true, force: true }); +t.done('t01-env');