playwright-intelligence-engine 2.0.0 → 2.0.2
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 +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- package/src/selector/selector-scorer.ts +344 -344
|
@@ -1,343 +1,343 @@
|
|
|
1
|
-
import { Project } from 'ts-morph';
|
|
2
|
-
import { ResolvedElement, SnapshotResolver } from '../trace/snapshot-resolver.js';
|
|
3
|
-
import { FailedAction } from '../core/types/diagnostic-context.js';
|
|
4
|
-
import { InsufficientIdentifiersError } from '../core/errors/engine-errors.js';
|
|
5
|
-
|
|
6
|
-
export type LocatorPlan =
|
|
7
|
-
| {
|
|
8
|
-
kind: 'getByRole';
|
|
9
|
-
role: string;
|
|
10
|
-
options?: {
|
|
11
|
-
name?: string;
|
|
12
|
-
exact?: boolean;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
| {
|
|
16
|
-
kind: 'getByText';
|
|
17
|
-
text: string;
|
|
18
|
-
options?: {
|
|
19
|
-
exact?: boolean;
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
| {
|
|
23
|
-
kind: 'getByLabel';
|
|
24
|
-
label: string;
|
|
25
|
-
options?: {
|
|
26
|
-
exact?: boolean;
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
| {
|
|
30
|
-
kind: 'getByPlaceholder';
|
|
31
|
-
placeholder: string;
|
|
32
|
-
options?: {
|
|
33
|
-
exact?: boolean;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
| {
|
|
37
|
-
kind: 'getByTestId';
|
|
38
|
-
testId: string;
|
|
39
|
-
}
|
|
40
|
-
| {
|
|
41
|
-
kind: 'locator';
|
|
42
|
-
selector: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export class CandidateExtractor {
|
|
46
|
-
/**
|
|
47
|
-
* Safely escapes a string literal for embedding inside TypeScript single-quoted strings.
|
|
48
|
-
* Handles backslashes, quotes, newlines, tabs, and unicode line separators.
|
|
49
|
-
*/
|
|
50
|
-
public static escapeLocatorString(val: string): string {
|
|
51
|
-
return val
|
|
52
|
-
.replace(/\\/g, '\\\\')
|
|
53
|
-
.replace(/'/g, "\\'")
|
|
54
|
-
.replace(/\r/g, '\\r')
|
|
55
|
-
.replace(/\n/g, '\\n')
|
|
56
|
-
.replace(/\t/g, '\\t')
|
|
57
|
-
.replace(/\u2028/g, '\\u2028')
|
|
58
|
-
.replace(/\u2029/g, '\\u2029');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Discovers the page property identifier used in a Page Object class using AST analysis.
|
|
63
|
-
* Inspects class property declarations, constructor parameters, and inheritance context.
|
|
64
|
-
* Does NOT rely on loose substring search in comments or unrelated text.
|
|
65
|
-
*/
|
|
66
|
-
public static discoverPageProperty(source?: string): string {
|
|
67
|
-
if (!source || typeof source !== 'string' || source.trim().length === 0) {
|
|
68
|
-
return 'this.page';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const project = new Project({ useInMemoryFileSystem: true });
|
|
73
|
-
const sourceFile = project.createSourceFile('temp-pom.ts', source);
|
|
74
|
-
const classes = sourceFile.getClasses();
|
|
75
|
-
|
|
76
|
-
for (const cls of classes) {
|
|
77
|
-
// 1. Inspect class properties
|
|
78
|
-
for (const prop of cls.getProperties()) {
|
|
79
|
-
const name = prop.getName();
|
|
80
|
-
const typeNode = prop.getTypeNode();
|
|
81
|
-
const typeText = typeNode ? typeNode.getText() : '';
|
|
82
|
-
|
|
83
|
-
if (name === '_page' || name === 'browserPage' || name === 'page') {
|
|
84
|
-
return `this.${name}`;
|
|
85
|
-
}
|
|
86
|
-
if (typeText.includes('Page')) {
|
|
87
|
-
return `this.${name}`;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 2. Inspect constructor parameter properties (e.g. constructor(protected _page: Page))
|
|
92
|
-
const ctor = cls.getConstructors()[0];
|
|
93
|
-
if (ctor) {
|
|
94
|
-
for (const param of ctor.getParameters()) {
|
|
95
|
-
const name = param.getName();
|
|
96
|
-
const isProperty = param.hasScopeKeyword() || param.isReadonly();
|
|
97
|
-
if (isProperty) {
|
|
98
|
-
if (name === '_page' || name === 'browserPage' || name === 'page') {
|
|
99
|
-
return `this.${name}`;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Fallback to strict regex if ts-morph parsing encounters incomplete fragment
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Strict structural regex check (not plain substring in comments)
|
|
110
|
-
if (/^\s*(?:readonly|private|protected|public)?\s*_page\s*[:=]/m.test(source)) {
|
|
111
|
-
return 'this._page';
|
|
112
|
-
}
|
|
113
|
-
if (/^\s*(?:readonly|private|protected|public)?\s*browserPage\s*[:=]/m.test(source)) {
|
|
114
|
-
return 'this.browserPage';
|
|
115
|
-
}
|
|
116
|
-
if (/^\s*(?:readonly|private|protected|public)?\s*page\s*[:=]/m.test(source)) {
|
|
117
|
-
return 'this.page';
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return 'this.page';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Generates a strongly typed, safe LocatorPlan representing a Playwright locator intent.
|
|
125
|
-
* Strictly refuses to generate bare tag fallbacks.
|
|
126
|
-
*/
|
|
127
|
-
public static generateLocatorPlan(el: ResolvedElement): LocatorPlan {
|
|
128
|
-
// Rule 1: Priority on ARIA role with accessible name (getByRole)
|
|
129
|
-
if (el.role && el.accessibleName && el.accessibleName.trim().length > 0) {
|
|
130
|
-
return {
|
|
131
|
-
kind: 'getByRole',
|
|
132
|
-
role: el.role,
|
|
133
|
-
options: {
|
|
134
|
-
name: el.accessibleName.trim(),
|
|
135
|
-
exact: true,
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Rule 2: Dedicated Test ID attribute (getByTestId)
|
|
141
|
-
const testId = el.attributes['data-testid'] ?? el.attributes['data-test'] ?? el.attributes['data-cy'];
|
|
142
|
-
if (testId && testId.trim().length > 0) {
|
|
143
|
-
return {
|
|
144
|
-
kind: 'getByTestId',
|
|
145
|
-
testId: testId.trim(),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Rule 3: Form label (getByLabel)
|
|
150
|
-
const labelAttr =
|
|
151
|
-
el.attributes['aria-label'] ??
|
|
152
|
-
el.attributes['aria-labelledby'] ??
|
|
153
|
-
el.attributes['data-label'] ??
|
|
154
|
-
el.attributes['label'];
|
|
155
|
-
if (labelAttr && labelAttr.trim().length > 0) {
|
|
156
|
-
return {
|
|
157
|
-
kind: 'getByLabel',
|
|
158
|
-
label: labelAttr.trim(),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
(el.tag === 'input' || el.tag === 'textarea' || el.role === 'textbox') &&
|
|
164
|
-
el.accessibleName &&
|
|
165
|
-
el.accessibleName.trim().length > 0
|
|
166
|
-
) {
|
|
167
|
-
return {
|
|
168
|
-
kind: 'getByLabel',
|
|
169
|
-
label: el.accessibleName.trim(),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Rule 4: Placeholder (getByPlaceholder)
|
|
174
|
-
if (el.attributes['placeholder'] && el.attributes['placeholder'].trim().length > 0) {
|
|
175
|
-
return {
|
|
176
|
-
kind: 'getByPlaceholder',
|
|
177
|
-
placeholder: el.attributes['placeholder'].trim(),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Rule 5: Accessible text (getByText)
|
|
182
|
-
if (el.text && el.text.trim().length > 0 && el.text.trim().length < 50) {
|
|
183
|
-
return {
|
|
184
|
-
kind: 'getByText',
|
|
185
|
-
text: el.text.trim(),
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Rule 6: Scoped non-hash, non-volatile ID selector locator(#id)
|
|
190
|
-
const id = el.attributes['id'];
|
|
191
|
-
const isVolatileId =
|
|
192
|
-
!id ||
|
|
193
|
-
/^[0-9a-f]{8,}$/i.test(id) ||
|
|
194
|
-
/^[a-z]+-[0-9a-f]{4,}$/i.test(id) ||
|
|
195
|
-
/^:r[0-9a-z]+:$/i.test(id) ||
|
|
196
|
-
/^(ember|chakra|radix)-\d+/i.test(id);
|
|
197
|
-
|
|
198
|
-
if (id && id.trim().length > 0 && !isVolatileId) {
|
|
199
|
-
return {
|
|
200
|
-
kind: 'locator',
|
|
201
|
-
selector: `#${id.trim()}`,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Rule 7: Scoped Class selector locator(tag.class) with semantic non-generated class
|
|
206
|
-
const cls = el.attributes['class'];
|
|
207
|
-
if (cls && !/css-[a-z0-9]+/i.test(cls)) {
|
|
208
|
-
const primaryClass = cls.split(' ').filter(Boolean)[0];
|
|
209
|
-
if (primaryClass && primaryClass.length > 2 && !/^[0-9a-f_-]{6,}$/i.test(primaryClass)) {
|
|
210
|
-
return {
|
|
211
|
-
kind: 'locator',
|
|
212
|
-
selector: `${el.tag}.${primaryClass}`,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Rule 8: Standalone distinct ARIA role if specific (e.g. dialog, alert, banner, search)
|
|
218
|
-
const specificRoles = new Set(['dialog', 'alert', 'banner', 'search', 'navigation', 'main', 'complementary']);
|
|
219
|
-
if (el.role && specificRoles.has(el.role)) {
|
|
220
|
-
return {
|
|
221
|
-
kind: 'getByRole',
|
|
222
|
-
role: el.role,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// AUDIT-13 Remediation: Bare tag fallback is strictly ELIMINATED.
|
|
227
|
-
throw new InsufficientIdentifiersError(
|
|
228
|
-
el.tag,
|
|
229
|
-
`Element <${el.tag}> lacks distinctive identifying attributes (accessible name, test ID, label, placeholder, stable id, or semantic class) to construct a safe locator. Bare tag fallback (locator('${el.tag}')) is strictly prohibited to prevent test flakiness and incorrect repairs.`
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Converts a typed LocatorPlan into an approved TypeScript expression string.
|
|
235
|
-
*/
|
|
236
|
-
public static planToExpression(plan: LocatorPlan, pageProperty = 'this.page'): string {
|
|
237
|
-
switch (plan.kind) {
|
|
238
|
-
case 'getByRole': {
|
|
239
|
-
const role = CandidateExtractor.escapeLocatorString(plan.role);
|
|
240
|
-
if (plan.options?.name) {
|
|
241
|
-
const name = CandidateExtractor.escapeLocatorString(plan.options.name);
|
|
242
|
-
return `${pageProperty}.getByRole('${role}', { name: '${name}' })`;
|
|
243
|
-
}
|
|
244
|
-
return `${pageProperty}.getByRole('${role}')`;
|
|
245
|
-
}
|
|
246
|
-
case 'getByTestId': {
|
|
247
|
-
const id = CandidateExtractor.escapeLocatorString(plan.testId);
|
|
248
|
-
return `${pageProperty}.getByTestId('${id}')`;
|
|
249
|
-
}
|
|
250
|
-
case 'getByLabel': {
|
|
251
|
-
const label = CandidateExtractor.escapeLocatorString(plan.label);
|
|
252
|
-
return `${pageProperty}.getByLabel('${label}')`;
|
|
253
|
-
}
|
|
254
|
-
case 'getByPlaceholder': {
|
|
255
|
-
const pl = CandidateExtractor.escapeLocatorString(plan.placeholder);
|
|
256
|
-
return `${pageProperty}.getByPlaceholder('${pl}')`;
|
|
257
|
-
}
|
|
258
|
-
case 'getByText': {
|
|
259
|
-
const txt = CandidateExtractor.escapeLocatorString(plan.text);
|
|
260
|
-
return `${pageProperty}.getByText('${txt}')`;
|
|
261
|
-
}
|
|
262
|
-
case 'locator': {
|
|
263
|
-
const sel = CandidateExtractor.escapeLocatorString(plan.selector);
|
|
264
|
-
return `${pageProperty}.locator('${sel}')`;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Generates a modern, user-centric Playwright locator expression.
|
|
271
|
-
* Delegates to generateLocatorPlan and planToExpression.
|
|
272
|
-
* Throws EngineError('INSUFFICIENT_IDENTIFIERS') if element lacks distinctive attributes.
|
|
273
|
-
*/
|
|
274
|
-
public static generateLocator(el: ResolvedElement, pageProperty = 'this.page'): string {
|
|
275
|
-
const plan = CandidateExtractor.generateLocatorPlan(el);
|
|
276
|
-
return CandidateExtractor.planToExpression(plan, pageProperty);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Extracts candidate elements from a resolved DOM tree matching the target action's semantics.
|
|
281
|
-
* Requires genuine semantic or structural affinity.
|
|
282
|
-
*/
|
|
283
|
-
public static extractCandidates(
|
|
284
|
-
root: ResolvedElement,
|
|
285
|
-
actionOrFailedAction?: FailedAction | { action?: string | undefined; selector?: string | undefined } | string
|
|
286
|
-
): ResolvedElement[] {
|
|
287
|
-
const allInteractive = SnapshotResolver.extractInteractiveElements(root);
|
|
288
|
-
|
|
289
|
-
let action = 'click';
|
|
290
|
-
|
|
291
|
-
if (actionOrFailedAction && typeof actionOrFailedAction !== 'string') {
|
|
292
|
-
action = (actionOrFailedAction.action ?? 'click').toLowerCase();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Filter candidates by action compatibility
|
|
296
|
-
return allInteractive.filter((element) => {
|
|
297
|
-
// Rule 1: Action type compatibility
|
|
298
|
-
if (action.includes('click')) {
|
|
299
|
-
const isClickable =
|
|
300
|
-
element.tag === 'button' ||
|
|
301
|
-
element.tag === 'a' ||
|
|
302
|
-
element.role === 'button' ||
|
|
303
|
-
element.role === 'link' ||
|
|
304
|
-
element.role === 'menuitem' ||
|
|
305
|
-
element.role === 'tab' ||
|
|
306
|
-
Boolean(element.attributes?.['onclick']) ||
|
|
307
|
-
Boolean(element.attributes?.['href']);
|
|
308
|
-
|
|
309
|
-
if (!isClickable && element.tag !== 'div' && element.tag !== 'span' && element.tag !== 'td') {
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
|
-
} else if (action.includes('fill') || action.includes('type')) {
|
|
313
|
-
const isInput =
|
|
314
|
-
element.tag === 'input' ||
|
|
315
|
-
element.tag === 'textarea' ||
|
|
316
|
-
element.role === 'textbox' ||
|
|
317
|
-
element.attributes?.['contenteditable'] === 'true';
|
|
318
|
-
|
|
319
|
-
if (!isInput) {
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Rule 2: Exclude hidden / disabled elements
|
|
325
|
-
if (
|
|
326
|
-
element.attributes?.['aria-hidden'] === 'true' ||
|
|
327
|
-
element.attributes?.['hidden'] !== undefined ||
|
|
328
|
-
element.attributes?.['disabled'] !== undefined ||
|
|
329
|
-
element.attributes?.['aria-disabled'] === 'true'
|
|
330
|
-
) {
|
|
331
|
-
return false;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Rule 3: Must have at least one valid identifier to be eligible for repair
|
|
335
|
-
try {
|
|
336
|
-
CandidateExtractor.generateLocatorPlan(element);
|
|
337
|
-
return true;
|
|
338
|
-
} catch {
|
|
339
|
-
return false; // Bare elements lacking identifiers are filtered out
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
1
|
+
import { Project } from 'ts-morph';
|
|
2
|
+
import { ResolvedElement, SnapshotResolver } from '../trace/snapshot-resolver.js';
|
|
3
|
+
import { FailedAction } from '../core/types/diagnostic-context.js';
|
|
4
|
+
import { InsufficientIdentifiersError } from '../core/errors/engine-errors.js';
|
|
5
|
+
|
|
6
|
+
export type LocatorPlan =
|
|
7
|
+
| {
|
|
8
|
+
kind: 'getByRole';
|
|
9
|
+
role: string;
|
|
10
|
+
options?: {
|
|
11
|
+
name?: string;
|
|
12
|
+
exact?: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
kind: 'getByText';
|
|
17
|
+
text: string;
|
|
18
|
+
options?: {
|
|
19
|
+
exact?: boolean;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
kind: 'getByLabel';
|
|
24
|
+
label: string;
|
|
25
|
+
options?: {
|
|
26
|
+
exact?: boolean;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
kind: 'getByPlaceholder';
|
|
31
|
+
placeholder: string;
|
|
32
|
+
options?: {
|
|
33
|
+
exact?: boolean;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
kind: 'getByTestId';
|
|
38
|
+
testId: string;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
kind: 'locator';
|
|
42
|
+
selector: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class CandidateExtractor {
|
|
46
|
+
/**
|
|
47
|
+
* Safely escapes a string literal for embedding inside TypeScript single-quoted strings.
|
|
48
|
+
* Handles backslashes, quotes, newlines, tabs, and unicode line separators.
|
|
49
|
+
*/
|
|
50
|
+
public static escapeLocatorString(val: string): string {
|
|
51
|
+
return val
|
|
52
|
+
.replace(/\\/g, '\\\\')
|
|
53
|
+
.replace(/'/g, "\\'")
|
|
54
|
+
.replace(/\r/g, '\\r')
|
|
55
|
+
.replace(/\n/g, '\\n')
|
|
56
|
+
.replace(/\t/g, '\\t')
|
|
57
|
+
.replace(/\u2028/g, '\\u2028')
|
|
58
|
+
.replace(/\u2029/g, '\\u2029');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Discovers the page property identifier used in a Page Object class using AST analysis.
|
|
63
|
+
* Inspects class property declarations, constructor parameters, and inheritance context.
|
|
64
|
+
* Does NOT rely on loose substring search in comments or unrelated text.
|
|
65
|
+
*/
|
|
66
|
+
public static discoverPageProperty(source?: string): string {
|
|
67
|
+
if (!source || typeof source !== 'string' || source.trim().length === 0) {
|
|
68
|
+
return 'this.page';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const project = new Project({ useInMemoryFileSystem: true });
|
|
73
|
+
const sourceFile = project.createSourceFile('temp-pom.ts', source);
|
|
74
|
+
const classes = sourceFile.getClasses();
|
|
75
|
+
|
|
76
|
+
for (const cls of classes) {
|
|
77
|
+
// 1. Inspect class properties
|
|
78
|
+
for (const prop of cls.getProperties()) {
|
|
79
|
+
const name = prop.getName();
|
|
80
|
+
const typeNode = prop.getTypeNode();
|
|
81
|
+
const typeText = typeNode ? typeNode.getText() : '';
|
|
82
|
+
|
|
83
|
+
if (name === '_page' || name === 'browserPage' || name === 'page') {
|
|
84
|
+
return `this.${name}`;
|
|
85
|
+
}
|
|
86
|
+
if (typeText.includes('Page')) {
|
|
87
|
+
return `this.${name}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. Inspect constructor parameter properties (e.g. constructor(protected _page: Page))
|
|
92
|
+
const ctor = cls.getConstructors()[0];
|
|
93
|
+
if (ctor) {
|
|
94
|
+
for (const param of ctor.getParameters()) {
|
|
95
|
+
const name = param.getName();
|
|
96
|
+
const isProperty = param.hasScopeKeyword() || param.isReadonly();
|
|
97
|
+
if (isProperty) {
|
|
98
|
+
if (name === '_page' || name === 'browserPage' || name === 'page') {
|
|
99
|
+
return `this.${name}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Fallback to strict regex if ts-morph parsing encounters incomplete fragment
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Strict structural regex check (not plain substring in comments)
|
|
110
|
+
if (/^\s*(?:readonly|private|protected|public)?\s*_page\s*[:=]/m.test(source)) {
|
|
111
|
+
return 'this._page';
|
|
112
|
+
}
|
|
113
|
+
if (/^\s*(?:readonly|private|protected|public)?\s*browserPage\s*[:=]/m.test(source)) {
|
|
114
|
+
return 'this.browserPage';
|
|
115
|
+
}
|
|
116
|
+
if (/^\s*(?:readonly|private|protected|public)?\s*page\s*[:=]/m.test(source)) {
|
|
117
|
+
return 'this.page';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return 'this.page';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generates a strongly typed, safe LocatorPlan representing a Playwright locator intent.
|
|
125
|
+
* Strictly refuses to generate bare tag fallbacks.
|
|
126
|
+
*/
|
|
127
|
+
public static generateLocatorPlan(el: ResolvedElement): LocatorPlan {
|
|
128
|
+
// Rule 1: Priority on ARIA role with accessible name (getByRole)
|
|
129
|
+
if (el.role && el.accessibleName && el.accessibleName.trim().length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
kind: 'getByRole',
|
|
132
|
+
role: el.role,
|
|
133
|
+
options: {
|
|
134
|
+
name: el.accessibleName.trim(),
|
|
135
|
+
exact: true,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Rule 2: Dedicated Test ID attribute (getByTestId)
|
|
141
|
+
const testId = el.attributes['data-testid'] ?? el.attributes['data-test'] ?? el.attributes['data-cy'];
|
|
142
|
+
if (testId && testId.trim().length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
kind: 'getByTestId',
|
|
145
|
+
testId: testId.trim(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Rule 3: Form label (getByLabel)
|
|
150
|
+
const labelAttr =
|
|
151
|
+
el.attributes['aria-label'] ??
|
|
152
|
+
el.attributes['aria-labelledby'] ??
|
|
153
|
+
el.attributes['data-label'] ??
|
|
154
|
+
el.attributes['label'];
|
|
155
|
+
if (labelAttr && labelAttr.trim().length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'getByLabel',
|
|
158
|
+
label: labelAttr.trim(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
(el.tag === 'input' || el.tag === 'textarea' || el.role === 'textbox') &&
|
|
164
|
+
el.accessibleName &&
|
|
165
|
+
el.accessibleName.trim().length > 0
|
|
166
|
+
) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'getByLabel',
|
|
169
|
+
label: el.accessibleName.trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Rule 4: Placeholder (getByPlaceholder)
|
|
174
|
+
if (el.attributes['placeholder'] && el.attributes['placeholder'].trim().length > 0) {
|
|
175
|
+
return {
|
|
176
|
+
kind: 'getByPlaceholder',
|
|
177
|
+
placeholder: el.attributes['placeholder'].trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Rule 5: Accessible text (getByText)
|
|
182
|
+
if (el.text && el.text.trim().length > 0 && el.text.trim().length < 50) {
|
|
183
|
+
return {
|
|
184
|
+
kind: 'getByText',
|
|
185
|
+
text: el.text.trim(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Rule 6: Scoped non-hash, non-volatile ID selector locator(#id)
|
|
190
|
+
const id = el.attributes['id'];
|
|
191
|
+
const isVolatileId =
|
|
192
|
+
!id ||
|
|
193
|
+
/^[0-9a-f]{8,}$/i.test(id) ||
|
|
194
|
+
/^[a-z]+-[0-9a-f]{4,}$/i.test(id) ||
|
|
195
|
+
/^:r[0-9a-z]+:$/i.test(id) ||
|
|
196
|
+
/^(ember|chakra|radix)-\d+/i.test(id);
|
|
197
|
+
|
|
198
|
+
if (id && id.trim().length > 0 && !isVolatileId) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'locator',
|
|
201
|
+
selector: `#${id.trim()}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Rule 7: Scoped Class selector locator(tag.class) with semantic non-generated class
|
|
206
|
+
const cls = el.attributes['class'];
|
|
207
|
+
if (cls && !/css-[a-z0-9]+/i.test(cls)) {
|
|
208
|
+
const primaryClass = cls.split(' ').filter(Boolean)[0];
|
|
209
|
+
if (primaryClass && primaryClass.length > 2 && !/^[0-9a-f_-]{6,}$/i.test(primaryClass)) {
|
|
210
|
+
return {
|
|
211
|
+
kind: 'locator',
|
|
212
|
+
selector: `${el.tag}.${primaryClass}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Rule 8: Standalone distinct ARIA role if specific (e.g. dialog, alert, banner, search)
|
|
218
|
+
const specificRoles = new Set(['dialog', 'alert', 'banner', 'search', 'navigation', 'main', 'complementary']);
|
|
219
|
+
if (el.role && specificRoles.has(el.role)) {
|
|
220
|
+
return {
|
|
221
|
+
kind: 'getByRole',
|
|
222
|
+
role: el.role,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// AUDIT-13 Remediation: Bare tag fallback is strictly ELIMINATED.
|
|
227
|
+
throw new InsufficientIdentifiersError(
|
|
228
|
+
el.tag,
|
|
229
|
+
`Element <${el.tag}> lacks distinctive identifying attributes (accessible name, test ID, label, placeholder, stable id, or semantic class) to construct a safe locator. Bare tag fallback (locator('${el.tag}')) is strictly prohibited to prevent test flakiness and incorrect repairs.`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Converts a typed LocatorPlan into an approved TypeScript expression string.
|
|
235
|
+
*/
|
|
236
|
+
public static planToExpression(plan: LocatorPlan, pageProperty = 'this.page'): string {
|
|
237
|
+
switch (plan.kind) {
|
|
238
|
+
case 'getByRole': {
|
|
239
|
+
const role = CandidateExtractor.escapeLocatorString(plan.role);
|
|
240
|
+
if (plan.options?.name) {
|
|
241
|
+
const name = CandidateExtractor.escapeLocatorString(plan.options.name);
|
|
242
|
+
return `${pageProperty}.getByRole('${role}', { name: '${name}' })`;
|
|
243
|
+
}
|
|
244
|
+
return `${pageProperty}.getByRole('${role}')`;
|
|
245
|
+
}
|
|
246
|
+
case 'getByTestId': {
|
|
247
|
+
const id = CandidateExtractor.escapeLocatorString(plan.testId);
|
|
248
|
+
return `${pageProperty}.getByTestId('${id}')`;
|
|
249
|
+
}
|
|
250
|
+
case 'getByLabel': {
|
|
251
|
+
const label = CandidateExtractor.escapeLocatorString(plan.label);
|
|
252
|
+
return `${pageProperty}.getByLabel('${label}')`;
|
|
253
|
+
}
|
|
254
|
+
case 'getByPlaceholder': {
|
|
255
|
+
const pl = CandidateExtractor.escapeLocatorString(plan.placeholder);
|
|
256
|
+
return `${pageProperty}.getByPlaceholder('${pl}')`;
|
|
257
|
+
}
|
|
258
|
+
case 'getByText': {
|
|
259
|
+
const txt = CandidateExtractor.escapeLocatorString(plan.text);
|
|
260
|
+
return `${pageProperty}.getByText('${txt}')`;
|
|
261
|
+
}
|
|
262
|
+
case 'locator': {
|
|
263
|
+
const sel = CandidateExtractor.escapeLocatorString(plan.selector);
|
|
264
|
+
return `${pageProperty}.locator('${sel}')`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generates a modern, user-centric Playwright locator expression.
|
|
271
|
+
* Delegates to generateLocatorPlan and planToExpression.
|
|
272
|
+
* Throws EngineError('INSUFFICIENT_IDENTIFIERS') if element lacks distinctive attributes.
|
|
273
|
+
*/
|
|
274
|
+
public static generateLocator(el: ResolvedElement, pageProperty = 'this.page'): string {
|
|
275
|
+
const plan = CandidateExtractor.generateLocatorPlan(el);
|
|
276
|
+
return CandidateExtractor.planToExpression(plan, pageProperty);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Extracts candidate elements from a resolved DOM tree matching the target action's semantics.
|
|
281
|
+
* Requires genuine semantic or structural affinity.
|
|
282
|
+
*/
|
|
283
|
+
public static extractCandidates(
|
|
284
|
+
root: ResolvedElement,
|
|
285
|
+
actionOrFailedAction?: FailedAction | { action?: string | undefined; selector?: string | undefined } | string
|
|
286
|
+
): ResolvedElement[] {
|
|
287
|
+
const allInteractive = SnapshotResolver.extractInteractiveElements(root);
|
|
288
|
+
|
|
289
|
+
let action = 'click';
|
|
290
|
+
|
|
291
|
+
if (actionOrFailedAction && typeof actionOrFailedAction !== 'string') {
|
|
292
|
+
action = (actionOrFailedAction.action ?? 'click').toLowerCase();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Filter candidates by action compatibility
|
|
296
|
+
return allInteractive.filter((element) => {
|
|
297
|
+
// Rule 1: Action type compatibility
|
|
298
|
+
if (action.includes('click')) {
|
|
299
|
+
const isClickable =
|
|
300
|
+
element.tag === 'button' ||
|
|
301
|
+
element.tag === 'a' ||
|
|
302
|
+
element.role === 'button' ||
|
|
303
|
+
element.role === 'link' ||
|
|
304
|
+
element.role === 'menuitem' ||
|
|
305
|
+
element.role === 'tab' ||
|
|
306
|
+
Boolean(element.attributes?.['onclick']) ||
|
|
307
|
+
Boolean(element.attributes?.['href']);
|
|
308
|
+
|
|
309
|
+
if (!isClickable && element.tag !== 'div' && element.tag !== 'span' && element.tag !== 'td') {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
} else if (action.includes('fill') || action.includes('type')) {
|
|
313
|
+
const isInput =
|
|
314
|
+
element.tag === 'input' ||
|
|
315
|
+
element.tag === 'textarea' ||
|
|
316
|
+
element.role === 'textbox' ||
|
|
317
|
+
element.attributes?.['contenteditable'] === 'true';
|
|
318
|
+
|
|
319
|
+
if (!isInput) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Rule 2: Exclude hidden / disabled elements
|
|
325
|
+
if (
|
|
326
|
+
element.attributes?.['aria-hidden'] === 'true' ||
|
|
327
|
+
element.attributes?.['hidden'] !== undefined ||
|
|
328
|
+
element.attributes?.['disabled'] !== undefined ||
|
|
329
|
+
element.attributes?.['aria-disabled'] === 'true'
|
|
330
|
+
) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Rule 3: Must have at least one valid identifier to be eligible for repair
|
|
335
|
+
try {
|
|
336
|
+
CandidateExtractor.generateLocatorPlan(element);
|
|
337
|
+
return true;
|
|
338
|
+
} catch {
|
|
339
|
+
return false; // Bare elements lacking identifiers are filtered out
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|