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.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +887 -225
- package/dist/intelligence/batch-repair-workflow.d.ts +52 -0
- package/dist/intelligence/batch-repair-workflow.d.ts.map +1 -0
- package/dist/intelligence/locator-disambiguation-engine.d.ts +4 -2
- package/dist/intelligence/locator-disambiguation-engine.d.ts.map +1 -1
- package/dist/repair/source-mapper.d.ts.map +1 -1
- package/dist/selector/repair-candidate-ranker.d.ts +2 -2
- package/dist/selector/repair-candidate-ranker.d.ts.map +1 -1
- package/dist/trace/archive-reader.d.ts +2 -0
- package/dist/trace/archive-reader.d.ts.map +1 -1
- package/dist/trace/snapshot-resolver.d.ts +7 -0
- package/dist/trace/snapshot-resolver.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/index.ts +263 -27
- package/src/core/version.ts +2 -2
- package/src/index.ts +1 -0
- package/src/intelligence/batch-repair-workflow.ts +476 -0
- package/src/intelligence/locator-disambiguation-engine.ts +54 -3
- package/src/repair/source-mapper.ts +26 -1
- package/src/selector/repair-candidate-ranker.ts +3 -2
- package/src/trace/archive-reader.ts +18 -3
- package/src/trace/snapshot-resolver.ts +32 -0
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|