pw-core 1.3.0 → 1.3.1
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 -0
- package/dist/cli.js +145 -421
- package/dist/codegen/action-formatter.d.ts +19 -0
- package/dist/codegen/action-formatter.js +160 -0
- package/dist/codegen/action-processor.d.ts +23 -0
- package/dist/codegen/action-processor.js +112 -0
- package/dist/codegen/floating-panel/client.d.ts +14 -1
- package/dist/codegen/floating-panel/client.js +20 -25
- package/dist/codegen/floating-panel/manager.js +16 -22
- package/dist/codegen/generator/action.validator.d.ts +2 -1
- package/dist/codegen/generator/action.validator.js +2 -1
- package/dist/codegen/generator/candidate-scorer.d.ts +7 -0
- package/dist/codegen/generator/candidate-scorer.js +206 -0
- package/dist/codegen/generator/dom-scanner.d.ts +39 -0
- package/dist/codegen/generator/dom-scanner.js +429 -0
- package/dist/codegen/generator/id-stability.d.ts +6 -0
- package/dist/codegen/generator/id-stability.js +38 -0
- package/dist/codegen/generator/index.d.ts +8 -10
- package/dist/codegen/generator/index.js +23 -678
- package/dist/codegen/generator/key-builder.d.ts +13 -0
- package/dist/codegen/generator/key-builder.js +32 -0
- package/dist/codegen/hover-tracker/client.d.ts +5 -0
- package/dist/codegen/hover-tracker/client.js +14 -15
- package/dist/codegen/hover-tracker/manager.js +2 -6
- package/dist/codegen/index.d.ts +9 -32
- package/dist/codegen/index.js +9 -1128
- package/dist/codegen/key-utils.d.ts +16 -0
- package/dist/codegen/key-utils.js +29 -1
- package/dist/codegen/project-finder.d.ts +29 -0
- package/dist/codegen/project-finder.js +283 -0
- package/dist/codegen/registry-matcher.d.ts +27 -0
- package/dist/codegen/registry-matcher.js +254 -0
- package/dist/codegen/registry-store.d.ts +52 -0
- package/dist/codegen/registry-store.js +652 -0
- package/dist/codegen/selector-parser.d.ts +16 -0
- package/dist/codegen/selector-parser.js +121 -0
- package/dist/codegen/types.d.ts +18 -1
- package/dist/codegen/uniqueness.d.ts +3 -0
- package/dist/codegen/uniqueness.js +15 -0
- package/dist/component/table.d.ts +2 -2
- package/dist/component/table.js +7 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/dist/page/actions/locator-actions.d.ts +6 -10
- package/dist/page/actions/locator-actions.js +24 -23
- package/dist/page/assertions/verify-chain.d.ts +11 -13
- package/dist/page/assertions/verify-chain.js +26 -7
- package/dist/page/assertions/verify-helpers.d.ts +5 -15
- package/dist/page/config.d.ts +25 -25
- package/dist/page/locators/dynamic-locator-resolver.js +22 -9
- package/dist/page/locators/resolver.d.ts +17 -11
- package/dist/page/locators/resolver.js +84 -78
- package/dist/page/registry.d.ts +35 -35
- package/dist/page/registry.js +86 -82
- package/dist/page/typed-page.d.ts +1 -0
- package/dist/page/typed-page.js +22 -12
- package/dist/page/types/proxy-methods.d.ts +11 -19
- package/dist/page/types/validation.d.ts +1 -1
- package/dist/page/utils/formatter.d.ts +3 -3
- package/dist/page/utils/formatter.js +5 -2
- package/package.json +6 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scoreAndRankCandidates = scoreAndRankCandidates;
|
|
4
|
+
const scorer_1 = require("../scorer");
|
|
5
|
+
const locator_restricted_1 = require("./locator.restricted");
|
|
6
|
+
const id_stability_1 = require("./id-stability");
|
|
7
|
+
const key_builder_1 = require("./key-builder");
|
|
8
|
+
function normalize(value) {
|
|
9
|
+
return value
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[-_\s]/g, '')
|
|
12
|
+
.replace(/[^\w]/g, '');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Transforms raw scanned candidates into fully scored and uniqueness-validated LocatorCandidates.
|
|
16
|
+
*/
|
|
17
|
+
async function scoreAndRankCandidates(page, scanResult) {
|
|
18
|
+
const targetNormalized = normalize(scanResult.targetText || scanResult.targetAccessibleName || '');
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const c of scanResult.candidates) {
|
|
21
|
+
const val = c.valueToScore || '';
|
|
22
|
+
const valLower = val.toLowerCase();
|
|
23
|
+
// No long values more than 50 chars
|
|
24
|
+
if (val.length > 50) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Reject unstable text
|
|
28
|
+
if (c.strategy === 'text') {
|
|
29
|
+
if (Object.values(locator_restricted_1.PATTERNS.restrictedTexts).some((keyword) => valLower.includes(keyword)) ||
|
|
30
|
+
val.length > 40) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Calculate priority
|
|
35
|
+
const priorityScore = (0, scorer_1.getPriorityScore)(c);
|
|
36
|
+
// Calculate Proximity Bonus
|
|
37
|
+
let proximityScore = 0;
|
|
38
|
+
if (c.source === 'target')
|
|
39
|
+
proximityScore = 300;
|
|
40
|
+
else if (c.source === 'child')
|
|
41
|
+
proximityScore = 250;
|
|
42
|
+
else if (c.source === 'sibling')
|
|
43
|
+
proximityScore = 250;
|
|
44
|
+
else if (c.source === 'parent')
|
|
45
|
+
proximityScore = 200;
|
|
46
|
+
else if (c.source === 'ancestor') {
|
|
47
|
+
if (c.depth === 1)
|
|
48
|
+
proximityScore = 150; // Grandparent
|
|
49
|
+
else
|
|
50
|
+
proximityScore = 100; // Ancestor depth 3
|
|
51
|
+
}
|
|
52
|
+
// Calculate Similarity Bonus
|
|
53
|
+
const candidateNormalized = normalize(val);
|
|
54
|
+
const similarityScore = targetNormalized && targetNormalized === candidateNormalized ? 200 : 0;
|
|
55
|
+
// Calculate Semantic Bonus
|
|
56
|
+
let semanticScore = 0;
|
|
57
|
+
if (/[a-zA-Z]/.test(valLower)) {
|
|
58
|
+
const hasHyphen = valLower.includes('-');
|
|
59
|
+
const hasUnderscore = valLower.includes('_');
|
|
60
|
+
const hasCamelCase = /[a-z][A-Z]/.test(val);
|
|
61
|
+
const wordsCount = valLower.split(/[-_\s]+/).filter(Boolean).length;
|
|
62
|
+
if (hasHyphen || hasUnderscore || hasCamelCase || wordsCount > 1) {
|
|
63
|
+
semanticScore = 100;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Calculate Accessibility Bonus
|
|
67
|
+
const accessibilityBonus = c.strategy === 'label' || c.strategy === 'ariaLabel' ? 150 : 0;
|
|
68
|
+
// Calculate Stability Bonus
|
|
69
|
+
const isStable = c.strategy === 'testId' ||
|
|
70
|
+
(c.strategy === 'id' && (0, id_stability_1.isStableId)(val)) ||
|
|
71
|
+
(c.strategy === 'dataAttribute' &&
|
|
72
|
+
(c.attrName === 'data-page' ||
|
|
73
|
+
c.attrName === 'data-section' ||
|
|
74
|
+
c.attrName === 'data-parent-id' ||
|
|
75
|
+
c.attrName === 'data-test-id' ||
|
|
76
|
+
c.attrName === 'data-testid' ||
|
|
77
|
+
c.attrName === 'datatestid'));
|
|
78
|
+
const stabilityBonus = isStable ? 100 : 0;
|
|
79
|
+
// Calculate Penalties
|
|
80
|
+
let penalties = 0;
|
|
81
|
+
if (c.strategy === 'role' && !c.selector.includes('name=')) {
|
|
82
|
+
penalties += 350; // penalize role without name so placeholder/label/text are preferred
|
|
83
|
+
}
|
|
84
|
+
if (c.strategy === 'id' && !(0, id_stability_1.isStableId)(val)) {
|
|
85
|
+
penalties += 600; // heavily penalize unstable IDs so they rank below semantic locators
|
|
86
|
+
}
|
|
87
|
+
if (c.strategy === 'class') {
|
|
88
|
+
if ((0, locator_restricted_1.isRestrictedClass)(val))
|
|
89
|
+
penalties += 1000;
|
|
90
|
+
if (locator_restricted_1.stateClasses.includes(valLower))
|
|
91
|
+
penalties += 1000;
|
|
92
|
+
if (/^[A-Z]/.test(val))
|
|
93
|
+
penalties += 1000;
|
|
94
|
+
}
|
|
95
|
+
const hasDecorative = locator_restricted_1.decorativeList.some((item) => valLower.includes(item) || c.selector.toLowerCase().includes(item));
|
|
96
|
+
if (hasDecorative) {
|
|
97
|
+
penalties += 500;
|
|
98
|
+
}
|
|
99
|
+
if (['true', 'false', '0', '1'].includes(valLower)) {
|
|
100
|
+
penalties += 500;
|
|
101
|
+
}
|
|
102
|
+
const isUuid = new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.uuidPattern, 'i').test(valLower) ||
|
|
103
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.numericPattern).test(valLower) ||
|
|
104
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.hexHashPattern, 'i').test(valLower) ||
|
|
105
|
+
new RegExp(locator_restricted_1.PATTERNS.unstableIdentifiers.longAlphaNumPattern, 'i').test(valLower);
|
|
106
|
+
if (isUuid) {
|
|
107
|
+
penalties += 500;
|
|
108
|
+
}
|
|
109
|
+
candidates.push({
|
|
110
|
+
selector: c.selector,
|
|
111
|
+
locator: c.locator,
|
|
112
|
+
source: c.source,
|
|
113
|
+
strategy: c.strategy,
|
|
114
|
+
depth: c.depth,
|
|
115
|
+
baseScore: priorityScore,
|
|
116
|
+
semanticScore,
|
|
117
|
+
proximityScore,
|
|
118
|
+
similarityScore,
|
|
119
|
+
uniquenessScore: 0,
|
|
120
|
+
contextScore: proximityScore,
|
|
121
|
+
totalScore: 0,
|
|
122
|
+
unique: false,
|
|
123
|
+
valueToScore: c.valueToScore,
|
|
124
|
+
attrName: c.attrName,
|
|
125
|
+
accessibilityBonus,
|
|
126
|
+
stabilityBonus,
|
|
127
|
+
penalties
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Verify uniqueness and assign final score
|
|
131
|
+
for (const c of candidates) {
|
|
132
|
+
try {
|
|
133
|
+
const count = await page.locator(c.selector).count();
|
|
134
|
+
c.unique = count === 1;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// If counting selector fails or times out, mark as non-unique
|
|
138
|
+
c.unique = false;
|
|
139
|
+
}
|
|
140
|
+
c.uniquenessScore = c.unique ? 0 : -500;
|
|
141
|
+
// final score calculation
|
|
142
|
+
c.totalScore =
|
|
143
|
+
c.baseScore +
|
|
144
|
+
c.proximityScore +
|
|
145
|
+
c.semanticScore +
|
|
146
|
+
c.similarityScore +
|
|
147
|
+
(c.accessibilityBonus || 0) +
|
|
148
|
+
(c.stabilityBonus || 0) +
|
|
149
|
+
c.uniquenessScore -
|
|
150
|
+
(c.penalties || 0);
|
|
151
|
+
// Generate key for registry
|
|
152
|
+
let generatedKey = '';
|
|
153
|
+
const isInputEl = ['input', 'textarea', 'select'].includes(scanResult.targetTagName);
|
|
154
|
+
if (isInputEl) {
|
|
155
|
+
const testIdCand = candidates.find((cand) => cand.strategy === 'testId');
|
|
156
|
+
if (testIdCand && testIdCand.valueToScore) {
|
|
157
|
+
generatedKey = (0, key_builder_1.generateKeyFromText)(testIdCand.valueToScore, scanResult.targetRole);
|
|
158
|
+
}
|
|
159
|
+
else if (scanResult.targetName) {
|
|
160
|
+
generatedKey = (0, key_builder_1.generateKeyFromText)(scanResult.targetName, scanResult.targetRole);
|
|
161
|
+
}
|
|
162
|
+
else if (scanResult.targetType) {
|
|
163
|
+
generatedKey = (0, key_builder_1.generateKeyFromText)(scanResult.targetType + 'Input', scanResult.targetRole);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
generatedKey = scanResult.targetTagName + 'Input';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const visibleText = scanResult.targetAccessibleName || scanResult.targetText || '';
|
|
171
|
+
if (visibleText && visibleText.trim().length > 0 && visibleText.length <= 50) {
|
|
172
|
+
generatedKey = (0, key_builder_1.generateKeyFromText)(visibleText.trim(), scanResult.targetRole);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
generatedKey = (0, key_builder_1.generateKeyFromCandidate)(c, scanResult.targetRole);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
c.generatedKey = generatedKey;
|
|
179
|
+
c.nearbyText = scanResult.targetText;
|
|
180
|
+
c.accessibleName = scanResult.targetAccessibleName;
|
|
181
|
+
}
|
|
182
|
+
// If a stable unique candidate exists, heavily penalize unstable candidates to prioritize the stable ones
|
|
183
|
+
const hasStableUnique = candidates.some((c) => c.unique &&
|
|
184
|
+
(c.strategy === 'testId' ||
|
|
185
|
+
(c.strategy === 'id' && (0, id_stability_1.isStableId)(c.valueToScore || '')) ||
|
|
186
|
+
(c.strategy === 'dataAttribute' &&
|
|
187
|
+
(c.attrName === 'data-parent-id' ||
|
|
188
|
+
c.attrName === 'data-test-id' ||
|
|
189
|
+
c.attrName === 'data-testid' ||
|
|
190
|
+
c.attrName === 'datatestid'))));
|
|
191
|
+
if (hasStableUnique) {
|
|
192
|
+
for (const c of candidates) {
|
|
193
|
+
const isCandidateStable = c.strategy === 'testId' ||
|
|
194
|
+
(c.strategy === 'id' && (0, id_stability_1.isStableId)(c.valueToScore || '')) ||
|
|
195
|
+
(c.strategy === 'dataAttribute' &&
|
|
196
|
+
(c.attrName === 'data-parent-id' ||
|
|
197
|
+
c.attrName === 'data-test-id' ||
|
|
198
|
+
c.attrName === 'data-testid' ||
|
|
199
|
+
c.attrName === 'datatestid'));
|
|
200
|
+
if (!isCandidateStable) {
|
|
201
|
+
c.totalScore -= 600;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return candidates.sort((a, b) => b.totalScore - a.totalScore);
|
|
206
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SelectorStrategyType } from '../types';
|
|
2
|
+
export interface RawCandidate {
|
|
3
|
+
selector: string;
|
|
4
|
+
locator: string;
|
|
5
|
+
source: 'target' | 'child' | 'sibling' | 'parent' | 'ancestor';
|
|
6
|
+
strategy: SelectorStrategyType;
|
|
7
|
+
depth: number;
|
|
8
|
+
valueToScore?: string;
|
|
9
|
+
attrName?: string;
|
|
10
|
+
roleVal?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DomScanResult {
|
|
13
|
+
candidates: RawCandidate[];
|
|
14
|
+
targetText: string;
|
|
15
|
+
targetAccessibleName: string;
|
|
16
|
+
targetRole: string;
|
|
17
|
+
targetType: string;
|
|
18
|
+
targetName: string;
|
|
19
|
+
targetTagName: string;
|
|
20
|
+
targetTestId: string;
|
|
21
|
+
targetId: string;
|
|
22
|
+
targetParentId: string;
|
|
23
|
+
}
|
|
24
|
+
export interface DomScannerPayload {
|
|
25
|
+
exactUtilities: readonly string[];
|
|
26
|
+
tailwindPrefixes: readonly string[];
|
|
27
|
+
stateClasses: readonly string[];
|
|
28
|
+
stylePatterns: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* In-browser evaluation function to scan the DOM search scope of a target element and collect raw locator candidates.
|
|
32
|
+
*/
|
|
33
|
+
export declare function scanElementInBrowser(element: Element, data: DomScannerPayload): DomScanResult;
|
|
34
|
+
export declare const domScannerPayload: {
|
|
35
|
+
exactUtilities: string[];
|
|
36
|
+
tailwindPrefixes: string[];
|
|
37
|
+
stateClasses: string[];
|
|
38
|
+
stylePatterns: string[];
|
|
39
|
+
};
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.domScannerPayload = void 0;
|
|
4
|
+
exports.scanElementInBrowser = scanElementInBrowser;
|
|
5
|
+
const locator_restricted_1 = require("./locator.restricted");
|
|
6
|
+
/**
|
|
7
|
+
* In-browser evaluation function to scan the DOM search scope of a target element and collect raw locator candidates.
|
|
8
|
+
*/
|
|
9
|
+
function scanElementInBrowser(element, data) {
|
|
10
|
+
const collected = [];
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
// Helper to check if a container contains multiple actions
|
|
13
|
+
const isMultiActionContainer = (container, target) => {
|
|
14
|
+
if (container === target)
|
|
15
|
+
return false;
|
|
16
|
+
const tag = container.tagName.toLowerCase();
|
|
17
|
+
if (['button', 'a', 'input', 'select', 'option'].includes(tag))
|
|
18
|
+
return false;
|
|
19
|
+
const actions = container.querySelectorAll('button, a, input, select, [role="button"], [role="link"]');
|
|
20
|
+
if (actions.length > 1) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
const containerText = (container.textContent || '').trim().replace(/\s+/g, ' ');
|
|
24
|
+
const targetText = (target.textContent || '').trim().replace(/\s+/g, ' ');
|
|
25
|
+
if (containerText && targetText && containerText !== targetText) {
|
|
26
|
+
let textChildrenCount = 0;
|
|
27
|
+
for (let i = 0; i < container.children.length; i++) {
|
|
28
|
+
const childText = (container.children[i].textContent || '').trim();
|
|
29
|
+
if (childText.length > 0) {
|
|
30
|
+
textChildrenCount++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (textChildrenCount > 1) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
// Collect elements in DOM Search Scope
|
|
40
|
+
const scopeElements = [];
|
|
41
|
+
const addElement = (el, source, depth) => {
|
|
42
|
+
if (!el || seen.has(el))
|
|
43
|
+
return;
|
|
44
|
+
seen.add(el);
|
|
45
|
+
scopeElements.push({ el, source, depth });
|
|
46
|
+
};
|
|
47
|
+
// 1. Target Element
|
|
48
|
+
addElement(element, 'target', 0);
|
|
49
|
+
// 2. Children (depth 1)
|
|
50
|
+
for (let i = 0; i < element.children.length; i++) {
|
|
51
|
+
addElement(element.children[i], 'child', 1);
|
|
52
|
+
}
|
|
53
|
+
// 3. Parent
|
|
54
|
+
const parentEl = element.parentElement;
|
|
55
|
+
addElement(parentEl, 'parent', 1);
|
|
56
|
+
// 4. Grandparent
|
|
57
|
+
const grandparentEl = parentEl ? parentEl.parentElement : null;
|
|
58
|
+
addElement(grandparentEl, 'ancestor', 1); // Grandparent is ancestor at depth 1
|
|
59
|
+
// 5. Ancestors (max depth 3)
|
|
60
|
+
let currAncestor = grandparentEl ? grandparentEl.parentElement : null;
|
|
61
|
+
let ancDepth = 2;
|
|
62
|
+
while (currAncestor && ancDepth <= 3) {
|
|
63
|
+
addElement(currAncestor, 'ancestor', ancDepth);
|
|
64
|
+
currAncestor = currAncestor.parentElement;
|
|
65
|
+
ancDepth++;
|
|
66
|
+
}
|
|
67
|
+
// 6. Previous siblings (3)
|
|
68
|
+
let prev = element.previousElementSibling;
|
|
69
|
+
let sCount = 0;
|
|
70
|
+
while (prev && sCount < 3) {
|
|
71
|
+
addElement(prev, 'sibling', 1);
|
|
72
|
+
prev = prev.previousElementSibling;
|
|
73
|
+
sCount++;
|
|
74
|
+
}
|
|
75
|
+
// 7. Next siblings (3)
|
|
76
|
+
let next = element.nextElementSibling;
|
|
77
|
+
sCount = 0;
|
|
78
|
+
while (next && sCount < 3) {
|
|
79
|
+
addElement(next, 'sibling', 1);
|
|
80
|
+
next = next.nextElementSibling;
|
|
81
|
+
sCount++;
|
|
82
|
+
}
|
|
83
|
+
// 8. Parent children
|
|
84
|
+
if (parentEl) {
|
|
85
|
+
for (let i = 0; i < parentEl.children.length; i++) {
|
|
86
|
+
addElement(parentEl.children[i], 'sibling', 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 9. Closest heading
|
|
90
|
+
const headings = Array.from(element.ownerDocument.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
91
|
+
let closestHeading = null;
|
|
92
|
+
let minHDist = Infinity;
|
|
93
|
+
for (const h of headings) {
|
|
94
|
+
const pos = h.compareDocumentPosition(element);
|
|
95
|
+
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
96
|
+
let dist = 0;
|
|
97
|
+
let curr = h;
|
|
98
|
+
while (curr && curr !== element) {
|
|
99
|
+
curr = (curr.nextElementSibling || curr.parentElement);
|
|
100
|
+
dist++;
|
|
101
|
+
if (dist > 50)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (dist < minHDist) {
|
|
105
|
+
minHDist = dist;
|
|
106
|
+
closestHeading = h;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (closestHeading) {
|
|
111
|
+
addElement(closestHeading, 'ancestor', 3);
|
|
112
|
+
}
|
|
113
|
+
// 10. Closest label
|
|
114
|
+
let closestLabel = null;
|
|
115
|
+
if (element.id) {
|
|
116
|
+
closestLabel = element.ownerDocument.querySelector(`label[for="${element.id}"]`);
|
|
117
|
+
}
|
|
118
|
+
if (!closestLabel) {
|
|
119
|
+
let curr = element.parentElement;
|
|
120
|
+
while (curr) {
|
|
121
|
+
if (curr.tagName.toLowerCase() === 'label') {
|
|
122
|
+
closestLabel = curr;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
curr = curr.parentElement;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (closestLabel) {
|
|
129
|
+
addElement(closestLabel, 'parent', 1);
|
|
130
|
+
}
|
|
131
|
+
// 11. Closest accessible element
|
|
132
|
+
let currAcc = element.parentElement;
|
|
133
|
+
while (currAcc) {
|
|
134
|
+
const hasRole = currAcc.getAttribute('role') ||
|
|
135
|
+
['button', 'a', 'input', 'select', 'textarea'].includes(currAcc.tagName.toLowerCase());
|
|
136
|
+
const hasAccName = currAcc.getAttribute('aria-label') || currAcc.getAttribute('title');
|
|
137
|
+
if (hasRole || hasAccName) {
|
|
138
|
+
addElement(currAcc, 'ancestor', 2);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
currAcc = currAcc.parentElement;
|
|
142
|
+
}
|
|
143
|
+
// Helper: getRole
|
|
144
|
+
const getRoleAttr = (el) => {
|
|
145
|
+
let roleAttr = el.getAttribute('role')?.trim() || null;
|
|
146
|
+
const tagName = el.tagName.toLowerCase();
|
|
147
|
+
if (!roleAttr) {
|
|
148
|
+
if (tagName === 'button')
|
|
149
|
+
roleAttr = 'button';
|
|
150
|
+
else if (tagName === 'a')
|
|
151
|
+
roleAttr = 'link';
|
|
152
|
+
else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName))
|
|
153
|
+
roleAttr = 'heading';
|
|
154
|
+
else if (tagName === 'input') {
|
|
155
|
+
const type = el.getAttribute('type') || 'text';
|
|
156
|
+
if (type === 'checkbox')
|
|
157
|
+
roleAttr = 'checkbox';
|
|
158
|
+
else if (type === 'radio')
|
|
159
|
+
roleAttr = 'radio';
|
|
160
|
+
else if (['text', 'email', 'url', 'search', 'tel', 'password'].includes(type))
|
|
161
|
+
roleAttr = 'textbox';
|
|
162
|
+
else if (['button', 'submit', 'reset'].includes(type))
|
|
163
|
+
roleAttr = 'button';
|
|
164
|
+
}
|
|
165
|
+
else if (tagName === 'textarea')
|
|
166
|
+
roleAttr = 'textbox';
|
|
167
|
+
else if (tagName === 'select')
|
|
168
|
+
roleAttr = 'combobox';
|
|
169
|
+
else if (tagName === 'img')
|
|
170
|
+
roleAttr = 'img';
|
|
171
|
+
}
|
|
172
|
+
return roleAttr || '';
|
|
173
|
+
};
|
|
174
|
+
// Helper: getAccessibleName
|
|
175
|
+
const getAccessibleName = (el) => {
|
|
176
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
177
|
+
const alt = el.getAttribute('alt');
|
|
178
|
+
const title = el.getAttribute('title');
|
|
179
|
+
let textContent = '';
|
|
180
|
+
if (el.tagName.toLowerCase() === 'select') {
|
|
181
|
+
const firstOpt = el.querySelector('option');
|
|
182
|
+
textContent = firstOpt ? firstOpt.textContent || '' : '';
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
textContent = el.textContent || '';
|
|
186
|
+
}
|
|
187
|
+
textContent = textContent.trim().replace(/\s+/g, ' ');
|
|
188
|
+
return (ariaLabel || alt || title || textContent || '').trim();
|
|
189
|
+
};
|
|
190
|
+
// Target information for context mapping
|
|
191
|
+
const targetTagName = element.tagName.toLowerCase();
|
|
192
|
+
const targetRole = getRoleAttr(element);
|
|
193
|
+
const targetAccessibleName = getAccessibleName(element);
|
|
194
|
+
const targetText = targetTagName === 'select'
|
|
195
|
+
? (element.querySelector('option')?.textContent || '').trim().replace(/\s+/g, ' ')
|
|
196
|
+
: (element.textContent || '').trim().replace(/\s+/g, ' ');
|
|
197
|
+
const targetType = element.getAttribute('type') || '';
|
|
198
|
+
const targetName = element.getAttribute('name') || '';
|
|
199
|
+
const targetTestId = element.getAttribute('data-testid') ||
|
|
200
|
+
element.getAttribute('data-test-id') ||
|
|
201
|
+
element.getAttribute('testid') ||
|
|
202
|
+
'';
|
|
203
|
+
const targetId = element.id || '';
|
|
204
|
+
const targetParentId = element.getAttribute('data-parent-id') || '';
|
|
205
|
+
// Candidate Collection for all elements in scope
|
|
206
|
+
for (const item of scopeElements) {
|
|
207
|
+
const el = item.el;
|
|
208
|
+
const source = item.source;
|
|
209
|
+
const depth = item.depth;
|
|
210
|
+
const tagName = el.tagName.toLowerCase();
|
|
211
|
+
// Action Validation: Reject if multi-action container
|
|
212
|
+
if (isMultiActionContainer(el, element)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// 1. testId candidate
|
|
216
|
+
const testIdVal = el.getAttribute('data-testid') ?? el.getAttribute('data-test-id') ?? el.getAttribute('data-testId');
|
|
217
|
+
if (testIdVal) {
|
|
218
|
+
collected.push({
|
|
219
|
+
selector: `[data-testid="${testIdVal}"]`,
|
|
220
|
+
locator: `codegenPage.getByTestId('${testIdVal}')`,
|
|
221
|
+
source,
|
|
222
|
+
strategy: 'testId',
|
|
223
|
+
depth,
|
|
224
|
+
valueToScore: testIdVal
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// 2. id candidate
|
|
228
|
+
const idVal = el.getAttribute('id');
|
|
229
|
+
if (idVal) {
|
|
230
|
+
collected.push({
|
|
231
|
+
selector: `#${idVal}`,
|
|
232
|
+
locator: `codegenPage.locator('#${idVal}')`,
|
|
233
|
+
source,
|
|
234
|
+
strategy: 'id',
|
|
235
|
+
depth,
|
|
236
|
+
valueToScore: idVal
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// 3. dataAttribute candidates
|
|
240
|
+
const allAttrs = el.getAttributeNames();
|
|
241
|
+
const validDataAttrs = allAttrs.filter((attr) => attr.startsWith('data-') && !['data-testid', 'data-test-id', 'data-testid'].includes(attr.toLowerCase()));
|
|
242
|
+
for (const dataAttr of validDataAttrs) {
|
|
243
|
+
const val = el.getAttribute(dataAttr);
|
|
244
|
+
if (val) {
|
|
245
|
+
collected.push({
|
|
246
|
+
selector: `[${dataAttr}="${val}"]`,
|
|
247
|
+
locator: `codegenPage.locator('[${dataAttr}="${val}"]')`,
|
|
248
|
+
source,
|
|
249
|
+
strategy: 'dataAttribute',
|
|
250
|
+
depth,
|
|
251
|
+
valueToScore: val,
|
|
252
|
+
attrName: dataAttr
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 4. role candidate
|
|
257
|
+
const roleVal = getRoleAttr(el);
|
|
258
|
+
if (roleVal) {
|
|
259
|
+
const accName = getAccessibleName(el);
|
|
260
|
+
const escapedName = accName.replace(/'/g, "\\'");
|
|
261
|
+
collected.push({
|
|
262
|
+
selector: accName ? `internal:role=${roleVal}[name="${accName.replace(/"/g, '\\"')}"i]` : `role=${roleVal}`,
|
|
263
|
+
locator: accName
|
|
264
|
+
? `codegenPage.getByRole('${roleVal}', { name: '${escapedName}' })`
|
|
265
|
+
: `codegenPage.getByRole('${roleVal}')`,
|
|
266
|
+
source,
|
|
267
|
+
strategy: 'role',
|
|
268
|
+
depth,
|
|
269
|
+
valueToScore: accName || roleVal,
|
|
270
|
+
roleVal
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// 5. label candidate
|
|
274
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
275
|
+
const ariaLabelledBy = el.getAttribute('aria-labelledby');
|
|
276
|
+
let labelText = ariaLabel || ariaLabelledBy;
|
|
277
|
+
let labelAttr = 'aria-label';
|
|
278
|
+
if (ariaLabelledBy)
|
|
279
|
+
labelAttr = 'aria-labelledby';
|
|
280
|
+
if (!labelText && el.getAttribute('id')) {
|
|
281
|
+
const labelEl = el.ownerDocument.querySelector(`label[for="${el.getAttribute('id')}"]`);
|
|
282
|
+
if (labelEl && labelEl.textContent) {
|
|
283
|
+
labelText = labelEl.textContent.trim();
|
|
284
|
+
labelAttr = 'label';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (labelText) {
|
|
288
|
+
collected.push({
|
|
289
|
+
selector: `internal:label="${labelText.replace(/"/g, '\\"')}"i`,
|
|
290
|
+
locator: `codegenPage.getByLabel('${labelText.replace(/'/g, "\\'")}')`,
|
|
291
|
+
source,
|
|
292
|
+
strategy: 'label',
|
|
293
|
+
depth,
|
|
294
|
+
valueToScore: labelText,
|
|
295
|
+
attrName: labelAttr
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// 6. placeholder candidate
|
|
299
|
+
const placeholderVal = el.getAttribute('placeholder');
|
|
300
|
+
if (placeholderVal) {
|
|
301
|
+
collected.push({
|
|
302
|
+
selector: `internal:attr=[placeholder="${placeholderVal.replace(/"/g, '\\"')}"i]`,
|
|
303
|
+
locator: `codegenPage.getByPlaceholder('${placeholderVal.replace(/'/g, "\\'")}')`,
|
|
304
|
+
source,
|
|
305
|
+
strategy: 'placeholder',
|
|
306
|
+
depth,
|
|
307
|
+
valueToScore: placeholderVal
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// 7. altText candidate
|
|
311
|
+
const altVal = el.getAttribute('alt');
|
|
312
|
+
if (altVal) {
|
|
313
|
+
collected.push({
|
|
314
|
+
selector: `internal:attr=[alt="${altVal.replace(/"/g, '\\"')}"i]`,
|
|
315
|
+
locator: `codegenPage.getByAltText('${altVal.replace(/'/g, "\\'")}')`,
|
|
316
|
+
source,
|
|
317
|
+
strategy: 'altText',
|
|
318
|
+
depth,
|
|
319
|
+
valueToScore: altVal
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// 8. title candidate
|
|
323
|
+
const titleVal = el.getAttribute('title');
|
|
324
|
+
if (titleVal) {
|
|
325
|
+
collected.push({
|
|
326
|
+
selector: `internal:attr=[title="${titleVal.replace(/"/g, '\\"')}"i]`,
|
|
327
|
+
locator: `codegenPage.getByTitle('${titleVal.replace(/'/g, "\\'")}')`,
|
|
328
|
+
source,
|
|
329
|
+
strategy: 'title',
|
|
330
|
+
depth,
|
|
331
|
+
valueToScore: titleVal
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// 9. text candidate
|
|
335
|
+
const textVal = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
336
|
+
if (textVal) {
|
|
337
|
+
const cleanedText = textVal.replace(/^\d+[\s\.\-_]*/, '').trim();
|
|
338
|
+
if (cleanedText && !/\d/.test(cleanedText)) {
|
|
339
|
+
const escapedText = cleanedText.replace(/"/g, '\\"');
|
|
340
|
+
const textSelector = cleanedText.includes(' ') ? `text="${escapedText}"` : `text=${escapedText}`;
|
|
341
|
+
collected.push({
|
|
342
|
+
selector: textSelector,
|
|
343
|
+
locator: `codegenPage.getByText('${cleanedText.replace(/'/g, "\\'")}')`,
|
|
344
|
+
source,
|
|
345
|
+
strategy: 'text',
|
|
346
|
+
depth,
|
|
347
|
+
valueToScore: cleanedText
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// 10. class candidates
|
|
352
|
+
const className = el.getAttribute('class');
|
|
353
|
+
if (className) {
|
|
354
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
355
|
+
const isStyleClass = (cName) => {
|
|
356
|
+
if (cName.includes('[') && cName.includes(']'))
|
|
357
|
+
return true;
|
|
358
|
+
if (cName.includes(':'))
|
|
359
|
+
return true;
|
|
360
|
+
if (data.exactUtilities.includes(cName))
|
|
361
|
+
return true;
|
|
362
|
+
if (data.stateClasses.includes(cName.toLowerCase()))
|
|
363
|
+
return true;
|
|
364
|
+
if (data.tailwindPrefixes.some((prefix) => cName.startsWith(prefix)))
|
|
365
|
+
return true;
|
|
366
|
+
return data.stylePatterns.some((pat) => new RegExp(pat).test(cName));
|
|
367
|
+
};
|
|
368
|
+
for (const cls of classes) {
|
|
369
|
+
if (cls.startsWith('translate-x') ||
|
|
370
|
+
cls.startsWith('translate-y') ||
|
|
371
|
+
isStyleClass(cls) ||
|
|
372
|
+
/^[A-Z]/.test(cls) ||
|
|
373
|
+
cls.toLowerCase().includes('border') ||
|
|
374
|
+
cls.length > 50) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
collected.push({
|
|
378
|
+
selector: `.${cls}`,
|
|
379
|
+
locator: `codegenPage.locator('.${cls}')`,
|
|
380
|
+
source,
|
|
381
|
+
strategy: 'class',
|
|
382
|
+
depth,
|
|
383
|
+
valueToScore: cls
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// 11. tag / css candidate
|
|
388
|
+
const semanticTags = ['button', 'a', 'input', 'select', 'textarea', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'form'];
|
|
389
|
+
if (semanticTags.includes(tagName)) {
|
|
390
|
+
collected.push({
|
|
391
|
+
selector: `${tagName}`,
|
|
392
|
+
locator: `codegenPage.locator('${tagName}')`,
|
|
393
|
+
source,
|
|
394
|
+
strategy: 'css',
|
|
395
|
+
depth,
|
|
396
|
+
valueToScore: tagName
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// 12. xpath candidate
|
|
400
|
+
if (semanticTags.includes(tagName)) {
|
|
401
|
+
collected.push({
|
|
402
|
+
selector: `xpath=//${tagName}`,
|
|
403
|
+
locator: `codegenPage.locator('//${tagName}')`,
|
|
404
|
+
source,
|
|
405
|
+
strategy: 'xpath',
|
|
406
|
+
depth,
|
|
407
|
+
valueToScore: tagName
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
candidates: collected,
|
|
413
|
+
targetText,
|
|
414
|
+
targetAccessibleName,
|
|
415
|
+
targetRole,
|
|
416
|
+
targetType,
|
|
417
|
+
targetName,
|
|
418
|
+
targetTagName,
|
|
419
|
+
targetTestId,
|
|
420
|
+
targetId,
|
|
421
|
+
targetParentId
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
exports.domScannerPayload = {
|
|
425
|
+
exactUtilities: locator_restricted_1.exactUtilities,
|
|
426
|
+
tailwindPrefixes: locator_restricted_1.tailwindPrefixes,
|
|
427
|
+
stateClasses: locator_restricted_1.stateClasses,
|
|
428
|
+
stylePatterns: locator_restricted_1.stylePatterns
|
|
429
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks whether an HTML element's ID attribute is stable and non-generated.
|
|
3
|
+
* Filters out framework auto-generated IDs (React, MUI, HeadlessUI, Ember, Angular),
|
|
4
|
+
* UUIDs, hashes, and purely numeric IDs.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isStableId(id: string): boolean;
|