@geometra/proxy 1.19.6 → 1.19.8

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.
@@ -5,11 +5,140 @@ function delay(ms) {
5
5
  }
6
6
  const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [contenteditable="true"]';
7
7
  const OPTION_PICKER_SELECTOR = '[role="option"], [role="menuitem"], [role="treeitem"], button, li, [data-value], [aria-selected], [aria-checked]';
8
- async function firstVisible(locator) {
8
+ function normalizedOptionLabel(value) {
9
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
10
+ }
11
+ function semanticSelectionAliases(value) {
12
+ const normalized = normalizedOptionLabel(value);
13
+ const aliases = new Set([normalized]);
14
+ if (normalized === 'yes' || normalized === 'true') {
15
+ for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
16
+ aliases.add(alias);
17
+ }
18
+ }
19
+ if (normalized === 'no' || normalized === 'false') {
20
+ for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
21
+ aliases.add(alias);
22
+ }
23
+ }
24
+ if (normalized === 'decline') {
25
+ for (const alias of ['prefer not', 'opt out', 'do not']) {
26
+ aliases.add(alias);
27
+ }
28
+ }
29
+ return [...aliases];
30
+ }
31
+ function hasNegativeSelectionCue(value) {
32
+ return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
33
+ }
34
+ function hasPositiveSelectionCue(value) {
35
+ return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
36
+ }
37
+ function selectionMatchScore(candidate, expected, exact) {
38
+ if (!candidate)
39
+ return null;
40
+ const normalizedCandidate = normalizedOptionLabel(candidate);
41
+ const normalizedExpected = normalizedOptionLabel(expected);
42
+ if (!normalizedCandidate || !normalizedExpected)
43
+ return null;
44
+ const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
45
+ const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
46
+ if (exact)
47
+ return normalizedCandidate === normalizedExpected ? 0 : null;
48
+ if (normalizedCandidate === normalizedExpected)
49
+ return 0;
50
+ if (normalizedCandidate.includes(normalizedExpected))
51
+ return normalizedCandidate.length - normalizedExpected.length;
52
+ if (expectsPositive && hasNegativeSelectionCue(normalizedCandidate))
53
+ return null;
54
+ if (expectsNegative && hasPositiveSelectionCue(normalizedCandidate))
55
+ return null;
56
+ const aliases = semanticSelectionAliases(normalizedExpected);
57
+ for (const alias of aliases) {
58
+ if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
59
+ return 40 + normalizedCandidate.length - alias.length;
60
+ }
61
+ }
62
+ const tokens = normalizedExpected.split(' ').filter(token => token.length >= 3);
63
+ if (tokens.length >= 2) {
64
+ const matchedTokens = tokens.filter(token => normalizedCandidate.includes(token));
65
+ if (matchedTokens.length >= Math.min(2, tokens.length)) {
66
+ return 80 + (tokens.length - matchedTokens.length) * 10;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ function distanceFromPreferredAnchor(box, anchor) {
72
+ if (anchor?.x === undefined && anchor?.y === undefined)
73
+ return 0;
74
+ const centerX = box.x + box.width / 2;
75
+ const centerY = box.y + box.height / 2;
76
+ return Math.abs(centerX - (anchor?.x ?? centerX)) + Math.abs(centerY - (anchor?.y ?? centerY));
77
+ }
78
+ function browserDisplayedValues(el) {
79
+ const values = new Set();
80
+ const push = (value) => {
81
+ const trimmed = value?.trim();
82
+ if (trimmed && trimmed.length <= 240)
83
+ values.add(trimmed);
84
+ };
85
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
86
+ push(el.value);
87
+ push(el.getAttribute('aria-valuetext') ?? undefined);
88
+ push(el.getAttribute('aria-label') ?? undefined);
89
+ }
90
+ if (el instanceof HTMLSelectElement) {
91
+ push(el.selectedOptions[0]?.textContent ?? undefined);
92
+ push(el.value);
93
+ }
94
+ push(el.getAttribute('aria-valuetext') ?? undefined);
95
+ push(el.getAttribute('aria-label') ?? undefined);
96
+ push(el.textContent ?? undefined);
97
+ let current = el.parentElement;
98
+ for (let depth = 0; current && depth < 4; depth++) {
99
+ const role = current.getAttribute('role');
100
+ if (role === 'listbox' || role === 'menu') {
101
+ current = current.parentElement;
102
+ continue;
103
+ }
104
+ const className = typeof current.className === 'string' ? current.className.toLowerCase() : '';
105
+ const looksLikeFieldContainer = role === 'combobox' ||
106
+ current.getAttribute('aria-haspopup') === 'listbox' ||
107
+ current.tagName.toLowerCase() === 'button' ||
108
+ className.includes('select') ||
109
+ className.includes('combo') ||
110
+ className.includes('chip');
111
+ if (looksLikeFieldContainer)
112
+ push(current.textContent ?? undefined);
113
+ current = current.parentElement;
114
+ }
115
+ return [...values];
116
+ }
117
+ async function firstVisible(locator, opts) {
9
118
  try {
10
- const first = locator.first();
11
- if (await first.isVisible())
12
- return first;
119
+ const count = Math.min(await locator.count(), opts?.maxCandidates ?? 8);
120
+ let bestVisible = null;
121
+ let bestQualified = null;
122
+ for (let i = 0; i < count; i++) {
123
+ const candidate = locator.nth(i);
124
+ if (!(await candidate.isVisible()))
125
+ continue;
126
+ const box = await candidate.boundingBox();
127
+ if (!box)
128
+ continue;
129
+ const score = distanceFromPreferredAnchor(box, opts?.preferredAnchor);
130
+ if (!bestVisible || score < bestVisible.score) {
131
+ bestVisible = { locator: candidate, score };
132
+ }
133
+ if ((opts?.minWidth ?? 0) <= box.width && (opts?.minHeight ?? 0) <= box.height) {
134
+ if (!bestQualified || score < bestQualified.score) {
135
+ bestQualified = { locator: candidate, score };
136
+ }
137
+ }
138
+ }
139
+ if (bestQualified)
140
+ return bestQualified.locator;
141
+ return opts?.fallbackToAnyVisible === false ? null : bestVisible?.locator ?? null;
13
142
  }
14
143
  catch {
15
144
  /* ignore */
@@ -32,7 +161,77 @@ async function locatorAnchorY(locator) {
32
161
  const bounds = await locator.boundingBox();
33
162
  return bounds ? bounds.y + bounds.height / 2 : undefined;
34
163
  }
35
- async function findLabeledControl(frame, fieldLabel, exact) {
164
+ async function resolveMeaningfulClickTarget(locator) {
165
+ const baseHandle = await locator.elementHandle();
166
+ if (!baseHandle)
167
+ return { handle: null };
168
+ const targetHandle = (await baseHandle.evaluateHandle((el) => {
169
+ function isTextLikeControl(node) {
170
+ if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)
171
+ return true;
172
+ if (node instanceof HTMLInputElement) {
173
+ return !['checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'hidden', 'range', 'color'].includes(node.type);
174
+ }
175
+ const role = node.getAttribute('role');
176
+ return role === 'textbox' || role === 'combobox';
177
+ }
178
+ function visible(node) {
179
+ if (!(node instanceof HTMLElement))
180
+ return false;
181
+ const rect = node.getBoundingClientRect();
182
+ if (rect.width <= 0 || rect.height <= 0)
183
+ return false;
184
+ const style = getComputedStyle(node);
185
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
186
+ }
187
+ if (!(el instanceof HTMLElement))
188
+ return el;
189
+ const rect = el.getBoundingClientRect();
190
+ if (!isTextLikeControl(el) || (rect.width >= 48 && rect.height >= 18))
191
+ return el;
192
+ let best = el;
193
+ let bestScore = Number.POSITIVE_INFINITY;
194
+ let current = el.parentElement;
195
+ let depth = 0;
196
+ while (current && depth < 6) {
197
+ if (visible(current)) {
198
+ const candidate = current.getBoundingClientRect();
199
+ const className = typeof current.className === 'string' ? current.className.toLowerCase() : '';
200
+ const role = current.getAttribute('role');
201
+ const looksLikeControl = role === 'combobox' ||
202
+ role === 'button' ||
203
+ current.getAttribute('aria-haspopup') === 'listbox' ||
204
+ className.includes('control') ||
205
+ className.includes('select') ||
206
+ className.includes('combo') ||
207
+ className.includes('input');
208
+ if (candidate.width >= rect.width &&
209
+ candidate.height >= rect.height &&
210
+ candidate.width > 0 &&
211
+ candidate.height > 0 &&
212
+ candidate.width <= window.innerWidth * 0.98 &&
213
+ candidate.height <= Math.max(window.innerHeight * 0.9, 320) &&
214
+ (candidate.width >= 48 || candidate.height >= 18)) {
215
+ const score = candidate.width * candidate.height + depth * 1000 - (looksLikeControl ? 20000 : 0);
216
+ if (score < bestScore) {
217
+ best = current;
218
+ bestScore = score;
219
+ }
220
+ }
221
+ }
222
+ current = current.parentElement;
223
+ depth++;
224
+ }
225
+ return best;
226
+ }));
227
+ const bounds = await targetHandle.boundingBox();
228
+ return {
229
+ handle: targetHandle,
230
+ anchorX: bounds ? bounds.x + bounds.width / 2 : undefined,
231
+ anchorY: bounds ? bounds.y + bounds.height / 2 : undefined,
232
+ };
233
+ }
234
+ async function findLabeledControl(frame, fieldLabel, exact, opts) {
36
235
  const directCandidates = [
37
236
  frame.getByLabel(fieldLabel, { exact }),
38
237
  frame.getByRole('combobox', { name: fieldLabel, exact }),
@@ -40,7 +239,7 @@ async function findLabeledControl(frame, fieldLabel, exact) {
40
239
  frame.getByRole('button', { name: fieldLabel, exact }),
41
240
  ];
42
241
  for (const candidate of directCandidates) {
43
- const visible = await firstVisible(candidate);
242
+ const visible = await firstVisible(candidate, { preferredAnchor: opts?.preferredAnchor });
44
243
  if (visible)
45
244
  return visible;
46
245
  }
@@ -102,16 +301,19 @@ async function findLabeledControl(frame, fieldLabel, exact) {
102
301
  return undefined;
103
302
  }
104
303
  function controlPriority(el) {
105
- if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)
106
- return 0;
304
+ const rect = el.getBoundingClientRect();
305
+ const sizePenalty = rect.width < 48 || rect.height < 18 ? 180 : 0;
306
+ if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
307
+ return sizePenalty;
308
+ }
107
309
  const role = el.getAttribute('role');
108
310
  if (role === 'combobox' || role === 'textbox')
109
- return 4;
311
+ return 4 + sizePenalty;
110
312
  if (el.getAttribute('aria-haspopup') === 'listbox')
111
- return 8;
313
+ return 8 + sizePenalty;
112
314
  if (el.tagName.toLowerCase() === 'button')
113
- return 12;
114
- return 24;
315
+ return 12 + sizePenalty;
316
+ return 24 + sizePenalty;
115
317
  }
116
318
  const labelNodes = Array.from(document.querySelectorAll('label, legend')).filter((el) => visible(el));
117
319
  let best = null;
@@ -121,14 +323,19 @@ async function findLabeledControl(frame, fieldLabel, exact) {
121
323
  continue;
122
324
  if (!visible(el))
123
325
  continue;
326
+ const rect = el.getBoundingClientRect();
124
327
  const explicit = explicitLabelText(el);
125
328
  if (matches(explicit)) {
126
- const score = controlPriority(el);
329
+ const centerX = rect.left + rect.width / 2;
330
+ const centerY = rect.top + rect.height / 2;
331
+ const anchorDistance = payload.anchorX === null && payload.anchorY === null
332
+ ? 0
333
+ : Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
334
+ const score = controlPriority(el) + anchorDistance / 8;
127
335
  if (!best || score < best.score)
128
336
  best = { index: i, score };
129
337
  continue;
130
338
  }
131
- const rect = el.getBoundingClientRect();
132
339
  for (const labelNode of labelNodes) {
133
340
  const labelText = labelNode.textContent?.trim();
134
341
  if (!matches(labelText))
@@ -141,25 +348,62 @@ async function findLabeledControl(frame, fieldLabel, exact) {
141
348
  const verticalDistance = rect.top >= labelRect.bottom - 12
142
349
  ? rect.top - labelRect.bottom
143
350
  : 200 + Math.abs(rect.top - labelRect.top);
144
- const score = 100 + verticalDistance * 3 + horizontalDistance + controlPriority(el);
351
+ const centerX = rect.left + rect.width / 2;
352
+ const centerY = rect.top + rect.height / 2;
353
+ const anchorDistance = payload.anchorX === null && payload.anchorY === null
354
+ ? 0
355
+ : Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
356
+ const score = 100 + verticalDistance * 3 + horizontalDistance + anchorDistance / 8 + controlPriority(el);
145
357
  if (!best || score < best.score)
146
358
  best = { index: i, score };
147
359
  }
148
360
  }
149
361
  return best?.index ?? -1;
150
- }, { fieldLabel, exact });
362
+ }, {
363
+ fieldLabel,
364
+ exact,
365
+ anchorX: opts?.preferredAnchor?.x ?? null,
366
+ anchorY: opts?.preferredAnchor?.y ?? null,
367
+ });
151
368
  return bestIndex >= 0 ? fallbackCandidates.nth(bestIndex) : null;
152
369
  }
