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