9b762c146f7a44a3bdc783cdd202356fc163b8fb braney Wed Sep 16 12:43:32 2026 -0700 docent: one targetConf.js for the target, the hg.conf, the central and the account, refs #37892 docent.js and tests/preflight.js had grown a second and then a third copy of the same lookups. That is exactly the pair that must not drift: preflight checks the fixtures for the server the RUN will drive, so a run that resolved its target, its hg.conf, its hgcentral or its account even slightly differently would be checked against the wrong machine, and the mismatch would show up as a green preflight in front of a red suite. targetConf.js answers the four questions in order, each from the one before: resolveTarget a `target:` (or DOCENT_TARGET) -> the .../cgi-bin URL to drive hgConfFor that URL -> the hg.conf it reads, if the server is on this box centralDbFor that conf -> which hgcentral it uses loginLookup that central -> the account to sign in with Nothing in it opens a browser or the network, which is why preflight can ask all four in the seconds before a run. loginLookup returns the facts plus, when the account cannot be used, ONE sentence saying why. That sentence is the substantive half and is now written once: the step throws it with a `login:` prefix, preflight prints it on the MISSING line. Each caller still phrases its own success line, since one logs and the other prints a fixture row. No password crosses that boundary to anything that prints. 248 lines net out of the two programs, 197 into the module. docent.js is no longer a single file, and three places now say so: its own require, the Run section of README.md, and docent.mk, whose mp4 rule gains targetConf.js as a prerequisite -- a change there changes what a tour renders, so it has to rebuild one. Nothing in the tree or in ~/docentTours copies docent.js; they all reference it where it sits, with targetConf.js beside it. Measured before and after, with no other change: preflight resolves the same account, central and conf for genome-test, hgwdev-braney, a ts park on 48087 and hgwbeta (which correctly has no section); tests/ is 18 of 18 with the derive baselines matching; tests/regress preflights 14 fixtures for 67 scripts. diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index 6efe9200c2e..626bbc0c08f 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -14,30 +14,34 @@ * * The high-level verbs bake in the quickLift/Convert mechanics (dbSNP composite * params, the hideDefaults-reverts-on-assembly-change bug, target lookup) so the * author writes intent, not selectors. See README.md for the language. * * The surface syntax is YAML so ordinary editors highlight it; the language is the * verb vocabulary layered on top, not the serialization. Scripts are named * .docent.yaml (a bare .docent also works). */ const { chromium } = require('playwright'); const yaml = require('js-yaml'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); +// Shared with tests/preflight.js so a run and its fixture check cannot disagree about +// which server is being driven, which hg.conf it reads, or which account signs in. +// docent.js is no longer a single file: targetConf.js has to travel with it. +const { resolveTarget, serverFor, loginLookup } = require('./targetConf.js'); // ---------- parse script + config ---------- const SCRIPT = process.argv[2]; if (!SCRIPT) { console.error('usage: node docent.js SCRIPT.docent.yaml [OUT.mp4]'); process.exit(2); } const doc = yaml.load(fs.readFileSync(SCRIPT, 'utf8')) || {}; // Lint: in a YAML flow map a colon needs a trailing space, so `{item:name5568747}` // parses as ONE key "item:name5568747" (value null) and the intended `item:` arg is // silently dropped -- the verb then quietly falls back to a default. Catch that here // (before the long browser run) by flagging any arg key that contains a ':'. (function lintSteps(steps) { let n = 0; const scan = (obj, where) => { if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { @@ -59,50 +63,38 @@ const base = path.basename(SCRIPT).replace(/\.(docent\.)?ya?ml$/i, '').replace(/\.docent$/i, ''); const FIGDIR = path.resolve(HERE, '..'); // figures dir beside the scripts const OUTMP4 = process.argv[3] || doc.mp4 || path.join(FIGDIR, base + '.mp4'); // Stills go to stills//. DOCENT_STILLS names a different PARENT ("stills.hires"), // which is how a high-resolution run keeps its figures beside the screen-resolution ones // instead of overwriting them. const STILLPARENT = process.env.DOCENT_STILLS; const STILLDIR = STILLPARENT ? path.resolve(HERE, STILLPARENT, base) : doc.stills ? path.resolve(HERE, doc.stills) : path.join(HERE, 'stills', base); // Saved sessions go to sessions//, beside stills/. `sessions:` and DOCENT_SESSIONS // name the PARENT (not the per-scenario directory), so `sessionUrlBase:` below always maps // onto it as /.txt, and a print run keeps its files out of the screen run's way. const SESSDIR = path.join( path.resolve(HERE, process.env.DOCENT_SESSIONS || doc.sessions || 'sessions'), base); -// `target:` takes a shorthand from this table, a bare `hgwdev-` sandbox name -// (expanded below), or a full https://.../cgi-bin URL. Default is genome-test, so a -// script that forgets to say where it runs does not silently hit someone's sandbox. -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 resolveTarget = t => { - if (!t) return SERVERS['genome-test']; - if (SERVERS[t]) return SERVERS[t]; - if (/^hgwdev-[a-z0-9._-]+$/i.test(t)) return `https://${t}.gi.ucsc.edu/cgi-bin`; // personal sandbox - return t; // full URL -}; -// DOCENT_TARGET overrides `target:` for the whole run, so a suite written against one -// server can be pointed at another -- a sandbox, a ticket park, a demo browser -- without -// editing the scripts it is written from. The trackDb cache below keys on SERVER, so a -// redirected run cannot read back a listing fetched from the server the script names. -const SERVER = resolveTarget(process.env.DOCENT_TARGET || doc.target).replace(/\/$/, ''); +// `target:` takes a shorthand (rr, genome-test, hgwdev, hgwbeta), a bare `hgwdev-` +// sandbox name, or a full https://.../cgi-bin URL; DOCENT_TARGET overrides it for the +// whole run, so a suite written against one server can be pointed at another -- a sandbox, +// a ticket park, a demo browser -- without editing the scripts. Both rules live in +// targetConf.js, beside the fixture check that has to agree with them. The trackDb cache +// below keys on SERVER, so a redirected run cannot read back a listing fetched from the +// server the script names. +const SERVER = serverFor(doc.target); // SCALE: the same tour rendered at k times the resolution, for figures that have to print. // Nothing is upscaled -- a still only ever has the pixels it was drawn with -- so each layer // is asked to draw k times as many while the layout is left alone: // // * deviceScaleFactor: k. The viewport keeps its 1x CSS size, so the page lays out exactly // as at 1x -- same line breaks, same jQuery-dialog width, same tooltip placement -- and // every bit of it is rasterized with k times the pixels. The retina case, natively. // * `pix` x k, so the server draws the browser image k times as wide, with `textSize` // stepped up to match so hgTracks makes the SAME layout decisions in that bigger image: // same tick spacing, same room for labels, same packing of features into rows. Without // the font, a wider image is a different picture rather than a bigger one. // * `zoom: 1/k` on the image table (SCALE_INIT below), handing that k-times-wider image // back the 1x amount of layout space. One image pixel then falls on exactly one device // pixel: native resolution, no resampling anywhere in the path. // @@ -328,164 +320,39 @@ r.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(6)', opacity: 0 }], { duration: 520, easing: 'ease-out' }); setTimeout(() => r.remove(), 540); }, true); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add(); }; // ---------- LOGIN: the one fixture that cannot live in the repository ---------- // hgCollection, and the saving half of hgSession, refuse to run for a visitor who is not // logged in (hgCollection.c doMiddle, "You must be logged in to edit collections"). The // login cookie is validated against a salted hash (login.cookieSalt, hg/lib/wikiLink.c), // so there is no way to hand the browser a cookie: a script that needs one of those pages // has to sign in the way a person does. // -// An account is a row in gbMembers in ONE hgcentral, so that database is what the -// credentials are keyed by -- not the server, and not the sandbox. genome-test, hgwdev, -// every hgwdev- sandbox and every ticket park read hgcentraltest, so one account -// covers all of them; hgwbeta reads hgcentralbeta and the RR reads hgcentral, which are -// different sets of accounts entirely. -// -// Which central a server reads is READ rather than assumed, because a sandbox may say so -// for itself: of the personal hg.conf files on hgwdev today, 45 set central.db to -// hgcentraltest and two do not (hgcentralgsid, hgcentralbeta). tests/preflight.js has a -// fuller copy of this hg.conf reader and prints what it found; keep the two in step. -const CENTRAL_BY_HOST = { // servers whose hg.conf is on another machine - 'genome.ucsc.edu': 'hgcentral', - 'genome-euro.ucsc.edu': 'hgcentral', // its own database of the same name - 'genome-asia.ucsc.edu': 'hgcentral', // ... and so is this one - 'hgwbeta.soe.ucsc.edu': 'hgcentralbeta', -}; - -function hgConfFor(server) { - let u; - try { u = new URL(server); } catch (e) { return null; } - const host = u.hostname, name = host.split('.')[0]; - if (name === 'hgwdev' || name === 'genome-test') return '/usr/local/apache/cgi-bin/hg.conf'; - if (name.startsWith('hgwdev-')) return `/usr/local/apache/cgi-bin-${name.slice(7)}/hg.conf`; - if ((host === '127.0.0.1' || host === 'localhost') && u.port) { - const root = process.env.TS_ROOT || path.join(os.homedir(), 'ticketSandboxes'); - let reg; - try { reg = fs.readFileSync(path.join(root, 'ports.tsv'), 'utf8'); } catch (e) { return null; } - for (const line of reg.split('\n')) { - const f = line.split('\t'); - if (f[1] === u.port) return path.join(root, f[0], 'cgi-bin', 'hg.conf'); - } - } - return null; -} - -// hg.conf as hg/lib/hgConfig.c reads it: `include` is relative to the including file, -// `delete` drops a name, and a later assignment wins over an earlier one. -function readHgConf(file, out = new Map(), seen = new Set(), depth = 0) { - if (depth > 10 || seen.has(file)) return out; - seen.add(file); - let text; - try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return out; } - for (const raw of text.split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - if (/^include\s/.test(line)) - readHgConf(path.resolve(path.dirname(file), line.slice(7).trim()), out, seen, depth + 1); - else if (/^delete\s/.test(line)) - for (const name of line.slice(6).trim().split(/\s+/)) out.delete(name); - else { - const eq = line.indexOf('='); - if (eq > 0) out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim()); - } - } - return out; -} - -function centralDbFor(server) { - const file = hgConfFor(server); - if (file) { - const db = readHgConf(file).get('central.db'); - if (db) return db; - } - try { return CENTRAL_BY_HOST[new URL(server).hostname] || null; } catch (e) { return null; } -} - -// A password cannot go in a script. It is read from a file outside the tree that only its -// owner can read -- the arrangement hg.conf uses for hg.conf.private, and for the same -// reason -- one section per hgcentral: -// -// [hgcentraltest] -// user=docentTest -// password=... -// -// [default] is used when no section matches, and when the central cannot be worked out -// at all (a server on another machine that is not in CENTRAL_BY_HOST). -// -// Nothing here prints a password, and `login:` has no argument that could carry one. -function loginFile() { - return process.env.DOCENT_LOGIN_FILE || path.join(os.homedir(), '.docentLogin'); -} - -// Parse the sectioned file into [{central, user, password}], in file order. Lines before -// any [section] are ignored rather than treated as a default: an unsectioned file is one -// written against the older single-account form, and silently using it everywhere is how -// an hgcentraltest password would reach the RR. -function loginSections(text) { - const out = []; - let cur = null; - for (const raw of text.split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const sec = /^\[(.+)\]$/.exec(line); - if (sec) { cur = { central: sec[1].trim(), user: '', password: '' }; out.push(cur); continue; } - const eq = line.indexOf('='); - if (eq < 0 || !cur) continue; - const k = line.slice(0, eq).trim(), v = line.slice(eq + 1).trim(); - if (k === 'user' || k === 'password') cur[k] = v; - } - return out; -} - +// Which account that is, and why it is keyed by hgcentral rather than by server, is in +// targetConf.js. All this adds is the failure: `login:` is a step, so it throws, while +// preflight reports the same sentence as a missing fixture. A password cannot go in a +// script, `login:` has no argument that could carry one, and nothing here prints one. function loginCreds(server) { - const env = process.env; - // A single-run override, for trying an account without writing it down. It applies to - // whatever server this run drives, which is why it wins over the file. - if (env.DOCENT_LOGIN_USER && env.DOCENT_LOGIN_PASSWORD) - return { user: env.DOCENT_LOGIN_USER, password: env.DOCENT_LOGIN_PASSWORD, from: 'the environment' }; - const central = centralDbFor(server); - const where = central ? `${server} (central.db ${central})` : `${server} (central.db unknown)`; - const file = loginFile(); - let text; - try { text = fs.readFileSync(file, 'utf8'); } - catch (e) { - throw new Error(`login: no credentials for ${where}. Write ${file} with a ` - + `[] section holding "user=" and "password=" lines (mode 0600), ` - + `or set DOCENT_LOGIN_USER and DOCENT_LOGIN_PASSWORD for this run.`); - } - // Refuse a file anyone else can read, the way hg/lib/hgConfig.c checkConfigPerms refuses - // a group- or world-readable hg.conf. A test that quietly used a readable password file - // would make one on every machine it ran on. - const perm = fs.statSync(file).mode & 0o777; - if (perm & 0o077) - throw new Error(`login: ${file} is readable by group or other (mode ${perm.toString(8)}); chmod 600 it`); - const secs = loginSections(text); - const match = (central && secs.find(x => x.central === central)) - || secs.find(x => x.central === 'default'); - if (!match) - throw new Error(`login: ${file} has no section for ${where}` - + (secs.length ? ` (it has ${secs.map(x => `[${x.central}]`).join(' ')})` : ' (it has no [section] at all)') - + '; add one, or a [default]'); - if (!match.user || !match.password) - throw new Error(`login: [${match.central}] in ${file} needs a "user=" line and a "password=" line`); - return { user: match.user, password: match.password, from: `[${match.central}] in ${file}` }; + const c = loginLookup(server); + if (c.why) throw new Error(`login: ${c.why}`); + return { user: c.user, password: c.password, + from: c.source === 'the environment' ? c.source : `[${c.section}] in ${c.source}` }; } function absurl(u) { if (/^https?:/.test(u)) return u; if (u.startsWith('/cgi-bin/')) return SERVER.replace(/\/cgi-bin$/, '') + u; if (u.startsWith('/')) return SERVER.replace(/\/cgi-bin$/, '') + u; return SERVER + '/' + u; } // DOCENT_DERIVE=1: print what each `track:` step turns into and stop, with no browser and // no server drive. Most of Docent's own decisions live in that derivation -- which // containers come along, which `_sel` goes with them, where a hideKids walk stops -- and // until now the only way to see them was the log of a full run against a live view. This // makes them cheap to look at, and cheap to diff when the derivation is changed. const DERIVE = !!process.env.DOCENT_DERIVE;