@geometra/proxy 1.42.0 → 1.44.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 +395 -2
- 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;AA42ED;;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;
|
|
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
|
@@ -2779,11 +2779,49 @@ function fieldValueMatches(actual, expected) {
|
|
|
2779
2779
|
async function setLocatorTextValue(locator, value) {
|
|
2780
2780
|
try {
|
|
2781
2781
|
return await locator.evaluate((el, nextValue) => {
|
|
2782
|
+
// React Greenhouse/Workday/Lever fix: React stores the previous value on
|
|
2783
|
+
// a hidden `_valueTracker` property that React uses to short-circuit
|
|
2784
|
+
// onChange when the value "hasn't changed". If we set el.value through
|
|
2785
|
+
// the prototype setter directly, the tracker still holds the old value,
|
|
2786
|
+
// React's `onChange` (synthetic event) never fires, and the controlled
|
|
2787
|
+
// form state stays empty even though the DOM input visibly displays
|
|
2788
|
+
// the new text. The verification step then reads the DOM value back,
|
|
2789
|
+
// sees the right characters, and reports success — but the form
|
|
2790
|
+
// submission fails with "this field is required" because React state
|
|
2791
|
+
// is the source of truth, not the DOM.
|
|
2792
|
+
//
|
|
2793
|
+
// The canonical fix (used by react-testing-library, enzyme, and the
|
|
2794
|
+
// React docs themselves): clear the tracker BEFORE setting the new
|
|
2795
|
+
// value. React then sees a transition from "" → newValue and runs
|
|
2796
|
+
// its onChange handler, which updates the controlled state.
|
|
2797
|
+
//
|
|
2798
|
+
// This is what was breaking Fivetran #309's "Country" and "Preferred
|
|
2799
|
+
// First Name" fields and any other Greenhouse/Workday/Lever input
|
|
2800
|
+
// backed by react-hook-form, formik, or react-final-form.
|
|
2801
|
+
function clearReactTracker(target) {
|
|
2802
|
+
const tracker = target._valueTracker;
|
|
2803
|
+
if (tracker && typeof tracker.setValue === 'function') {
|
|
2804
|
+
try {
|
|
2805
|
+
tracker.setValue('');
|
|
2806
|
+
}
|
|
2807
|
+
catch { /* ignore */ }
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2782
2810
|
function dispatch(target) {
|
|
2811
|
+
// input bubbles → drives React's onChange via the synthetic event system
|
|
2812
|
+
// change bubbles → drives blur-style validators (Yup, Joi, native forms)
|
|
2783
2813
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2784
2814
|
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2815
|
+
// beforeinput (some controlled inputs use it for IME-safe handlers)
|
|
2816
|
+
try {
|
|
2817
|
+
target.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText' }));
|
|
2818
|
+
}
|
|
2819
|
+
catch { /* ignore in non-supporting browsers */ }
|
|
2785
2820
|
}
|
|
2786
2821
|
function setInputLikeValue(target, next) {
|
|
2822
|
+
// Clear the React value tracker BEFORE setting the new value so React
|
|
2823
|
+
// sees a transition and runs the controlled-input onChange handler.
|
|
2824
|
+
clearReactTracker(target);
|
|
2787
2825
|
const proto = target instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
2788
2826
|
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
2789
2827
|
if (descriptor?.set) {
|
|
@@ -2809,6 +2847,7 @@ async function setLocatorTextValue(locator, value) {
|
|
|
2809
2847
|
}
|
|
2810
2848
|
if (el instanceof HTMLElement && (el.isContentEditable || el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'combobox')) {
|
|
2811
2849
|
el.focus();
|
|
2850
|
+
clearReactTracker(el);
|
|
2812
2851
|
el.textContent = nextValue;
|
|
2813
2852
|
dispatch(el);
|
|
2814
2853
|
return true;
|
|
@@ -2841,6 +2880,19 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
2841
2880
|
continue;
|
|
2842
2881
|
}
|
|
2843
2882
|
if (field.kind === 'text') {
|
|
2883
|
+
// Bug #2 (v1.43): if this text field's label looks like a
|
|
2884
|
+
// verification-code / OTP prompt AND its value is a plausible code
|
|
2885
|
+
// (4+ chars, no whitespace), skip the in-page native batch fill.
|
|
2886
|
+
// The native path writes `el.value = value` on the first matching
|
|
2887
|
+
// textbox, which for an OTP widget is cell 0 (maxlength=1) — the
|
|
2888
|
+
// assignment silently truncates to the first char, the readback
|
|
2889
|
+
// then reports "mismatch", and the fallback path tries again with
|
|
2890
|
+
// the same broken strategy. Leaving this field out of the native
|
|
2891
|
+
// batch means it falls through to `setFieldText`, which auto-
|
|
2892
|
+
// routes to the `fillOtp` primitive.
|
|
2893
|
+
if (labelLooksLikeOtp(field.fieldLabel) && /^\S{4,}$/.test(field.value)) {
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2844
2896
|
pending.push({
|
|
2845
2897
|
index,
|
|
2846
2898
|
kind: 'text',
|
|
@@ -3166,7 +3218,304 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3166
3218
|
}
|
|
3167
3219
|
return results;
|
|
3168
3220
|
}
|
|
3221
|
+
/**
|
|
3222
|
+
* Regex covering every field label the Greenhouse / Ashby / Lever / Workday
|
|
3223
|
+
* verification-code / security-code / 2FA UIs use. Matching this on a fill
|
|
3224
|
+
* target triggers the OTP-box detection path before falling back to the
|
|
3225
|
+
* plain text-fill pipeline. Shared between setFieldText, fillFields, and
|
|
3226
|
+
* the explicit geometra_fill_otp tool so any entry point auto-handles the
|
|
3227
|
+
* 8-cell input pattern. See Bug #2 in the v1.43 release notes for the
|
|
3228
|
+
* Greenhouse 8-box security-code flow that broke without this.
|
|
3229
|
+
*/
|
|
3230
|
+
const OTP_LABEL_HINT_PATTERN = /(security|verification|one[- ]?time|authentication|access)\s*code|\botp\b|\bpasscode\b|\b2fa\b|\bmfa\b/i;
|
|
3231
|
+
function labelLooksLikeOtp(label) {
|
|
3232
|
+
if (!label)
|
|
3233
|
+
return false;
|
|
3234
|
+
return OTP_LABEL_HINT_PATTERN.test(label);
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* Find an OTP / verification-code input group on the page.
|
|
3238
|
+
*
|
|
3239
|
+
* An OTP group is detected when a frame contains ≥2 sibling `<input>`
|
|
3240
|
+
* elements where each:
|
|
3241
|
+
* - has `maxlength="1"` (or `maxLength: 1` via the DOM property)
|
|
3242
|
+
* - has `type="text"`, `type="tel"`, `type="number"`, `inputmode="numeric"`,
|
|
3243
|
+
* or an empty type (defaults to text)
|
|
3244
|
+
* - shares a common parent with the others
|
|
3245
|
+
* - has a roughly comparable y-coordinate (same row) and strictly
|
|
3246
|
+
* increasing x-coordinates (left-to-right visual order)
|
|
3247
|
+
*
|
|
3248
|
+
* When `fieldLabel` is passed, the search is scoped to the nearest
|
|
3249
|
+
* labelled form section containing that label (so the generic detector
|
|
3250
|
+
* cannot be hijacked by an unrelated per-character autosave field
|
|
3251
|
+
* elsewhere on the page).
|
|
3252
|
+
*
|
|
3253
|
+
* Returns the group as a Playwright `Locator` referring to ALL boxes in
|
|
3254
|
+
* DOM order, plus the cell count. The caller clicks box 0 via the
|
|
3255
|
+
* center-of-bounds coordinate path (semantic click resolves to whichever
|
|
3256
|
+
* box paints topmost, which in Greenhouse's layout is always box 7) and
|
|
3257
|
+
* uses `page.keyboard.type(value, { delay })` to feed characters one by
|
|
3258
|
+
* one, letting React's onKeyDown focus-advance handler commit each cell.
|
|
3259
|
+
*/
|
|
3260
|
+
async function findOtpBoxGroup(page, fieldLabel, expectedLength) {
|
|
3261
|
+
for (const frame of page.frames()) {
|
|
3262
|
+
// If a label is provided, scope the search to that label's nearest
|
|
3263
|
+
// ancestor form section. Without this, the generic detector could
|
|
3264
|
+
// hijack an unrelated row of per-character inputs elsewhere on the
|
|
3265
|
+
// page (e.g. a zip-code splitter elsewhere in the form).
|
|
3266
|
+
let scope = null;
|
|
3267
|
+
if (fieldLabel) {
|
|
3268
|
+
try {
|
|
3269
|
+
const labeledInput = await frame
|
|
3270
|
+
.getByLabel(fieldLabel, { exact: false })
|
|
3271
|
+
.first()
|
|
3272
|
+
.elementHandle({ timeout: 500 });
|
|
3273
|
+
if (labeledInput) {
|
|
3274
|
+
// Walk up to the nearest form/fieldset/group wrapper so the
|
|
3275
|
+
// sibling scan has a stable root even when the label is not a
|
|
3276
|
+
// direct parent.
|
|
3277
|
+
scope = await labeledInput.evaluateHandle((el) => {
|
|
3278
|
+
let cur = el;
|
|
3279
|
+
for (let depth = 0; depth < 6 && cur; depth++) {
|
|
3280
|
+
if (cur.tagName === 'FORM' ||
|
|
3281
|
+
cur.tagName === 'FIELDSET' ||
|
|
3282
|
+
cur.getAttribute('role') === 'group' ||
|
|
3283
|
+
cur.getAttribute('role') === 'form' ||
|
|
3284
|
+
(cur.getAttribute('class') ?? '').toLowerCase().includes('otp') ||
|
|
3285
|
+
(cur.getAttribute('data-otp') !== null)) {
|
|
3286
|
+
return cur;
|
|
3287
|
+
}
|
|
3288
|
+
cur = cur.parentElement;
|
|
3289
|
+
}
|
|
3290
|
+
return el.parentElement ?? el;
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
catch {
|
|
3295
|
+
scope = null;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
const candidate = await frame.evaluate(({ scopeHint, wantLength }) => {
|
|
3299
|
+
const scopeRoot = scopeHint ?? document.body;
|
|
3300
|
+
if (!scopeRoot)
|
|
3301
|
+
return null;
|
|
3302
|
+
// Gather every <input> in the scope whose maxlength is 1.
|
|
3303
|
+
const all = Array.from(scopeRoot.querySelectorAll('input'));
|
|
3304
|
+
const singles = all.filter((input) => {
|
|
3305
|
+
if (!input.isConnected)
|
|
3306
|
+
return false;
|
|
3307
|
+
const style = getComputedStyle(input);
|
|
3308
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
3309
|
+
return false;
|
|
3310
|
+
const rect = input.getBoundingClientRect();
|
|
3311
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
3312
|
+
return false;
|
|
3313
|
+
const maxAttr = input.getAttribute('maxlength');
|
|
3314
|
+
const max = maxAttr !== null ? Number(maxAttr) : input.maxLength;
|
|
3315
|
+
if (!Number.isFinite(max) || max !== 1)
|
|
3316
|
+
return false;
|
|
3317
|
+
const type = (input.type ?? 'text').toLowerCase();
|
|
3318
|
+
if (type && !['text', 'tel', 'number', 'password', ''].includes(type))
|
|
3319
|
+
return false;
|
|
3320
|
+
return true;
|
|
3321
|
+
});
|
|
3322
|
+
if (singles.length < 2)
|
|
3323
|
+
return null;
|
|
3324
|
+
// Group by direct parent — all cells of an OTP widget are siblings
|
|
3325
|
+
// inside a single wrapper (the `<input>`s might be wrapped in a
|
|
3326
|
+
// span each, but the wrappers share the same grandparent row).
|
|
3327
|
+
const byParent = new Map();
|
|
3328
|
+
for (const input of singles) {
|
|
3329
|
+
// Climb up to the nearest wrapper that contains ALL siblings
|
|
3330
|
+
// on the same row.
|
|
3331
|
+
let wrapper = input.parentElement;
|
|
3332
|
+
for (let depth = 0; depth < 3 && wrapper; depth++) {
|
|
3333
|
+
const group = byParent.get(wrapper) ?? [];
|
|
3334
|
+
group.push(input);
|
|
3335
|
+
byParent.set(wrapper, group);
|
|
3336
|
+
wrapper = wrapper.parentElement;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
// Score candidate groups: a group qualifies if all its inputs are
|
|
3340
|
+
// on the same row (y-delta < 8px) and their x-coordinates are
|
|
3341
|
+
// strictly increasing (left-to-right). Accept the largest
|
|
3342
|
+
// qualifying group.
|
|
3343
|
+
let best = null;
|
|
3344
|
+
for (const [root, inputs] of byParent.entries()) {
|
|
3345
|
+
if (inputs.length < 2)
|
|
3346
|
+
continue;
|
|
3347
|
+
// De-dup since a given input will be counted under multiple
|
|
3348
|
+
// wrapper ancestors during the climb above.
|
|
3349
|
+
const unique = Array.from(new Set(inputs));
|
|
3350
|
+
if (unique.length < 2)
|
|
3351
|
+
continue;
|
|
3352
|
+
const sorted = unique
|
|
3353
|
+
.map((input) => ({ input, rect: input.getBoundingClientRect() }))
|
|
3354
|
+
.sort((a, b) => a.rect.left - b.rect.left);
|
|
3355
|
+
const rows = sorted.map((entry) => entry.rect.top);
|
|
3356
|
+
const yMin = Math.min(...rows);
|
|
3357
|
+
const yMax = Math.max(...rows);
|
|
3358
|
+
if (yMax - yMin > 8)
|
|
3359
|
+
continue;
|
|
3360
|
+
let increasing = true;
|
|
3361
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
3362
|
+
if (sorted[i].rect.left <= sorted[i - 1].rect.left) {
|
|
3363
|
+
increasing = false;
|
|
3364
|
+
break;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
if (!increasing)
|
|
3368
|
+
continue;
|
|
3369
|
+
if (!best || sorted.length > best.inputs.length) {
|
|
3370
|
+
best = { inputs: sorted.map((entry) => entry.input), root };
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
if (!best)
|
|
3374
|
+
return null;
|
|
3375
|
+
if (wantLength !== undefined && best.inputs.length !== wantLength) {
|
|
3376
|
+
// Only accept the group if its cell count is compatible with
|
|
3377
|
+
// the typed value. Compatibility: either exact match, or the
|
|
3378
|
+
// group is ≥ the value length (allow typing a 6-char code into
|
|
3379
|
+
// an 8-box group with 2 empty trailing boxes).
|
|
3380
|
+
if (best.inputs.length < wantLength)
|
|
3381
|
+
return null;
|
|
3382
|
+
}
|
|
3383
|
+
// Stamp a unique data attribute on each cell so the caller can
|
|
3384
|
+
// address them unambiguously via a single selector. Using a
|
|
3385
|
+
// content-hash-ish random id avoids collisions with existing
|
|
3386
|
+
// data attributes and re-renders across re-entry.
|
|
3387
|
+
const marker = `data-geometra-otp-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
3388
|
+
best.inputs.forEach((input, index) => {
|
|
3389
|
+
input.setAttribute(marker, String(index));
|
|
3390
|
+
});
|
|
3391
|
+
return { cssSelector: `[${marker}]`, count: best.inputs.length };
|
|
3392
|
+
}, { scopeHint: scope, wantLength: expectedLength });
|
|
3393
|
+
if (candidate) {
|
|
3394
|
+
return {
|
|
3395
|
+
boxes: frame.locator(candidate.cssSelector),
|
|
3396
|
+
cellCount: candidate.count,
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
return null;
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Fill an OTP / verification-code input group with a value, char by char.
|
|
3404
|
+
*
|
|
3405
|
+
* Detection is generic — see `findOtpBoxGroup`. The actual typing strategy
|
|
3406
|
+
* uses `page.keyboard.type()` (not a low-level `keyboard.insertText()`
|
|
3407
|
+
* batch) so that every char dispatches a real keydown/keypress/keyup event
|
|
3408
|
+
* cycle, which is what React's per-cell onKeyDown handler listens for to
|
|
3409
|
+
* auto-advance focus to the next box.
|
|
3410
|
+
*
|
|
3411
|
+
* Why not `fill_form` or `fill_fields` for this? The 8 boxes share
|
|
3412
|
+
* accessible bounds because they're visually adjacent and a11y collapses
|
|
3413
|
+
* them into one logical textbox node. Any semantic "find the textbox and
|
|
3414
|
+
* write 8 chars" path writes the entire string to box 0, which has
|
|
3415
|
+
* maxlength=1 and silently truncates to one char. The pattern can only be
|
|
3416
|
+
* driven through physical key events.
|
|
3417
|
+
*
|
|
3418
|
+
* Verification: after typing, read the `.value` of every box and confirm
|
|
3419
|
+
* it matches the expected per-cell char. Throws a descriptive error if
|
|
3420
|
+
* verification fails so callers get an honest failure instead of a silent
|
|
3421
|
+
* "success" that leaves the form in a bad state.
|
|
3422
|
+
*/
|
|
3423
|
+
export async function fillOtp(page, value, opts) {
|
|
3424
|
+
if (!value) {
|
|
3425
|
+
throw new Error('fillOtp: value is empty — nothing to type');
|
|
3426
|
+
}
|
|
3427
|
+
const group = await findOtpBoxGroup(page, opts?.fieldLabel, value.length);
|
|
3428
|
+
if (!group) {
|
|
3429
|
+
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
|
+
}
|
|
3431
|
+
// Click the leftmost box via its center point. We use a low-level
|
|
3432
|
+
// bounding-box click because a semantic "click the first input" path
|
|
3433
|
+
// resolves to whichever input paints topmost in the stacking order,
|
|
3434
|
+
// which in Greenhouse's CSS grid layout is always box N-1 (the last
|
|
3435
|
+
// cell). The bounding-box center click is unambiguous and puts focus on
|
|
3436
|
+
// cell 0 every time.
|
|
3437
|
+
const firstBox = group.boxes.nth(0);
|
|
3438
|
+
await firstBox.scrollIntoViewIfNeeded();
|
|
3439
|
+
const box = await firstBox.boundingBox();
|
|
3440
|
+
if (!box) {
|
|
3441
|
+
throw new Error('fillOtp: first OTP cell has no bounding box (detached or invisible?)');
|
|
3442
|
+
}
|
|
3443
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
3444
|
+
// Clear any pre-existing values in all cells so we write a clean slate.
|
|
3445
|
+
// Some widgets pre-populate cells with zero-width spaces or previous
|
|
3446
|
+
// values after a re-render; typing over them would offset the
|
|
3447
|
+
// auto-advance count by one.
|
|
3448
|
+
const perCellCount = group.cellCount;
|
|
3449
|
+
for (let i = 0; i < perCellCount; i++) {
|
|
3450
|
+
try {
|
|
3451
|
+
await group.boxes.nth(i).evaluate((el) => {
|
|
3452
|
+
if (el instanceof HTMLInputElement) {
|
|
3453
|
+
el.value = '';
|
|
3454
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3455
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3456
|
+
}
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
catch {
|
|
3460
|
+
/* individual cell may have been removed/re-rendered; tolerate */
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
// Re-focus cell 0 after clearing — dispatching input events may have
|
|
3464
|
+
// blurred the active element on some React builds.
|
|
3465
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
3466
|
+
// Type the value char by char. `page.keyboard.type` already dispatches
|
|
3467
|
+
// keydown+keypress+input+keyup for each character and honors the delay.
|
|
3468
|
+
// 30ms is enough for React's onKeyDown handler to run and move focus
|
|
3469
|
+
// to the next cell before the next char arrives.
|
|
3470
|
+
const perCharDelay = opts?.perCharDelayMs ?? 30;
|
|
3471
|
+
await page.keyboard.type(value, { delay: perCharDelay });
|
|
3472
|
+
// Small settle window for the last cell's onChange propagation.
|
|
3473
|
+
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
|
+
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] ?? '' });
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
if (mismatches.length > 0) {
|
|
3496
|
+
const summary = mismatches
|
|
3497
|
+
.map((m) => `cell ${m.index}: expected "${m.expected}", got "${m.got}"`)
|
|
3498
|
+
.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(', ')}]`);
|
|
3500
|
+
}
|
|
3501
|
+
return { cellCount: perCellCount, filledCount: expected.length };
|
|
3502
|
+
}
|
|
3169
3503
|
export async function setFieldText(page, fieldLabel, value, opts) {
|
|
3504
|
+
// Bug #2 (v1.43) auto-routing: if the caller's label looks like a
|
|
3505
|
+
// verification-code / security-code / OTP prompt, try the dedicated OTP
|
|
3506
|
+
// path first. If no OTP group is detected, fall through to the normal
|
|
3507
|
+
// text-fill path — the label match alone is not enough to guarantee the
|
|
3508
|
+
// field is split into cells. If the group IS detected, any thrown error
|
|
3509
|
+
// from fillOtp propagates out of setFieldText unchanged: silent fallback
|
|
3510
|
+
// to the plain text-fill path would write the whole string into box 0
|
|
3511
|
+
// and pretend success, which is the bug we're fixing.
|
|
3512
|
+
if (labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
|
|
3513
|
+
const group = await findOtpBoxGroup(page, fieldLabel, value.length);
|
|
3514
|
+
if (group) {
|
|
3515
|
+
await fillOtp(page, value, { fieldLabel });
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3170
3519
|
const exact = opts?.exact ?? false;
|
|
3171
3520
|
const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId);
|
|
3172
3521
|
if (!locator) {
|
|
@@ -3343,6 +3692,34 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
3343
3692
|
}
|
|
3344
3693
|
}
|
|
3345
3694
|
candidates.sort((a, b) => a.score - b.score);
|
|
3695
|
+
// Snapshot a stable "selection signature" for an option group so we can
|
|
3696
|
+
// verify that a click actually committed the choice. This is what
|
|
3697
|
+
// separates a real selection from a no-op click on a button whose
|
|
3698
|
+
// aria-label is unrelated to the visible text (Modal/Ashby pattern: Yes/No
|
|
3699
|
+
// buttons whose accessible name is the form name like "Application"
|
|
3700
|
+
// because the rendering library passed the wrong label down). The click
|
|
3701
|
+
// event fires, the DOM mutates a focus ring, and the legacy code returned
|
|
3702
|
+
// true unconditionally — but the controlled-component state never moved,
|
|
3703
|
+
// so the form failed validation on submit.
|
|
3704
|
+
//
|
|
3705
|
+
// The signature captures every state attribute and class list mutation
|
|
3706
|
+
// that React-style stateful buttons typically toggle on selection:
|
|
3707
|
+
// aria-pressed, aria-checked, aria-selected, data-state, class names,
|
|
3708
|
+
// disabled, and the input element's `checked` if present. If NONE of
|
|
3709
|
+
// these change after the click, the click was a no-op and we return false
|
|
3710
|
+
// so the caller falls through to the next strategy.
|
|
3711
|
+
function selectionSignature(opts) {
|
|
3712
|
+
return opts.map(el => {
|
|
3713
|
+
const ariaPressed = el.getAttribute('aria-pressed') ?? '';
|
|
3714
|
+
const ariaChecked = el.getAttribute('aria-checked') ?? '';
|
|
3715
|
+
const ariaSelected = el.getAttribute('aria-selected') ?? '';
|
|
3716
|
+
const dataState = el.getAttribute('data-state') ?? '';
|
|
3717
|
+
const dataSelected = el.getAttribute('data-selected') ?? '';
|
|
3718
|
+
const className = (el instanceof HTMLElement ? el.className : '') ?? '';
|
|
3719
|
+
const checked = el instanceof HTMLInputElement ? String(el.checked) : '';
|
|
3720
|
+
return `${ariaPressed}|${ariaChecked}|${ariaSelected}|${dataState}|${dataSelected}|${className}|${checked}`;
|
|
3721
|
+
}).join('||');
|
|
3722
|
+
}
|
|
3346
3723
|
for (const candidate of candidates) {
|
|
3347
3724
|
const options = Array.from(candidate.root.querySelectorAll('input[type="radio"], input[type="checkbox"], [role="radio"], [role="checkbox"], label, button'));
|
|
3348
3725
|
for (const option of options) {
|
|
@@ -3355,16 +3732,32 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
3355
3732
|
return option.checked;
|
|
3356
3733
|
}
|
|
3357
3734
|
if (option instanceof HTMLLabelElement) {
|
|
3735
|
+
const labelBeforeSig = selectionSignature(options);
|
|
3358
3736
|
option.click();
|
|
3359
3737
|
const control = option.control;
|
|
3360
3738
|
if (control instanceof HTMLInputElement)
|
|
3361
3739
|
return control.checked;
|
|
3362
|
-
|
|
3740
|
+
// No backing input — verify via group signature change so we don't
|
|
3741
|
+
// silently no-op on label wrappers whose target is unparented.
|
|
3742
|
+
const labelAfterSig = selectionSignature(options);
|
|
3743
|
+
return labelBeforeSig !== labelAfterSig;
|
|
3363
3744
|
}
|
|
3364
|
-
option.click();
|
|
3365
3745
|
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
3746
|
+
option.click();
|
|
3366
3747
|
return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
|
|
3367
3748
|
}
|
|
3749
|
+
// Plain <button> path. This is where Modal/Ashby Yes/No buttons land
|
|
3750
|
+
// when their aria-label is broken. Snapshot the group's selection
|
|
3751
|
+
// signature, click, then re-snapshot. If nothing changed, the click
|
|
3752
|
+
// was a no-op — return false so the caller can try the next strategy
|
|
3753
|
+
// (pickListboxOption fallback, etc) instead of silently succeeding.
|
|
3754
|
+
const beforeSig = selectionSignature(options);
|
|
3755
|
+
option.click();
|
|
3756
|
+
const afterSig = selectionSignature(options);
|
|
3757
|
+
if (beforeSig === afterSig) {
|
|
3758
|
+
// Click was a structural no-op. Don't silently succeed.
|
|
3759
|
+
return false;
|
|
3760
|
+
}
|
|
3368
3761
|
return true;
|
|
3369
3762
|
}
|
|
3370
3763
|
}
|