playwright-intelligence-engine 2.0.4 → 2.1.0

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.
@@ -0,0 +1,476 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { ArchiveReader } from '../trace/archive-reader.js';
6
+ import { runTraceProposalWorkflow } from './trace-proposal-workflow.js';
7
+ import { discoverRepairSourceMapping } from '../repair/source-mapper.js';
8
+ import { AstPatcher } from '../repair/ast-patcher.js';
9
+ import { StatementPatcher } from '../repair/statement-patcher.js';
10
+ import { WorktreeManager, WorktreeInfo } from '../sandbox/worktree-manager.js';
11
+ import { ExecutionRunner } from '../sandbox/execution-runner.js';
12
+ import { DiffAllowlistValidator } from '../repair/diff-allowlist.js';
13
+ import { clusterFailures, FailureObservation, FailureCluster } from './failure-clustering.js';
14
+ import { LlmRepairProposal } from './llm-proposal.js';
15
+ import { DiagnosticReportGenerator } from '../diagnostics/diagnostic-report.js';
16
+ import { bootstrapProjectIntelligence } from './project-intelligence.js';
17
+
18
+ export interface BatchRepairWorkflowInput {
19
+ workspaceRoot?: string | undefined;
20
+ tracePaths?: string[] | undefined;
21
+ execute?: boolean | undefined;
22
+ autoPromote?: boolean | undefined;
23
+ configPath?: string | undefined;
24
+ worktreeId?: string | undefined;
25
+ enableBrowserInvestigation?: boolean | undefined;
26
+ browserUrl?: string | undefined;
27
+ browserAllowedHosts?: string[] | undefined;
28
+ enableTestExecution?: boolean | undefined;
29
+ }
30
+
31
+ export type BatchItemStatus =
32
+ | 'PENDING'
33
+ | 'PROPOSAL_READY'
34
+ | 'ABSTAINED'
35
+ | 'VERIFIED'
36
+ | 'PROMOTED'
37
+ | 'VERIFICATION_FAILED'
38
+ | 'REJECTED'
39
+ | 'FAILED';
40
+
41
+ export interface BatchRepairItem {
42
+ tracePath: string;
43
+ testTitle?: string | undefined;
44
+ status: BatchItemStatus;
45
+ category: string;
46
+ confidence: number;
47
+ reason?: string | undefined;
48
+ failedTarget?: string | undefined;
49
+ healedLocator?: string | undefined;
50
+ targetFilePath?: string | undefined;
51
+ testFilePath?: string | undefined;
52
+ className?: string | undefined;
53
+ memberName?: string | undefined;
54
+ pattern?: 'page-object' | 'inline-locator' | 'statement-patch' | undefined;
55
+ proposal?: LlmRepairProposal | undefined;
56
+ fullDiff?: string | undefined;
57
+ }
58
+
59
+ export interface BatchRepairWorkflowResult {
60
+ status: 'COMPLETED' | 'PARTIAL' | 'ABSTAINED' | 'FAILED';
61
+ totalTraces: number;
62
+ repairedCount: number;
63
+ abstainedCount: number;
64
+ failedCount: number;
65
+ items: BatchRepairItem[];
66
+ clusters: FailureCluster[];
67
+ worktree?: WorktreeInfo | undefined;
68
+ executionPassed?: boolean | undefined;
69
+ promotedFiles?: string[] | undefined;
70
+ reportJsonPath?: string | undefined;
71
+ }
72
+
73
+ function discoverFailingTraces(workspaceRoot: string): string[] {
74
+ const candidateDirs = [
75
+ path.resolve(workspaceRoot, 'test-results'),
76
+ path.resolve(workspaceRoot, 'playwright-report'),
77
+ path.resolve(workspaceRoot),
78
+ ];
79
+
80
+ const found: { filePath: string; mtime: number; hasFailure: boolean; isTestResults: boolean }[] = [];
81
+ const seen = new Set<string>();
82
+
83
+ for (const dir of candidateDirs) {
84
+ if (!fsSync.existsSync(dir)) continue;
85
+ try {
86
+ const entries = fsSync.readdirSync(dir, { recursive: true });
87
+ for (const entry of entries) {
88
+ const fullPath = path.join(dir, String(entry));
89
+ if (fullPath.endsWith('.zip') && !seen.has(fullPath)) {
90
+ try {
91
+ const stat = fsSync.statSync(fullPath);
92
+ if (stat.isFile()) {
93
+ seen.add(fullPath);
94
+ let hasFailure = false;
95
+ try {
96
+ const discovery = ArchiveReader.findTraceFileEntries(fullPath);
97
+ hasFailure = Boolean(discovery.hasFailure);
98
+ } catch {}
99
+ const isTestResults = fullPath.includes('test-results');
100
+ found.push({ filePath: fullPath, mtime: stat.mtimeMs, hasFailure, isTestResults });
101
+ }
102
+ } catch {}
103
+ }
104
+ }
105
+ } catch {}
106
+ }
107
+
108
+ // Filter for traces with confirmed failures, prioritizing test-results over duplicate report copies
109
+ const testResultsFailing = found.filter((f) => f.isTestResults && f.hasFailure);
110
+ const failing = testResultsFailing.length > 0 ? testResultsFailing : found.filter((f) => f.hasFailure);
111
+ const pool = failing.length > 0 ? failing : found;
112
+ return pool
113
+ .sort((a, b) => {
114
+ if (a.hasFailure !== b.hasFailure) return a.hasFailure ? -1 : 1;
115
+ if (a.isTestResults !== b.isTestResults) return a.isTestResults ? -1 : 1;
116
+ return b.mtime - a.mtime;
117
+ })
118
+ .map((f) => f.filePath);
119
+ }
120
+
121
+ /**
122
+ * Executes multi-trace failure diagnosis, clustering, isolated single-worktree
123
+ * batch verification, and atomic promotion for the entire repository.
124
+ */
125
+ export async function runBatchRepairWorkflow(
126
+ input: BatchRepairWorkflowInput,
127
+ ): Promise<BatchRepairWorkflowResult> {
128
+ const workspaceRoot = path.resolve(input.workspaceRoot ?? process.cwd());
129
+ const tracePaths = input.tracePaths && input.tracePaths.length > 0
130
+ ? input.tracePaths.map((p) => path.resolve(workspaceRoot, p))
131
+ : discoverFailingTraces(workspaceRoot);
132
+
133
+ if (tracePaths.length === 0) {
134
+ return {
135
+ status: 'ABSTAINED',
136
+ totalTraces: 0,
137
+ repairedCount: 0,
138
+ abstainedCount: 0,
139
+ failedCount: 0,
140
+ items: [],
141
+ clusters: [],
142
+ executionPassed: true,
143
+ promotedFiles: [],
144
+ };
145
+ }
146
+
147
+ const items: BatchRepairItem[] = [];
148
+ const failureObservations: FailureObservation[] = [];
149
+ const intelligence = await bootstrapProjectIntelligence(workspaceRoot);
150
+
151
+ // Phase 1: Diagnose all failing traces in parallel
152
+ for (const tracePath of tracePaths) {
153
+ try {
154
+ const proposalRun = await runTraceProposalWorkflow({
155
+ tracePath,
156
+ workspaceRoot,
157
+ enableBrowserInvestigation: input.enableBrowserInvestigation,
158
+ browserUrl: input.browserUrl,
159
+ browserAllowedHosts: input.browserAllowedHosts,
160
+ enableTestExecution: input.enableTestExecution,
161
+ });
162
+
163
+ const { correlated, agent } = proposalRun;
164
+ const category = correlated.classification.category;
165
+ const confidence = correlated.classification.confidence;
166
+ const testTitle = correlated.context.failedAction.sourceFile
167
+ ? path.basename(correlated.context.failedAction.sourceFile)
168
+ : 'Unknown Spec';
169
+
170
+ failureObservations.push({
171
+ id: path.basename(tracePath, '.zip'),
172
+ repository: path.basename(workspaceRoot),
173
+ testTitle,
174
+ sourceFile: correlated.context.failedAction.sourceFile,
175
+ category,
176
+ action: correlated.context.failedAction.action,
177
+ selector: correlated.context.failedAction.selector,
178
+ errorMessage: correlated.context.failedAction.errorStack?.split('\n')[0] ?? correlated.context.failedAction.action,
179
+ errorStack: correlated.context.failedAction.errorStack,
180
+ tracePath,
181
+ });
182
+
183
+ if (correlated.classification.shouldAbstain) {
184
+ items.push({
185
+ tracePath,
186
+ testTitle,
187
+ status: 'ABSTAINED',
188
+ category,
189
+ confidence,
190
+ reason: correlated.classification.abstentionReason ?? correlated.classification.reason ?? 'Abstained by diagnostic policy',
191
+ failedTarget: correlated.context.failedAction.selector,
192
+ });
193
+ continue;
194
+ }
195
+
196
+ if (!agent.proposal || agent.proposal.decision === 'ABSTAIN') {
197
+ items.push({
198
+ tracePath,
199
+ testTitle,
200
+ status: 'ABSTAINED',
201
+ category,
202
+ confidence,
203
+ reason: agent.proposal?.reasoningSummary ?? agent.errors.join('; ') ?? 'Proposal abstained',
204
+ failedTarget: correlated.context.failedAction.selector,
205
+ });
206
+ continue;
207
+ }
208
+
209
+ const selected = DiagnosticReportGenerator.selectRepairCandidate(correlated, intelligence);
210
+ const proposedLocator = selected?.locatorExpression ?? (agent.proposal.operations[0] as any)?.locator;
211
+ const failedTarget = correlated.context.failedAction.selector ?? (agent.proposal.operations[0] as any)?.target;
212
+
213
+ if (!proposedLocator) {
214
+ items.push({
215
+ tracePath,
216
+ testTitle,
217
+ status: 'ABSTAINED',
218
+ category,
219
+ confidence,
220
+ reason: 'No unique candidate locator survived ranking and abstention gates',
221
+ failedTarget,
222
+ });
223
+ continue;
224
+ }
225
+
226
+ // Source mapping
227
+ const mapping = await discoverRepairSourceMapping({
228
+ workspaceRoot,
229
+ failedAction: correlated.context.failedAction,
230
+ });
231
+
232
+ if (mapping) {
233
+ items.push({
234
+ tracePath,
235
+ testTitle,
236
+ status: 'PROPOSAL_READY',
237
+ category,
238
+ confidence,
239
+ reason: agent.proposal.reasoningSummary,
240
+ failedTarget,
241
+ healedLocator: proposedLocator,
242
+ targetFilePath: mapping.pomFilePath,
243
+ testFilePath: mapping.testFilePath,
244
+ className: mapping.className,
245
+ memberName: mapping.memberName,
246
+ pattern: 'page-object',
247
+ proposal: agent.proposal,
248
+ });
249
+ } else {
250
+ // Fallback: check if the failure is directly in a test spec file
251
+ const sourceFile = correlated.context.failedAction.sourceFile;
252
+ const resolvedSpec = sourceFile ? path.resolve(workspaceRoot, sourceFile) : undefined;
253
+ if (resolvedSpec && fsSync.existsSync(resolvedSpec) && (resolvedSpec.includes('.spec.') || resolvedSpec.includes('.test.'))) {
254
+ items.push({
255
+ tracePath,
256
+ testTitle,
257
+ status: 'PROPOSAL_READY',
258
+ category,
259
+ confidence,
260
+ reason: agent.proposal.reasoningSummary,
261
+ failedTarget,
262
+ healedLocator: proposedLocator,
263
+ targetFilePath: resolvedSpec,
264
+ testFilePath: resolvedSpec,
265
+ pattern: 'inline-locator',
266
+ proposal: agent.proposal,
267
+ });
268
+ } else {
269
+ items.push({
270
+ tracePath,
271
+ testTitle,
272
+ status: 'ABSTAINED',
273
+ category,
274
+ confidence,
275
+ reason: 'Source mapping could not locate a unique Page Object or test file to patch',
276
+ failedTarget,
277
+ healedLocator: proposedLocator,
278
+ });
279
+ }
280
+ }
281
+ } catch (err: any) {
282
+ items.push({
283
+ tracePath,
284
+ status: 'FAILED',
285
+ category: 'INTERNAL_ERROR',
286
+ confidence: 0,
287
+ reason: err?.message ?? String(err),
288
+ });
289
+ }
290
+ }
291
+
292
+ const clusters = clusterFailures(failureObservations);
293
+ const proposableItems = items.filter((item) => item.status === 'PROPOSAL_READY');
294
+
295
+ if (proposableItems.length === 0 || (!input.execute && !input.autoPromote)) {
296
+ return {
297
+ status: proposableItems.length > 0 ? 'PARTIAL' : (items.every((i) => i.status === 'ABSTAINED') ? 'ABSTAINED' : 'FAILED'),
298
+ totalTraces: items.length,
299
+ repairedCount: 0,
300
+ abstainedCount: items.filter((i) => i.status === 'ABSTAINED').length,
301
+ failedCount: items.filter((i) => i.status === 'FAILED').length,
302
+ items,
303
+ clusters,
304
+ };
305
+ }
306
+
307
+ // Phase 2: Batch verification in a SINGLE isolated Git worktree
308
+ const manager = new WorktreeManager(workspaceRoot);
309
+ const batchWorktreeId = input.worktreeId ?? `batch-repair-${crypto.randomUUID().slice(0, 8)}`;
310
+ let worktree: WorktreeInfo;
311
+ try {
312
+ worktree = await manager.createWorktree(batchWorktreeId);
313
+ } catch (err: any) {
314
+ return {
315
+ status: 'FAILED',
316
+ totalTraces: items.length,
317
+ repairedCount: 0,
318
+ abstainedCount: items.filter((i) => i.status === 'ABSTAINED').length,
319
+ failedCount: items.length,
320
+ items: items.map((i) => (i.status === 'PROPOSAL_READY' ? { ...i, status: 'FAILED', reason: `Worktree creation failed: ${err.message}` } : i)),
321
+ clusters,
322
+ };
323
+ }
324
+
325
+ let executionPassed = false;
326
+ const promotedFiles: string[] = [];
327
+ const modifiedFileContents = new Map<string, string>(); // relative path -> patched content
328
+
329
+ try {
330
+ // Group proposals by target file
331
+ const byTargetFile = new Map<string, BatchRepairItem[]>();
332
+ for (const item of proposableItems) {
333
+ if (!item.targetFilePath) continue;
334
+ const list = byTargetFile.get(item.targetFilePath) ?? [];
335
+ list.push(item);
336
+ byTargetFile.set(item.targetFilePath, list);
337
+ }
338
+
339
+ const astPatcher = new AstPatcher();
340
+ const statementPatcher = new StatementPatcher();
341
+
342
+ // Sequentially patch each file inside the worktree
343
+ for (const [targetFile, targetItems] of byTargetFile.entries()) {
344
+ const relTarget = path.relative(workspaceRoot, targetFile);
345
+ const worktreeTarget = path.resolve(worktree.worktreePath, relTarget);
346
+ if (!fsSync.existsSync(worktreeTarget)) {
347
+ for (const it of targetItems) {
348
+ it.status = 'REJECTED';
349
+ it.reason = `Worktree target file does not exist: '${relTarget}'`;
350
+ }
351
+ continue;
352
+ }
353
+
354
+ let currentSource = await fs.readFile(worktreeTarget, 'utf8');
355
+
356
+ for (const item of targetItems) {
357
+ try {
358
+ if (item.pattern === 'page-object' && item.className && item.memberName && item.healedLocator) {
359
+ const patchRes = astPatcher.patchPageObjectInMemory(currentSource, {
360
+ className: item.className,
361
+ memberName: item.memberName,
362
+ replacementExpression: item.healedLocator,
363
+ });
364
+ currentSource = patchRes.patchedSource;
365
+ item.fullDiff = patchRes.diff;
366
+ } else if (item.pattern === 'inline-locator' && item.failedTarget && item.healedLocator) {
367
+ const patchRes = statementPatcher.patchStatementInMemory(currentSource, {
368
+ kind: 'replace-inline-locator',
369
+ oldLocatorText: item.failedTarget,
370
+ newLocatorExpression: item.healedLocator,
371
+ }, path.basename(relTarget));
372
+ currentSource = patchRes.patchedSource;
373
+ item.fullDiff = patchRes.diff;
374
+ }
375
+ } catch (patchErr: any) {
376
+ item.status = 'REJECTED';
377
+ item.reason = `AST patching rejected: ${patchErr?.message ?? String(patchErr)}`;
378
+ }
379
+ }
380
+
381
+ // Safety checks: AST Allowlist & Grammar validation
382
+ let allAllowed = true;
383
+ for (const item of targetItems) {
384
+ if (item.healedLocator) {
385
+ const checkGrammar = DiffAllowlistValidator.validateLocatorGrammar(item.healedLocator);
386
+ if (!checkGrammar.isValid) {
387
+ item.status = 'REJECTED';
388
+ item.reason = `Invalid locator grammar: ${checkGrammar.reason}`;
389
+ allAllowed = false;
390
+ }
391
+ }
392
+ }
393
+ if (!allAllowed) continue;
394
+
395
+ // Save patched content to worktree and record for promotion
396
+ await fs.writeFile(worktreeTarget, currentSource, 'utf8');
397
+ modifiedFileContents.set(relTarget, currentSource);
398
+ }
399
+
400
+ // Collect all impacted test spec paths
401
+ const impactedSpecs = [...new Set(
402
+ proposableItems
403
+ .filter((it) => it.status === 'PROPOSAL_READY')
404
+ .map((it) => it.testFilePath)
405
+ .filter(Boolean) as string[],
406
+ )];
407
+
408
+ if (impactedSpecs.length > 0) {
409
+ const relSpecs = impactedSpecs.map((s) => path.relative(workspaceRoot, s).replace(/\\/g, '/'));
410
+ const testArgs = ['playwright', 'test', ...relSpecs, '--reporter=line'];
411
+ if (input.configPath) {
412
+ testArgs.push('--config', path.relative(workspaceRoot, input.configPath).replace(/\\/g, '/'));
413
+ }
414
+
415
+ const execResult = await ExecutionRunner.run('npx', {
416
+ cwd: worktree.worktreePath,
417
+ args: testArgs,
418
+ timeoutMs: 120000,
419
+ });
420
+
421
+ const passedMatch = execResult.stdout.match(/(\d+)\s+passed/i);
422
+ const passedCount = passedMatch ? parseInt(passedMatch[1] ?? '0', 10) : 0;
423
+ executionPassed = execResult.exitCode === 0 && passedCount > 0 && !execResult.timedOut;
424
+
425
+ for (const it of proposableItems) {
426
+ if (it.status === 'PROPOSAL_READY') {
427
+ it.status = executionPassed ? 'VERIFIED' : 'VERIFICATION_FAILED';
428
+ if (!executionPassed) {
429
+ it.reason = execResult.timedOut ? 'Execution timed out in worktree' : `Worktree Playwright tests failed (exitCode=${execResult.exitCode})`;
430
+ }
431
+ }
432
+ }
433
+
434
+ // Phase 3: Atomic Promotion if requested and verified
435
+ if (executionPassed && input.autoPromote) {
436
+ for (const [relPath, content] of modifiedFileContents.entries()) {
437
+ const workspaceDest = path.resolve(workspaceRoot, relPath);
438
+ await fs.mkdir(path.dirname(workspaceDest), { recursive: true });
439
+ await fs.writeFile(workspaceDest, content, 'utf8');
440
+ promotedFiles.push(relPath);
441
+ }
442
+ for (const it of proposableItems) {
443
+ if (it.status === 'VERIFIED') {
444
+ it.status = 'PROMOTED';
445
+ }
446
+ }
447
+ }
448
+ }
449
+ } finally {
450
+ // Always clean up isolated worktree
451
+ await manager.destroyWorktree(batchWorktreeId);
452
+ }
453
+
454
+ const repairedCount = items.filter((i) => i.status === 'VERIFIED' || i.status === 'PROMOTED').length;
455
+ const abstainedCount = items.filter((i) => i.status === 'ABSTAINED').length;
456
+ const failedCount = items.filter((i) => i.status === 'FAILED' || i.status === 'VERIFICATION_FAILED' || i.status === 'REJECTED').length;
457
+
458
+ const status = repairedCount > 0 && failedCount === 0
459
+ ? 'COMPLETED'
460
+ : repairedCount > 0
461
+ ? 'PARTIAL'
462
+ : (abstainedCount > 0 && repairedCount === 0 ? 'ABSTAINED' : 'FAILED');
463
+
464
+ return {
465
+ status,
466
+ totalTraces: items.length,
467
+ repairedCount,
468
+ abstainedCount,
469
+ failedCount,
470
+ items,
471
+ clusters,
472
+ worktree,
473
+ executionPassed,
474
+ promotedFiles,
475
+ };
476
+ }
@@ -1,4 +1,4 @@
1
- import { ResolvedElement } from '../trace/snapshot-resolver.js';
1
+ import { ResolvedElement, BoundingBox } from '../trace/snapshot-resolver.js';
2
2
  import { CandidateExtractor } from '../selector/candidate-extractor.js';
