@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.
- package/dist/dom-actions.d.ts +28 -0
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +552 -0
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +74 -0
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +14 -2
- package/dist/geometry-ws.js.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/dom-actions.d.ts
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"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"}
|
package/dist/dom-actions.js
CHANGED
|
@@ -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 {
|