370
+ function textMatches(candidate, expected, exact) {
371
+ return selectionMatchScore(candidate, expected, exact) !== null;
372
+ }
373
+ function displayedValueMatchesSelection(candidate, expected, exact, selectedOptionText) {
374
+ if (textMatches(candidate, expected, exact))
375
+ return true;
376
+ if (!candidate || !selectedOptionText || exact)
377
+ return false;
378
+ const normalizedCandidate = normalizedOptionLabel(candidate);
379
+ const normalizedSelectedOption = normalizedOptionLabel(selectedOptionText);
380
+ if (!normalizedCandidate || normalizedCandidate.length < 2 || !normalizedSelectedOption)
381
+ return false;
382
+ return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
383
+ }
153
384
  async function openDropdownControl(page, fieldLabel, exact) {
154
385
  for (const frame of page.frames()) {
155
386
  const locator = await findLabeledControl(frame, fieldLabel, exact);
156
387
  if (!locator)
157
388
  continue;
158
389
  await locator.scrollIntoViewIfNeeded();
159
- const anchorY = await locatorAnchorY(locator);
390
+ const handle = await locator.elementHandle();
391
+ const clickTarget = await resolveMeaningfulClickTarget(locator);
160
392
  const editable = await locatorIsEditable(locator);
161
- await locator.click();
162
- return { locator, editable, anchorY };
393
+ if (clickTarget.handle) {
394
+ await clickTarget.handle.scrollIntoViewIfNeeded();
395
+ await clickTarget.handle.click();
396
+ }
397
+ else {
398
+ await locator.click();
399
+ }
400
+ return {
401
+ locator,
402
+ handle,
403
+ editable,
404
+ anchorX: clickTarget.anchorX,
405
+ anchorY: clickTarget.anchorY ?? await locatorAnchorY(locator),
406
+ };
163
407
  }
164
408
  throw new Error(`listboxPick: no visible combobox/dropdown matching field "${fieldLabel}"`);
165
409
  }
