@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.
- package/dist/a11y-enrich.d.ts.map +1 -1
- package/dist/a11y-enrich.js +249 -5
- package/dist/a11y-enrich.js.map +1 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +491 -39
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +56 -2
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +94 -70
- package/dist/geometry-ws.js.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/dom-actions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
-
}, {
|
|
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
|
|
390
|
+
const handle = await locator.elementHandle();
|
|
391
|
+
const clickTarget = await resolveMeaningfulClickTarget(locator);
|
|
160
392
|
const editable = await locatorIsEditable(locator);
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
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
|
|
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
|
|
480
|
+
return null;
|
|
217
481
|
const normalizedCandidate = normalize(candidate);
|
|
218
482
|
const normalizedExpected = normalize(payload.label);
|
|
219
483
|
if (!normalizedCandidate || !normalizedExpected)
|
|
220
|
-
return
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
253
|
-
const
|
|
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,
|
|
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
|
|
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,
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
/**
|