6f3f3596f3dc61a8a738717b9701e69a86b5bb0b
braney
  Mon Sep 21 17:24:33 2026 -0700
docent: wait: {gone:} for a selector to leave, and rm38257 stops racing itself

rm38257 went red in the 2026-09-21 nightly with the #38257 fix still live on
genome-test.  The script was waiting on the wrong half of the click.

hgHubConnect.js switches the tab synchronously inside the click dispatch, while
topLinks.js closes the Account popup from a setTimeout(..., 0).  So the tab is
the half that settles FIRST, and waiting for it returned a tick early: the
expect after it read a page that still had the popup on it.  Measured with a
MutationObserver over the click, the tab goes active at t=43ms and the popup
goes on the next task.

wait: now also takes {gone: <selector>}, which waits for a selector to leave the
DOM, so a script can wait on the vanishing half of an answer.  rm38257 uses it.

Six runs each way on genome-test, same build, only the script differing: 6 of 6
green with the new wait:, 3 of 6 red with the old one, failing at the same step
with the same message the nightly printed.  The whole directory is green, 98 of
98.

refs #37892, refs #38252

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index bfd84537ada..84fc05298e5 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -2416,31 +2416,46 @@
             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 'wait': await page.waitForSelector(arg, { timeout: 15000 }); 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;
+      }
       case 'sleep': await sleep(Number(arg)); return;
       default: console.warn('unknown verb:', verb);
     }
     await sleep(PACE);
   }
 
   if (SCALE > 1)
     console.log(`scale: ${SCALE}x -- pix=${PIX}, textSize=${TEXTSIZE}, dpr=${SCALE} at ${VW}x${VH}, `
                 + `stills only (no mp4) -> ${STILLDIR}`);
   if (doc.reset) await page.goto(absurl('/cgi-bin/cartReset?skipLs=1'), { waitUntil: 'domcontentloaded' });
   const steps = doc.steps || [];
   const timing = [];
   for (let i = 0; i < steps.length; i++) {
     const s = norm(steps[i]);
     const t0 = Date.now();