@@ -196,12 +440,7 @@ async function typeIntoActiveEditableElement(page, text) {
196
440
  }
197
441
  return false;
198
442
  }
199
- async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
200
- const roleOption = await firstVisible(page.getByRole('option', { name: label, exact }));
201
- if (roleOption) {
202
- await roleOption.click();
203
- return true;
204
- }
443
+ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
205
444
  for (const frame of page.frames()) {
206
445
  const candidates = frame.locator(OPTION_PICKER_SELECTOR);
207
446
  const count = await candidates.count();
@@ -211,14 +450,63 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
211
450
  function normalize(value) {
212
451
  return value.replace(/\s+/g, ' ').trim().toLowerCase();
213
452
  }
214
- function matches(candidate) {
453
+ function aliases(value) {
454
+ const out = new Set([value]);
455
+ if (value === 'yes' || value === 'true') {
456
+ for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
457
+ out.add(alias);
458
+ }
459
+ }
460
+ if (value === 'no' || value === 'false') {
461
+ for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
462
+ out.add(alias);
463
+ }
464
+ }
465
+ if (value === 'decline') {
466
+ for (const alias of ['prefer not', 'opt out', 'do not']) {
467
+ out.add(alias);
468
+ }
469
+ }
470
+ return [...out];
471
+ }
472
+ function hasNegativeCue(value) {
473
+ return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
474
+ }
475
+ function hasPositiveCue(value) {
476
+ return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
477
+ }
478
+ function matchScore(candidate) {
215
479
  if (!candidate)
216
- return false;
480
+ return null;
217
481
  const normalizedCandidate = normalize(candidate);
218
482
  const normalizedExpected = normalize(payload.label);
219
483
  if (!normalizedCandidate || !normalizedExpected)
220
- return false;
221
- return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
484
+ return null;
485
+ const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
486
+ const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
487
+ if (payload.exact)
488
+ return normalizedCandidate === normalizedExpected ? 0 : null;
489
+ if (normalizedCandidate === normalizedExpected)
490
+ return 0;
491
+ if (normalizedCandidate.includes(normalizedExpected))
492
+ return normalizedCandidate.length - normalizedExpected.length;
493
+ if (expectsPositive && hasNegativeCue(normalizedCandidate))
494
+ return null;
495
+ if (expectsNegative && hasPositiveCue(normalizedCandidate))
496
+ return null;
497
+ for (const alias of aliases(normalizedExpected)) {
498
+ if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
499
+ return 40 + normalizedCandidate.length - alias.length;
500
+ }
501
+ }
502
+ const tokens = normalizedExpected.split(' ').filter(token => token.length >= 3);
503
+ if (tokens.length >= 2) {
504
+ const matches = tokens.filter(token => normalizedCandidate.includes(token));
505
+ if (matches.length >= Math.min(2, tokens.length)) {
506
+ return 80 + (tokens.length - matches.length) * 10;
507
+ }
508
+ }
509
+ return null;
222
510
  }
