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,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
|
+
}
|