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: }, 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: } 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: '); + 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();