223
511
  function visible(el) {
224
512
  if (!(el instanceof HTMLElement))
@@ -242,24 +530,174 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
242
530
  if (!visible(el))
243
531
  continue;
244
532
  const candidateText = el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
245
- if (!matches(candidateText))
533
+ const match = matchScore(candidateText);
534
+ if (match === null)
246
535
  continue;
247
536
  const rect = el.getBoundingClientRect();
537
+ const centerX = rect.left + rect.width / 2;
248
538
  const centerY = rect.top + rect.height / 2;
249
539
  const upwardPenalty = payload.anchorY === null || centerY >= payload.anchorY - 16
250
540
  ? 0
251
541
  : 140;
252
- const proximity = payload.anchorY === null ? rect.top : Math.abs(centerY - payload.anchorY);
253
- const score = popupWeight(el) + upwardPenalty + proximity;
542
+ const verticalProximity = payload.anchorY === null ? rect.top : Math.abs(centerY - payload.anchorY);
543
+ const horizontalProximity = payload.anchorX === null ? 0 : Math.abs(centerX - payload.anchorX);
544
+ const score = popupWeight(el) + upwardPenalty + verticalProximity + horizontalProximity / 2 + match * 2;
254
545
  if (!best || score < best.score)
255
546
  best = { index: i, score };
256
547
  }
