@geometra/proxy 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type { Locator, Page } from 'playwright';
2
2
  import type { ClientChoiceType, ClientFillField } from './types.js';
3
+ export declare function delay(ms: number): Promise<void>;
3
4
  export type FileAttachStrategy = 'auto' | 'chooser' | 'hidden' | 'drop';
4
5
  export interface FillLookupCache {
5
6
  control: Map<string, Locator | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAwB,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEnE,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;AAqCvE,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,CAAA;IACpC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,CAAA;IACrC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,CAAA;CACvC;AAED,wBAAgB,qBAAqB,IAAI,eAAe,CAMvD;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAIjE;AA83ED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,CAclE;AAiVD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,EAAE,EACf,IAAI,CAAC,EAAE;IACL,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,kBAAkB,CAAA;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,eAAe,CAAA;CACxB,GACA,OAAO,CAAC,IAAI,CAAC,CA+Df;AAszBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,GACtD,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAkNrD;AAeD,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACpE,OAAO,CAAC,IAAI,CAAC,CAyCf;AA6PD,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,GAAG,OAAO,EACvB,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACpE,OAAO,CAAC,IAAI,CAAC,CA2Df;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACnH,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED,MAAM,MAAM,aAAa,GAAG,eAAe,CAAA;AAE3C,wBAAsB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,EAAE,KAAK,kBAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAiHpH;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0ClH;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,eAAe,CAAA;CAAE,GACzI,OAAO,CAAC,IAAI,CAAC,CAuNf;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,WAAW,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CACnC;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiJ1G;AAED,wBAAsB,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4C/G"}
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- function delay(ms) {
3
+ export function delay(ms) {
4
4
  return new Promise(r => setTimeout(r, ms));
5
5
  }
6
6
  const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [contenteditable="true"]';
@@ -1873,11 +1873,22 @@ async function pressEnterToCommitListbox(page, handle) {
1873
1873
  * no form ancestor, etc.) — "unknown" is NOT a veto, same convention as
1874
1874
  * `readAriaInvalid`.
1875
1875
  */
1876
- async function readFormLevelInvalidState(handle) {
1876
+ async function readFormLevelInvalidState(handle, opts) {
1877
1877
  if (!handle)
1878
1878
  return false;
1879
+ // Default behavior preserves the existing veto logic: a blank required
1880
+ // hidden input only counts as invalid when SOMETHING else in the wrapper
1881
+ // also flags an error (role=alert / data-invalid / data-error). That's
1882
+ // safe for read-only checks where we don't know whether a commit was
1883
+ // recently attempted. When `requireWrapperFlag: false`, the function
1884
+ // becomes stricter and treats a blank required hidden input as
1885
+ // definitively-not-committed even without a visible error flag — the
1886
+ // caller (postCommitVerify) is asserting that a commit was just attempted,
1887
+ // so the hidden input's emptiness IS authoritative. Bug surfaced by
1888
+ // JobForge round-2 marathon — Airtable PM AI #94 phone-country combobox.
1889
+ const requireWrapperFlag = opts?.requireWrapperFlag ?? true;
1879
1890
  try {
1880
- return await handle.evaluate((el) => {
1891
+ return await handle.evaluate((el, requireWrapperFlagFn) => {
1881
1892
  if (!(el instanceof Element))
1882
1893
  return false;
1883
1894
  // Find the closest <form>. No form = can't verify, not a veto.
@@ -1923,9 +1934,15 @@ async function readFormLevelInvalidState(handle) {
1923
1934
  // hidden input is blank. react-select writes an empty string to
1924
1935
  // its hidden input when uncommitted.
1925
1936
  if (input.required && (input.value === '' || input.value == null)) {
1926
- // Only veto if SOMETHING in the wrapper also flags the field as
1927
- // in-error. A blank hidden input on a page that hasn't attempted
1928
- // submit yet is common and non-authoritative.
1937
+ if (!requireWrapperFlagFn) {
1938
+ // Caller (postCommitVerify) just attempted a commit; a blank
1939
+ // required hidden input is authoritative even without a
1940
+ // visible error flag.
1941
+ return true;
1942
+ }
1943
+ // Read-only mode: only veto if SOMETHING in the wrapper also
1944
+ // flags the field as in-error. A blank hidden input on a page
1945
+ // that hasn't attempted submit yet is common and non-authoritative.
1929
1946
  const flaggedInWrapper = wrapper.querySelector('[role="alert"]') ||
1930
1947
  wrapper.querySelector('[data-invalid]') ||
1931
1948
  wrapper.querySelector('[data-error]');
@@ -1955,7 +1972,7 @@ async function readFormLevelInvalidState(handle) {
1955
1972
  return true;
1956
1973
  }
1957
1974
  return false;
1958
- });
1975
+ }, requireWrapperFlag);
1959
1976
  }
1960
1977
  catch {
1961
1978
  return false;
@@ -2532,22 +2549,130 @@ async function attachViaChooser(page, paths, clickX, clickY) {
2532
2549
  ]);
2533
2550
  await chooser.setFiles(paths);
2534
2551
  }
2535
- /** Synthetic drop at (x,y) using file bytes from the proxy host (targets elementFromPoint). */
2552
+ /**
2553
+ * Map common file extensions to MIME types so react-dropzone's `accept` filter
2554
+ * does not silently drop our synthetic files. Defaults to application/octet-stream
2555
+ * for unknowns, which is rejected by most dropzones configured with accept.
2556
+ */
2557
+ function mimeTypeForPath(path) {
2558
+ const ext = path.toLowerCase().split('.').pop() ?? '';
2559
+ switch (ext) {
2560
+ case 'pdf': return 'application/pdf';
2561
+ case 'doc': return 'application/msword';
2562
+ case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
2563
+ case 'txt': return 'text/plain';
2564
+ case 'rtf': return 'application/rtf';
2565
+ case 'png': return 'image/png';
2566
+ case 'jpg':
2567
+ case 'jpeg': return 'image/jpeg';
2568
+ case 'gif': return 'image/gif';
2569
+ case 'webp': return 'image/webp';
2570
+ case 'svg': return 'image/svg+xml';
2571
+ default: return 'application/octet-stream';
2572
+ }
2573
+ }
2574
+ /**
2575
+ * Synthetic drop at (x,y) using file bytes from the proxy host.
2576
+ *
2577
+ * react-dropzone (widely used by Greenhouse, Ashby, and others) does NOT listen
2578
+ * for a bare `drop` event — its `getRootProps` wires up a full
2579
+ * `dragenter` → `dragover` → `drop` sequence, and react-dropzone's internal
2580
+ * state machine silently ignores a `drop` that never saw `dragenter`/`dragover`.
2581
+ * It also requires a proper `dataTransfer.types` containing "Files" AND correct
2582
+ * MIME types for any configured `accept` filter, otherwise the file is
2583
+ * rejected.
2584
+ *
2585
+ * We dispatch the full sequence on the deepest visible element at (x,y) and
2586
+ * bubble up — plus a fallback walk-up that targets any ancestor whose class
2587
+ * names, data-attributes, or role look dropzone-ish, so we hit both the inner
2588
+ * button layer and the wrapper that actually has the listeners.
2589
+ */
2536
2590
  async function attachViaDropPlaywright(page, paths, dropX, dropY) {
2537
2591
  const fs = await import('node:fs/promises');
2538
2592
  const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
2539
2593
  const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
2594
+ const mimes = paths.map(mimeTypeForPath);
2540
2595
  await page.mouse.move(dropX, dropY);
2541
- await page.mainFrame().evaluate(({ bufs, ns, x, y }) => {
2542
- const dt = new DataTransfer();
2543
- for (let i = 0; i < bufs.length; i++) {
2544
- const u8 = new Uint8Array(bufs[i]);
2545
- const blob = new Blob([u8]);
2546
- dt.items.add(new File([blob], ns[i], { type: 'application/octet-stream' }));
2596
+ await page.mainFrame().evaluate(({ bufs, ns, ms, x, y }) => {
2597
+ const makeDataTransfer = () => {
2598
+ const dt = new DataTransfer();
2599
+ for (let i = 0; i < bufs.length; i++) {
2600
+ const u8 = new Uint8Array(bufs[i]);
2601
+ const blob = new Blob([u8], { type: ms[i] });
2602
+ dt.items.add(new File([blob], ns[i], { type: ms[i] }));
2603
+ }
2604
+ return dt;
2605
+ };
2606
+ const dispatchSequence = (target) => {
2607
+ // Each event needs its own DataTransfer because some frameworks
2608
+ // consume the items list during dragenter/dragover inspection.
2609
+ for (const type of ['dragenter', 'dragover', 'drop']) {
2610
+ const dt = makeDataTransfer();
2611
+ const ev = new DragEvent(type, {
2612
+ bubbles: true,
2613
+ cancelable: true,
2614
+ composed: true,
2615
+ clientX: x,
2616
+ clientY: y,
2617
+ dataTransfer: dt,
2618
+ });
2619
+ // Some browsers/frameworks freeze the dataTransfer getter; re-define
2620
+ // it defensively so listeners read the files.
2621
+ try {
2622
+ Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
2623
+ }
2624
+ catch { /* ignore */ }
2625
+ target.dispatchEvent(ev);
2626
+ }
2627
+ };
2628
+ const deepest = document.elementFromPoint(x, y);
2629
+ const targets = [];
2630
+ if (deepest)
2631
+ targets.push(deepest);
2632
+ // Walk up a few ancestors looking for anything that looks like a dropzone
2633
+ // root (react-dropzone emits `data-rfd-*`; Greenhouse uses div.drop-zone;
2634
+ // Ashby uses role="button" wrappers). We dispatch on the first dropzone-ish
2635
+ // ancestor AND the deepest element, because we cannot tell statically
2636
+ // which one has the real listener.
2637
+ let p = deepest?.parentElement ?? null;
2638
+ for (let depth = 0; depth < 12 && p; depth++) {
2639
+ const tag = p.tagName.toLowerCase();
2640
+ const attrs = p.getAttributeNames();
2641
+ const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
2642
+ (p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
2643
+ tag === 'label' ||
2644
+ p.getAttribute('role') === 'button';
2645
+ if (looksLikeDropzone && !targets.includes(p))
2646
+ targets.push(p);
2647
+ p = p.parentElement;
2547
2648
  }
2548
- const target = document.elementFromPoint(x, y) ?? document.body;
2549
- target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dt }));
2550
- }, { bufs: buffers.map(b => Array.from(b)), ns: names, x: dropX, y: dropY });
2649
+ if (targets.length === 0)
2650
+ targets.push(document.body);
2651
+ for (const t of targets)
2652
+ dispatchSequence(t);
2653
+ // As a final fallback, if there is a hidden input[type=file] scoped to
2654
+ // the nearest form/section, set its `files` via DataTransfer and dispatch
2655
+ // the React-friendly native input value setter so react-hook-form notices.
2656
+ const host = deepest?.closest('form, [role="form"], fieldset, section, div') ?? document.body;
2657
+ const fileInput = host.querySelector('input[type="file"]');
2658
+ if (fileInput) {
2659
+ const dt = makeDataTransfer();
2660
+ try {
2661
+ Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
2662
+ }
2663
+ catch { /* ignore */ }
2664
+ try {
2665
+ // React overrides the input value setter; we need the native prototype
2666
+ // setter so React sees the change.
2667
+ const proto = Object.getPrototypeOf(fileInput);
2668
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
2669
+ descriptor?.set?.call(fileInput, '');
2670
+ }
2671
+ catch { /* ignore */ }
2672
+ fileInput.dispatchEvent(new Event('input', { bubbles: true }));
2673
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }));
2674
+ }
2675
+ }, { bufs: buffers.map(b => Array.from(b)), ns: names, ms: mimes, x: dropX, y: dropY });
2551
2676
  }
2552
2677
  /**
2553
2678
  * `strategy`:
@@ -3103,6 +3228,23 @@ async function attemptNativeBatchFill(page, fields) {
3103
3228
  return explicit;
3104
3229
  return el.textContent?.trim() || undefined;
3105
3230
  }
3231
+ // Snapshot every state attribute that React-style stateful buttons
3232
+ // typically toggle on selection. Used to verify that a click on a
3233
+ // button-shaped group radio actually committed (not a structural
3234
+ // no-op). Mirrors `selectionSignature` in `chooseValueFromLabeledGroup`
3235
+ // — kept in sync because they live in different page.evaluate scopes.
3236
+ function selectionSignature(opts) {
3237
+ return opts.map(el => {
3238
+ const ariaPressed = el.getAttribute('aria-pressed') ?? '';
3239
+ const ariaChecked = el.getAttribute('aria-checked') ?? '';
3240
+ const ariaSelected = el.getAttribute('aria-selected') ?? '';
3241
+ const dataState = el.getAttribute('data-state') ?? '';
3242
+ const dataSelected = el.getAttribute('data-selected') ?? '';
3243
+ const className = (el instanceof HTMLElement ? el.className : '') ?? '';
3244
+ const checked = el instanceof HTMLInputElement ? String(el.checked) : '';
3245
+ return `${ariaPressed}|${ariaChecked}|${ariaSelected}|${dataState}|${dataSelected}|${className}|${checked}`;
3246
+ }).join('||');
3247
+ }
3106
3248
  function setGroupedChoice(fieldLabel, value, exact) {
3107
3249
  const groups = Array.from(document.querySelectorAll('fieldset, [role="radiogroup"], [role="group"]'))
3108
3250
  .filter((el) => visible(el) && matches(groupPrompt(el), fieldLabel, exact));
@@ -3129,7 +3271,30 @@ async function attemptNativeBatchFill(page, fields) {
3129
3271
  return control.checked;
3130
3272
  return true;
3131
3273
  }
3274
+ // role=radio / role=checkbox: click and verify aria-checked
3275
+ // / aria-selected. The native batch fill used to silently return
3276
+ // true here, which masked Pinecone's commit-on-rerender bug.
3277
+ if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
3278
+ option.click();
3279
+ return (option.getAttribute('aria-checked') === 'true' ||
3280
+ option.getAttribute('aria-selected') === 'true');
3281
+ }
3282
+ // Plain <button> path. This is where Pinecone / LangChain Ashby
3283
+ // Yes/No groups land. The native batch fill previously returned
3284
+ // true unconditionally, masking the silent-fail mode where the
3285
+ // form re-renders mid-flow with a shifted field-id prefix that
3286
+ // wipes the radio's commit state. Snapshot the group's selection
3287
+ // signature, click, then re-snapshot — if nothing changed, the
3288
+ // click was a structural no-op and we return false so the higher-
3289
+ // level setFieldChoice fallback (chooseValueFromLabeledGroup,
3290
+ // which has its own retry logic) can take over. Bug surfaced by
3291
+ // JobForge round-2 marathon — Pinecone Sr SWE Database Team
3292
+ // #320 and LangChain SE Manager #325.
3293
+ const beforeSig = selectionSignature(options);
3132
3294
  option.click();
3295
+ const afterSig = selectionSignature(options);
3296
+ if (beforeSig === afterSig)
3297
+ return false;
3133
3298
  return true;
3134
3299
  }
3135
3300
  }
@@ -3384,9 +3549,25 @@ async function findOtpBoxGroup(page, fieldLabel, expectedLength) {
3384
3549
  // address them unambiguously via a single selector. Using a
3385
3550
  // content-hash-ish random id avoids collisions with existing
3386
3551
  // data attributes and re-renders across re-entry.
3552
+ //
3553
+ // Before stamping the new marker, strip any STALE markers from
3554
+ // ALL prior calls anywhere in the document. Without this, a re-rendered
3555
+ // OTP form (e.g. after a stale-OTP submit failure) can leave detached
3556
+ // nodes carrying old data-geometra-otp-* attributes that selectors
3557
+ // could otherwise hit before resolving the live cells. (Bug surfaced
3558
+ // by JobForge round-2 marathon — Glean ML Engineer #174.)
3559
+ const stale = document.querySelectorAll('[data-geometra-otp-stamp]');
3560
+ for (const node of Array.from(stale)) {
3561
+ for (const attr of Array.from(node.attributes)) {
3562
+ if (attr.name.startsWith('data-geometra-otp-')) {
3563
+ node.removeAttribute(attr.name);
3564
+ }
3565
+ }
3566
+ }
3387
3567
  const marker = `data-geometra-otp-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
3388
3568
  best.inputs.forEach((input, index) => {
3389
3569
  input.setAttribute(marker, String(index));
3570
+ input.setAttribute('data-geometra-otp-stamp', '1');
3390
3571
  });
3391
3572
  return { cssSelector: `[${marker}]`, count: best.inputs.length };
3392
3573
  }, { scopeHint: scope, wantLength: expectedLength });
@@ -3428,6 +3609,17 @@ export async function fillOtp(page, value, opts) {
3428
3609
  if (!group) {
3429
3610
  throw new Error(`fillOtp: no OTP box group found${opts?.fieldLabel ? ` near label "${opts.fieldLabel}"` : ''}. Expected ≥2 sibling <input maxlength="1"> elements on the same row.`);
3430
3611
  }
3612
+ // Sanity check: the locator we just stamped must resolve to exactly the
3613
+ // expected number of cells, all connected to the live document. If the
3614
+ // form re-rendered between findOtpBoxGroup's marker stamp and now, the
3615
+ // marker may have been wiped from the DOM and the count drops to 0.
3616
+ // Throw a clear stale-cell error rather than proceeding to type into
3617
+ // nothing. (Bug surfaced by JobForge round-2 marathon — Glean ML
3618
+ // Engineer #174 second-attempt failure.)
3619
+ const liveCount = await group.boxes.count();
3620
+ if (liveCount !== group.cellCount) {
3621
+ throw new Error(`fillOtp: OTP cell group went stale before fill (expected ${group.cellCount} cells, found ${liveCount}). The form likely re-rendered between detection and fill — retry the call.`);
3622
+ }
3431
3623
  // Click the leftmost box via its center point. We use a low-level
3432
3624
  // bounding-box click because a semantic "click the first input" path
3433
3625
  // resolves to whichever input paints topmost in the stacking order,
@@ -3441,6 +3633,22 @@ export async function fillOtp(page, value, opts) {
3441
3633
  throw new Error('fillOtp: first OTP cell has no bounding box (detached or invisible?)');
3442
3634
  }
3443
3635
  await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
3636
+ // Verify focus actually landed on a cell of THIS group (not a stacked
3637
+ // overlay or a sibling element that happened to share the click point).
3638
+ // If the click missed, typing would feed characters to whatever element
3639
+ // happens to hold focus — usually nothing useful — and the readback
3640
+ // would mysteriously show all-empty cells.
3641
+ const focusedOnGroup = await firstBox.evaluate((el) => {
3642
+ const active = document.activeElement;
3643
+ if (!active)
3644
+ return false;
3645
+ // Either the click landed on cell 0 itself, or focus auto-advanced to
3646
+ // a sibling cell with the same data-geometra-otp-stamp.
3647
+ return active === el || (active instanceof HTMLElement && active.hasAttribute('data-geometra-otp-stamp'));
3648
+ });
3649
+ if (!focusedOnGroup) {
3650
+ throw new Error('fillOtp: bounding-box click on cell 0 did not land focus inside the OTP group. The cell may be covered by an overlay or the group is stale — retry after re-detecting.');
3651
+ }
3444
3652
  // Clear any pre-existing values in all cells so we write a clean slate.
3445
3653
  // Some widgets pre-populate cells with zero-width spaces or previous
3446
3654
  // values after a re-render; typing over them would offset the
@@ -3471,35 +3679,131 @@ export async function fillOtp(page, value, opts) {
3471
3679
  await page.keyboard.type(value, { delay: perCharDelay });
3472
3680
  // Small settle window for the last cell's onChange propagation.
3473
3681
  await delay(80);
3474
- // Verify: read every cell's value and confirm per-char match.
3475
- const readback = [];
3476
- for (let i = 0; i < perCellCount; i++) {
3477
- const cellValue = await group.boxes.nth(i).evaluate((el) => {
3478
- if (el instanceof HTMLInputElement)
3479
- return el.value;
3480
- if (el instanceof HTMLElement)
3481
- return el.textContent ?? '';
3482
- return '';
3483
- });
3484
- readback.push(cellValue ?? '');
3485
- }
3486
- // For each typed char, the corresponding cell must contain exactly that
3487
- // char. Cells beyond the typed length are allowed to be empty.
3488
3682
  const expected = Array.from(value);
3489
- const mismatches = [];
3490
- for (let i = 0; i < expected.length; i++) {
3491
- if (readback[i] !== expected[i]) {
3492
- mismatches.push({ index: i, expected: expected[i], got: readback[i] ?? '' });
3683
+ const liveGroup = group;
3684
+ // Readback helper reads every cell's current value / text content so
3685
+ // we can compare against the expected char at the same index.
3686
+ async function readCurrentCells() {
3687
+ const out = [];
3688
+ for (let i = 0; i < perCellCount; i++) {
3689
+ const cellValue = await liveGroup.boxes.nth(i).evaluate((el) => {
3690
+ if (el instanceof HTMLInputElement)
3691
+ return el.value;
3692
+ if (el instanceof HTMLElement)
3693
+ return el.textContent ?? '';
3694
+ return '';
3695
+ });
3696
+ out.push(cellValue ?? '');
3493
3697
  }
3494
- }
3698
+ return out;
3699
+ }
3700
+ let readback = await readCurrentCells();
3701
+ let mismatches = computeOtpMismatches(readback, expected);
3702
+ // Recovery path: some Greenhouse / React builds let cell 0 accept the
3703
+ // entire string because the onKeyDown auto-advance handler fires after
3704
+ // the input event, so the first char lands in cell 0 but the second
3705
+ // char also lands there (no maxlength enforcement on a controlled input)
3706
+ // — cells 1..N-1 end up empty and cell 0 holds the full value. Also
3707
+ // observed: partial fills where cells 0..K hold multiple chars and
3708
+ // cells K+1.. hold the rest. Rather than throwing and asking the caller
3709
+ // to retry fresh (which often hits the same race), we do a deterministic
3710
+ // per-cell recovery: clear every cell again, then for EACH cell
3711
+ // individually click its center and type exactly one char. This is
3712
+ // slower (~N clicks) but guarantees a 1:1 mapping regardless of whether
3713
+ // the widget's focus-advance handler is working. Surfaced by the
3714
+ // JobForge Hex AI Engineering Lead #310 apply flow on 2026-04-11, where
3715
+ // the initial `page.keyboard.type` dumped "ctUwV3" into cell 0 and
3716
+ // "c"/"t"/"U" into cells 5/6/7 (focus advanced erratically late in the
3717
+ // typing sequence), and the post-verify readback momentarily showed a
3718
+ // stale-but-matching state so the tool returned success.
3719
+ if (mismatches.length > 0) {
3720
+ // Capture per-cell bounding boxes BEFORE any clearing / re-typing so
3721
+ // we have stable screen coordinates to click even if the widget
3722
+ // re-renders mid-recovery.
3723
+ const cellBoxes = [];
3724
+ for (let i = 0; i < perCellCount; i++) {
3725
+ try {
3726
+ cellBoxes.push(await group.boxes.nth(i).boundingBox());
3727
+ }
3728
+ catch {
3729
+ cellBoxes.push(null);
3730
+ }
3731
+ }
3732
+ // Clear every cell again. React-controlled cells sometimes reject a
3733
+ // direct `.value = ''` the first time; pairing the DOM reset with a
3734
+ // keyboard Select-All + Delete cycle per cell during the type loop
3735
+ // below is more reliable.
3736
+ for (let i = 0; i < perCellCount; i++) {
3737
+ try {
3738
+ await group.boxes.nth(i).evaluate((el) => {
3739
+ if (el instanceof HTMLInputElement) {
3740
+ el.value = '';
3741
+ el.dispatchEvent(new Event('input', { bubbles: true }));
3742
+ el.dispatchEvent(new Event('change', { bubbles: true }));
3743
+ }
3744
+ });
3745
+ }
3746
+ catch {
3747
+ /* tolerate */
3748
+ }
3749
+ }
3750
+ // Per-cell typing: click each cell at its center, then type the single
3751
+ // expected char. We do NOT rely on the widget's auto-advance — we
3752
+ // always explicitly click the next cell for the next char. Before
3753
+ // typing, reset the target cell's value directly in the DOM so the
3754
+ // new char overwrites instead of appending. This is faster and more
3755
+ // deterministic than going through the keyboard Select-All + Delete
3756
+ // path on every cell.
3757
+ for (let i = 0; i < expected.length; i++) {
3758
+ const cellBox = cellBoxes[i];
3759
+ if (!cellBox)
3760
+ continue;
3761
+ try {
3762
+ await liveGroup.boxes.nth(i).evaluate((el) => {
3763
+ if (el instanceof HTMLInputElement) {
3764
+ el.value = '';
3765
+ el.dispatchEvent(new Event('input', { bubbles: true }));
3766
+ el.dispatchEvent(new Event('change', { bubbles: true }));
3767
+ }
3768
+ });
3769
+ }
3770
+ catch {
3771
+ /* tolerate */
3772
+ }
3773
+ const cx = cellBox.x + cellBox.width / 2;
3774
+ const cy = cellBox.y + cellBox.height / 2;
3775
+ await page.mouse.click(cx, cy);
3776
+ await page.keyboard.type(expected[i], { delay: perCharDelay });
3777
+ }
3778
+ // Settle, re-read, re-compare.
3779
+ await delay(60);
3780
+ readback = await readCurrentCells();
3781
+ mismatches = computeOtpMismatches(readback, expected);
3782
+ }
3783
+ // Final verification. If recovery did not resolve the mismatch, throw
3784
+ // with the full diagnostic so callers get an honest failure instead of
3785
+ // a silent "success" that leaves cells holding the wrong chars.
3495
3786
  if (mismatches.length > 0) {
3787
+ const allEmpty = readback.slice(0, expected.length).every((v) => v === '');
3788
+ if (allEmpty) {
3789
+ throw new Error(`fillOtp: typed ${expected.length} chars but ALL ${expected.length} target cells are still empty after per-cell recovery. Focus was lost mid-flow or the cell group was re-rendered after detection. Retry the call to re-detect the live cells.`);
3790
+ }
3496
3791
  const summary = mismatches
3497
3792
  .map((m) => `cell ${m.index}: expected "${m.expected}", got "${m.got}"`)
3498
3793
  .join('; ');
3499
- throw new Error(`fillOtp: typed ${expected.length} chars into ${perCellCount}-cell group but readback mismatch — ${summary}. Readback: [${readback.map((v) => JSON.stringify(v)).join(', ')}]`);
3794
+ throw new Error(`fillOtp: typed ${expected.length} chars into ${perCellCount}-cell group but readback mismatch even after per-cell recovery — ${summary}. Readback: [${readback.map((v) => JSON.stringify(v)).join(', ')}]`);
3500
3795
  }
