@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.
@@ -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":"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"}
1
+ {"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"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"}
@@ -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
- return true;
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
  }