257
548
  return best?.index ?? -1;
258
- }, { label, exact, anchorY: anchorY ?? null });
549
+ }, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
259
550
  if (bestIndex >= 0) {
551
+ const selectedText = (await candidates
552
+ .nth(bestIndex)
553
+ .evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '')
554
+ .catch(() => '')) || null;
260
555
  await candidates.nth(bestIndex).click();
556
+ return selectedText;
557
+ }
558
+ }
559
+ return null;
560
+ }
561
+ async function locatorDisplayedValues(locator) {
562
+ try {
563
+ return await locator.evaluate(browserDisplayedValues);
564
+ }
565
+ catch {
566
+ return [];
567
+ }
568
+ }
569
+ async function elementHandleDisplayedValues(handle) {
570
+ try {
571
+ return await handle.evaluate(browserDisplayedValues);
572
+ }
573
+ catch {
574
+ return [];
575
+ }
576
+ }
577
+ async function visibleOptionIsSelected(page, label, exact, anchor) {
578
+ for (const frame of page.frames()) {
579
+ const candidates = frame.locator(OPTION_PICKER_SELECTOR);
580
+ const count = await candidates.count();
581
+ if (count === 0)
582
+ continue;
583
+ const selected = await candidates.evaluateAll((elements, payload) => {
584
+ function normalize(value) {
585
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
586
+ }
587
+ function aliases(value) {
588
+ const out = new Set([value]);
589
+ if (value === 'yes' || value === 'true') {
590
+ for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
591
+ out.add(alias);
592
+ }
593
+ }
594
+ if (value === 'no' || value === 'false') {
595
+ for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
596
+ out.add(alias);
597
+ }
598
+ }
599
+ if (value === 'decline') {
600
+ for (const alias of ['prefer not', 'opt out', 'do not']) {
601
+ out.add(alias);
602
+ }
603
+ }
604
+ return [...out];
605
+ }
606
+ function hasNegativeCue(value) {
607
+ return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
608
+ }
609
+ function hasPositiveCue(value) {
610
+ return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
611
+ }
612
+ function matchScore(candidate) {
613
+ if (!candidate)
614
+ return null;
615
+ const normalizedCandidate = normalize(candidate);
616
+ const normalizedExpected = normalize(payload.label);
617
+ if (!normalizedCandidate || !normalizedExpected)
618
+ return null;
619
+ const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
620
+ const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
621
+ if (payload.exact)
622
+ return normalizedCandidate === normalizedExpected ? 0 : null;
623
+ if (normalizedCandidate === normalizedExpected)
624
+ return 0;
625
+ if (normalizedCandidate.includes(normalizedExpected))
626
+ return normalizedCandidate.length - normalizedExpected.length;
627
+ if (expectsPositive && hasNegativeCue(normalizedCandidate))
628
+ return null;
629
+ if (expectsNegative && hasPositiveCue(normalizedCandidate))
630
+ return null;
631
+ for (const alias of aliases(normalizedExpected)) {
632
+ if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
633
+ return 40 + normalizedCandidate.length - alias.length;
634
+ }
635
+ }
636
+ return null;
637
+ }
638
+ function visible(el) {
639
+ if (!(el instanceof HTMLElement))
640
+ return false;
641
+ const rect = el.getBoundingClientRect();
642
+ if (rect.width <= 0 || rect.height <= 0)
643
+ return false;
644
+ const style = getComputedStyle(el);
645
+ return style.display !== 'none' && style.visibility !== 'hidden';
646
+ }
647
+ function isSelected(el) {
648
+ return (el.getAttribute('aria-selected') === 'true' ||
649
+ el.getAttribute('aria-checked') === 'true' ||
650
+ el.getAttribute('data-selected') === 'true' ||
651
+ el.getAttribute('data-state') === 'checked' ||
652
+ el.getAttribute('data-state') === 'on');
653
+ }
654
+ let bestScore = Number.POSITIVE_INFINITY;
655
+ for (const el of elements) {
656
+ if (!(el instanceof Element))
657
+ continue;
658
+ if (!visible(el))
659
+ continue;
660
+ if (!isSelected(el))
661
+ continue;
662
+ const text = el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
663
+ const match = matchScore(text);
664
+ if (match === null)
665
+ continue;
666
+ const rect = el.getBoundingClientRect();
667
+ const centerX = rect.left + rect.width / 2;
668
+ const centerY = rect.top + rect.height / 2;
669
+ const distance = payload.anchorX === null && payload.anchorY === null
670
+ ? 0
671
+ : Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
672
+ bestScore = Math.min(bestScore, match * 2 + distance / 2);
673
+ }
674
+ return Number.isFinite(bestScore);
675
+ }, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
676
+ if (selected)
261
677
  return true;
