3d98068ead92a95ea4b714f31f01119100a3c099
braney
  Wed Sep 16 10:26:18 2026 -0700
docent: preflight prints how the target is configured, refs #37892

make test TARGET=hgwdev-you points the whole directory at another server, and a
red script there can be that machine's configuration rather than a bug.  A
different curatedHubPrefix genuinely changes what a quickLift hop produces, and
a db.trackDb with a private table in front of the shared one changes what
exact: true counts.  Both failures look exactly like a bug.  The README already
warned about it, in prose, which arrives after someone has spent an hour.

make preflight now prints the settings that decide it, so a redirected run
carries its own explanation in the log:

target      https://hgwdev-braney.gi.ucsc.edu/cgi-bin
/usr/local/apache/cgi-bin-braney/hg.conf
central.db                   hgcentraltest
db.trackDb                   trackDb_braney,trackDb
curatedHubPrefix             braney
browser.quickLift            on
browser.quickLiftAlignments  on
browser.recTrackSets         on

central.db is on the list because preflight already checks named sessions and
that setting says which hgcentral it looked in.

The value printed is the EFFECTIVE one.  The reader follows include and delete
the way hg/lib/hgConfig.c does, with a later assignment winning over an earlier
one, so a sandbox conf that sets nothing still shows what it inherits from the
shared conf it includes.  Reading cgi-bin-braney/hg.conf on its own says
browser.quickLiftAlignments is unset; the CGI there sees it on, from the
include on line 2.  A ts park is the mirror case: 37547's frozen conf sets
db.trackDb twice and the second one, 114 lines later, wins.

Only the six keys in HG_CONF_KEYS are ever printed.  The includes lead to
hg.conf.private, which holds database passwords, so the reader parses whatever
it is pointed at and prints nothing off that list.

There is no way to ask a browser over http what its hg.conf says, so which file
to read is a lookup by convention: genome-test and hgwdev read
/usr/local/apache/cgi-bin/hg.conf, an hgwdev-<name> sandbox or demo reads
cgi-bin-<name>, and a ts park on 127.0.0.1 is found by port in its ports.tsv.
Anything off this machine -- hgwbeta, the RR -- says the conf cannot be read
from here, which is true and better than a guess.

Even with that in the log, a config difference and a code difference can still
look alike.  The reliable way to tell them apart is to swap only the binary:
drop a control build's CGIs into the same sandbox, leave its hg.conf alone, and
re-run.  If the failures follow the binary they are the code.  That experiment
is what turned nine plausible failures on the #37547 branch into nine proven
ones, and both READMEs now say so.

Verified against all three shapes: genome-test, hgwdev-braney, hgwdev-demo9,
and the #37547 park on 127.0.0.1:48087.  Both test directories preflight clean.

diff --git src/hg/utils/docent/tests/preflight.js src/hg/utils/docent/tests/preflight.js
index 61aa2fda210..dca3fed0c66 100644
--- src/hg/utils/docent/tests/preflight.js
+++ src/hg/utils/docent/tests/preflight.js
@@ -10,51 +10,142 @@
  * been renamed or deleted produces no error: hgTracks answers 200 with a page titled
  * "Very Early Error" whose body reads "Could not find session NAME for user USER", the
  * page carries no track image, and every `noText:` assertion on it passes. The run goes
  * green while testing nothing. A hub URL that has moved behaves the same way.
  *
  * Fixtures are read out of the scripts rather than from a list kept beside them, so the
  * two cannot drift. A fixture named in no script is not checked, which is the point.
  *
  * Exit 0 if every fixture resolved, 1 otherwise, so a nightly run can tell "the fixtures
  * are gone" from "a bug came back". Without this they are the same red.
  *
  * With no script names it checks every *.docent.yaml in DIR, which is what you want
  * interactively. Name scripts to check only those: the nightly run passes the COMMITTED
  * list, because a directory also holds work in progress and a dead fixture belonging to
  * a script that is not running should not be reported as a problem.
+ *
+ * It also prints how the target is CONFIGURED, which is the other half of reading a
+ * redirected run. `make test TARGET=hgwdev-you` against a personal sandbox can go red
+ * for reasons that are not the code: a different curatedHubPrefix changes what a
+ * quickLift hop produces, and a db.trackDb with a private table in front of the shared
+ * one changes what `exact: true` counts. Those failures look exactly like a bug, and
+ * telling the two apart has cost an hour more than once. So when the target is served
+ * from THIS machine the settings that decide it are printed beside the fixtures, and the
+ * log then carries its own explanation.
+ *
+ * Only the handful of settings named in HG_CONF_KEYS is ever printed. hg.conf includes
+ * hg.conf.private, which holds database passwords, so the reader below parses whatever
+ * the includes lead to and prints nothing that is not on that list.
  */
 'use strict';
 const fs = require('fs');
 const path = require('path');
