pw-core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +777 -0
- package/dist/codegen/config/context-weights.d.ts +8 -0
- package/dist/codegen/config/context-weights.js +11 -0
- package/dist/codegen/config/ignored-attributes.d.ts +1 -0
- package/dist/codegen/config/ignored-attributes.js +4 -0
- package/dist/codegen/config/ignored-classes.d.ts +1 -0
- package/dist/codegen/config/ignored-classes.js +144 -0
- package/dist/codegen/config/ignored-values.d.ts +1 -0
- package/dist/codegen/config/ignored-values.js +4 -0
- package/dist/codegen/config/index.d.ts +29 -0
- package/dist/codegen/config/index.js +17 -0
- package/dist/codegen/config/strategy-order.d.ts +1 -0
- package/dist/codegen/config/strategy-order.js +18 -0
- package/dist/codegen/config/weights.d.ts +15 -0
- package/dist/codegen/config/weights.js +18 -0
- package/dist/codegen/floating-panel/client.d.ts +1 -0
- package/dist/codegen/floating-panel/client.js +248 -0
- package/dist/codegen/floating-panel/index.d.ts +4 -0
- package/dist/codegen/floating-panel/index.js +20 -0
- package/dist/codegen/floating-panel/manager.d.ts +19 -0
- package/dist/codegen/floating-panel/manager.js +90 -0
- package/dist/codegen/floating-panel/template.d.ts +2 -0
- package/dist/codegen/floating-panel/template.js +58 -0
- package/dist/codegen/floating-panel/types.d.ts +7 -0
- package/dist/codegen/floating-panel/types.js +2 -0
- package/dist/codegen/generator/action.validator.d.ts +5 -0
- package/dist/codegen/generator/action.validator.js +27 -0
- package/dist/codegen/generator/index.d.ts +13 -0
- package/dist/codegen/generator/index.js +724 -0
- package/dist/codegen/generator/locator.restricted.d.ts +36 -0
- package/dist/codegen/generator/locator.restricted.js +127 -0
- package/dist/codegen/generator/locator.validator.d.ts +8 -0
- package/dist/codegen/generator/locator.validator.js +38 -0
- package/dist/codegen/hover-tracker/client.d.ts +1 -0
- package/dist/codegen/hover-tracker/client.js +75 -0
- package/dist/codegen/hover-tracker/index.d.ts +3 -0
- package/dist/codegen/hover-tracker/index.js +19 -0
- package/dist/codegen/hover-tracker/manager.d.ts +11 -0
- package/dist/codegen/hover-tracker/manager.js +28 -0
- package/dist/codegen/hover-tracker/types.d.ts +3 -0
- package/dist/codegen/hover-tracker/types.js +2 -0
- package/dist/codegen/index.d.ts +35 -0
- package/dist/codegen/index.js +1147 -0
- package/dist/codegen/key-utils.d.ts +23 -0
- package/dist/codegen/key-utils.js +68 -0
- package/dist/codegen/scorer/base-score.d.ts +1 -0
- package/dist/codegen/scorer/base-score.js +7 -0
- package/dist/codegen/scorer/context-score.d.ts +2 -0
- package/dist/codegen/scorer/context-score.js +27 -0
- package/dist/codegen/scorer/index.d.ts +5 -0
- package/dist/codegen/scorer/index.js +23 -0
- package/dist/codegen/scorer/locator.ranking.d.ts +15 -0
- package/dist/codegen/scorer/locator.ranking.js +83 -0
- package/dist/codegen/scorer/semantic-score.d.ts +1 -0
- package/dist/codegen/scorer/semantic-score.js +47 -0
- package/dist/codegen/types.d.ts +35 -0
- package/dist/codegen/types.js +2 -0
- package/dist/component/table.js +14 -10
- package/dist/constants/methods.d.ts +2 -0
- package/dist/constants/methods.js +33 -0
- package/dist/constants/strategies.d.ts +2 -0
- package/dist/constants/strategies.js +47 -0
- package/dist/page/actions/locator-actions.js +15 -4
- package/dist/page/assertions/verify-chain.d.ts +39 -32
- package/dist/page/assertions/verify-chain.js +18 -4
- package/dist/page/assertions/verify-helpers.js +12 -3
- package/dist/page/config.d.ts +109 -71
- package/dist/page/config.js +10 -31
- package/dist/page/locators/dynamic-locator-resolver.js +29 -9
- package/dist/page/locators/resolver.d.ts +15 -15
- package/dist/page/locators/resolver.js +151 -32
- package/dist/page/registry.d.ts +43 -6
- package/dist/page/registry.js +90 -18
- package/dist/page/typed-page.d.ts +33 -29
- package/dist/page/typed-page.js +40 -11
- package/dist/page/types/proxy-methods.d.ts +69 -54
- package/dist/page/types/validation.d.ts +48 -1
- package/dist/page/utils/caller-location.js +13 -9
- package/dist/page/utils/formatter.js +6 -3
- package/dist/types/methods.d.ts +4 -0
- package/dist/types/methods.js +2 -0
- package/dist/types/strategies.d.ts +17 -0
- package/dist/types/strategies.js +2 -0
- package/package.json +8 -1
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isStableId = isStableId;
|
|
4
|
+
exports.generateKeyFromText = generateKeyFromText;
|
|
5
|
+
exports.generateKeyFromCandidate = generateKeyFromCandidate;
|
|
6
|
+
exports.generateSmartLocator = generateSmartLocator;
|
|
7
|
+
const scorer_1 = require("../scorer");
|
|
8
|
+
const locator_restricted_1 = require("./locator.restricted");
|
|
9
|
+
const key_utils_1 = require("../key-utils");
|
|
10
|
+
const locator_validator_1 = require("./locator.validator");
|
|
11
|
+
function isStableId(id) {
|
|
12
|
+
if (!id)
|
|
13
|
+
return false;
|
|
14
|
+
const lower = id.toLowerCase();
|
|
15
|
+
// React/MUI/HeadlessUI/Ember auto-generated patterns
|
|
16
|
+
if (lower.startsWith(':r') && /\d/.test(lower))
|
|
17
|
+
return false;
|
|
18
|
+
if (lower.startsWith('mui-'))
|
|
19
|
+
return false;
|
|
20
|
+
if (lower.startsWith('headlessui-'))
|
|
21
|
+
return false;
|
|
22
|
+
if (lower.startsWith('ember-'))
|
|
23
|
+
return false;
|
|
24
|
+
if (lower.startsWith('ng-'))
|
|
25
|
+
return false;
|
|
26
|
+
// UUID pattern
|
|
27
|
+
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
28
|
+
if (uuidRegex.test(lower))
|
|
29
|
+
return false;
|
|
30
|
+
// Long hex hashes or random looking alpha-numeric (e.g. f4c2d91, 8d72b3ef)
|
|
31
|
+
const hexHashRegex = /^[0-9a-f]{7,8}$/i;
|
|
32
|
+
if (hexHashRegex.test(lower))
|
|
33
|
+
return false;
|
|
34
|
+
// Purely numeric or ends with a long random number (e.g. button-12847291)
|
|
35
|
+
if (/^\d+$/.test(lower))
|
|
36
|
+
return false;
|
|
37
|
+
if (/\d{5,}$/.test(lower))
|
|
38
|
+
return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function generateKeyFromText(text, role) {
|
|
42
|
+
if (!text)
|
|
43
|
+
return '';
|
|
44
|
+
const roleSuffix = role === 'button' ? 'Btn' : undefined;
|
|
45
|
+
return (0, key_utils_1.toRegistryKey)(text, { roleSuffix });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Derive a camelCase key from a candidate's attribute/selector value.
|
|
49
|
+
* E.g. data-parent-id="why-pw-core" → whyPwCore
|
|
50
|
+
*/
|
|
51
|
+
function generateKeyFromCandidate(candidate, role) {
|
|
52
|
+
const val = candidate.valueToScore || '';
|
|
53
|
+
if (!val)
|
|
54
|
+
return '';
|
|
55
|
+
// For data-attribute, id, testId strategies — use the attribute value directly
|
|
56
|
+
if (['dataAttribute', 'id', 'idAttribute', 'testId'].includes(candidate.strategy)) {
|
|
57
|
+
return generateKeyFromText(val, role);
|
|
58
|
+
}
|
|
59
|
+
// For role/label/text — use the text value
|
|
60
|
+
if (['role', 'label', 'text', 'placeholder', 'altText', 'title'].includes(candidate.strategy)) {
|
|
61
|
+
return generateKeyFromText(val, role);
|
|
62
|
+
}
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
async function generateSmartLocator(page, selector) {
|
|
66
|
+
if (!selector)
|
|
67
|
+
return null;
|
|
68
|
+
const locator = page.locator(selector);
|
|
69
|
+
const count = await locator.count().catch(() => 0);
|
|
70
|
+
if (count === 0)
|
|
71
|
+
return null;
|
|
72
|
+
// Evaluate candidate collection and DOM context extraction inside the browser page
|
|
73
|
+
const result = await locator
|
|
74
|
+
.first()
|
|
75
|
+
.evaluate((element, data) => {
|
|
76
|
+
const collected = [];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const getElementAttributes = (el) => {
|
|
79
|
+
const attrs = {};
|
|
80
|
+
const names = el.getAttributeNames();
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
attrs[name] = el.getAttribute(name) || '';
|
|
83
|
+
}
|
|
84
|
+
return attrs;
|
|
85
|
+
};
|
|
86
|
+
// Helper to check if a container contains multiple actions
|
|
87
|
+
const isMultiActionContainer = (container, target) => {
|
|
88
|
+
if (container === target)
|
|
89
|
+
return false;
|
|
90
|
+
const tag = container.tagName.toLowerCase();
|
|
91
|
+
if (['button', 'a', 'input', 'select', 'option'].includes(tag))
|
|
92
|
+
return false;
|
|
93
|
+
const actions = container.querySelectorAll('button, a, input, select, [role="button"], [role="link"]');
|
|
94
|
+
if (actions.length > 1) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const containerText = (container.textContent || '').trim().replace(/\s+/g, ' ');
|
|
98
|
+
const targetText = (target.textContent || '').trim().replace(/\s+/g, ' ');
|
|
99
|
+
if (containerText && targetText && containerText !== targetText) {
|
|
100
|
+
let textChildrenCount = 0;
|
|
101
|
+
for (let i = 0; i < container.children.length; i++) {
|
|
102
|
+
const childText = (container.children[i].textContent || '').trim();
|
|
103
|
+
if (childText.length > 0) {
|
|
104
|
+
textChildrenCount++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (textChildrenCount > 1) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
// Collect elements in DOM Search Scope
|
|
114
|
+
const scopeElements = [];
|
|
115
|
+
const addElement = (el, source, depth) => {
|
|
116
|
+
if (!el || seen.has(el))
|
|
117
|
+
return;
|
|
118
|
+
seen.add(el);
|
|
119
|
+
scopeElements.push({ el, source, depth });
|
|
120
|
+
};
|
|
121
|
+
// 1. Target Element
|
|
122
|
+
addElement(element, 'target', 0);
|
|
123
|
+
// 2. Children (depth 1)
|
|
124
|
+
for (let i = 0; i < element.children.length; i++) {
|
|
125
|
+
addElement(element.children[i], 'child', 1);
|
|
126
|
+
}
|
|
127
|
+
// 3. Parent
|
|
128
|
+
const parentEl = element.parentElement;
|
|
129
|
+
addElement(parentEl, 'parent', 1);
|
|
130
|
+
// 4. Grandparent
|
|
131
|
+
const grandparentEl = parentEl ? parentEl.parentElement : null;
|
|
132
|
+
addElement(grandparentEl, 'ancestor', 1); // Grandparent is ancestor at depth 1
|
|
133
|
+
// 5. Ancestors (max depth 3)
|
|
134
|
+
let currAncestor = grandparentEl ? grandparentEl.parentElement : null;
|
|
135
|
+
let ancDepth = 2;
|
|
136
|
+
while (currAncestor && ancDepth <= 3) {
|
|
137
|
+
addElement(currAncestor, 'ancestor', ancDepth);
|
|
138
|
+
currAncestor = currAncestor.parentElement;
|
|
139
|
+
ancDepth++;
|
|
140
|
+
}
|
|
141
|
+
// 6. Previous siblings (3)
|
|
142
|
+
let prev = element.previousElementSibling;
|
|
143
|
+
let sCount = 0;
|
|
144
|
+
while (prev && sCount < 3) {
|
|
145
|
+
addElement(prev, 'sibling', 1);
|
|
146
|
+
prev = prev.previousElementSibling;
|
|
147
|
+
sCount++;
|
|
148
|
+
}
|
|
149
|
+
// 7. Next siblings (3)
|
|
150
|
+
let next = element.nextElementSibling;
|
|
151
|
+
sCount = 0;
|
|
152
|
+
while (next && sCount < 3) {
|
|
153
|
+
addElement(next, 'sibling', 1);
|
|
154
|
+
next = next.nextElementSibling;
|
|
155
|
+
sCount++;
|
|
156
|
+
}
|
|
157
|
+
// 8. Parent children
|
|
158
|
+
if (parentEl) {
|
|
159
|
+
for (let i = 0; i < parentEl.children.length; i++) {
|
|
160
|
+
addElement(parentEl.children[i], 'sibling', 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 9. Closest heading
|
|
164
|
+
const headings = Array.from(element.ownerDocument.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
165
|
+
let closestHeading = null;
|
|
166
|
+
let minHDist = Infinity;
|
|
167
|
+
for (const h of headings) {
|
|
168
|
+
const pos = h.compareDocumentPosition(element);
|
|
169
|
+
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
170
|
+
let dist = 0;
|
|
171
|
+
let curr = h;
|
|
172
|
+
while (curr && curr !== element) {
|
|
173
|
+
curr = (curr.nextElementSibling || curr.parentElement);
|
|
174
|
+
dist++;
|
|
175
|
+
if (dist > 50)
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (dist < minHDist) {
|
|
179
|
+
minHDist = dist;
|
|
180
|
+
closestHeading = h;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (closestHeading) {
|
|
185
|
+
addElement(closestHeading, 'ancestor', 3);
|
|
186
|
+
}
|
|
187
|
+
// 10. Closest label
|
|
188
|
+
let closestLabel = null;
|
|
189
|
+
if (element.id) {
|
|
190
|
+
closestLabel = element.ownerDocument.querySelector(`label[for="${element.id}"]`);
|
|
191
|
+
}
|
|
192
|
+
if (!closestLabel) {
|
|
193
|
+
let curr = element.parentElement;
|
|
194
|
+
while (curr) {
|
|
195
|
+
if (curr.tagName.toLowerCase() === 'label') {
|
|
196
|
+
closestLabel = curr;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
curr = curr.parentElement;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (closestLabel) {
|
|
203
|
+
addElement(closestLabel, 'parent', 1);
|
|
204
|
+
}
|
|
205
|
+
// 11. Closest accessible element
|
|
206
|
+
let currAcc = element.parentElement;
|
|
207
|
+
while (currAcc) {
|
|
208
|
+
const hasRole = currAcc.getAttribute('role') ||
|
|
209
|
+
['button', 'a', 'input', 'select', 'textarea'].includes(currAcc.tagName.toLowerCase());
|
|
210
|
+
const hasAccName = currAcc.getAttribute('aria-label') || currAcc.getAttribute('title');
|
|
211
|
+
if (hasRole || hasAccName) {
|
|
212
|
+
addElement(currAcc, 'ancestor', 2);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
currAcc = currAcc.parentElement;
|
|
216
|
+
}
|
|
217
|
+
// Helper: getRole
|
|
218
|
+
const getRoleAttr = (el) => {
|
|
219
|
+
let roleAttr = el.getAttribute('role')?.trim() || null;
|
|
220
|
+
const tagName = el.tagName.toLowerCase();
|
|
221
|
+
if (!roleAttr) {
|
|
222
|
+
if (tagName === 'button')
|
|
223
|
+
roleAttr = 'button';
|
|
224
|
+
else if (tagName === 'a')
|
|
225
|
+
roleAttr = 'link';
|
|
226
|
+
else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName))
|
|
227
|
+
roleAttr = 'heading';
|
|
228
|
+
else if (tagName === 'input') {
|
|
229
|
+
const type = el.getAttribute('type') || 'text';
|
|
230
|
+
if (type === 'checkbox')
|
|
231
|
+
roleAttr = 'checkbox';
|
|
232
|
+
else if (type === 'radio')
|
|
233
|
+
roleAttr = 'radio';
|
|
234
|
+
else if (['text', 'email', 'url', 'search', 'tel', 'password'].includes(type))
|
|
235
|
+
roleAttr = 'textbox';
|
|
236
|
+
else if (['button', 'submit', 'reset'].includes(type))
|
|
237
|
+
roleAttr = 'button';
|
|
238
|
+
}
|
|
239
|
+
else if (tagName === 'textarea')
|
|
240
|
+
roleAttr = 'textbox';
|
|
241
|
+
else if (tagName === 'select')
|
|
242
|
+
roleAttr = 'combobox';
|
|
243
|
+
else if (tagName === 'img')
|
|
244
|
+
roleAttr = 'img';
|
|
245
|
+
}
|
|
246
|
+
return roleAttr || '';
|
|
247
|
+
};
|
|
248
|
+
// Helper: getAccessibleName
|
|
249
|
+
const getAccessibleName = (el) => {
|
|
250
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
251
|
+
const alt = el.getAttribute('alt');
|
|
252
|
+
const title = el.getAttribute('title');
|
|
253
|
+
let textContent = '';
|
|
254
|
+
if (el.tagName.toLowerCase() === 'select') {
|
|
255
|
+
const firstOpt = el.querySelector('option');
|
|
256
|
+
textContent = firstOpt ? (firstOpt.textContent || '') : '';
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
textContent = el.textContent || '';
|
|
260
|
+
}
|
|
261
|
+
textContent = textContent.trim().replace(/\s+/g, ' ');
|
|
262
|
+
return (ariaLabel || alt || title || textContent || '').trim();
|
|
263
|
+
};
|
|
264
|
+
// Target information for context mapping
|
|
265
|
+
const targetTagName = element.tagName.toLowerCase();
|
|
266
|
+
const targetRole = getRoleAttr(element);
|
|
267
|
+
const targetAccessibleName = getAccessibleName(element);
|
|
268
|
+
const targetText = targetTagName === 'select'
|
|
269
|
+
? (element.querySelector('option')?.textContent || '').trim().replace(/\s+/g, ' ')
|
|
270
|
+
: (element.textContent || '').trim().replace(/\s+/g, ' ');
|
|
271
|
+
const targetType = element.getAttribute('type') || '';
|
|
272
|
+
const targetName = element.getAttribute('name') || '';
|
|
273
|
+
const targetTestId = element.getAttribute('data-testid') || element.getAttribute('data-test-id') || element.getAttribute('testid') || '';
|
|
274
|
+
const targetId = element.id || '';
|
|
275
|
+
const targetParentId = element.getAttribute('data-parent-id') || '';
|
|
276
|
+
// Candidate Collection for all elements in scope
|
|
277
|
+
for (const item of scopeElements) {
|
|
278
|
+
const el = item.el;
|
|
279
|
+
const source = item.source;
|
|
280
|
+
const depth = item.depth;
|
|
281
|
+
const tagName = el.tagName.toLowerCase();
|
|
282
|
+
// Phase 1 Action Validation: Reject if multi-action container
|
|
283
|
+
if (isMultiActionContainer(el, element)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// 1. testId candidate
|
|
287
|
+
const testIdVal = el.getAttribute('data-testid') ?? el.getAttribute('data-test-id') ?? el.getAttribute('data-testId');
|
|
288
|
+
if (testIdVal) {
|
|
289
|
+
collected.push({
|
|
290
|
+
selector: `[data-testid="${testIdVal}"]`,
|
|
291
|
+
locator: `codegenPage.getByTestId('${testIdVal}')`,
|
|
292
|
+
source,
|
|
293
|
+
strategy: 'testId',
|
|
294
|
+
depth,
|
|
295
|
+
valueToScore: testIdVal
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// 2. id candidate
|
|
299
|
+
const idVal = el.getAttribute('id');
|
|
300
|
+
if (idVal) {
|
|
301
|
+
collected.push({
|
|
302
|
+
selector: `#${idVal}`,
|
|
303
|
+
locator: `codegenPage.locator('#${idVal}')`,
|
|
304
|
+
source,
|
|
305
|
+
strategy: 'id',
|
|
306
|
+
depth,
|
|
307
|
+
valueToScore: idVal
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// 3. dataAttribute candidates
|
|
311
|
+
const allAttrs = el.getAttributeNames();
|
|
312
|
+
const validDataAttrs = allAttrs.filter((attr) => attr.startsWith('data-') && !['data-testid', 'data-test-id', 'data-testid'].includes(attr.toLowerCase()));
|
|
313
|
+
for (const dataAttr of validDataAttrs) {
|
|
314
|
+
const val = el.getAttribute(dataAttr);
|
|
315
|
+
if (val) {
|
|
316
|
+
collected.push({
|
|
317
|
+
selector: `[${dataAttr}="${val}"]`,
|
|
318
|
+
locator: `codegenPage.locator('[${dataAttr}="${val}"]')`,
|
|
319
|
+
source,
|
|
320
|
+
strategy: 'dataAttribute',
|
|
321
|
+
depth,
|
|
322
|
+
valueToScore: val,
|
|
323
|
+
attrName: dataAttr
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// 4. role candidate
|
|
328
|
+
const roleVal = getRoleAttr(el);
|
|
329
|
+
if (roleVal) {
|
|
330
|
+
const accName = getAccessibleName(el);
|
|
331
|
+
const escapedName = accName.replace(/'/g, "\\'");
|
|
332
|
+
collected.push({
|
|
333
|
+
selector: accName ? `internal:role=${roleVal}[name="${accName.replace(/"/g, '\\"')}"i]` : `role=${roleVal}`,
|
|
334
|
+
locator: accName
|
|
335
|
+
? `codegenPage.getByRole('${roleVal}', { name: '${escapedName}' })`
|
|
336
|
+
: `codegenPage.getByRole('${roleVal}')`,
|
|
337
|
+
source,
|
|
338
|
+
strategy: 'role',
|
|
339
|
+
depth,
|
|
340
|
+
valueToScore: accName || roleVal,
|
|
341
|
+
roleVal
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// 5. label candidate
|
|
345
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
346
|
+
const ariaLabelledBy = el.getAttribute('aria-labelledby');
|
|
347
|
+
let labelText = ariaLabel || ariaLabelledBy;
|
|
348
|
+
let labelAttr = 'aria-label';
|
|
349
|
+
if (ariaLabelledBy)
|
|
350
|
+
labelAttr = 'aria-labelledby';
|
|
351
|
+
if (!labelText && el.getAttribute('id')) {
|
|
352
|
+
const labelEl = el.ownerDocument.querySelector(`label[for="${el.getAttribute('id')}"]`);
|
|
353
|
+
if (labelEl && labelEl.textContent) {
|
|
354
|
+
labelText = labelEl.textContent.trim();
|
|
355
|
+
labelAttr = 'label';
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (labelText) {
|
|
359
|
+
collected.push({
|
|
360
|
+
selector: `internal:label="${labelText.replace(/"/g, '\\"')}"i`,
|
|
361
|
+
locator: `codegenPage.getByLabel('${labelText.replace(/'/g, "\\'")}')`,
|
|
362
|
+
source,
|
|
363
|
+
strategy: 'label',
|
|
364
|
+
depth,
|
|
365
|
+
valueToScore: labelText,
|
|
366
|
+
attrName: labelAttr
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// 6. placeholder candidate
|
|
370
|
+
const placeholderVal = el.getAttribute('placeholder');
|
|
371
|
+
if (placeholderVal) {
|
|
372
|
+
collected.push({
|
|
373
|
+
selector: `internal:attr=[placeholder="${placeholderVal.replace(/"/g, '\\"')}"i]`,
|
|
374
|
+
locator: `codegenPage.getByPlaceholder('${placeholderVal.replace(/'/g, "\\'")}')`,
|
|
375
|
+
source,
|
|
376
|
+
strategy: 'placeholder',
|
|
377
|
+
depth,
|
|
378
|
+
valueToScore: placeholderVal
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// 7. altText candidate
|
|
382
|
+
const altVal = el.getAttribute('alt');
|
|
383
|
+
if (altVal) {
|
|
384
|
+
collected.push({
|
|
385
|
+
selector: `internal:attr=[alt="${altVal.replace(/"/g, '\\"')}"i]`,
|
|
386
|
+
locator: `codegenPage.getByAltText('${altVal.replace(/'/g, "\\'")}')`,
|
|
387
|
+
source,
|
|
388
|
+
strategy: 'altText',
|
|
389
|
+
depth,
|
|
390
|
+
valueToScore: altVal
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
// 8. title candidate
|
|
394
|
+
const titleVal = el.getAttribute('title');
|
|
395
|
+
if (titleVal) {
|
|
396
|
+
collected.push({
|
|
397
|
+
selector: `internal:attr=[title="${titleVal.replace(/"/g, '\\"')}"i]`,
|
|
398
|
+
locator: `codegenPage.getByTitle('${titleVal.replace(/'/g, "\\'")}')`,
|
|
399
|
+
source,
|
|
400
|
+
strategy: 'title',
|
|
401
|
+
depth,
|
|
402
|
+
valueToScore: titleVal
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// 9. text candidate
|
|
406
|
+
const textVal = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
407
|
+
if (textVal) {
|
|
408
|
+
const cleanedText = textVal.replace(/^\d+[\s\.\-_]*/, '').trim();
|
|
409
|
+
if (cleanedText && !/\d/.test(cleanedText)) {
|
|
410
|
+
const escapedText = cleanedText.replace(/"/g, '\\"');
|
|
411
|
+
const textSelector = cleanedText.includes(' ') ? `text="${escapedText}"` : `text=${escapedText}`;
|
|
412
|
+
collected.push({
|
|
413
|
+
selector: textSelector,
|
|
414
|
+
locator: `codegenPage.getByText('${cleanedText.replace(/'/g, "\\'")}')`,
|
|
415
|
+
source,
|
|
416
|
+
strategy: 'text',
|
|
417
|
+
depth,
|
|
418
|
+
valueToScore: cleanedText
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// 10. class candidates
|
|
423
|
+
const className = el.getAttribute('class');
|
|
424
|
+
if (className) {
|
|
425
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
426
|
+
const isStyleClass = (cName) => {
|
|
427
|
+
if (cName.includes('[') && cName.includes(']'))
|
|
428
|
+
return true;
|
|
429
|
+
if (cName.includes(':'))
|
|
430
|
+
return true;
|
|
431
|
+
if (data.exactUtilities.includes(cName))
|
|
432
|
+
return true;
|
|
433
|
+
if (data.stateClasses.includes(cName.toLowerCase()))
|
|
434
|
+
return true;
|
|
435
|
+
if (data.tailwindPrefixes.some((prefix) => cName.startsWith(prefix)))
|
|
436
|
+
return true;
|
|
437
|
+
return data.stylePatterns.some((pat) => new RegExp(pat).test(cName));
|
|
438
|
+
};
|
|
439
|
+
for (const cls of classes) {
|
|
440
|
+
if (cls.startsWith('translate-x') ||
|
|
441
|
+
cls.startsWith('translate-y') ||
|
|
442
|
+
isStyleClass(cls) ||
|
|
443
|
+
/^[A-Z]/.test(cls) ||
|
|
444
|
+
cls.toLowerCase().includes('border') ||
|
|
445
|
+
cls.length > 50) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
collected.push({
|
|
449
|
+
selector: `.${cls}`,
|
|
450
|
+
locator: `codegenPage.locator('.${cls}')`,
|
|
451
|
+
source,
|
|
452
|
+
strategy: 'class',
|
|
453
|
+
depth,
|
|
454
|
+
valueToScore: cls
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// 11. tag / css candidate
|
|
459
|
+
const semanticTags = ['button', 'a', 'input', 'select', 'textarea', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'form'];
|
|
460
|
+
if (semanticTags.includes(tagName)) {
|
|
461
|
+
collected.push({
|
|
462
|
+
selector: `${tagName}`,
|
|
463
|
+
locator: `codegenPage.locator('${tagName}')`,
|
|
464
|
+
source,
|
|
465
|
+
strategy: 'css',
|
|
466
|
+
depth,
|
|
467
|
+
valueToScore: tagName
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// 12. xpath candidate
|
|
471
|
+
if (semanticTags.includes(tagName)) {
|
|
472
|
+
collected.push({
|
|
473
|
+
selector: `xpath=//${tagName}`,
|
|
474
|
+
locator: `codegenPage.locator('//${tagName}')`,
|
|
475
|
+
source,
|
|
476
|
+
strategy: 'xpath',
|
|
477
|
+
depth,
|
|
478
|
+
valueToScore: tagName
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
candidates: collected,
|
|
484
|
+
targetText,
|
|
485
|
+
targetAccessibleName,
|
|
486
|
+
targetRole,
|
|
487
|
+
targetType,
|
|
488
|
+
targetName,
|
|
489
|
+
targetTagName,
|
|
490
|
+
targetTestId,
|
|
491
|
+
targetId,
|
|
492
|
+
targetParentId
|
|
493
|
+
};
|
|
494
|
+
}, { exactUtilities: locator_restricted_1.exactUtilities, tailwindPrefixes: locator_restricted_1.tailwindPrefixes, stateClasses: locator_restricted_1.stateClasses, stylePatterns: locator_restricted_1.stylePatterns }, { timeout: 1000 })
|
|
495
|
+
.catch(() => null);
|
|
496
|
+
if (!result || !result.candidates || result.candidates.length === 0)
|
|
497
|
+
return null;
|
|
498
|
+
const normalize = (value) => {
|
|
499
|
+
return value
|
|
500
|
+
.toLowerCase()
|
|
501
|
+
.replace(/[-_\s]/g, '')
|
|
502
|
+
.replace(/[^\w]/g, '');
|
|
503
|
+
};
|
|
504
|
+
const targetNormalized = normalize(result.targetText || result.targetAccessibleName || '');
|
|
505
|
+
// Filter and Score Candidates on Node.js side
|
|
506
|
+
const candidates = [];
|
|
507
|
+
for (const c of result.candidates) {
|
|
508
|
+
const val = c.valueToScore || '';
|
|
509
|
+
const valLower = val.toLowerCase();
|
|
510
|
+
// No long values not more than 50 chars
|
|
511
|
+
if (val.length > 50) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
// Reject unstable text
|
|
515
|
+
if (c.strategy === 'text') {
|
|
516
|
+
if (Object.values(locator_restricted_1.PATTERNS.restrictedTexts).some((keyword) => valLower.includes(keyword)) ||
|
|
517
|
+
val.length > 40) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Calculate priority
|
|
522
|
+
const priorityScore = (0, scorer_1.getPriorityScore)(c);
|
|
523
|
+
// Calculate Proximity Bonus
|
|
524
|
+
let proximityScore = 0;
|
|
525
|
+
if (c.source === 'target')
|
|
526
|
+
proximityScore = 300;
|
|
527
|
+
else if (c.source === 'child')
|
|
528
|
+
proximityScore = 250;
|
|
529
|
+
else if (c.source === 'sibling')
|
|
530
|
+
proximityScore = 250;
|
|
531
|
+
else if (c.source === 'parent')
|
|
532
|
+
proximityScore = 200;
|
|
533
|
+
else if (c.source === 'ancestor') {
|
|
534
|
+
if (c.depth === 1)
|
|
535
|
+
proximityScore = 150; // Grandparent
|
|
536
|
+
else
|
|
537
|
+
proximityScore = 100; // Ancestor depth 3
|
|
538
|
+
}
|
|
539
|
+
// Calculate Similarity Bonus
|
|
540
|
+
const candidateNormalized = normalize(val);
|
|
541
|
+
const similarityScore = targetNormalized && targetNormalized === candidateNormalized ? 200 : 0;
|
|
542
|
+
// Calculate Semantic Bonus
|
|
543
|
+
let semanticScore = 0;
|
|
544
|
+
if (/[a-zA-Z]/.test(valLower)) {
|
|
545
|
+
const hasHyphen = valLower.includes('-');
|
|
546
|
+
const hasUnderscore = valLower.includes('_');
|
|
547
|
+
const hasCamelCase = /[a-z][A-Z]/.test(val);
|
|
548
|
+
const wordsCount = valLower.split(/[-_\s]+/).filter(Boolean).length;
|
|
549
|
+
if (hasHyphen || hasUnderscore || hasCamelCase || wordsCount > 1) {
|
|
550
|
+
semanticScore = 100;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Calculate Accessibility Bonus
|
|
554
|
+
const accessibilityBonus = c.strategy === 'label' || c.strategy === 'ariaLabel' ? 150 : 0;
|
|
555
|
+
// Calculate Stability Bonus
|
|
556
|
+
const isStable = c.strategy === 'testId' ||
|
|
557
|
+
(c.strategy === 'id' && isStableId(val)) ||
|
|
558
|
+
(c.strategy === 'dataAttribute' &&
|
|
559
|
+
(c.attrName === 'data-page' ||
|
|
560
|
+
c.attrName === 'data-section' ||
|
|
561
|
+
c.attrName === 'data-parent-id' ||
|
|
562
|
+
c.attrName === 'data-test-id' ||
|
|
563
|
+
c.attrName === 'data-testid' ||
|
|
564
|
+
c.attrName === 'datatestid'));
|
|
565
|
+
const stabilityBonus = isStable ? 100 : 0;
|
|
566
|
+
// Calculate Penalties
|
|
567
|
+
let penalties = 0;
|
|
568
|
+
if (c.strategy === 'role' && !c.selector.includes('name=')) {
|
|
569
|
+
penalties += 350; // penalize role without name so placeholder/label/text are preferred
|
|
570
|
+
}
|
|
571
|
+
if (c.strategy === 'id' && !isStableId(val)) {
|
|
572
|
+
penalties += 600; // heavily penalize unstable IDs so they rank below semantic locators
|
|
573
|
+
}
|
|
574
|
+
if (c.strategy === 'class') {
|
|
575
|
+
if ((0, locator_restricted_1.isRestrictedClass)(val))
|
|
576
|
+
penalties += 1000;
|
|
577
|
+
if (locator_restricted_1.stateClasses.includes(valLower))
|
|
578
|
+
penalties += 1000;
|
|
579
|
+
if (/^[A-Z]/.test(val))
|
|
580
|
+
penalties += 1000;
|
|
581
|
+
}
|
|
582
|
+
const hasDecorative = locator_restricted_1.decorativeList.some((item) => valLower.includes(item) || c.selector.toLowerCase().includes(item));
|
|
583
|
+
if (hasDecorative) {
|
|
584
|
+
penalties += 500;
|
|
585
|
+
}
|
|
586
|
+
if (['true', 'false', '0', '1'].includes(valLower)) {
|
|
587
|
+
penalties += 500;
|
|
588
|
+
}
|
|
589
|
+
const isUuid = new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.uuidPattern, 'i').test(valLower) ||
|
|
590
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.numericPattern).test(valLower) ||
|
|
591
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.hexHashPattern, 'i').test(valLower) ||
|
|
592
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.longAlphaNumPattern, 'i').test(valLower);
|
|
593
|
+
if (isUuid) {
|
|
594
|
+
penalties += 500;
|
|
595
|
+
}
|
|
596
|
+
candidates.push({
|
|
597
|
+
selector: c.selector,
|
|
598
|
+
locator: c.locator,
|
|
599
|
+
source: c.source,
|
|
600
|
+
strategy: c.strategy,
|
|
601
|
+
depth: c.depth,
|
|
602
|
+
baseScore: priorityScore,
|
|
603
|
+
semanticScore: semanticScore,
|
|
604
|
+
proximityScore: proximityScore,
|
|
605
|
+
similarityScore: similarityScore,
|
|
606
|
+
uniquenessScore: 0,
|
|
607
|
+
contextScore: proximityScore,
|
|
608
|
+
totalScore: 0,
|
|
609
|
+
unique: false,
|
|
610
|
+
valueToScore: c.valueToScore,
|
|
611
|
+
attrName: c.attrName,
|
|
612
|
+
accessibilityBonus,
|
|
613
|
+
stabilityBonus,
|
|
614
|
+
penalties
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
// Verify uniqueness and assign final score
|
|
618
|
+
for (const c of candidates) {
|
|
619
|
+
try {
|
|
620
|
+
const count = await page.locator(c.selector).count();
|
|
621
|
+
c.unique = count === 1;
|
|
622
|
+
}
|
|
623
|
+
catch (e) {
|
|
624
|
+
c.unique = false;
|
|
625
|
+
}
|
|
626
|
+
c.uniquenessScore = c.unique ? 0 : -500;
|
|
627
|
+
// final score calculation
|
|
628
|
+
const extra = c;
|
|
629
|
+
c.totalScore =
|
|
630
|
+
c.baseScore +
|
|
631
|
+
c.proximityScore +
|
|
632
|
+
c.semanticScore +
|
|
633
|
+
c.similarityScore +
|
|
634
|
+
(extra.accessibilityBonus || 0) +
|
|
635
|
+
(extra.stabilityBonus || 0) +
|
|
636
|
+
c.uniquenessScore -
|
|
637
|
+
(extra.penalties || 0);
|
|
638
|
+
// Generate key for registry
|
|
639
|
+
let generatedKey = '';
|
|
640
|
+
const isInputEl = ['input', 'textarea', 'select'].includes(result.targetTagName);
|
|
641
|
+
if (isInputEl) {
|
|
642
|
+
const testIdCand = candidates.find(cand => cand.strategy === 'testId');
|
|
643
|
+
if (testIdCand && testIdCand.valueToScore) {
|
|
644
|
+
generatedKey = generateKeyFromText(testIdCand.valueToScore, result.targetRole);
|
|
645
|
+
}
|
|
646
|
+
else if (result.targetName) {
|
|
647
|
+
generatedKey = generateKeyFromText(result.targetName, result.targetRole);
|
|
648
|
+
}
|
|
649
|
+
else if (result.targetType) {
|
|
650
|
+
generatedKey = generateKeyFromText(result.targetType + 'Input', result.targetRole);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
generatedKey = result.targetTagName + 'Input';
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
const visibleText = result.targetAccessibleName || result.targetText || '';
|
|
658
|
+
if (visibleText && visibleText.trim().length > 0 && visibleText.length <= 50) {
|
|
659
|
+
generatedKey = generateKeyFromText(visibleText.trim(), result.targetRole);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
generatedKey = generateKeyFromCandidate(c, result.targetRole);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
c.generatedKey = generatedKey;
|
|
666
|
+
c.nearbyText = result.targetText;
|
|
667
|
+
c.accessibleName = result.targetAccessibleName;
|
|
668
|
+
}
|
|
669
|
+
// If a stable unique candidate exists, heavily penalize unstable candidates to prioritize the stable ones
|
|
670
|
+
const hasStableUnique = candidates.some((c) => c.unique &&
|
|
671
|
+
(c.strategy === 'testId' ||
|
|
672
|
+
(c.strategy === 'id' && isStableId(c.valueToScore || '')) ||
|
|
673
|
+
(c.strategy === 'dataAttribute' &&
|
|
674
|
+
(c.attrName === 'data-parent-id' ||
|
|
675
|
+
c.attrName === 'data-test-id' ||
|
|
676
|
+
c.attrName === 'data-testid' ||
|
|
677
|
+
c.attrName === 'datatestid'))));
|
|
678
|
+
if (hasStableUnique) {
|
|
679
|
+
for (const c of candidates) {
|
|
680
|
+
const isCandidateStable = c.strategy === 'testId' ||
|
|
681
|
+
(c.strategy === 'id' && isStableId(c.valueToScore || '')) ||
|
|
682
|
+
(c.strategy === 'dataAttribute' &&
|
|
683
|
+
(c.attrName === 'data-parent-id' ||
|
|
684
|
+
c.attrName === 'data-test-id' ||
|
|
685
|
+
c.attrName === 'data-testid' ||
|
|
686
|
+
c.attrName === 'datatestid'));
|
|
687
|
+
if (!isCandidateStable) {
|
|
688
|
+
c.totalScore -= 600;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const sorted = candidates.sort((a, b) => b.totalScore - a.totalScore);
|
|
693
|
+
const assignExtraAttrs = (c) => {
|
|
694
|
+
if (c) {
|
|
695
|
+
c.targetTestId = result.targetTestId;
|
|
696
|
+
c.targetId = result.targetId;
|
|
697
|
+
c.targetParentId = result.targetParentId;
|
|
698
|
+
}
|
|
699
|
+
return c;
|
|
700
|
+
};
|
|
701
|
+
let decidedCandidate = sorted.find((c) => c.unique && c.source === 'target');
|
|
702
|
+
let isUnique = true;
|
|
703
|
+
if (!decidedCandidate) {
|
|
704
|
+
decidedCandidate = sorted.find((c) => c.source === 'target') || sorted[0];
|
|
705
|
+
isUnique = false;
|
|
706
|
+
}
|
|
707
|
+
if (decidedCandidate) {
|
|
708
|
+
const validated = (0, locator_validator_1.validateAndSelectLocator)(decidedCandidate, sorted, result.targetText);
|
|
709
|
+
if (validated !== decidedCandidate) {
|
|
710
|
+
decidedCandidate = validated;
|
|
711
|
+
isUnique = validated.unique;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (decidedCandidate) {
|
|
715
|
+
if (!isUnique) {
|
|
716
|
+
if (!decidedCandidate.selector.includes('>> nth=')) {
|
|
717
|
+
decidedCandidate.selector = `${decidedCandidate.selector} >> nth=0`;
|
|
718
|
+
decidedCandidate.locator = `${decidedCandidate.locator}.first()`;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return assignExtraAttrs(decidedCandidate);
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|