3501
3796
  return { cellCount: perCellCount, filledCount: expected.length };
3502
3797
  }
3798
+ function computeOtpMismatches(readback, expected) {
3799
+ const mismatches = [];
3800
+ for (let i = 0; i < expected.length; i++) {
3801
+ if (readback[i] !== expected[i]) {
3802
+ mismatches.push({ index: i, expected: expected[i], got: readback[i] ?? '' });
3803
+ }
3804
+ }
3805
+ return mismatches;
3806
+ }
3503
3807
  export async function setFieldText(page, fieldLabel, value, opts) {
3504
3808
  // Bug #2 (v1.43) auto-routing: if the caller's label looks like a
3505
3809
  // verification-code / security-code / OTP prompt, try the dedicated OTP
@@ -4184,7 +4488,16 @@ export async function pickListboxOption(page, label, opts) {
4184
4488
  // [data-invalid] flag inside the field wrapper (react-hook-form,
4185
4489
  // Formik, Ashby forms, etc.). If the form disagrees with the trigger,
4186
4490
  // the commit did not land and the caller should retry.
4187
- if (await readFormLevelInvalidState(openedHandle))
4491
+ //
4492
+ // We use the STRICT variant here (`requireWrapperFlag: false`) because
4493
+ // we just attempted a commit — a required hidden input that is still
4494
+ // empty IS authoritative even before the user submits the form. Without
4495
+ // this, Greenhouse's phone-country-code combobox (Airtable PM AI #94)
4496
+ // false-passes verification: the trigger shows "+1" but the hidden
4497
+ // input stays blank because react-select's keyboard-Enter commit
4498
+ // didn't bind to the country sub-control. Bug surfaced by JobForge
4499
+ // round-2 marathon.
4500
+ if (await readFormLevelInvalidState(openedHandle, { requireWrapperFlag: false }))
4188
4501
  return false;
4189
4502
  return true;
4190
4503
  };