9bd1a5356bcf1a68ea715e5b0d9c3731203f6d88 braney Sat Sep 26 12:12:14 2026 -0700 docent: hubUpload: verb, which drops files into the Hub Upload dashboard, and a value: check in expect: for what a form field holds, refs #37892, #38398 Nothing is uploaded by hubUpload:, so a run leaves nothing on the server. value: exists because a selector cannot read a field that a framework draws: it sets the value property, and [value=...] reads only the attribute. diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index 1f2e7842d24..bece0ad1c9f 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -1561,30 +1561,31 @@ // that never hid, a pinned tooltip that grabbed the neighbouring item, an Apache 414 // page where the view should be. All of those shipped once and all were caught by eye. // Stating the expectation instead stops the run, non-zero, at the step that broke it -- // `make` then fails rather than writing a wrong figure over a right one. // // expect: {rows: [ruler, mane]} these rows were drawn // expect: {rows: [ruler, mane], exact: true} ... and nothing else // expect: {rows: [ruler, mane], ordered: true} ... in that order, top to bottom // expect: {noRows: [clinvarCnv]} this row was not // expect: {height: 2000} the still is no taller than this ("<1200" etc.) // expect: {tip: "mismatch A->C"} the tooltip now up says this // expect: {text: "...", noText: "..."} the page does / does not contain this // expect: {url: "hgSearch", noUrl: "%E2%80%8B"} the address bar does / does not // expect: {has: "#td_data_mane map[name=map_center_mane]"} this selector matches // expect: {noHas: "#td_data_knownGene map[name=map_center_mane]"} ... does not + // expect: {value: {sel: "#positionInput", is: "chr7:1-100"}} this form field holds this // expect: {box: {sel: "#topRightLinks", inside: "#main-menu-whole"}} where it sits // expect: {color: {track: crm4, is: "0,0,255"}} the items in that row are drawn blue // expect: {color: {track: crm4, part: label, is: "0,255,0"}} ... its center label green // // `text:`, `noText:`, `has:`, `noHas:`, `rows:` and `noRows:` all take one value or a LIST // of them. That matters most for the two text checks: a check that stringifies its argument // turns ["a", "b"] into "a,b", which no page contains, so it would pass on anything -- and // pass silently, which is worse than failing. // // `url:`/`noUrl:` are a substring check on the CURRENT address, which is the only place // some things are visible at all: which CGI a click actually reached, and what the page // put in a query string. #36387's fix strips zero-width characters out of a search term // before the position box submits it, and the term is invisible in the rendered page -- // the only evidence either way is whether `%E2%80%8B` survives into the URL. // @@ -1682,30 +1683,53 @@ if (seen.text.includes(want)) bad.push(`page contains "${want}"`); if (o.url != null && !url.includes(String(o.url))) bad.push(`url is "${url}", wanted it to contain "${o.url}"`); if (o.noUrl != null && url.includes(String(o.noUrl))) bad.push(`url contains "${o.noUrl}": ${url}`); for (const sel of list(o.has)) { const n = await page.locator(sel).count().catch(() => -1); if (n === 0) bad.push(`nothing matches "${sel}"`); else if (n < 0) bad.push(`has: cannot read the selector "${sel}"`); } for (const sel of list(o.noHas)) { const n = await page.locator(sel).count().catch(() => -1); if (n > 0) bad.push(`${n} element(s) match "${sel}", wanted none`); else if (n < 0) bad.push(`noHas: cannot read the selector "${sel}"`); } + // value: what a form field holds NOW. A selector cannot ask that: a framework that draws + // the field sets the value PROPERTY, and [value=...] matches only the attribute, which it + // never touches. The selector has to name exactly one field, since reading the first of + // several would quietly pick one. A list is allowed, like color:. + for (const one of (o.value == null ? [] : (Array.isArray(o.value) ? o.value : [o.value]))) { + if (!one || typeof one !== 'object' || !one.sel) { + bad.push('value: takes {sel: , is: } or not: in place of is:'); + continue; + } + if (one.is == null && one.not == null) { bad.push(`value: ${one.sel} needs is: or not:`); continue; } + const n = await page.locator(one.sel).count().catch(() => -1); + if (n !== 1) { + bad.push(n < 0 ? `value: cannot read the selector "${one.sel}"` + : `value: ${n} elements match "${one.sel}", wanted exactly one`); + continue; + } + const got = await page.locator(one.sel).inputValue({ timeout: 2000 }).catch(() => null); + if (got == null) bad.push(`value: "${one.sel}" is not a form field`); + else if (one.is != null && got !== String(one.is)) + bad.push(`${one.sel} holds "${got}", wanted "${one.is}"`); + else if (one.not != null && got === String(one.not)) + bad.push(`${one.sel} holds "${got}", which it should not`); + } // box: where an element sits. A list is allowed and every entry is checked, so one step // can state a whole layout and a failure names every part of it that came out wrong. for (const one of (o.box == null ? [] : (Array.isArray(o.box) ? o.box : [o.box]))) bad.push(...await boxCheck((typeof one === 'object') ? one : { sel: one })); // color: the pixels hgTracks drew in a row, which no other check here can see. A list // is allowed, and every entry is checked, so one step can state the whole of a color // matrix and a failure names every row that came out wrong rather than only the first. for (const one of (o.color == null ? [] : (Array.isArray(o.color) ? o.color : [o.color]))) { const c = (typeof one === 'object') ? one : { is: one }; const where = `${c.track}${(c.part === 'label' || c.part === 'center') ? "'s center label" : ''}`; if (!c.track) bad.push('color: needs a track'); else if (c.is == null && c.not == null) bad.push('color: needs is: or not:'); else { const got = await rowColors(c).catch(e => ({ err: e.message })); const show = g => g.top.slice(0, 3) @@ -2353,30 +2377,70 @@ await clickGlide('#Submit'); await page.waitForLoadState('load'); // The manage page appears on success; click through to the browser (a data error // re-shows the add page instead, so guard on the button being present). const goSel = (o.goto === 'current') ? '#submitGoBack' : '#submit'; if (await page.locator(goSel).count()) { await clickGlide(goSel); await page.waitForSelector('#imgTbl').catch(() => {}); await captureState(); } else { console.warn('addCustomTrack: submit did not reach the manage page (data error?)'); } if (o.shot) { await shot(o.shot); return; } break; } + case 'hubUpload': { + // Add files to the Hub Upload dashboard (hgHubConnect -> Hub Upload -> Upload), the + // way a user dropping them there would. Takes {name:, text:} for one file, or + // {files: [{name:, text:}, ...]} for several, and shot:. The files in one step arrive + // together, as one batch, which is what the dashboard does with a multi-file drop. + // Nothing is uploaded: the files wait in the dashboard, so a run leaves nothing on the + // server. The page needs a user, so a login: step comes first. + const o = (arg && typeof arg === 'object') ? arg : {}; + const files = o.files || (o.name != null ? [{ name: o.name, text: o.text }] : []); + if (!files.length) + throw new Error('hubUpload: takes {name:, text:} or {files: [{name:, text:}, ...]}'); + await nav(`/cgi-bin/hgHubConnect?db=${state.db}#hubUpload`); + await page.waitForSelector('#hubUpload', { timeout: 15000 }); + // The Upload button is a DataTables button, disabled until the user's file list has + // come back, which only happens for a logged-in user on a server with storeUserFiles. + await page.waitForFunction(() => { + const b = document.querySelector('.uploadButton'); + return !!b && !b.classList.contains('disabled'); + }, null, { timeout: 20000 }).catch(() => { + throw new Error('hubUpload: the Upload button never became clickable -- is there a ' + + 'login: step before this one, and is storeUserFiles on for this server?'); + }); + await clickGlide('.uploadButton'); + await page.waitForSelector('.uppy-Dashboard-inner', { timeout: 15000 }); + // The dashboard has two hidden file inputs, one of them with webkitdirectory for a + // folder drop. The plain one is the file drop. + const input = (await page.evaluateHandle(() => + [...document.querySelectorAll('input.uppy-Dashboard-input')] + .find(i => i.type === 'file' && !i.webkitdirectory))).asElement(); + if (!input) throw new Error('hubUpload: the Hub Upload dashboard has no file input'); + await input.setInputFiles(files.map(f => ({ + name: String(f.name), mimeType: 'text/plain', buffer: Buffer.from(String(f.text ?? '')), + }))); + // Wait on uppy's own list rather than on what the dashboard draws: with one file the + // dashboard can show the file card instead of the list, so no list item appears. + await page.waitForFunction(n => typeof uppy !== 'undefined' && uppy.getFiles().length >= n, + files.length, { timeout: 15000 }); + if (o.shot) { await shot(o.shot); return; } + break; + } case 'drag': await drag(arg); break; case 'open': if (arg === 'lift') { await clickGlide('main a[href*="hgTracks"]'); await page.waitForSelector('#imgTbl'); await captureState(); } break; case 'zoom': { const btn = (arg === 'in') ? '#hgt\\.in2' : '#hgt\\.out2'; const was = await page.evaluate(() => { try { return `${hgTracks.winStart}-${hgTracks.winEnd}`; } catch (_) { return ''; } }); await clickGlide(btn); await page.waitForSelector('#imgTbl'); // The zoom buttons redraw the image in place (ajax), so #imgTbl never went away and // waiting for it proves nothing: the next step can read the OLD view's map boxes and // report an item "not found" that simply is not in view yet. Wait for the window to // change instead. Until FAST there was always a dwell here hiding this. if (was) await page.waitForFunction( w => { try { return `${hgTracks.winStart}-${hgTracks.winEnd}` !== w; } catch (_) { return false; } },