3
3
 
4
4
  export type DisambiguationStrategy =
@@ -6,6 +6,7 @@ export type DisambiguationStrategy =
6
6
  | 'VISIBILITY_FILTER'
7
7
  | 'CONTAINER_ANCHOR'
8
8
  | 'LANDMARK_SECTION'
9
+ | 'SPATIAL_COORDINATE_MATCH'
9
10
  | 'EXPLICIT_POSITIONAL_INTENT'
10
11
  | 'ABSTAIN_AMBIGUOUS';
11
12
 
@@ -14,6 +15,7 @@ export interface DisambiguationContext {
14
15
  intendedAction?: string | undefined;
15
16
  testIntentText?: string | undefined;
16
17
  rootDom?: ResolvedElement | undefined;
18
+ targetBoundingBox?: BoundingBox | undefined;
17
19
  }
18
20
 
19
21
  export interface DisambiguationResult {
@@ -101,7 +103,15 @@ export class LocatorDisambiguationEngine {
101
103
  }
102
104
  }
103
105
 
104
- // 5. Explicit Positional Intent Verification
106
+ // 5. Spatial Coordinate Disambiguation (Bounding Box Proximity)
107
+ if (context.targetBoundingBox) {
108
+ const spatialResult = this.attemptSpatialDisambiguation(candidates, context.targetBoundingBox, pageProperty);
109
+ if (spatialResult) {
110
+ return spatialResult;
111
+ }
112
+ }
113
+
114
+ // 6. Explicit Positional Intent Verification
105
115
  const hasExplicitPositionalIntent = this.verifyExplicitPositionalIntent(context);
