playwright-intelligence-engine 2.0.1 → 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,331 +1,331 @@
1
- import { ResolvedElement } from '../trace/snapshot-resolver.js';
2
- import { CandidateExtractor } from '../selector/candidate-extractor.js';
3
-
4
- export type DisambiguationStrategy =
5
- | 'UNIQUE_DIRECT_MATCH'
6
- | 'VISIBILITY_FILTER'
7
- | 'CONTAINER_ANCHOR'
8
- | 'LANDMARK_SECTION'
9
- | 'EXPLICIT_POSITIONAL_INTENT'
10
- | 'ABSTAIN_AMBIGUOUS';
11
-
12
- export interface DisambiguationContext {
13
- originalSelector?: string | undefined;
14
- intendedAction?: string | undefined;
15
- testIntentText?: string | undefined;
16
- rootDom?: ResolvedElement | undefined;
17
- }
18
-
19
- export interface DisambiguationResult {
20
- strategy: DisambiguationStrategy;
21
- resolvedCandidate?: ResolvedElement;
22
- resolvedExpression?: string;
23
- scopedContainer?: ResolvedElement;
24
- containerAnchorText?: string;
25
- matchCount: number;
26
- explanation: string;
27
- confidence: number;
28
- isUnique: boolean;
29
- }
30
-
31
- export class LocatorDisambiguationEngine {
32
- /**
33
- * Evaluates multiple candidate elements through the evidence-based disambiguation hierarchy:
34
- * 1. Unique Direct Match (matchCount === 1)
35
- * 2. Visibility Filtering (filter { visible: true })
36
- * 3. Container Anchoring (tr, card, list item with distinguishing text)
37
- * 4. Landmark / Section Scoping (header, nav, main, dialog)
38
- * 5. Explicit Positional Intent (.first(), .nth() only if original test explicitly declared it)
39
- * 6. Safe Abstention (never guess positional intent)
40
- */
41
- public static disambiguate(
42
- candidates: ResolvedElement[],
43
- context: DisambiguationContext = {},
44
- pageProperty = 'page'
45
- ): DisambiguationResult {
46
- if (candidates.length === 0) {
47
- return {
48
- strategy: 'ABSTAIN_AMBIGUOUS',
49
- matchCount: 0,
50
- explanation: 'Zero matching candidate elements found.',
51
- confidence: 0,
52
- isUnique: false,
53
- };
54
- }
55
-
56
- // 1. Single Unique Candidate
57
- if (candidates.length === 1) {
58
- const candidate = candidates[0]!;
59
- const expr = CandidateExtractor.generateLocator(candidate, pageProperty);
60
- return {
61
- strategy: 'UNIQUE_DIRECT_MATCH',
62
- resolvedCandidate: candidate,
63
- resolvedExpression: expr,
64
- matchCount: 1,
65
- explanation: 'Candidate is strictly unique in the DOM snapshot (matchCount === 1).',
66
- confidence: 1.0,
67
- isUnique: true,
68
- };
69
- }
70
-
71
- // 2. Visibility Filtering
72
- const visibleCandidates = candidates.filter((c) => this.isElementVisible(c));
73
- if (visibleCandidates.length === 1) {
74
- const candidate = visibleCandidates[0]!;
75
- const baseExpr = CandidateExtractor.generateLocator(candidate, pageProperty);
76
- const resolvedExpression = `${baseExpr}.filter({ visible: true })`;
77
- return {
78
- strategy: 'VISIBILITY_FILTER',
79
- resolvedCandidate: candidate,
80
- resolvedExpression,
81
- matchCount: candidates.length,
82
- explanation: `Disambiguated by visibility: exactly 1 candidate is visible and interactive while ${candidates.length - 1} duplicate(s) are hidden or off-screen.`,
83
- confidence: 0.95,
84
- isUnique: true,
85
- };
86
- }
87
-
88
- // 3. Container Anchoring
89
- if (context.rootDom) {
90
- const anchorResult = this.attemptContainerAnchoring(candidates, context, pageProperty);
91
- if (anchorResult) {
92
- return anchorResult;
93
- }
94
- }
95
-
96
- // 4. Landmark / Section Scoping
97
- if (context.rootDom && (context.originalSelector || context.testIntentText)) {
98
- const landmarkResult = this.attemptLandmarkScoping(candidates, context, pageProperty);
99
- if (landmarkResult) {
100
- return landmarkResult;
101
- }
102
- }
103
-
104
- // 5. Explicit Positional Intent Verification
105
- const hasExplicitPositionalIntent = this.verifyExplicitPositionalIntent(context);
106
- if (hasExplicitPositionalIntent) {
107
- const candidate = candidates[0]!;
108
- const baseExpr = CandidateExtractor.generateLocator(candidate, pageProperty);
109
- const positionalMethod = hasExplicitPositionalIntent.method; // '.first()', '.last()', or '.nth(N)'
110
- const resolvedExpression = `${baseExpr}${positionalMethod}`;
111
- return {
112
- strategy: 'EXPLICIT_POSITIONAL_INTENT',
113
- resolvedCandidate: candidate,
114
- resolvedExpression,
115
- matchCount: candidates.length,
116
- explanation: `Verified explicit positional intent: original test or requirement explicitly specified ${positionalMethod}. Positional selection preserved with verified evidence.`,
117
- confidence: 0.90,
118
- isUnique: true,
119
- };
120
- }
121
-
122
- // 6. Safe Abstention (Never invent positional intent!)
123
- return {
124
- strategy: 'ABSTAIN_AMBIGUOUS',
125
- matchCount: candidates.length,
126
- explanation: `NON_UNIQUE_MATCH: Locator matches ${candidates.length} elements. Disambiguation failed across visibility filtering, container anchoring, and landmark scoping. In accordance with PIE safety invariants, positional intent (.first()/.nth()) is NOT invented. Safe abstention enforced.`,
127
- confidence: 0,
128
- isUnique: false,
129
- };
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // Internal Resolution Strategies
134
- // ---------------------------------------------------------------------------
135
-
136
- private static isElementVisible(el: ResolvedElement): boolean {
137
- if (el.attributes['hidden'] === 'true' || el.attributes['hidden'] !== undefined) {
138
- return false;
139
- }
140
- if (el.attributes['aria-hidden'] === 'true') {
141
- return false;
142
- }
143
- const style = (el.attributes['style'] ?? '').toLowerCase();
144
- if (style.includes('display: none') || style.includes('visibility: hidden') || style.includes('opacity: 0')) {
145
- return false;
146
- }
147
- return true;
148
- }
149
-
150
- private static attemptContainerAnchoring(
151
- candidates: ResolvedElement[],
152
- context: DisambiguationContext,
153
- pageProperty: string
154
- ): DisambiguationResult | null {
155
- if (!context.rootDom) return null;
156
-
157
- // For each candidate, find its parent container chain
158
- const candidatePaths = candidates.map((c) => this.findPathToElement(context.rootDom!, c));
159
-
160
- // Try finding the closest enclosing container: tr, li, article, card
161
- for (let cIdx = 0; cIdx < candidates.length; cIdx++) {
162
- const path = candidatePaths[cIdx];
163
- if (!path || path.length < 2) continue;
164
-
165
- // Search bottom-up for a container
166
- for (let i = path.length - 2; i >= 0; i--) {
167
- const container = path[i]!;
168
- const isContainer =
169
- container.tag === 'tr' ||
170
- container.tag === 'li' ||
171
- container.tag === 'article' ||
172
- container.tag === 'section' ||
173
- (container.attributes['data-testid'] ?? '').includes('card') ||
174
- (container.attributes['data-testid'] ?? '').includes('item') ||
175
- (container.attributes['class'] ?? '').includes('card') ||
176
- (container.attributes['class'] ?? '').includes('item') ||
177
- container.role === 'row' ||
178
- container.role === 'listitem';
179
-
180
- if (isContainer) {
181
- // Check distinguishing text inside this container
182
- const containerText = this.getCleanText(container);
183
- if (containerText.length > 2 && containerText.length < 80) {
184
- // Check if other candidates' containers have different text
185
- const otherContainersHaveText = candidatePaths.some((otherPath, oIdx) => {
186
- if (oIdx === cIdx || !otherPath) return false;
187
- const otherContainer = otherPath[i];
188
- if (!otherContainer) return false;
189
- return this.getCleanText(otherContainer).includes(containerText);
190
- });
191
-
192
- // Distinguishing text must NOT appear in other candidates' containers
193
- if (!otherContainersHaveText) {
194
- const candidate = candidates[cIdx]!;
195
- const innerPlan = CandidateExtractor.generateLocatorPlan(candidate);
196
- const innerExpr = CandidateExtractor.planToExpression(innerPlan, '').replace(/^\./, '');
197
- const containerTag = container.attributes['data-testid']
198
- ? `[data-testid="${container.attributes['data-testid']}"]`
199
- : container.tag;
200
- const escapedText = CandidateExtractor.escapeLocatorString(containerText);
201
- const resolvedExpression = `${pageProperty}.locator('${containerTag}').filter({ hasText: '${escapedText}' }).${innerExpr}`;
202
-
203
- return {
204
- strategy: 'CONTAINER_ANCHOR',
205
- resolvedCandidate: candidate,
206
- resolvedExpression,
207
- scopedContainer: container,
208
- containerAnchorText: containerText,
209
- matchCount: candidates.length,
210
- explanation: `Disambiguated via container anchoring: target is uniquely scoped within <${container.tag}> anchored by distinguishing text '${containerText}'.`,
211
- confidence: 0.92,
212
- isUnique: true,
213
- };
214
- }
215
- }
216
- }
217
- }
218
- }
219
-
220
- return null;
221
- }
222
-
223
- private static attemptLandmarkScoping(
224
- candidates: ResolvedElement[],
225
- context: DisambiguationContext,
226
- pageProperty: string
227
- ): DisambiguationResult | null {
228
- if (!context.rootDom) return null;
229
-
230
- const landmarks = ['header', 'nav', 'main', 'footer', 'aside', 'dialog'];
231
- const textToCheck = `${context.originalSelector ?? ''} ${context.testIntentText ?? ''}`.toLowerCase();
232
-
233
- for (const landmark of landmarks) {
234
- if (textToCheck.includes(landmark)) {
235
- // Collect all candidates residing inside this landmark
236
- const candidatesInLandmark = candidates.filter((candidate) => {
237
- const path = this.findPathToElement(context.rootDom!, candidate);
238
- return Boolean(path && path.some((el) => el.tag === landmark || el.role === landmark));
239
- });
240
-
241
- // ONLY when exactly one candidate is inside this landmark is it unique
242
- if (candidatesInLandmark.length === 1) {
243
- const candidate = candidatesInLandmark[0]!;
244
- const innerPlan = CandidateExtractor.generateLocatorPlan(candidate);
245
- const innerExpr = CandidateExtractor.planToExpression(innerPlan, '').replace(/^\./, '');
246
- const resolvedExpression = `${pageProperty}.locator('${landmark}').${innerExpr}`;
247
-
248
- return {
249
- strategy: 'LANDMARK_SECTION',
250
- resolvedCandidate: candidate,
251
- resolvedExpression,
252
- matchCount: candidates.length,
253
- explanation: `Disambiguated via landmark scoping: exactly one target candidate scoped to <${landmark}> based on context.`,
254
- confidence: 0.88,
255
- isUnique: true,
256
- };
257
- }
258
- }
259
- }
260
-
261
- return null;
262
- }
263
-
264
- private static verifyExplicitPositionalIntent(
265
- context: DisambiguationContext
266
- ): { method: string; index: number } | null {
267
- const sel = context.originalSelector ?? '';
268
- const intent = (context.testIntentText ?? '').toLowerCase();
269
-
270
- // 1. Check original selector method calls
271
- if (sel.includes('.first()')) {
272
- return { method: '.first()', index: 0 };
273
- }
274
- if (sel.includes('.last()')) {
275
- return { method: '.last()', index: -1 };
276
- }
277
- const nthMatch = sel.match(/\.nth\((\d+)\)/);
278
- if (nthMatch && nthMatch[1] !== undefined) {
279
- const idx = parseInt(nthMatch[1], 10);
280
- return { method: `.nth(${idx})`, index: idx };
281
- }
282
-
283
- // 2. Check explicit test intent text
284
- if (intent.includes('first ') || intent.includes('1st ') || intent.includes('top item') || intent.includes('top result')) {
285
- return { method: '.first()', index: 0 };
286
- }
287
- if (intent.includes('second ') || intent.includes('2nd ')) {
288
- return { method: '.nth(1)', index: 1 };
289
- }
290
- if (intent.includes('third ') || intent.includes('3rd ')) {
291
- return { method: '.nth(2)', index: 2 };
292
- }
293
- if (intent.includes('last item') || intent.includes('last row')) {
294
- return { method: '.last()', index: -1 };
295
- }
296
-
297
- return null;
298
- }
299
-
300
- private static findPathToElement(
301
- root: ResolvedElement,
302
- target: ResolvedElement,
303
- currentPath: ResolvedElement[] = []
304
- ): ResolvedElement[] | null {
305
- const nextPath = [...currentPath, root];
306
- if (root === target) {
307
- return nextPath;
308
- }
309
-
310
- for (const child of root.children ?? []) {
311
- const found = this.findPathToElement(child, target, nextPath);
312
- if (found) return found;
313
- }
314
-
315
- return null;
316
- }
317
-
318
- private static getCleanText(el: ResolvedElement): string {
319
- if (el.text && el.text.trim().length > 0) {
320
- return el.text.trim();
321
- }
322
- // Collect child text
323
- const childTexts: string[] = [];
324
- for (const child of el.children ?? []) {
325
- if (child.text && child.text.trim().length > 0) {
326
- childTexts.push(child.text.trim());
327
- }
328
- }
329
- return childTexts.join(' ').trim();
330
- }
331
- }
1
+ import { ResolvedElement } from '../trace/snapshot-resolver.js';
2
+ import { CandidateExtractor } from '../selector/candidate-extractor.js';
3
+
4
+ export type DisambiguationStrategy =
5
+ | 'UNIQUE_DIRECT_MATCH'
6
+ | 'VISIBILITY_FILTER'
7
+ | 'CONTAINER_ANCHOR'
8
+ | 'LANDMARK_SECTION'
9
+ | 'EXPLICIT_POSITIONAL_INTENT'
10
+ | 'ABSTAIN_AMBIGUOUS';
11
+
12
+ export interface DisambiguationContext {
13
+ originalSelector?: string | undefined;
14
+ intendedAction?: string | undefined;
15
+ testIntentText?: string | undefined;
16
+ rootDom?: ResolvedElement | undefined;
17
+ }
18
+
19
+ export interface DisambiguationResult {
20
+ strategy: DisambiguationStrategy;
21
+ resolvedCandidate?: ResolvedElement;
22
+ resolvedExpression?: string;
23
+ scopedContainer?: ResolvedElement;
24
+ containerAnchorText?: string;
25
+ matchCount: number;
26
+ explanation: string;
27
+ confidence: number;
28
+ isUnique: boolean;
29
+ }
30
+
31
+ export class LocatorDisambiguationEngine {
32
+ /**
33
+ * Evaluates multiple candidate elements through the evidence-based disambiguation hierarchy:
34
+ * 1. Unique Direct Match (matchCount === 1)
35
+ * 2. Visibility Filtering (filter { visible: true })
36
+ * 3. Container Anchoring (tr, card, list item with distinguishing text)
37
+ * 4. Landmark / Section Scoping (header, nav, main, dialog)
38
+ * 5. Explicit Positional Intent (.first(), .nth() only if original test explicitly declared it)
39
+ * 6. Safe Abstention (never guess positional intent)
40
+ */
41
+ public static disambiguate(
42
+ candidates: ResolvedElement[],
43
+ context: DisambiguationContext = {},
44
+ pageProperty = 'page'
45
+ ): DisambiguationResult {
46
+ if (candidates.length === 0) {
47
+ return {
48
+ strategy: 'ABSTAIN_AMBIGUOUS',
49
+ matchCount: 0,
50
+ explanation: 'Zero matching candidate elements found.',
51
+ confidence: 0,
52
+ isUnique: false,
53
+ };
54
+ }
55
+
56
+ // 1. Single Unique Candidate
57
+ if (candidates.length === 1) {
58
+ const candidate = candidates[0]!;
59
+ const expr = CandidateExtractor.generateLocator(candidate, pageProperty);
60
+ return {
61
+ strategy: 'UNIQUE_DIRECT_MATCH',
62
+ resolvedCandidate: candidate,
63
+ resolvedExpression: expr,
64
+ matchCount: 1,
65
+ explanation: 'Candidate is strictly unique in the DOM snapshot (matchCount === 1).',
66
+ confidence: 1.0,
67
+ isUnique: true,
68
+ };
69
+ }
70
+
71
+ // 2. Visibility Filtering
72
+ const visibleCandidates = candidates.filter((c) => this.isElementVisible(c));
73
+ if (visibleCandidates.length === 1) {
74
+ const candidate = visibleCandidates[0]!;
75
+ const baseExpr = CandidateExtractor.generateLocator(candidate, pageProperty);
76
+ const resolvedExpression = `${baseExpr}.filter({ visible: true })`;
77
+ return {
78
+ strategy: 'VISIBILITY_FILTER',
79
+ resolvedCandidate: candidate,
80
+ resolvedExpression,
81
+ matchCount: candidates.length,
82
+ explanation: `Disambiguated by visibility: exactly 1 candidate is visible and interactive while ${candidates.length - 1} duplicate(s) are hidden or off-screen.`,
83
+ confidence: 0.95,
84
+ isUnique: true,
85
+ };
86
+ }
87
+
88
+ // 3. Container Anchoring
89
+ if (context.rootDom) {
90
+ const anchorResult = this.attemptContainerAnchoring(candidates, context, pageProperty);
91
+ if (anchorResult) {
92
+ return anchorResult;
93
+ }
94
+ }
95
+
96
+ // 4. Landmark / Section Scoping
97
+ if (context.rootDom && (context.originalSelector || context.testIntentText)) {
98
+ const landmarkResult = this.attemptLandmarkScoping(candidates, context, pageProperty);
99
+ if (landmarkResult) {
100
+ return landmarkResult;
101
+ }
102
+ }
103
+
104
+ // 5. Explicit Positional Intent Verification
105
+ const hasExplicitPositionalIntent = this.verifyExplicitPositionalIntent(context);
106
+ if (hasExplicitPositionalIntent) {
107
+ const candidate = candidates[0]!;
108
+ const baseExpr = CandidateExtractor.generateLocator(candidate, pageProperty);
109
+ const positionalMethod = hasExplicitPositionalIntent.method; // '.first()', '.last()', or '.nth(N)'
110
+ const resolvedExpression = `${baseExpr}${positionalMethod}`;
111
+ return {
112
+ strategy: 'EXPLICIT_POSITIONAL_INTENT',
113
+ resolvedCandidate: candidate,
114
+ resolvedExpression,
115
+ matchCount: candidates.length,
116
+ explanation: `Verified explicit positional intent: original test or requirement explicitly specified ${positionalMethod}. Positional selection preserved with verified evidence.`,
117
+ confidence: 0.90,
118
+ isUnique: true,
119
+ };
120
+ }
121
+
122
+ // 6. Safe Abstention (Never invent positional intent!)
123
+ return {
124
+ strategy: 'ABSTAIN_AMBIGUOUS',
125
+ matchCount: candidates.length,
126
+ explanation: `NON_UNIQUE_MATCH: Locator matches ${candidates.length} elements. Disambiguation failed across visibility filtering, container anchoring, and landmark scoping. In accordance with PIE safety invariants, positional intent (.first()/.nth()) is NOT invented. Safe abstention enforced.`,
127
+ confidence: 0,
128
+ isUnique: false,
129
+ };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Internal Resolution Strategies
134
+ // ---------------------------------------------------------------------------
135
+
136
+ private static isElementVisible(el: ResolvedElement): boolean {
137
+ if (el.attributes['hidden'] === 'true' || el.attributes['hidden'] !== undefined) {
138
+ return false;
139
+ }
140
+ if (el.attributes['aria-hidden'] === 'true') {
141
+ return false;
142
+ }
143
+ const style = (el.attributes['style'] ?? '').toLowerCase();
144
+ if (style.includes('display: none') || style.includes('visibility: hidden') || style.includes('opacity: 0')) {
145
+ return false;
146
+ }
147
+ return true;
148
+ }
149
+
150
+ private static attemptContainerAnchoring(
151
+ candidates: ResolvedElement[],
152
+ context: DisambiguationContext,
153
+ pageProperty: string
154
+ ): DisambiguationResult | null {
155
+ if (!context.rootDom) return null;
156
+
157
+ // For each candidate, find its parent container chain
158
+ const candidatePaths = candidates.map((c) => this.findPathToElement(context.rootDom!, c));
159
+
160
+ // Try finding the closest enclosing container: tr, li, article, card
161
+ for (let cIdx = 0; cIdx < candidates.length; cIdx++) {
162
+ const path = candidatePaths[cIdx];
163
+ if (!path || path.length < 2) continue;
164
+
165
+ // Search bottom-up for a container
166
+ for (let i = path.length - 2; i >= 0; i--) {
167
+ const container = path[i]!;
168
+ const isContainer =
169
+ container.tag === 'tr' ||
170
+ container.tag === 'li' ||
171
+ container.tag === 'article' ||
172
+ container.tag === 'section' ||
173
+ (container.attributes['data-testid'] ?? '').includes('card') ||
174
+ (container.attributes['data-testid'] ?? '').includes('item') ||
175
+ (container.attributes['class'] ?? '').includes('card') ||
176
+ (container.attributes['class'] ?? '').includes('item') ||
177
+ container.role === 'row' ||
178
+ container.role === 'listitem';
179
+
180
+ if (isContainer) {
181
+ // Check distinguishing text inside this container
182
+ const containerText = this.getCleanText(container);
183
+ if (containerText.length > 2 && containerText.length < 80) {
184
+ // Check if other candidates' containers have different text
185
+ const otherContainersHaveText = candidatePaths.some((otherPath, oIdx) => {
186
+ if (oIdx === cIdx || !otherPath) return false;
187
+ const otherContainer = otherPath[i];
188
+ if (!otherContainer) return false;
189
+ return this.getCleanText(otherContainer).includes(containerText);
190
+ });
191
+
192
+ // Distinguishing text must NOT appear in other candidates' containers
193
+ if (!otherContainersHaveText) {
194
+ const candidate = candidates[cIdx]!;
195
+ const innerPlan = CandidateExtractor.generateLocatorPlan(candidate);
196
+ const innerExpr = CandidateExtractor.planToExpression(innerPlan, '').replace(/^\./, '');
197
+ const containerTag = container.attributes['data-testid']
198
+ ? `[data-testid="${container.attributes['data-testid']}"]`
199
+ : container.tag;
200
+ const escapedText = CandidateExtractor.escapeLocatorString(containerText);
201
+ const resolvedExpression = `${pageProperty}.locator('${containerTag}').filter({ hasText: '${escapedText}' }).${innerExpr}`;
202
+
203
+ return {
204
+ strategy: 'CONTAINER_ANCHOR',
205
+ resolvedCandidate: candidate,
206
+ resolvedExpression,
207
+ scopedContainer: container,
208
+ containerAnchorText: containerText,
209
+ matchCount: candidates.length,
210
+ explanation: `Disambiguated via container anchoring: target is uniquely scoped within <${container.tag}> anchored by distinguishing text '${containerText}'.`,
211
+ confidence: 0.92,
212
+ isUnique: true,
213
+ };
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ private static attemptLandmarkScoping(
224
+ candidates: ResolvedElement[],
225
+ context: DisambiguationContext,
226
+ pageProperty: string
227
+ ): DisambiguationResult | null {
228
+ if (!context.rootDom) return null;
229
+
230
+ const landmarks = ['header', 'nav', 'main', 'footer', 'aside', 'dialog'];
231
+ const textToCheck = `${context.originalSelector ?? ''} ${context.testIntentText ?? ''}`.toLowerCase();
232
+
233
+ for (const landmark of landmarks) {
234
+ if (textToCheck.includes(landmark)) {
235
+ // Collect all candidates residing inside this landmark
236
+ const candidatesInLandmark = candidates.filter((candidate) => {
237
+ const path = this.findPathToElement(context.rootDom!, candidate);
238
+ return Boolean(path && path.some((el) => el.tag === landmark || el.role === landmark));
239
+ });
240
+
241
+ // ONLY when exactly one candidate is inside this landmark is it unique
242
+ if (candidatesInLandmark.length === 1) {
243
+ const candidate = candidatesInLandmark[0]!;
244
+ const innerPlan = CandidateExtractor.generateLocatorPlan(candidate);
245
+ const innerExpr = CandidateExtractor.planToExpression(innerPlan, '').replace(/^\./, '');
246
+ const resolvedExpression = `${pageProperty}.locator('${landmark}').${innerExpr}`;
247
+
248
+ return {
249
+ strategy: 'LANDMARK_SECTION',
250
+ resolvedCandidate: candidate,
251
+ resolvedExpression,
252
+ matchCount: candidates.length,
253
+ explanation: `Disambiguated via landmark scoping: exactly one target candidate scoped to <${landmark}> based on context.`,
254
+ confidence: 0.88,
255
+ isUnique: true,
256
+ };
257
+ }
258
+ }
259
+ }
260
+
261
+ return null;
262
+ }
263
+
264
+ private static verifyExplicitPositionalIntent(
265
+ context: DisambiguationContext
266
+ ): { method: string; index: number } | null {
267
+ const sel = context.originalSelector ?? '';
268
+ const intent = (context.testIntentText ?? '').toLowerCase();
269
+
270
+ // 1. Check original selector method calls
271
+ if (sel.includes('.first()')) {
272
+ return { method: '.first()', index: 0 };
273
+ }
274
+ if (sel.includes('.last()')) {
275
+ return { method: '.last()', index: -1 };
276
+ }
277
+ const nthMatch = sel.match(/\.nth\((\d+)\)/);
278
+ if (nthMatch && nthMatch[1] !== undefined) {
279
+ const idx = parseInt(nthMatch[1], 10);
280
+ return { method: `.nth(${idx})`, index: idx };
281
+ }
282
+
283
+ // 2. Check explicit test intent text
284
+ if (intent.includes('first ') || intent.includes('1st ') || intent.includes('top item') || intent.includes('top result')) {
285
+ return { method: '.first()', index: 0 };
286
+ }
287
+ if (intent.includes('second ') || intent.includes('2nd ')) {
288
+ return { method: '.nth(1)', index: 1 };
289
+ }
290
+ if (intent.includes('third ') || intent.includes('3rd ')) {
291
+ return { method: '.nth(2)', index: 2 };
292
+ }
293
+ if (intent.includes('last item') || intent.includes('last row')) {
294
+ return { method: '.last()', index: -1 };
295
+ }
296
+
297
+ return null;
298
+ }
299
+
300
+ private static findPathToElement(
301
+ root: ResolvedElement,
302
+ target: ResolvedElement,
303
+ currentPath: ResolvedElement[] = []
304
+ ): ResolvedElement[] | null {
305
+ const nextPath = [...currentPath, root];
306
+ if (root === target) {
307
+ return nextPath;
308
+ }
309
+
310
+ for (const child of root.children ?? []) {
311
+ const found = this.findPathToElement(child, target, nextPath);
312
+ if (found) return found;
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ private static getCleanText(el: ResolvedElement): string {
319
+ if (el.text && el.text.trim().length > 0) {
320
+ return el.text.trim();
321
+ }
322
+ // Collect child text
323
+ const childTexts: string[] = [];
324
+ for (const child of el.children ?? []) {
325
+ if (child.text && child.text.trim().length > 0) {
326
+ childTexts.push(child.text.trim());
327
+ }
328
+ }
329
+ return childTexts.join(' ').trim();
330
+ }
331
+ }