@geometra/proxy 1.19.7 → 1.19.9

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,24 +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
+ 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
+ }
8
117
  async function firstVisible(locator, opts) {
9
118
  try {
10
119
  const count = Math.min(await locator.count(), opts?.maxCandidates ?? 8);
11
- let firstAnyVisible = null;
120
+ let bestVisible = null;
121
+ let bestQualified = null;
12
122
  for (let i = 0; i < count; i++) {
13
123
  const candidate = locator.nth(i);
14
124
  if (!(await candidate.isVisible()))
15
125
  continue;
16
- if (!firstAnyVisible)
17
- firstAnyVisible = candidate;
18
126
  const box = await candidate.boundingBox();
19
127
  if (!box)
20
128
  continue;
129
+ const score = distanceFromPreferredAnchor(box, opts?.preferredAnchor);
130
+ if (!bestVisible || score < bestVisible.score) {
131
+ bestVisible = { locator: candidate, score };
132
+ }
21
133
  if ((opts?.minWidth ?? 0) <= box.width && (opts?.minHeight ?? 0) <= box.height) {
22
- return candidate;
134
+ if (!bestQualified || score < bestQualified.score) {
135
+ bestQualified = { locator: candidate, score };
136
+ }
23
137
  }
24
138
  }
25
- return opts?.fallbackToAnyVisible === false ? null : firstAnyVisible;
139
+ if (bestQualified)
140
+ return bestQualified.locator;
141
+ return opts?.fallbackToAnyVisible === false ? null : bestVisible?.locator ?? null;
26
142
  }
27
143
  catch {
28
144
  /* ignore */
@@ -45,7 +161,77 @@ async function locatorAnchorY(locator) {
45
161
  const bounds = await locator.boundingBox();
46
162
  return bounds ? bounds.y + bounds.height / 2 : undefined;
47
163
  }
48
- 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) {
49
235
  const directCandidates = [
50
236
  frame.getByLabel(fieldLabel, { exact }),
51
237
  frame.getByRole('combobox', { name: fieldLabel, exact }),
@@ -53,7 +239,7 @@ async function findLabeledControl(frame, fieldLabel, exact) {
53
239
  frame.getByRole('button', { name: fieldLabel, exact }),
54
240
  ];
55
241
  for (const candidate of directCandidates) {
56
- const visible = await firstVisible(candidate, { minWidth: 48, minHeight: 18, fallbackToAnyVisible: false });
242
+ const visible = await firstVisible(candidate, { preferredAnchor: opts?.preferredAnchor });
57
243
  if (visible)
58
244
  return visible;
59
245
  }
@@ -137,14 +323,19 @@ async function findLabeledControl(frame, fieldLabel, exact) {
137
323
  continue;
138
324
  if (!visible(el))
139
325
  continue;
326
+ const rect = el.getBoundingClientRect();
140
327
  const explicit = explicitLabelText(el);
141
328
  if (matches(explicit)) {
142
- 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;
143
335
  if (!best || score < best.score)
144
336
  best = { index: i, score };
145
337
  continue;
146
338
  }
147
- const rect = el.getBoundingClientRect();
148
339
  for (const labelNode of labelNodes) {
149
340
  const labelText = labelNode.textContent?.trim();
150
341
  if (!matches(labelText))
@@ -157,23 +348,38 @@ async function findLabeledControl(frame, fieldLabel, exact) {
157
348
  const verticalDistance = rect.top >= labelRect.bottom - 12
158
349
  ? rect.top - labelRect.bottom
159
350
  : 200 + Math.abs(rect.top - labelRect.top);
160
- 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);
161
357
  if (!best || score < best.score)
162
358
  best = { index: i, score };
163
359
  }
164
360
  }
165
361
  return best?.index ?? -1;
166
- }, { fieldLabel, exact });
362
+ }, {
363
+ fieldLabel,
364
+ exact,
365
+ anchorX: opts?.preferredAnchor?.x ?? null,
366
+ anchorY: opts?.preferredAnchor?.y ?? null,
367
+ });
167
368
  return bestIndex >= 0 ? fallbackCandidates.nth(bestIndex) : null;
