1fbda5badde574c10884e5339fdda1f5b7495990
braney
  Thu Sep 24 13:45:30 2026 -0700
docent: regression script for the Sessions page Replace keeping who can load it, refs #38311

rm38311 saves a private session from the save card, saves over it with the
private box unticked, and checks that the row still has its lock.  It passes on
hgwbeta (v504) and genome-test, and fails on hgw0 (v503).

Docent changes it needed:
- a fill: verb, to type into any form field
- login: no longer dies when a navigation is still under way during its
bad-password check, which is what broke it on ticket parks
- a park is driven on its https port, because hgLogin posts its form to
https:// on the port it was reached on; the parks' self-signed certificate
is accepted on loopback targets only
- hgw0 maps to hgcentral, and a park's https port finds its hg.conf

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index 84fc05298e5..1f2e7842d24 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -379,32 +379,38 @@
 
 // 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;
 
 const T_START = Date.now();
 (async () => {
   // Before the browser: nothing in the derivation touches the page, and the point is to
   // not pay for one. (The helpers below are function declarations, so they are hoisted.)
   if (DERIVE) { await deriveMain(); return; }
   fs.mkdirSync(STILLDIR, { recursive: true });
   const browser = await chromium.launch({ headless: true, args: ['--force-color-profile=srgb'] });
+  // A ticket park's https port serves one self-signed certificate shared by every park, so
+  // accept it there -- and only there, never for a real server. The https port is the one a
+  // `login:` needs on a park: hgLogin writes its form action as https:// on the port it was
+  // reached on, so the plain-http port posts the password to a TLS listener that is not there.
+  const loopback = /^https:\/\/(127\.0\.0\.1|localhost)(:|\/)/.test(SERVER);
   const ctx = await browser.newContext({
     viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE,
+    ...(loopback ? { ignoreHTTPSErrors: true } : {}),
     ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }),
   });
   if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS);
   await ctx.addInitScript(TIP_INIT);
   await ctx.addInitScript(CURSOR_INIT, { box: CURSOR_BOX, svg: CURSOR_SVG });
   await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} });
   const page = await ctx.newPage();
   const T_REC = Date.now();        // the recorder starts with the page; see FLASH below
   const shotSecs = [];             // when each shot: was taken, seconds into the recording
   const cur = { x: 120, y: 120 };
   const pinnedTips = [];   // recorded mouseover tooltips for the next pinShot (per view)
 
   async function captureState() {
     try {
       const u = new URL(page.url());
@@ -2281,31 +2287,43 @@
         // needs a user -- hgCollection above all. Credentials come from loginCreds(),
         // never from the script. Takes no argument, or {shot:}.
         const o = (arg && typeof arg === 'object') ? arg : {};
         const c = loginCreds(SERVER);
         console.log(`LOGIN ${c.user} on ${SERVER} (credentials from ${c.from})`);
         await nav('/cgi-bin/hgLogin?hgLogin.do.displayLoginPage=1');
         await page.waitForSelector('#accountLoginForm', { timeout: 15000 });
         await glideTo('#userName'); await page.click('#userName');
         await typeIn(page, '#userName', c.user);
         await typeIn(page, '#password', c.password);
         await clickGlide('input[name="hgLogin.do.displayLogin"]');
         await page.waitForLoadState('load');
         // hgLogin answers a bad password by drawing the same form again with a red
         // message, which is a perfectly good page: without this check every later step
         // would run logged out and the failure would surface somewhere else entirely.
-        if (await page.$('#accountLoginForm')) {
+        // On a fast server (a loopback ticket park) a navigation can still be under way when
+        // this query runs -- the POST's own answer, or the good-password page's redirect
+        // below -- and Playwright throws "Execution context was destroyed". Either page
+        // could be the one arriving, so wait for it to load and ask again rather than guess.
+        let onForm;
+        for (let tries = 0; ; tries++) {
+          try { onForm = await page.$('#accountLoginForm'); break; }
+          catch (e) {
+            if (!/Execution context was destroyed/.test(e.message) || tries >= 4) throw e;
+            await page.waitForLoadState('load').catch(() => {});
+          }
+        }
+        if (onForm) {
           const why = (await page.innerText('body')).split('\n').map(l => l.trim())
                         .filter(Boolean).slice(0, 8).join(' | ');
           throw new Error(`login: still on the login page as ${c.user} -- ${why}`);
         }
         // hgLogin answers a good password with a page that navigates ITSELF a moment
         // later: returnToURL(150) writes setTimeout(function(){location=...}, 150). Return
         // while that timer is pending and the next step's goto: races it, and the browser
         // aborts one of the two -- which arrives as a flat `net::ERR_ABORTED` on a URL
         // that is perfectly fine. So wait for the redirect to land before going on. The
         // failure path above is checked first, since that page never leaves hgLogin and
         // there is no redirect to wait for.
         await page.waitForURL(u => !/\/hgLogin(\?|$)/.test(String(u)), { timeout: 10000 })
                   .catch(() => {});
         await page.waitForLoadState('load').catch(() => {});
         await captureState();
@@ -2416,30 +2434,43 @@
             await captureState();
           }
           if (arg.shot) { await shot(arg.shot); return; }
         } else {
           // Plain selector click. Strip target=_blank first so an external link (e.g. a
           // dbSNP id -> NIH) navigates in THIS tab instead of a popup we can't screenshot,
           // then wait out the navigation so a following shot captures the destination page
           // (a no-op if the click didn't navigate).
           await page.evaluate(s => document.querySelectorAll(s).forEach(e => e.removeAttribute('target')), arg).catch(() => {});
           await clickGlide(arg);
           await page.waitForLoadState('load').catch(() => {});
           await captureState();
         }
         break;
       case 'hover': await glideTo(arg); await page.hover(arg); break;
+      case 'fill': {
+        // Type into an arbitrary form field: {<selector>: <text>}, one or more pairs, in order.
+        // The named verbs type into the boxes they own (position, hub URL, hub search); this is
+        // for any other box, such as the session name on the Sessions page save card (#38311).
+        // The field is emptied first, so a value left from an earlier step cannot run into it.
+        if (!arg || typeof arg !== 'object') throw new Error('fill: takes {<selector>: <text>}');
+        for (const [sel, text] of Object.entries(arg)) {
+          await glideTo(sel);
+          await page.fill(sel, '');
+          await typeIn(page, sel, text);
+        }
+        break;
+      }
       case 'wait': {
         // A selector to wait FOR, or {gone: <sel>} for one to wait OUT. Both directions are
         // needed because a script that asserts what a click did has to wait on the half of the
         // answer that settles LAST, and that is not always the half that appears. #38257 is the
         // case that forced it: hgHubConnect.js switches the tab inside the click dispatch, while
         // topLinks.js closes the Account popup from a setTimeout(..., 0), so waiting for the tab
         // returns a tick early and the popup is still on the page. That is a race the script
         // loses about half the time, and it reads as the fix having come undone.
         if (arg && typeof arg === 'object') {
           if (!arg.gone) throw new Error('wait: an object argument takes gone: <selector>');
           await page.waitForSelector(arg.gone, { state: 'detached', timeout: 15000 });
         } else {
           await page.waitForSelector(arg, { timeout: 15000 });
         }
         break;