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.
Files changed (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. 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
+ }