@geometra/proxy 1.41.0 → 1.43.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.
@@ -23,6 +23,34 @@ export declare function attachFiles(page: Page, paths: string[], opts?: {
23
23
  dropY?: number;
24
24
  cache?: FillLookupCache;
25
25
  }): Promise<void>;
26
+ /**
27
+ * Fill an OTP / verification-code input group with a value, char by char.
28
+ *
29
+ * Detection is generic — see `findOtpBoxGroup`. The actual typing strategy
30
+ * uses `page.keyboard.type()` (not a low-level `keyboard.insertText()`
31
+ * batch) so that every char dispatches a real keydown/keypress/keyup event
32
+ * cycle, which is what React's per-cell onKeyDown handler listens for to
33
+ * auto-advance focus to the next box.
34
+ *
35
+ * Why not `fill_form` or `fill_fields` for this? The 8 boxes share
36
+ * accessible bounds because they're visually adjacent and a11y collapses
37
+ * them into one logical textbox node. Any semantic "find the textbox and
38
+ * write 8 chars" path writes the entire string to box 0, which has
39
+ * maxlength=1 and silently truncates to one char. The pattern can only be
40
+ * driven through physical key events.
41
+ *
42
+ * Verification: after typing, read the `.value` of every box and confirm
43
+ * it matches the expected per-cell char. Throws a descriptive error if
44
+ * verification fails so callers get an honest failure instead of a silent
45
+ * "success" that leaves the form in a bad state.
46
+ */
47
+ export declare function fillOtp(page: Page, value: string, opts?: {
48
+ fieldLabel?: string;
49
+ perCharDelayMs?: number;
50
+ }): Promise<{
51
+ cellCount: number;
52
+ filledCount: number;
53
+ }>;
26
54
  export declare function setFieldText(page: Page, fieldLabel: string, value: string, opts?: {
27
55
  exact?: boolean;
28
56
  cache?: FillLookupCache;
@@ -1 +1 @@
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;AAMnE,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;AAkoED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,CAclE;AA4OD,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;AAwgBD,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,CAyBf;AA+MD,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,CA8Lf;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
+ {"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"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"}
@@ -1736,6 +1736,231 @@ async function readTriggerShowsPlaceholder(handle) {
1736
1736
  return false;
1737
1737
  }
1738
1738
  }
1739
+ /**
1740
+ * Detect whether a combobox trigger is a "searchable/autocomplete style"
1741
+ * combobox (React Select, Headless UI, Radix, Ant Design Select, etc.).
1742
+ *
1743
+ * These libraries commit their controlled `onChange` state on **keyboard
1744
+ * Enter**, not on a synthetic mouse click dispatched via Playwright's
1745
+ * `.click()`. For regular mouse interactions they use a bubbling
1746
+ * `mousedown`/`pointerdown` path that does not round-trip through React
1747
+ * Select's internal `selectOption` handler in some Remix-wrapped builds
1748
+ * (notably Greenhouse's ATS embed), so the option click fires visually but
1749
+ * the form state stays empty. Pressing Enter on the focused combobox input
1750
+ * after the click puts the selection through the keyboard code path which
1751
+ * ALWAYS commits.
1752
+ *
1753
+ * Detection is fully generic — no hostname or site-specific branching.
1754
+ * The signals we look for are:
1755
+ *
1756
+ * 1. `aria-autocomplete` on the element or its nearest ancestor input is
1757
+ * `"list"` or `"both"`. This is the standards-aligned signal for any
1758
+ * combobox that filters options as the user types.
1759
+ * 2. A class pattern indicative of React Select (`select__control`,
1760
+ * `select__`), React Suite picker (`rs-picker`), or Ant Design Select
1761
+ * (`ant-select`) on the element or any ancestor up to 5 levels.
1762
+ * 3. `role="combobox"` present together with `aria-expanded` on the
1763
+ * element or an ancestor — the ARIA 1.2 combobox pattern.
1764
+ *
1765
+ * Native `<select>` elements do NOT match any of these signals, so the
1766
+ * caller can safely gate its `Enter` dispatch on this check without
1767
+ * breaking non-searchable listboxes.
1768
+ */
1769
+ async function isAutocompleteCombobox(handle) {
1770
+ if (!handle)
1771
+ return false;
1772
+ try {
1773
+ return await handle.evaluate((el) => {
1774
+ if (!(el instanceof Element))
1775
+ return false;
1776
+ let cur = el;
1777
+ let depth = 0;
1778
+ while (cur && depth < 5) {
1779
+ // aria-autocomplete on the element or an ancestor input/combobox.
1780
+ const ac = cur.getAttribute('aria-autocomplete');
1781
+ if (ac === 'list' || ac === 'both')
1782
+ return true;
1783
+ // Class-based fingerprint for known autocomplete libraries.
1784
+ const className = (cur.getAttribute('class') ?? '').toLowerCase();
1785
+ if (className) {
1786
+ if (className.includes('select__control') ||
1787
+ className.includes('select__') ||
1788
+ className.includes('rs-picker') ||
1789
+ className.includes('ant-select') ||
1790
+ className.includes('headlessui-combobox') ||
1791
+ className.includes('cmdk-')) {
1792
+ return true;
1793
+ }
1794
+ }
1795
+ // ARIA 1.2 combobox pattern: role="combobox" + aria-expanded is a
1796
+ // strong signal that the control commits via keyboard semantics.
1797
+ if (cur.getAttribute('role') === 'combobox' && cur.hasAttribute('aria-expanded')) {
1798
+ return true;
1799
+ }
1800
+ cur = cur.parentElement;
1801
+ depth++;
1802
+ }
1803
+ // Also look downward for a descendant input that declares
1804
+ // aria-autocomplete — some Headless UI wrappers expose the control via
1805
+ // a sibling input inside the trigger container.
1806
+ const descendant = el.querySelector?.('[aria-autocomplete="list"], [aria-autocomplete="both"]');
1807
+ return !!descendant;
1808
+ });
1809
+ }
1810
+ catch {
1811
+ return false;
1812
+ }
1813
+ }
1814
+ /**
1815
+ * Dispatch a keyboard `Enter` to commit an autocomplete-style combobox's
1816
+ * selection after the option has been clicked. Only fires if the trigger
1817
+ * looks like a searchable combobox per {@link isAutocompleteCombobox}.
1818
+ *
1819
+ * Why this exists: Playwright's `.click()` on a React Select option DOM
1820
+ * element dispatches a synthetic `pointerdown`/`mouseup`/`click` sequence
1821
+ * that a subset of React Select forks (and every Remix-wrapped Greenhouse
1822
+ * ATS build we've seen) will NOT commit through `selectOption`. The
1823
+ * library's `keydown Enter` handler, however, unconditionally commits via
1824
+ * the same internal `setValue` the visual selection uses. Pressing Enter
1825
+ * after the click is therefore a universal "force commit" primitive for
1826
+ * searchable comboboxes that benefit every library without touching the
1827
+ * native-<select> happy path.
1828
+ *
1829
+ * Intentionally swallows errors — this is a best-effort commit helper and
1830
+ * a missing page/focus should not break the surrounding selection flow.
1831
+ */
1832
+ async function pressEnterToCommitListbox(page, handle) {
1833
+ if (!handle)
1834
+ return false;
1835
+ if (!(await isAutocompleteCombobox(handle)))
1836
+ return false;
1837
+ try {
1838
+ await page.keyboard.press('Enter');
1839
+ // Short settle window for the library to run its onChange -> form
1840
+ // state update. 80ms is the same budget used elsewhere in this file
1841
+ // for React Select commit propagation.
1842
+ await delay(80);
1843
+ return true;
1844
+ }
1845
+ catch {
1846
+ return false;
1847
+ }
1848
+ }
1849
+ /**
1850
+ * Read form-level invalid state for a combobox trigger.
1851
+ *
1852
+ * `readAriaInvalid` / `readTriggerShowsPlaceholder` only consult attributes
1853
+ * on the trigger element and its direct ancestors. That catches react-select's
1854
+ * revert pattern and the Greenhouse "stay at placeholder" pattern, but it
1855
+ * misses the case where the combobox's visible chrome says "committed"
1856
+ * while the surrounding *form's* controlled state still reports the field
1857
+ * as invalid. The form-level source of truth lives in sibling hidden
1858
+ * inputs and in react-hook-form / Formik style `role="alert"` /
1859
+ * `.error` / `[data-invalid]` elements inside the field wrapper.
1860
+ *
1861
+ * This function walks up to the closest `<form>` ancestor of the trigger
1862
+ * and, within the trigger's nearest field wrapper (the closest ancestor
1863
+ * with a `<label>`-like role, bounded by the form), checks for any of:
1864
+ *
1865
+ * - `<input>` hidden siblings with `aria-invalid="true"` or `[data-invalid]`
1866
+ * - `[role="alert"]` or `.error` / `[data-error]` siblings (rendered by
1867
+ * react-hook-form's `<ErrorMessage>`, Formik, Ashby's form lib, etc.)
1868
+ * - A sibling with the `invalid:required` / `required` error text class
1869
+ *
1870
+ * Returns `true` if the form reports the field as invalid — callers should
1871
+ * treat this as a definitive "commit failed" signal and retry. Returns
1872
+ * `false` if everything looks clean OR if the walk fails (handle detached,
1873
+ * no form ancestor, etc.) — "unknown" is NOT a veto, same convention as
1874
+ * `readAriaInvalid`.
1875
+ */
1876
+ async function readFormLevelInvalidState(handle) {
1877
+ if (!handle)
1878
+ return false;
1879
+ try {
1880
+ return await handle.evaluate((el) => {
1881
+ if (!(el instanceof Element))
1882
+ return false;
1883
+ // Find the closest <form>. No form = can't verify, not a veto.
1884
+ const form = el.closest('form');
1885
+ if (!form)
1886
+ return false;
1887
+ // Find the field wrapper — the closest ancestor that "contains a label"
1888
+ // and is still inside the form. This is the react-hook-form convention:
1889
+ // every field lives under a wrapper that renders both the control and
1890
+ // the error message as siblings. If we can't find one, use a bounded
1891
+ // walk up to ~6 ancestors and look at each one's siblings.
1892
+ let wrapper = el;
1893
+ let depth = 0;
1894
+ while (wrapper && wrapper !== form && depth < 6) {
1895
+ // A "field wrapper" is anything that contains a <label>, a legend,
1896
+ // or a [class*="field"] class up to 6 ancestors deep.
1897
+ const hasLabel = wrapper.querySelector && (wrapper.querySelector('label') || wrapper.querySelector('legend'));
1898
+ const className = (wrapper.getAttribute('class') ?? '').toLowerCase();
1899
+ const looksLikeField = className.includes('field') ||
1900
+ className.includes('form-group') ||
1901
+ className.includes('form-control') ||
1902
+ className.includes('input-wrapper');
1903
+ if (hasLabel || looksLikeField)
1904
+ break;
1905
+ wrapper = wrapper.parentElement;
1906
+ depth++;
1907
+ }
1908
+ if (!wrapper || wrapper === form)
1909
+ wrapper = el.parentElement ?? el;
1910
+ // 1. Hidden sibling <input> with aria-invalid / data-invalid. This is
1911
+ // the react-hook-form + react-select pattern: the Controller writes
1912
+ // the value into a hidden input, and form validation marks that
1913
+ // hidden input invalid.
1914
+ const hiddenInputs = wrapper.querySelectorAll('input[type="hidden"], input[aria-hidden="true"]');
1915
+ for (const input of hiddenInputs) {
1916
+ const ariaInvalid = input.getAttribute('aria-invalid');
1917
+ if (ariaInvalid === 'true' || ariaInvalid === '')
1918
+ return true;
1919
+ if (input.hasAttribute('data-invalid'))
1920
+ return true;
1921
+ // Required + empty value is a definitive "not committed" signal,
1922
+ // but only when the element is actually marked required AND the
1923
+ // hidden input is blank. react-select writes an empty string to
1924
+ // its hidden input when uncommitted.
1925
+ 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.
1929
+ const flaggedInWrapper = wrapper.querySelector('[role="alert"]') ||
1930
+ wrapper.querySelector('[data-invalid]') ||
1931
+ wrapper.querySelector('[data-error]');
1932
+ if (flaggedInWrapper)
1933
+ return true;
1934
+ }
1935
+ }
1936
+ // 2. role="alert" or [data-invalid] / [data-error] / .error inside the
1937
+ // wrapper. react-hook-form's ErrorMessage renders role=alert when a
1938
+ // validator fires; Formik renders a div.error; Ashby uses
1939
+ // data-invalid.
1940
+ const alerts = wrapper.querySelectorAll('[role="alert"], [data-invalid], [data-error], [class*="error-message"], [class*="errorMessage"]');
1941
+ for (const alert of alerts) {
1942
+ if (!(alert instanceof HTMLElement))
1943
+ continue;
1944
+ // Must be visible — hidden slots don't count as a veto.
1945
+ const rect = alert.getBoundingClientRect();
1946
+ if (rect.width <= 0 || rect.height <= 0)
1947
+ continue;
1948
+ const style = getComputedStyle(alert);
1949
+ if (style.display === 'none' || style.visibility === 'hidden')
1950
+ continue;
1951
+ const text = alert.textContent?.trim() ?? '';
1952
+ // Empty alerts are placeholders rendered for layout; skip them.
1953
+ if (!text)
1954
+ continue;
1955
+ return true;
1956
+ }
1957
+ return false;
1958
+ });
1959
+ }
1960
+ catch {
1961
+ return false;
1962
+ }
1963
+ }
1739
1964
  /**
1740
1965
  * Read the picked option from a custom combobox's sibling presentational
1741
1966
  * element ONLY. Skips the input's own .value (which is the typed search
@@ -2616,6 +2841,19 @@ async function attemptNativeBatchFill(page, fields) {
2616
2841
  continue;
2617
2842
  }
2618
2843
  if (field.kind === 'text') {
2844
+ // Bug #2 (v1.43): if this text field's label looks like a
2845
+ // verification-code / OTP prompt AND its value is a plausible code
2846
+ // (4+ chars, no whitespace), skip the in-page native batch fill.
2847
+ // The native path writes `el.value = value` on the first matching
2848
+ // textbox, which for an OTP widget is cell 0 (maxlength=1) — the
2849
+ // assignment silently truncates to the first char, the readback
2850
+ // then reports "mismatch", and the fallback path tries again with
2851
+ // the same broken strategy. Leaving this field out of the native
2852
+ // batch means it falls through to `setFieldText`, which auto-
2853
+ // routes to the `fillOtp` primitive.
2854
+ if (labelLooksLikeOtp(field.fieldLabel) && /^\S{4,}$/.test(field.value)) {
2855
+ continue;
2856
+ }
2619
2857
  pending.push({
2620
2858
  index,
2621
2859
  kind: 'text',
@@ -2941,7 +3179,304 @@ async function attemptNativeBatchFill(page, fields) {
2941
3179
  }
2942
3180
  return results;
2943
3181
  }
3182
+ /**
3183
+ * Regex covering every field label the Greenhouse / Ashby / Lever / Workday
3184
+ * verification-code / security-code / 2FA UIs use. Matching this on a fill
3185
+ * target triggers the OTP-box detection path before falling back to the
3186
+ * plain text-fill pipeline. Shared between setFieldText, fillFields, and
3187
+ * the explicit geometra_fill_otp tool so any entry point auto-handles the
3188
+ * 8-cell input pattern. See Bug #2 in the v1.43 release notes for the
3189
+ * Greenhouse 8-box security-code flow that broke without this.
3190
+ */
3191
+ const OTP_LABEL_HINT_PATTERN = /(security|verification|one[- ]?time|authentication|access)\s*code|\botp\b|\bpasscode\b|\b2fa\b|\bmfa\b/i;
3192
+ function labelLooksLikeOtp(label) {
3193
+ if (!label)
3194
+ return false;
3195
+ return OTP_LABEL_HINT_PATTERN.test(label);
3196
+ }
3197
+ /**
3198
+ * Find an OTP / verification-code input group on the page.
3199
+ *
3200
+ * An OTP group is detected when a frame contains ≥2 sibling `<input>`
3201
+ * elements where each:
3202
+ * - has `maxlength="1"` (or `maxLength: 1` via the DOM property)
3203
+ * - has `type="text"`, `type="tel"`, `type="number"`, `inputmode="numeric"`,
3204
+ * or an empty type (defaults to text)
3205
+ * - shares a common parent with the others
3206
+ * - has a roughly comparable y-coordinate (same row) and strictly
3207
+ * increasing x-coordinates (left-to-right visual order)
3208
+ *
3209
+ * When `fieldLabel` is passed, the search is scoped to the nearest
3210
+ * labelled form section containing that label (so the generic detector
3211
+ * cannot be hijacked by an unrelated per-character autosave field
3212
+ * elsewhere on the page).
3213
+ *
3214
+ * Returns the group as a Playwright `Locator` referring to ALL boxes in
3215
+ * DOM order, plus the cell count. The caller clicks box 0 via the
3216
+ * center-of-bounds coordinate path (semantic click resolves to whichever
3217
+ * box paints topmost, which in Greenhouse's layout is always box 7) and
3218
+ * uses `page.keyboard.type(value, { delay })` to feed characters one by
3219
+ * one, letting React's onKeyDown focus-advance handler commit each cell.
3220
+ */
3221
+ async function findOtpBoxGroup(page, fieldLabel, expectedLength) {
3222
+ for (const frame of page.frames()) {
3223
+ // If a label is provided, scope the search to that label's nearest
3224
+ // ancestor form section. Without this, the generic detector could
3225
+ // hijack an unrelated row of per-character inputs elsewhere on the
3226
+ // page (e.g. a zip-code splitter elsewhere in the form).
3227
+ let scope = null;
3228
+ if (fieldLabel) {
3229
+ try {
3230
+ const labeledInput = await frame
3231
+ .getByLabel(fieldLabel, { exact: false })
3232
+ .first()
3233
+ .elementHandle({ timeout: 500 });
3234
+ if (labeledInput) {
3235
+ // Walk up to the nearest form/fieldset/group wrapper so the
3236
+ // sibling scan has a stable root even when the label is not a
3237
+ // direct parent.
3238
+ scope = await labeledInput.evaluateHandle((el) => {
3239
+ let cur = el;
3240
+ for (let depth = 0; depth < 6 && cur; depth++) {
3241
+ if (cur.tagName === 'FORM' ||
3242
+ cur.tagName === 'FIELDSET' ||
3243
+ cur.getAttribute('role') === 'group' ||
3244
+ cur.getAttribute('role') === 'form' ||
3245
+ (cur.getAttribute('class') ?? '').toLowerCase().includes('otp') ||
3246
+ (cur.getAttribute('data-otp') !== null)) {
3247
+ return cur;
3248
+ }
3249
+ cur = cur.parentElement;
3250
+ }
3251
+ return el.parentElement ?? el;
3252
+ });
3253
+ }
3254
+ }
3255
+ catch {
3256
+ scope = null;
3257
+ }
3258
+ }
3259
+ const candidate = await frame.evaluate(({ scopeHint, wantLength }) => {
3260
+ const scopeRoot = scopeHint ?? document.body;
3261
+ if (!scopeRoot)
3262
+ return null;
3263
+ // Gather every <input> in the scope whose maxlength is 1.
3264
+ const all = Array.from(scopeRoot.querySelectorAll('input'));
3265
+ const singles = all.filter((input) => {
3266
+ if (!input.isConnected)
3267
+ return false;
3268
+ const style = getComputedStyle(input);
3269
+ if (style.display === 'none' || style.visibility === 'hidden')
3270
+ return false;
3271
+ const rect = input.getBoundingClientRect();
3272
+ if (rect.width <= 0 || rect.height <= 0)
3273
+ return false;
3274
+ const maxAttr = input.getAttribute('maxlength');
3275
+ const max = maxAttr !== null ? Number(maxAttr) : input.maxLength;
3276
+ if (!Number.isFinite(max) || max !== 1)
3277
+ return false;
3278
+ const type = (input.type ?? 'text').toLowerCase();
3279
+ if (type && !['text', 'tel', 'number', 'password', ''].includes(type))
3280
+ return false;
3281
+ return true;
3282
+ });
3283
+ if (singles.length < 2)
3284
+ return null;
3285
+ // Group by direct parent — all cells of an OTP widget are siblings
3286
+ // inside a single wrapper (the `<input>`s might be wrapped in a
3287
+ // span each, but the wrappers share the same grandparent row).
3288
+ const byParent = new Map();
3289
+ for (const input of singles) {
3290
+ // Climb up to the nearest wrapper that contains ALL siblings
3291
+ // on the same row.
3292
+ let wrapper = input.parentElement;
3293
+ for (let depth = 0; depth < 3 && wrapper; depth++) {
3294
+ const group = byParent.get(wrapper) ?? [];
3295
+ group.push(input);
3296
+ byParent.set(wrapper, group);
3297
+ wrapper = wrapper.parentElement;
3298
+ }
3299
+ }
3300
+ // Score candidate groups: a group qualifies if all its inputs are
3301
+ // on the same row (y-delta < 8px) and their x-coordinates are
3302
+ // strictly increasing (left-to-right). Accept the largest
3303
+ // qualifying group.
3304
+ let best = null;
3305
+ for (const [root, inputs] of byParent.entries()) {
3306
+ if (inputs.length < 2)
3307
+ continue;
3308
+ // De-dup since a given input will be counted under multiple
3309
+ // wrapper ancestors during the climb above.
3310
+ const unique = Array.from(new Set(inputs));
3311
+ if (unique.length < 2)
3312
+ continue;
3313
+ const sorted = unique
3314
+ .map((input) => ({ input, rect: input.getBoundingClientRect() }))
3315
+ .sort((a, b) => a.rect.left - b.rect.left);
3316
+ const rows = sorted.map((entry) => entry.rect.top);
3317
+ const yMin = Math.min(...rows);
3318
+ const yMax = Math.max(...rows);
3319
+ if (yMax - yMin > 8)
3320
+ continue;
3321
+ let increasing = true;
3322
+ for (let i = 1; i < sorted.length; i++) {
3323
+ if (sorted[i].rect.left <= sorted[i - 1].rect.left) {
3324
+ increasing = false;
3325
+ break;
3326
+ }
3327
+ }
3328
+ if (!increasing)
3329
+ continue;
3330
+ if (!best || sorted.length > best.inputs.length) {
3331
+ best = { inputs: sorted.map((entry) => entry.input), root };
3332
+ }
3333
+ }
3334
+ if (!best)
3335
+ return null;
3336
+ if (wantLength !== undefined && best.inputs.length !== wantLength) {
3337
+ // Only accept the group if its cell count is compatible with
3338
+ // the typed value. Compatibility: either exact match, or the
3339
+ // group is ≥ the value length (allow typing a 6-char code into
3340
+ // an 8-box group with 2 empty trailing boxes).
3341
+ if (best.inputs.length < wantLength)
3342
+ return null;
3343
+ }
3344
+ // Stamp a unique data attribute on each cell so the caller can
3345
+ // address them unambiguously via a single selector. Using a
3346
+ // content-hash-ish random id avoids collisions with existing
3347
+ // data attributes and re-renders across re-entry.
3348
+ const marker = `data-geometra-otp-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
3349
+ best.inputs.forEach((input, index) => {
3350
+ input.setAttribute(marker, String(index));
3351
+ });
3352
+ return { cssSelector: `[${marker}]`, count: best.inputs.length };
3353
+ }, { scopeHint: scope, wantLength: expectedLength });
3354
+ if (candidate) {
3355
+ return {
3356
+ boxes: frame.locator(candidate.cssSelector),
3357
+ cellCount: candidate.count,
3358
+ };
3359
+ }
3360
+ }
3361
+ return null;
3362
+ }
3363
+ /**
3364
+ * Fill an OTP / verification-code input group with a value, char by char.
3365
+ *
3366
+ * Detection is generic — see `findOtpBoxGroup`. The actual typing strategy
3367
+ * uses `page.keyboard.type()` (not a low-level `keyboard.insertText()`
3368
+ * batch) so that every char dispatches a real keydown/keypress/keyup event
3369
+ * cycle, which is what React's per-cell onKeyDown handler listens for to
3370
+ * auto-advance focus to the next box.
3371
+ *
3372
+ * Why not `fill_form` or `fill_fields` for this? The 8 boxes share
3373
+ * accessible bounds because they're visually adjacent and a11y collapses
3374
+ * them into one logical textbox node. Any semantic "find the textbox and
3375
+ * write 8 chars" path writes the entire string to box 0, which has
3376
+ * maxlength=1 and silently truncates to one char. The pattern can only be
3377
+ * driven through physical key events.
3378
+ *
3379
+ * Verification: after typing, read the `.value` of every box and confirm
3380
+ * it matches the expected per-cell char. Throws a descriptive error if
3381
+ * verification fails so callers get an honest failure instead of a silent
3382
+ * "success" that leaves the form in a bad state.
3383
+ */
3384
+ export async function fillOtp(page, value, opts) {
3385
+ if (!value) {
3386
+ throw new Error('fillOtp: value is empty — nothing to type');
3387
+ }
3388
+ const group = await findOtpBoxGroup(page, opts?.fieldLabel, value.length);
3389
+ if (!group) {
3390
+ 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.`);
3391
+ }
3392
+ // Click the leftmost box via its center point. We use a low-level
3393
+ // bounding-box click because a semantic "click the first input" path
3394
+ // resolves to whichever input paints topmost in the stacking order,
3395
+ // which in Greenhouse's CSS grid layout is always box N-1 (the last
3396
+ // cell). The bounding-box center click is unambiguous and puts focus on
3397
+ // cell 0 every time.
3398
+ const firstBox = group.boxes.nth(0);
3399
+ await firstBox.scrollIntoViewIfNeeded();
3400
+ const box = await firstBox.boundingBox();
3401
+ if (!box) {
3402
+ throw new Error('fillOtp: first OTP cell has no bounding box (detached or invisible?)');
3403
+ }
3404
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
3405
+ // Clear any pre-existing values in all cells so we write a clean slate.
3406
+ // Some widgets pre-populate cells with zero-width spaces or previous
3407
+ // values after a re-render; typing over them would offset the
3408
+ // auto-advance count by one.
3409
+ const perCellCount = group.cellCount;
3410
+ for (let i = 0; i < perCellCount; i++) {
3411
+ try {
3412
+ await group.boxes.nth(i).evaluate((el) => {
3413
+ if (el instanceof HTMLInputElement) {
3414
+ el.value = '';
3415
+ el.dispatchEvent(new Event('input', { bubbles: true }));
3416
+ el.dispatchEvent(new Event('change', { bubbles: true }));
3417
+ }
3418
+ });
3419
+ }
3420
+ catch {
3421
+ /* individual cell may have been removed/re-rendered; tolerate */
3422
+ }
3423
+ }
3424
+ // Re-focus cell 0 after clearing — dispatching input events may have
3425
+ // blurred the active element on some React builds.
3426
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
3427
+ // Type the value char by char. `page.keyboard.type` already dispatches
3428
+ // keydown+keypress+input+keyup for each character and honors the delay.
3429
+ // 30ms is enough for React's onKeyDown handler to run and move focus
3430
+ // to the next cell before the next char arrives.
3431
+ const perCharDelay = opts?.perCharDelayMs ?? 30;
3432
+ await page.keyboard.type(value, { delay: perCharDelay });
3433
+ // Small settle window for the last cell's onChange propagation.
3434
+ await delay(80);
3435
+ // Verify: read every cell's value and confirm per-char match.
3436
+ const readback = [];
3437
+ for (let i = 0; i < perCellCount; i++) {
3438
+ const cellValue = await group.boxes.nth(i).evaluate((el) => {
3439
+ if (el instanceof HTMLInputElement)
3440
+ return el.value;
3441
+ if (el instanceof HTMLElement)
3442
+ return el.textContent ?? '';
3443
+ return '';
3444
+ });
3445
+ readback.push(cellValue ?? '');
3446
+ }
3447
+ // For each typed char, the corresponding cell must contain exactly that
3448
+ // char. Cells beyond the typed length are allowed to be empty.
3449
+ const expected = Array.from(value);
3450
+ const mismatches = [];
3451
+ for (let i = 0; i < expected.length; i++) {
3452
+ if (readback[i] !== expected[i]) {
3453
+ mismatches.push({ index: i, expected: expected[i], got: readback[i] ?? '' });
3454
+ }
3455
+ }
3456
+ if (mismatches.length > 0) {
3457
+ const summary = mismatches
3458
+ .map((m) => `cell ${m.index}: expected "${m.expected}", got "${m.got}"`)
3459
+ .join('; ');
3460
+ throw new Error(`fillOtp: typed ${expected.length} chars into ${perCellCount}-cell group but readback mismatch — ${summary}. Readback: [${readback.map((v) => JSON.stringify(v)).join(', ')}]`);
3461
+ }
3462
+ return { cellCount: perCellCount, filledCount: expected.length };
3463
+ }
2944
3464
  export async function setFieldText(page, fieldLabel, value, opts) {
3465
+ // Bug #2 (v1.43) auto-routing: if the caller's label looks like a
3466
+ // verification-code / security-code / OTP prompt, try the dedicated OTP
3467
+ // path first. If no OTP group is detected, fall through to the normal
3468
+ // text-fill path — the label match alone is not enough to guarantee the
3469
+ // field is split into cells. If the group IS detected, any thrown error
3470
+ // from fillOtp propagates out of setFieldText unchanged: silent fallback
3471
+ // to the plain text-fill path would write the whole string into box 0
3472
+ // and pretend success, which is the bug we're fixing.
3473
+ if (labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
3474
+ const group = await findOtpBoxGroup(page, fieldLabel, value.length);
3475
+ if (group) {
3476
+ await fillOtp(page, value, { fieldLabel });
3477
+ return;
3478
+ }
3479
+ }
2945
3480
  const exact = opts?.exact ?? false;
2946
3481
  const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId);
2947
3482
  if (!locator) {
@@ -3487,6 +4022,15 @@ export async function pickListboxOption(page, label, opts) {
3487
4022
  if (!selectedOptionText)
3488
4023
  return false;
3489
4024
  attemptedSelection = true;
4025
+ // Force-commit for searchable/autocomplete comboboxes (React Select,
4026
+ // Headless UI, Radix, Ant Design, etc.). Some library versions — most
4027
+ // notably Greenhouse's Remix-wrapped react-select — visually select the
4028
+ // option on synthetic mouse click but never invoke `onChange`, leaving
4029
+ // the controlled form state empty. Dispatching a keyboard `Enter` on
4030
+ // the focused combobox input puts the selection through the keyboard
4031
+ // commit path, which ALL tested libraries honor. No-op for native
4032
+ // <select> / plain ARIA listboxes — see isAutocompleteCombobox.
4033
+ await pressEnterToCommitListbox(page, openedHandle);
3490
4034
  if (!opts?.fieldLabel ||
3491
4035
  await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
3492
4036
  editable: openedEditable,
@@ -3551,6 +4095,14 @@ export async function pickListboxOption(page, label, opts) {
3551
4095
  return false;
3552
4096
  if (await readTriggerShowsPlaceholder(openedHandle))
3553
4097
  return false;
4098
+ // Form-level validation: even if the trigger chrome says "committed",
4099
+ // the surrounding <form> may still report the field as invalid via a
4100
+ // hidden input's aria-invalid, a role=alert error message, or a
4101
+ // [data-invalid] flag inside the field wrapper (react-hook-form,
4102
+ // Formik, Ashby forms, etc.). If the form disagrees with the trigger,
4103
+ // the commit did not land and the caller should retry.
4104
+ if (await readFormLevelInvalidState(openedHandle))
4105
+ return false;
3554
4106
  return true;
3555
4107
  };
3556
4108
  try {