168
369
  }
169
370
  function textMatches(candidate, expected, exact) {
170
- if (!candidate)
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)
171
377
  return false;
172
- const normalizedCandidate = candidate.replace(/\s+/g, ' ').trim().toLowerCase();
173
- const normalizedExpected = expected.replace(/\s+/g, ' ').trim().toLowerCase();
174
- if (!normalizedCandidate || !normalizedExpected)
378
+ const normalizedCandidate = normalizedOptionLabel(candidate);
379
+ const normalizedSelectedOption = normalizedOptionLabel(selectedOptionText);
380
+ if (!normalizedCandidate || normalizedCandidate.length < 2 || !normalizedSelectedOption)
175
381
  return false;
176
- return exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
382
+ return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
177
383
  }
178
384
  async function openDropdownControl(page, fieldLabel, exact) {
179
385
  for (const frame of page.frames()) {
@@ -181,10 +387,23 @@ async function openDropdownControl(page, fieldLabel, exact) {
181
387
  if (!locator)
182
388
  continue;
183
389
  await locator.scrollIntoViewIfNeeded();
184
- const anchorY = await locatorAnchorY(locator);
390
+ const handle = await locator.elementHandle();
391
+ const clickTarget = await resolveMeaningfulClickTarget(locator);
185
392
  const editable = await locatorIsEditable(locator);
186
- await locator.click();
187
- 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
+ };
188
407
  }
189
408
  throw new Error(`listboxPick: no visible combobox/dropdown matching field "${fieldLabel}"`);
190
409
  }
@@ -221,12 +440,7 @@ async function typeIntoActiveEditableElement(page, text) {
221
440
  }
222
441
  return false;
223
442
  }
224
- async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
225
- const roleOption = await firstVisible(page.getByRole('option', { name: label, exact }));
226
- if (roleOption) {
227
- await roleOption.click();
228
- return true;
229
- }
443
+ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
230
444
  for (const frame of page.frames()) {
231
445
  const candidates = frame.locator(OPTION_PICKER_SELECTOR);
232
446
  const count = await candidates.count();
@@ -236,14 +450,63 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
236
450
  function normalize(value) {
237
451
  return value.replace(/\s+/g, ' ').trim().toLowerCase();
238
452
  }
239
- 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) {
240
479
  if (!candidate)
241
- return false;
480
+ return null;
242
481
  const normalizedCandidate = normalize(candidate);
243
482
  const normalizedExpected = normalize(payload.label);
244
483
  if (!normalizedCandidate || !normalizedExpected)
245
- return false;
246
- 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;
247
510
  }
248
511
  function visible(el) {
249
512
  if (!(el instanceof HTMLElement))
@@ -267,61 +530,173 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
267
530
  if (!visible(el))
268
531
  continue;
269
532
  const candidateText = el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
270
- if (!matches(candidateText))
533
+ const match = matchScore(candidateText);
534
+ if (match === null)
271
535
  continue;
272
536
  const rect = el.getBoundingClientRect();
537
+ const centerX = rect.left + rect.width / 2;
273
538
  const centerY = rect.top + rect.height / 2;
274
539
  const upwardPenalty = payload.anchorY === null || centerY >= payload.anchorY - 16
275
540
  ? 0
276
541
  : 140;
277
- const proximity = payload.anchorY === null ? rect.top : Math.abs(centerY - payload.anchorY);
278
- 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;
279
545
  if (!best || score < best.score)
280
546
  best = { index: i, score };
281
547
  }
282
548
  return best?.index ?? -1;
283
- }, { label, exact, anchorY: anchorY ?? null });
549
+ }, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
284
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;
285
555
  await candidates.nth(bestIndex).click();
286
- return true;
556
+ return selectedText;
287
557
  }
288
558
  }
289
- return false;
559
+ return null;
290
560
  }
