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: <selector>, is: <text>} 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; } },