+const os = require('os');
 const yaml = require('js-yaml');
 
 const DIR = process.argv[2] || '.';
 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`;
   return t;
 };
 const enc = encodeURIComponent;
 
+// The settings that change what a test sees, and nothing else. central.db says which
+// hgcentral the named sessions above were looked for in; db.trackDb which trackDb tables
+// the track names resolve against; curatedHubPrefix which curated hubs are attached; the
+// browser.* three whether the features several scripts here depend on are even on.
+const HG_CONF_KEYS = ['central.db', 'db.trackDb', 'curatedHubPrefix',
+                      'browser.quickLift', 'browser.quickLiftAlignments',
+                      'browser.recTrackSets'];
+
+// Which hg.conf the server named by a target: reads, when that server runs on this
+// machine. There is no way to ask a browser over http what its hg.conf says, so this is
+// a lookup by convention rather than a measurement, and it answers null for anything off
+// this host -- hgwbeta, the RR, a colleague's machine.
+function hgConfFor(server) {
+  let u;
+  try { u = new URL(server); } catch (e) { return null; }
+  const host = u.hostname;                            // 127.0.0.1 keeps its dots
+  const name = host.split('.')[0];                    // hgwdev-braney out of the FQDN
+  if (name === 'hgwdev' || name === 'genome-test') return '/usr/local/apache/cgi-bin/hg.conf';
+  if (name.startsWith('hgwdev-'))                     // a sandbox or a demo browser
+    return `/usr/local/apache/cgi-bin-${name.slice('hgwdev-'.length)}/hg.conf`;
+  if ((host === '127.0.0.1' || host === 'localhost') && u.port) {
+    // A ticket park from `ts`: its port is in the registry, and the frozen hg.conf sits
+    // under the ticket's own directory. A park is the one target whose conf is NOT the
+    // live sandbox's, which is the whole reason for parking it.
+    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` pulls another file in relative to the
+// including one, `delete` drops a name, and a later assignment wins over an earlier one
+// (parseConfigLine hashAdds and cfgOption reads the most recently added).
+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;
+}
+
+const PAD = '              ';
+function reportTargetConf(server) {
+  console.log(`  target      ${server}`);
+  const file = hgConfFor(server);
+  if (!file) {
+    console.log(`${PAD}not on this machine, so its hg.conf cannot be read from here`);
+    return;
+  }
+  if (!fs.existsSync(file)) {
+    console.log(`${PAD}${file} -- no such file`);
+    return;
+  }
+  const conf = readHgConf(file);
+  const w = Math.max(...HG_CONF_KEYS.map(k => k.length));
+  console.log(`${PAD}${file}`);
+  for (const k of HG_CONF_KEYS)
+    console.log(`${PAD}${k.padEnd(w)}  ${conf.has(k) ? conf.get(k) : 'unset'}`);
+}
+
 const fixtures = [];
 const add = f => fixtures.push(f);
 
 async function get(url) {
   const r = await fetch(url, { redirect: 'follow' });
   return { status: r.status, body: await r.text() };
 }
 
 // A session is present when the page does NOT carry hgSession's own not-found message.
 // Requiring a track image instead would be wrong: a session may legitimately restore a
 // view with every track hidden.
 function sessionCheck(server, user, name) {
   const url = `${server}/hgTracks?hgS_doOtherUser=submit`
     + `&hgS_otherUserName=${enc(user)}&hgS_otherUserSessionName=${enc(name)}`;
   return async () => {
@@ -138,30 +229,33 @@
       if (url) add({ script: f, kind: 'hub', label: url, check: urlCheck(url, 'hub') });
     } else if (verb === 'addCustomTrack') {
       if (o && o.url) add({ script: f, kind: 'ct-url', label: o.url, check: urlCheck(o.url) });
       const rel = o && (o.file || o.pasteFile);
       if (rel) add({ script: f, kind: 'file', label: rel, check: fileCheck(path.join(DIR, rel)) });
     }
   }
 }
 
 for (const [server, f] of seenServer) {
   fixtures.unshift({ script: f, kind: 'server', label: server,
                      check: urlCheck(`${server}/hgTracks?db=hg38&pix=800`) });
 }
 
 (async () => {
+  // Before the fixtures, because it is what a red line further down has to be read
+  // against. Costs no network and no browser.
+  for (const server of seenServer.keys()) reportTargetConf(server);
   if (!fixtures.length) {
     console.log(`preflight: ${scripts.length} script(s), no external fixtures to check`);
     process.exit(0);
   }
   // One fixture named by several scripts is one check, reported against all of them.
   const byKey = new Map();
   for (const fx of fixtures) {
     const k = `${fx.kind} ${fx.label}`;
     if (!byKey.has(k)) byKey.set(k, { ...fx, scripts: [] });
     byKey.get(k).scripts.push(fx.script);
   }
   const all = [...byKey.values()];
   const results = await Promise.all(all.map(async fx => {
     let why;
     try {