291
- async function locatorDisplayedValue(locator) {
561
+ async function locatorDisplayedValues(locator) {
292
562
  try {
293
- return await locator.evaluate((el) => {
294
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
295
- return el.value?.trim() || el.getAttribute('aria-valuetext')?.trim() || el.getAttribute('aria-label')?.trim() || undefined;
296
- }
297
- if (el instanceof HTMLSelectElement) {
298
- return el.selectedOptions[0]?.textContent?.trim() || el.value?.trim() || undefined;
299
- }
300
- const ariaValueText = el.getAttribute('aria-valuetext')?.trim();
301
- if (ariaValueText)
302
- return ariaValueText;
303
- const ariaLabel = el.getAttribute('aria-label')?.trim();
304
- if (ariaLabel)
305
- return ariaLabel;
306
- const text = el.textContent?.trim();
307
- return text || undefined;
308
- });
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);
309
572
  }
310
573
  catch {
311
- return undefined;
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)
677
+ return true;
312
678
  }
679
+ return false;
313
680
  }
314
- async function confirmListboxSelection(page, fieldLabel, label, exact) {
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
+ }
315
688
  const deadline = Date.now() + 1500;
316
689
  while (Date.now() < deadline) {
317
690
  for (const frame of page.frames()) {
318
- const locator = await findLabeledControl(frame, fieldLabel, exact);
691
+ const locator = await findLabeledControl(frame, fieldLabel, exact, { preferredAnchor: anchor });
319
692
  if (!locator)
320
693
  continue;
321
- const value = await locatorDisplayedValue(locator);
322
- if (textMatches(value, label, exact))
694
+ const values = await locatorDisplayedValues(locator);
695
+ if (values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)))
323
696
  return true;
324
697
  }
698
+ if (await visibleOptionIsSelected(page, label, exact, anchor))
699
+ return true;
325
700
  await delay(100);
326
701
  }
327
702
  return false;
@@ -478,12 +853,15 @@ export async function selectNativeOption(page, x, y, opt) {
478
853
  * Custom listbox / combobox (ARIA): optional click to open, then `getByRole('option')`.
479
854
  */
480
855
  export async function pickListboxOption(page, label, opts) {
481
- let anchorY;
856
+ let anchor;
482
857
  const exact = opts?.exact ?? false;
483
858
  let attemptedSelection = false;
859
+ let selectedOptionText;
860
+ let openedHandle;
484
861
  if (opts?.fieldLabel) {
485
862
  const opened = await openDropdownControl(page, opts.fieldLabel, exact);
486
- anchorY = opened.anchorY;
863
+ anchor = { x: opened.anchorX, y: opened.anchorY };
864
+ openedHandle = opened.handle;
487
865
  const query = opts.query ?? label;
488
866
  if (query && opened.editable) {
489
867
  await typeIntoEditableLocator(page, opened.locator, query);
@@ -495,15 +873,18 @@ export async function pickListboxOption(page, label, opts) {
495
873
  }
496
874
  else if (opts?.openX !== undefined && opts?.openY !== undefined) {
497
875
  await page.mouse.click(opts.openX, opts.openY);
498
- anchorY = opts.openY;
876
+ anchor = { x: opts.openX, y: opts.openY };
499
877
  await delay(120);
500
878
  }
501
879
  const deadline = Date.now() + 3000;
502
880
  while (Date.now() < deadline) {
503
- if (await clickVisibleOptionCandidate(page, label, exact, anchorY)) {
881
+ selectedOptionText = (await clickVisibleOptionCandidate(page, label, exact, anchor)) ?? undefined;
882
+ if (selectedOptionText) {
504
883
  attemptedSelection = true;
505
- if (!opts?.fieldLabel || await confirmListboxSelection(page, opts.fieldLabel, label, exact))
884
+ if (!opts?.fieldLabel ||
885
+ await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText)) {
506
886
  return;
887
+ }
507
888
  }
508
889
  await delay(120);
509
890
  }