678
+ }
679
+ return false;
680
+ }
681
+ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, currentHandle, selectedOptionText) {
682
+ if (currentHandle) {
683
+ const immediateValues = await elementHandleDisplayedValues(currentHandle);
684
+ if (immediateValues.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText))) {
685
+ return true;
686
+ }
687
+ }
688
+ const deadline = Date.now() + 1500;
689
+ while (Date.now() < deadline) {
690
+ for (const frame of page.frames()) {
691
+ const locator = await findLabeledControl(frame, fieldLabel, exact, { preferredAnchor: anchor });
692
+ if (!locator)
693
+ continue;
694
+ const values = await locatorDisplayedValues(locator);
695
+ if (values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)))
696
+ return true;
262
697
  }
698
+ if (await visibleOptionIsSelected(page, label, exact, anchor))
699
+ return true;
700
+ await delay(100);
263
701
  }
264
702
  return false;
265
703
  }
@@ -415,10 +853,15 @@ export async function selectNativeOption(page, x, y, opt) {
415
853
  * Custom listbox / combobox (ARIA): optional click to open, then `getByRole('option')`.
416
854
  */
417
855
  export async function pickListboxOption(page, label, opts) {
418
- let anchorY;
856
+ let anchor;
857
+ const exact = opts?.exact ?? false;
858
+ let attemptedSelection = false;
859
+ let selectedOptionText;
860
+ let openedHandle;
419
861
  if (opts?.fieldLabel) {
420
- const opened = await openDropdownControl(page, opts.fieldLabel, opts?.exact ?? false);
421
- anchorY = opened.anchorY;
862
+ const opened = await openDropdownControl(page, opts.fieldLabel, exact);
863
+ anchor = { x: opened.anchorX, y: opened.anchorY };
864
+ openedHandle = opened.handle;
422
865
  const query = opts.query ?? label;
423
866
  if (query && opened.editable) {
424
867
  await typeIntoEditableLocator(page, opened.locator, query);
@@ -430,15 +873,24 @@ export async function pickListboxOption(page, label, opts) {
430
873
  }
431
874
  else if (opts?.openX !== undefined && opts?.openY !== undefined) {
432
875
  await page.mouse.click(opts.openX, opts.openY);
433
- anchorY = opts.openY;
876
+ anchor = { x: opts.openX, y: opts.openY };
434
877
  await delay(120);
435
878
  }
436
879
  const deadline = Date.now() + 3000;
437
880
  while (Date.now() < deadline) {
438
- if (await clickVisibleOptionCandidate(page, label, opts?.exact ?? false, anchorY))
439
- return;
881
+ selectedOptionText = (await clickVisibleOptionCandidate(page, label, exact, anchor)) ?? undefined;
882
+ if (selectedOptionText) {
883
+ attemptedSelection = true;
884
+ if (!opts?.fieldLabel ||
885
+ await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText)) {
886
+ return;
887
+ }
888
+ }
440
889
  await delay(120);
441
890
  }
891
+ if (opts?.fieldLabel && attemptedSelection) {
892
+ throw new Error(`listboxPick: selected "${label}" but could not confirm it on field "${opts.fieldLabel}"`);
893
+ }
442
894
  throw new Error(`listboxPick: no visible option matching "${label}"`);
443
895
  }
444
896
  /**