106
116
  if (hasExplicitPositionalIntent) {
107
117
  const candidate = candidates[0]!;
@@ -119,7 +129,7 @@ export class LocatorDisambiguationEngine {
119
129
  };
120
130
  }
121
131
 
122
- // 6. Safe Abstention (Never invent positional intent!)
132
+ // 7. Safe Abstention (Never invent positional intent!)
123
133
  return {
124
134
  strategy: 'ABSTAIN_AMBIGUOUS',
125
135
  matchCount: candidates.length,
@@ -315,6 +325,47 @@ export class LocatorDisambiguationEngine {
315
325
  return null;
316
326
  }
317
327
 
328
+ private static attemptSpatialDisambiguation(
329
+ candidates: ResolvedElement[],
330
+ targetBox: BoundingBox,
331
+ pageProperty: string,
332
+ ): DisambiguationResult | undefined {
333
+ const candidatesWithBoxes = candidates.filter((c) => c.boundingBox !== undefined);
334
+ if (candidatesWithBoxes.length === 0) return undefined;
335
+
336
+ const targetCenterX = targetBox.x + targetBox.width / 2;
337
+ const targetCenterY = targetBox.y + targetBox.height / 2;
338
+
339
+ const scored = candidatesWithBoxes
340
+ .map((c) => {
341
+ const box = c.boundingBox!;
342
+ const candCenterX = box.x + box.width / 2;
343
+ const candCenterY = box.y + box.height / 2;
344
+ const distance = Math.hypot(targetCenterX - candCenterX, targetCenterY - candCenterY);
345
+ const sizeDiff = Math.abs(targetBox.width - box.width) + Math.abs(targetBox.height - box.height);
346
+ return { candidate: c, distance, sizeDiff, totalScore: distance + sizeDiff * 0.5 };
347
+ })
348
+ .sort((a, b) => a.totalScore - b.totalScore);
349
+
350
+ const best = scored[0];
351
+ const secondBest = scored[1];
352
+
353
+ if (best && (!secondBest || (secondBest.distance - best.distance > 20) || best.distance < 15)) {
354
+ const baseExpr = CandidateExtractor.generateLocator(best.candidate, pageProperty);
355
+ return {
356
+ strategy: 'SPATIAL_COORDINATE_MATCH',
357
+ resolvedCandidate: best.candidate,
358
+ resolvedExpression: baseExpr,
359
+ matchCount: candidates.length,
360
+ explanation: `Disambiguated via spatial layout coordinates: candidate element aligns with target coordinates within ${best.distance.toFixed(1)}px distance.`,
361
+ confidence: 0.94,
362
+ isUnique: true,
363
+ };
364
+ }
365
+
366
+ return undefined;
367
+ }
368
+
318
369
  private static getCleanText(el: ResolvedElement): string {
319
370
  if (el.text && el.text.trim().length > 0) {
320
371
  return el.text.trim();
@@ -45,6 +45,20 @@ function normalize(value: string): string {
45
45
  return value.replace(/\s+/g, '').replace(/;$/, '');
46
46
  }
47
47
 
48
+ function extractInternalSelectorValues(selector: string): string[] {
49
+ const values: string[] = [];
50
+ if (!selector.startsWith('internal:')) return values;
51
+ const quoted = selector.matchAll(/(?:name|text|label|testid|placeholder)=["']([^"']+)["']/gi);
52
+ for (const m of quoted) {
53
+ if (m[1]) values.push(m[1]);
54
+ }
55
+ const unquoted = selector.matchAll(/(?:name|text|label|testid|placeholder)=([^\]\s]+)/gi);
56
+ for (const m of unquoted) {
57
+ if (m[1] && !m[1].startsWith('"') && !m[1].startsWith("'")) values.push(m[1].replace(/i$/, ''));
58
+ }
59
+ return values;
60
+ }
61
+
48
62
  function selectorMatches(selector: string, locator: ExtractedLocatorAst): { score: number; evidence: string[] } | undefined {
49
63
  const wanted = normalize(selector);
50
64
  const source = normalize(locator.selectorText);
@@ -54,6 +68,14 @@ function selectorMatches(selector: string, locator: ExtractedLocatorAst): { scor
54
68
  return { score: 100, evidence: ['Failed selector expression matched a Page Object locator expression'] };
55
69
  }
56
70
 
71
+ const internalValues = extractInternalSelectorValues(selector);
72
+ for (const val of internalValues) {
73
+ const normVal = normalize(val);
74
+ if (normVal && source.includes(normVal)) {
75
+ return { score: 95, evidence: [`Failed internal selector value '${val}' matched a Page Object locator expression`] };
76
+ }
77
+ }
78
+
57
79
  // Trace formats sometimes emit only the locator argument (for example
58
80
  // "#submit") instead of the full page.locator("#submit") expression.
59
81
  const argumentMatch = source.match(/(?:locator|getByRole|getByText|getByTestId|getByLabel|getByPlaceholder|getByAltText|getByTitle)\((.*)\)/);
@@ -122,7 +144,10 @@ export async function discoverRepairSourceMapping(input: {
122
144
  } catch {
123
145
  continue;
124
146
  }
125
- if (!normalize(sourceText).includes(normalizedSelector)) continue;
147
+ const internalValues = extractInternalSelectorValues(selector);
148
+ const hasMatchInSource = normalize(sourceText).includes(normalizedSelector) ||
149
+ internalValues.some((val) => normalize(sourceText).includes(normalize(val)));
150
+ if (!hasMatchInSource) continue;
126
151
  const result = await new StaticAstCollector().collect({ targetFilePath: filePath });
127
152
  const payload = result[0]?.payload as AstPayload | undefined;
128
153
  if (!payload?.isPageObject || !payload.locators) continue;
@@ -1,6 +1,6 @@
1
1
  import { ProjectIntelligenceSnapshot, getDecayedHistoricalSelectorScore } from '../intelligence/project-intelligence.js';
2
2
  import { CandidateExtractor } from './candidate-extractor.js';
3
- import { ResolvedElement } from '../trace/snapshot-resolver.js';
3
+ import { ResolvedElement, BoundingBox } from '../trace/snapshot-resolver.js';
4
4
  import { ScoredCandidate } from './abstention-policy.js';
5
5
  import { SelectorScorer, SelectorProjectContext } from './selector-scorer.js';
6
6
  import { LocatorDisambiguationEngine } from '../intelligence/locator-disambiguation-engine.js';
@@ -17,6 +17,7 @@ export class RepairCandidateRanker {
17
17
  intelligence?: ProjectIntelligenceSnapshot,
18
18
  rootDom?: ResolvedElement,
19
19
  testIntentText?: string,
20
+ targetBoundingBox?: BoundingBox,
20
21
  ): RankedRepairCandidate[] {
21
22
  const projectContext: SelectorProjectContext | undefined = intelligence?.profile
22
23
  ? {
@@ -35,7 +36,7 @@ export class RepairCandidateRanker {
35
36
  candidates.length > 1
36
37
  ? LocatorDisambiguationEngine.disambiguate(
37
38
  candidates,
38
- { originalSelector, intendedAction, rootDom, testIntentText },
39
+ { originalSelector, intendedAction, rootDom, testIntentText, targetBoundingBox },
39
40
  'page'
40
41
  )
41
42
  : undefined;