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,544 +1,544 @@
|
|
|
1
|
-
import { Project, SyntaxKind, Node, Statement } from 'ts-morph';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { AbstentionError } from '../core/errors/engine-errors.js';
|
|
5
|
-
import { AstPatcher } from './ast-patcher.js';
|
|
6
|
-
import { AssertionHasher } from './assertion-hasher.js';
|
|
7
|
-
|
|
8
|
-
export type StatementPatchOperation =
|
|
9
|
-
| 'replace-timeout-with-wait'
|
|
10
|
-
| 'wrap-action-with-response'
|
|
11
|
-
| 'insert-readiness-check'
|
|
12
|
-
| 'replace-inline-locator'
|
|
13
|
-
| 'replace-statement';
|
|
14
|
-
|
|
15
|
-
export interface BaseStatementPatchPlan {
|
|
16
|
-
kind: StatementPatchOperation;
|
|
17
|
-
/** 1-based line number in the source file. If omitted, matching is done by text pattern. */
|
|
18
|
-
targetLine?: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ReplaceTimeoutPlan extends BaseStatementPatchPlan {
|
|
22
|
-
kind: 'replace-timeout-with-wait';
|
|
23
|
-
/** Replacement wait statement, e.g. "await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible({ timeout: 5000 });" */
|
|
24
|
-
replacementWaitStatement: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface WrapActionWithResponsePlan extends BaseStatementPatchPlan {
|
|
28
|
-
kind: 'wrap-action-with-response';
|
|
29
|
-
/** Target action substring to locate if targetLine is omitted, e.g. "click()" or locator name */
|
|
30
|
-
targetActionSubstring?: string;
|
|
31
|
-
/** Predicate string for page.waitForResponse, e.g. "resp => resp.url().includes('/api/auth') && resp.status() === 200" */
|
|
32
|
-
responsePredicate: string;
|
|
33
|
-
/** Optional custom timeout in ms for waitForResponse */
|
|
34
|
-
timeoutMs?: number;
|
|
35
|
-
/** Optional explicit page variable name, defaults to auto-detected or "page" */
|
|
36
|
-
pageVarName?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface InsertReadinessCheckPlan extends BaseStatementPatchPlan {
|
|
40
|
-
kind: 'insert-readiness-check';
|
|
41
|
-
/** Target action substring to locate if targetLine is omitted */
|
|
42
|
-
targetActionSubstring?: string;
|
|
43
|
-
/** Statement to insert before action, e.g. "await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();" */
|
|
44
|
-
readinessStatement: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ReplaceInlineLocatorPlan extends BaseStatementPatchPlan {
|
|
48
|
-
kind: 'replace-inline-locator';
|
|
49
|
-
/** Old locator text in statement, e.g. "page.locator('.btn-login')" */
|
|
50
|
-
oldLocatorText: string;
|
|
51
|
-
/** New locator expression, e.g. "page.getByRole('button', { name: 'Log In' })" */
|
|
52
|
-
newLocatorExpression: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface ReplaceStatementPlan extends BaseStatementPatchPlan {
|
|
56
|
-
kind: 'replace-statement';
|
|
57
|
-
/** Target substring to locate statement if targetLine is omitted */
|
|
58
|
-
targetSubstring?: string;
|
|
59
|
-
/** Complete new statement to replace existing one */
|
|
60
|
-
newStatement: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export type StatementPatchPlan =
|
|
64
|
-
| ReplaceTimeoutPlan
|
|
65
|
-
| WrapActionWithResponsePlan
|
|
66
|
-
| InsertReadinessCheckPlan
|
|
67
|
-
| ReplaceInlineLocatorPlan
|
|
68
|
-
| ReplaceStatementPlan;
|
|
69
|
-
|
|
70
|
-
export interface StatementPatchResult {
|
|
71
|
-
success: boolean;
|
|
72
|
-
operation: StatementPatchOperation;
|
|
73
|
-
originalStatement: string;
|
|
74
|
-
replacementStatement: string;
|
|
75
|
-
patchedSource: string;
|
|
76
|
-
diff: string;
|
|
77
|
-
importAdded?: boolean;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export class StatementPatcher {
|
|
81
|
-
private project: Project;
|
|
82
|
-
|
|
83
|
-
constructor(tsConfigFilePath?: string) {
|
|
84
|
-
if (tsConfigFilePath) {
|
|
85
|
-
this.project = new Project({
|
|
86
|
-
tsConfigFilePath,
|
|
87
|
-
skipAddingFilesFromTsConfig: false,
|
|
88
|
-
});
|
|
89
|
-
} else {
|
|
90
|
-
this.project = new Project({
|
|
91
|
-
skipAddingFilesFromTsConfig: true,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Patches a test source string in-memory without touching disk.
|
|
98
|
-
*/
|
|
99
|
-
public patchStatementInMemory(
|
|
100
|
-
sourceText: string,
|
|
101
|
-
plan: StatementPatchPlan,
|
|
102
|
-
fileName = 'test.spec.ts'
|
|
103
|
-
): StatementPatchResult {
|
|
104
|
-
const tempProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
105
|
-
const sourceFile = tempProject.createSourceFile(fileName, sourceText);
|
|
106
|
-
|
|
107
|
-
let importAdded = false;
|
|
108
|
-
|
|
109
|
-
// Check if expect import is needed
|
|
110
|
-
if (
|
|
111
|
-
plan.kind === 'replace-timeout-with-wait' ||
|
|
112
|
-
plan.kind === 'insert-readiness-check' ||
|
|
113
|
-
plan.kind === 'replace-statement'
|
|
114
|
-
) {
|
|
115
|
-
const codeToCheck =
|
|
116
|
-
plan.kind === 'replace-timeout-with-wait'
|
|
117
|
-
? plan.replacementWaitStatement
|
|
118
|
-
: plan.kind === 'insert-readiness-check'
|
|
119
|
-
? plan.readinessStatement
|
|
120
|
-
: plan.newStatement;
|
|
121
|
-
|
|
122
|
-
if (codeToCheck.includes('expect(')) {
|
|
123
|
-
importAdded = this.ensurePlaywrightExpectImport(sourceFile);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let result: {
|
|
128
|
-
originalStatement: string;
|
|
129
|
-
replacementStatement: string;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
switch (plan.kind) {
|
|
133
|
-
case 'replace-timeout-with-wait':
|
|
134
|
-
result = this.executeReplaceTimeout(sourceFile, plan);
|
|
135
|
-
break;
|
|
136
|
-
case 'wrap-action-with-response':
|
|
137
|
-
result = this.executeWrapActionWithResponse(sourceFile, plan);
|
|
138
|
-
break;
|
|
139
|
-
case 'insert-readiness-check':
|
|
140
|
-
result = this.executeInsertReadinessCheck(sourceFile, plan);
|
|
141
|
-
break;
|
|
142
|
-
case 'replace-inline-locator':
|
|
143
|
-
result = this.executeReplaceInlineLocator(sourceFile, plan);
|
|
144
|
-
break;
|
|
145
|
-
case 'replace-statement':
|
|
146
|
-
result = this.executeReplaceStatement(sourceFile, plan);
|
|
147
|
-
break;
|
|
148
|
-
default:
|
|
149
|
-
throw new AbstentionError(
|
|
150
|
-
`Unsupported statement patch operation: ${(plan as BaseStatementPatchPlan).kind}`,
|
|
151
|
-
'AST_UNSUPPORTED_STATEMENT_OPERATION'
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Validate that syntax remains valid after patching
|
|
156
|
-
const diagnostics = tempProject.getProgram().getSyntacticDiagnostics(sourceFile);
|
|
157
|
-
if (diagnostics.length > 0) {
|
|
158
|
-
const firstError = diagnostics[0]?.getMessageText();
|
|
159
|
-
const errorMsg = typeof firstError === 'string' ? firstError : firstError?.getMessageText();
|
|
160
|
-
throw new AbstentionError(
|
|
161
|
-
`Syntax error after applying statement patch: ${errorMsg ?? 'unknown syntax error'}`,
|
|
162
|
-
'AST_SYNTAX_ERROR'
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const patchedSource = sourceFile.getFullText();
|
|
167
|
-
const diff = AstPatcher.generateDiff(sourceText, patchedSource, fileName);
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
success: true,
|
|
171
|
-
operation: plan.kind,
|
|
172
|
-
originalStatement: result.originalStatement,
|
|
173
|
-
replacementStatement: result.replacementStatement,
|
|
174
|
-
patchedSource,
|
|
175
|
-
diff,
|
|
176
|
-
importAdded,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Patches a test file on disk atomically.
|
|
182
|
-
*/
|
|
183
|
-
public patchStatementInFile(filePath: string, plan: StatementPatchPlan): StatementPatchResult {
|
|
184
|
-
const resolvedPath = path.resolve(filePath);
|
|
185
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
186
|
-
throw new AbstentionError(`Target file not found at ${resolvedPath}`, 'AST_FILE_NOT_FOUND');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const sourceFile = this.project.getSourceFile(resolvedPath) ?? this.project.addSourceFileAtPath(resolvedPath);
|
|
190
|
-
const sourceText = sourceFile.getFullText();
|
|
191
|
-
const fileName = path.basename(resolvedPath);
|
|
192
|
-
const result = this.patchStatementInMemory(sourceText, plan, fileName);
|
|
193
|
-
|
|
194
|
-
const targetDir = path.dirname(resolvedPath);
|
|
195
|
-
const tempFile = path.join(
|
|
196
|
-
targetDir,
|
|
197
|
-
`.${path.basename(resolvedPath)}.${Date.now()}.${Math.random().toString(36).substring(2, 6)}.tmp`
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
fs.writeFileSync(tempFile, result.patchedSource, 'utf8');
|
|
202
|
-
fs.renameSync(tempFile, resolvedPath);
|
|
203
|
-
sourceFile.refreshFromFileSystemSync();
|
|
204
|
-
return result;
|
|
205
|
-
} catch (err: any) {
|
|
206
|
-
if (fs.existsSync(tempFile)) {
|
|
207
|
-
try {
|
|
208
|
-
fs.unlinkSync(tempFile);
|
|
209
|
-
} catch {
|
|
210
|
-
// ignore cleanup failure
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
throw err;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
// Execution Handlers
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
private executeReplaceTimeout(
|
|
222
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
223
|
-
plan: ReplaceTimeoutPlan
|
|
224
|
-
): { originalStatement: string; replacementStatement: string } {
|
|
225
|
-
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
226
|
-
const text = stmt.getText();
|
|
227
|
-
return text.includes('waitForTimeout');
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const targetStatement = this.selectSingleTargetStatement(candidateStatements, plan, 'waitForTimeout');
|
|
231
|
-
const originalStatement = targetStatement.getText();
|
|
232
|
-
const replacement = plan.replacementWaitStatement.trim().endsWith(';')
|
|
233
|
-
? plan.replacementWaitStatement.trim()
|
|
234
|
-
: `${plan.replacementWaitStatement.trim()};`;
|
|
235
|
-
|
|
236
|
-
targetStatement.replaceWithText(replacement);
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
originalStatement,
|
|
240
|
-
replacementStatement: replacement,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private executeWrapActionWithResponse(
|
|
245
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
246
|
-
plan: WrapActionWithResponsePlan
|
|
247
|
-
): { originalStatement: string; replacementStatement: string } {
|
|
248
|
-
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
249
|
-
const text = stmt.getText();
|
|
250
|
-
if (plan.targetActionSubstring) {
|
|
251
|
-
return text.includes(plan.targetActionSubstring);
|
|
252
|
-
}
|
|
253
|
-
return (
|
|
254
|
-
text.includes('.click(') ||
|
|
255
|
-
text.includes('.fill(') ||
|
|
256
|
-
text.includes('.press(') ||
|
|
257
|
-
text.includes('.selectOption(') ||
|
|
258
|
-
text.includes('.check(')
|
|
259
|
-
);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const targetStatement = this.selectSingleTargetStatement(
|
|
263
|
-
candidateStatements,
|
|
264
|
-
plan,
|
|
265
|
-
plan.targetActionSubstring ?? 'action'
|
|
266
|
-
);
|
|
267
|
-
const originalStatement = targetStatement.getText();
|
|
268
|
-
|
|
269
|
-
// Extract bare expression without leading await and trailing semicolon
|
|
270
|
-
let bareAction = originalStatement.trim();
|
|
271
|
-
if (bareAction.startsWith('await ')) {
|
|
272
|
-
bareAction = bareAction.substring(6).trim();
|
|
273
|
-
}
|
|
274
|
-
if (bareAction.endsWith(';')) {
|
|
275
|
-
bareAction = bareAction.substring(0, bareAction.length - 1).trim();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Auto-detect page variable
|
|
279
|
-
let pageVar = plan.pageVarName ?? 'page';
|
|
280
|
-
if (!plan.pageVarName) {
|
|
281
|
-
const pageMatch = bareAction.match(/(\b\w*[pP]age\w*)\./);
|
|
282
|
-
if (pageMatch && pageMatch[1]) {
|
|
283
|
-
pageVar = pageMatch[1];
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const timeoutArg = plan.timeoutMs ? `, { timeout: ${plan.timeoutMs} }` : '';
|
|
288
|
-
const replacementStatement = `await Promise.all([\n ${pageVar}.waitForResponse(${plan.responsePredicate}${timeoutArg}),\n ${bareAction},\n]);`;
|
|
289
|
-
|
|
290
|
-
targetStatement.replaceWithText(replacementStatement);
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
originalStatement,
|
|
294
|
-
replacementStatement,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
private executeInsertReadinessCheck(
|
|
299
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
300
|
-
plan: InsertReadinessCheckPlan
|
|
301
|
-
): { originalStatement: string; replacementStatement: string } {
|
|
302
|
-
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
303
|
-
const text = stmt.getText();
|
|
304
|
-
if (plan.targetActionSubstring) {
|
|
305
|
-
return text.includes(plan.targetActionSubstring);
|
|
306
|
-
}
|
|
307
|
-
return text.includes('.click(') || text.includes('.fill(');
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
const targetStatement = this.selectSingleTargetStatement(
|
|
311
|
-
candidateStatements,
|
|
312
|
-
plan,
|
|
313
|
-
plan.targetActionSubstring ?? 'click/fill'
|
|
314
|
-
);
|
|
315
|
-
const originalStatement = targetStatement.getText();
|
|
316
|
-
const parent = targetStatement.getParent();
|
|
317
|
-
|
|
318
|
-
const readinessCode = plan.readinessStatement.trim().endsWith(';')
|
|
319
|
-
? plan.readinessStatement.trim()
|
|
320
|
-
: `${plan.readinessStatement.trim()};`;
|
|
321
|
-
|
|
322
|
-
if (Node.isBlock(parent)) {
|
|
323
|
-
const index = targetStatement.getChildIndex();
|
|
324
|
-
parent.insertStatements(index, readinessCode);
|
|
325
|
-
} else {
|
|
326
|
-
// Fallback: replace target statement with readiness + target
|
|
327
|
-
targetStatement.replaceWithText(`${readinessCode}\n${originalStatement}`);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return {
|
|
331
|
-
originalStatement,
|
|
332
|
-
replacementStatement: `${readinessCode}\n${originalStatement}`,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
private executeReplaceInlineLocator(
|
|
337
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
338
|
-
plan: ReplaceInlineLocatorPlan
|
|
339
|
-
): { originalStatement: string; replacementStatement: string } {
|
|
340
|
-
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
341
|
-
return stmt.getText().includes(plan.oldLocatorText);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
const targetStatement = this.selectSingleTargetStatement(
|
|
345
|
-
candidateStatements,
|
|
346
|
-
plan,
|
|
347
|
-
`'${plan.oldLocatorText}'`
|
|
348
|
-
);
|
|
349
|
-
const originalStatement = targetStatement.getText();
|
|
350
|
-
|
|
351
|
-
// AST-level node replacement
|
|
352
|
-
let replacedInAst = false;
|
|
353
|
-
const calls = targetStatement.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
354
|
-
for (const call of calls) {
|
|
355
|
-
if (call.getText().trim() === plan.oldLocatorText.trim()) {
|
|
356
|
-
call.replaceWithText(plan.newLocatorExpression);
|
|
357
|
-
replacedInAst = true;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (!replacedInAst) {
|
|
363
|
-
targetStatement.forEachDescendant((node) => {
|
|
364
|
-
if (!replacedInAst && node.getText().trim() === plan.oldLocatorText.trim()) {
|
|
365
|
-
node.replaceWithText(plan.newLocatorExpression);
|
|
366
|
-
replacedInAst = true;
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (!replacedInAst) {
|
|
372
|
-
const patchedStatementText = originalStatement.replace(plan.oldLocatorText, plan.newLocatorExpression);
|
|
373
|
-
if (patchedStatementText === originalStatement) {
|
|
374
|
-
throw new AbstentionError(
|
|
375
|
-
`Failed to replace '${plan.oldLocatorText}' with '${plan.newLocatorExpression}'`,
|
|
376
|
-
'AST_REPLACEMENT_FAILED'
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
targetStatement.replaceWithText(patchedStatementText);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
originalStatement,
|
|
384
|
-
replacementStatement: targetStatement.getText(),
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
private executeReplaceStatement(
|
|
389
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
390
|
-
plan: ReplaceStatementPlan
|
|
391
|
-
): { originalStatement: string; replacementStatement: string } {
|
|
392
|
-
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
393
|
-
if (plan.targetSubstring) {
|
|
394
|
-
return stmt.getText().includes(plan.targetSubstring);
|
|
395
|
-
}
|
|
396
|
-
return true;
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
const targetStatement = this.selectSingleTargetStatement(
|
|
400
|
-
candidateStatements,
|
|
401
|
-
plan,
|
|
402
|
-
plan.targetSubstring ?? 'target'
|
|
403
|
-
);
|
|
404
|
-
const originalStatement = targetStatement.getText();
|
|
405
|
-
const replacement = plan.newStatement.trim().endsWith(';')
|
|
406
|
-
? plan.newStatement.trim()
|
|
407
|
-
: `${plan.newStatement.trim()};`;
|
|
408
|
-
|
|
409
|
-
targetStatement.replaceWithText(replacement);
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
originalStatement,
|
|
413
|
-
replacementStatement: replacement,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
private selectSingleTargetStatement(
|
|
418
|
-
candidates: Statement[],
|
|
419
|
-
plan: BaseStatementPatchPlan,
|
|
420
|
-
label: string
|
|
421
|
-
): Statement {
|
|
422
|
-
if (candidates.length === 0) {
|
|
423
|
-
throw new AbstentionError(
|
|
424
|
-
plan.targetLine
|
|
425
|
-
? `No ${label} statement found at line ${plan.targetLine}`
|
|
426
|
-
: `No ${label} statement found in source file`,
|
|
427
|
-
'AST_STATEMENT_NOT_FOUND'
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
if (candidates.length > 1) {
|
|
431
|
-
throw new AbstentionError(
|
|
432
|
-
plan.targetLine
|
|
433
|
-
? `Ambiguous statement match on line ${plan.targetLine}: found ${candidates.length} matching ${label} statements. Provide a more specific targetSubstring or unique locator to disambiguate.`
|
|
434
|
-
: `Ambiguous statement match: found ${candidates.length} ${label} statements. Provide targetLine to specify which statement to transform.`,
|
|
435
|
-
'AST_AMBIGUOUS_STATEMENT_TARGET'
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
const stmt = candidates[0]!;
|
|
439
|
-
this.assertNotAssertion(stmt);
|
|
440
|
-
return stmt;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// ---------------------------------------------------------------------------
|
|
444
|
-
// Helper Methods
|
|
445
|
-
// ---------------------------------------------------------------------------
|
|
446
|
-
|
|
447
|
-
private findStatements(
|
|
448
|
-
sourceFile: import('ts-morph').SourceFile,
|
|
449
|
-
targetLine?: number,
|
|
450
|
-
predicate?: (stmt: Statement) => boolean
|
|
451
|
-
): Statement[] {
|
|
452
|
-
const statements: Statement[] = [];
|
|
453
|
-
|
|
454
|
-
// Traverse all expression statements and variable statements in tests/blocks
|
|
455
|
-
const allStatements = [
|
|
456
|
-
...sourceFile.getDescendantsOfKind(SyntaxKind.ExpressionStatement),
|
|
457
|
-
...sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement),
|
|
458
|
-
];
|
|
459
|
-
|
|
460
|
-
for (const stmt of allStatements) {
|
|
461
|
-
// Exclude outer test wrapper calls (e.g. test('...', () => { ... }))
|
|
462
|
-
if (Node.isExpressionStatement(stmt)) {
|
|
463
|
-
const expr = stmt.getExpression();
|
|
464
|
-
if (Node.isCallExpression(expr)) {
|
|
465
|
-
const callText = expr.getExpression().getText();
|
|
466
|
-
if (
|
|
467
|
-
callText === 'test' ||
|
|
468
|
-
callText === 'test.describe' ||
|
|
469
|
-
callText === 'test.step' ||
|
|
470
|
-
callText === 'describe' ||
|
|
471
|
-
callText === 'it'
|
|
472
|
-
) {
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (targetLine !== undefined) {
|
|
479
|
-
const startLine = stmt.getStartLineNumber();
|
|
480
|
-
const endLine = stmt.getEndLineNumber();
|
|
481
|
-
if (targetLine < startLine || targetLine > endLine) {
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (predicate && !predicate(stmt)) {
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
statements.push(stmt);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return statements;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private assertNotAssertion(stmt: Statement): void {
|
|
497
|
-
const identifiers = AssertionHasher.discoverAssertionIdentifiers(stmt);
|
|
498
|
-
|
|
499
|
-
// Guard against removing or tampering with assertions (standard or aliased)
|
|
500
|
-
const callExpressions = stmt.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
501
|
-
for (const call of callExpressions) {
|
|
502
|
-
if (AssertionHasher.isAssertionCall(call, identifiers)) {
|
|
503
|
-
throw new AbstentionError(
|
|
504
|
-
'Modifying or removing test assertions via StatementPatcher is strictly prohibited',
|
|
505
|
-
'AST_ASSERTION_TAMPERING_PROHIBITED'
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const trimmed = stmt.getText().trim();
|
|
511
|
-
for (const id of identifiers) {
|
|
512
|
-
if (trimmed.startsWith(`${id}(`) || trimmed.startsWith(`await ${id}(`)) {
|
|
513
|
-
throw new AbstentionError(
|
|
514
|
-
'Modifying or removing test assertions via StatementPatcher is strictly prohibited',
|
|
515
|
-
'AST_ASSERTION_TAMPERING_PROHIBITED'
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
private ensurePlaywrightExpectImport(sourceFile: import('ts-morph').SourceFile): boolean {
|
|
522
|
-
const importDecls = sourceFile.getImportDeclarations();
|
|
523
|
-
const pwImport = importDecls.find((decl) => {
|
|
524
|
-
const spec = decl.getModuleSpecifierValue();
|
|
525
|
-
return spec === '@playwright/test';
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
if (pwImport) {
|
|
529
|
-
const namedImports = pwImport.getNamedImports().map((i) => i.getName());
|
|
530
|
-
if (!namedImports.includes('expect')) {
|
|
531
|
-
pwImport.addNamedImport('expect');
|
|
532
|
-
return true;
|
|
533
|
-
}
|
|
534
|
-
return false;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// No @playwright/test import found; add one at the top of the file
|
|
538
|
-
sourceFile.insertImportDeclaration(0, {
|
|
539
|
-
moduleSpecifier: '@playwright/test',
|
|
540
|
-
namedImports: ['expect'],
|
|
541
|
-
});
|
|
542
|
-
return true;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
1
|
+
import { Project, SyntaxKind, Node, Statement } from 'ts-morph';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { AbstentionError } from '../core/errors/engine-errors.js';
|
|
5
|
+
import { AstPatcher } from './ast-patcher.js';
|
|
6
|
+
import { AssertionHasher } from './assertion-hasher.js';
|
|
7
|
+
|
|
8
|
+
export type StatementPatchOperation =
|
|
9
|
+
| 'replace-timeout-with-wait'
|
|
10
|
+
| 'wrap-action-with-response'
|
|
11
|
+
| 'insert-readiness-check'
|
|
12
|
+
| 'replace-inline-locator'
|
|
13
|
+
| 'replace-statement';
|
|
14
|
+
|
|
15
|
+
export interface BaseStatementPatchPlan {
|
|
16
|
+
kind: StatementPatchOperation;
|
|
17
|
+
/** 1-based line number in the source file. If omitted, matching is done by text pattern. */
|
|
18
|
+
targetLine?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReplaceTimeoutPlan extends BaseStatementPatchPlan {
|
|
22
|
+
kind: 'replace-timeout-with-wait';
|
|
23
|
+
/** Replacement wait statement, e.g. "await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible({ timeout: 5000 });" */
|
|
24
|
+
replacementWaitStatement: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WrapActionWithResponsePlan extends BaseStatementPatchPlan {
|
|
28
|
+
kind: 'wrap-action-with-response';
|
|
29
|
+
/** Target action substring to locate if targetLine is omitted, e.g. "click()" or locator name */
|
|
30
|
+
targetActionSubstring?: string;
|
|
31
|
+
/** Predicate string for page.waitForResponse, e.g. "resp => resp.url().includes('/api/auth') && resp.status() === 200" */
|
|
32
|
+
responsePredicate: string;
|
|
33
|
+
/** Optional custom timeout in ms for waitForResponse */
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
/** Optional explicit page variable name, defaults to auto-detected or "page" */
|
|
36
|
+
pageVarName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InsertReadinessCheckPlan extends BaseStatementPatchPlan {
|
|
40
|
+
kind: 'insert-readiness-check';
|
|
41
|
+
/** Target action substring to locate if targetLine is omitted */
|
|
42
|
+
targetActionSubstring?: string;
|
|
43
|
+
/** Statement to insert before action, e.g. "await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();" */
|
|
44
|
+
readinessStatement: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReplaceInlineLocatorPlan extends BaseStatementPatchPlan {
|
|
48
|
+
kind: 'replace-inline-locator';
|
|
49
|
+
/** Old locator text in statement, e.g. "page.locator('.btn-login')" */
|
|
50
|
+
oldLocatorText: string;
|
|
51
|
+
/** New locator expression, e.g. "page.getByRole('button', { name: 'Log In' })" */
|
|
52
|
+
newLocatorExpression: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ReplaceStatementPlan extends BaseStatementPatchPlan {
|
|
56
|
+
kind: 'replace-statement';
|
|
57
|
+
/** Target substring to locate statement if targetLine is omitted */
|
|
58
|
+
targetSubstring?: string;
|
|
59
|
+
/** Complete new statement to replace existing one */
|
|
60
|
+
newStatement: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type StatementPatchPlan =
|
|
64
|
+
| ReplaceTimeoutPlan
|
|
65
|
+
| WrapActionWithResponsePlan
|
|
66
|
+
| InsertReadinessCheckPlan
|
|
67
|
+
| ReplaceInlineLocatorPlan
|
|
68
|
+
| ReplaceStatementPlan;
|
|
69
|
+
|
|
70
|
+
export interface StatementPatchResult {
|
|
71
|
+
success: boolean;
|
|
72
|
+
operation: StatementPatchOperation;
|
|
73
|
+
originalStatement: string;
|
|
74
|
+
replacementStatement: string;
|
|
75
|
+
patchedSource: string;
|
|
76
|
+
diff: string;
|
|
77
|
+
importAdded?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class StatementPatcher {
|
|
81
|
+
private project: Project;
|
|
82
|
+
|
|
83
|
+
constructor(tsConfigFilePath?: string) {
|
|
84
|
+
if (tsConfigFilePath) {
|
|
85
|
+
this.project = new Project({
|
|
86
|
+
tsConfigFilePath,
|
|
87
|
+
skipAddingFilesFromTsConfig: false,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
this.project = new Project({
|
|
91
|
+
skipAddingFilesFromTsConfig: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Patches a test source string in-memory without touching disk.
|
|
98
|
+
*/
|
|
99
|
+
public patchStatementInMemory(
|
|
100
|
+
sourceText: string,
|
|
101
|
+
plan: StatementPatchPlan,
|
|
102
|
+
fileName = 'test.spec.ts'
|
|
103
|
+
): StatementPatchResult {
|
|
104
|
+
const tempProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
105
|
+
const sourceFile = tempProject.createSourceFile(fileName, sourceText);
|
|
106
|
+
|
|
107
|
+
let importAdded = false;
|
|
108
|
+
|
|
109
|
+
// Check if expect import is needed
|
|
110
|
+
if (
|
|
111
|
+
plan.kind === 'replace-timeout-with-wait' ||
|
|
112
|
+
plan.kind === 'insert-readiness-check' ||
|
|
113
|
+
plan.kind === 'replace-statement'
|
|
114
|
+
) {
|
|
115
|
+
const codeToCheck =
|
|
116
|
+
plan.kind === 'replace-timeout-with-wait'
|
|
117
|
+
? plan.replacementWaitStatement
|
|
118
|
+
: plan.kind === 'insert-readiness-check'
|
|
119
|
+
? plan.readinessStatement
|
|
120
|
+
: plan.newStatement;
|
|
121
|
+
|
|
122
|
+
if (codeToCheck.includes('expect(')) {
|
|
123
|
+
importAdded = this.ensurePlaywrightExpectImport(sourceFile);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let result: {
|
|
128
|
+
originalStatement: string;
|
|
129
|
+
replacementStatement: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
switch (plan.kind) {
|
|
133
|
+
case 'replace-timeout-with-wait':
|
|
134
|
+
result = this.executeReplaceTimeout(sourceFile, plan);
|
|
135
|
+
break;
|
|
136
|
+
case 'wrap-action-with-response':
|
|
137
|
+
result = this.executeWrapActionWithResponse(sourceFile, plan);
|
|
138
|
+
break;
|
|
139
|
+
case 'insert-readiness-check':
|
|
140
|
+
result = this.executeInsertReadinessCheck(sourceFile, plan);
|
|
141
|
+
break;
|
|
142
|
+
case 'replace-inline-locator':
|
|
143
|
+
result = this.executeReplaceInlineLocator(sourceFile, plan);
|
|
144
|
+
break;
|
|
145
|
+
case 'replace-statement':
|
|
146
|
+
result = this.executeReplaceStatement(sourceFile, plan);
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
throw new AbstentionError(
|
|
150
|
+
`Unsupported statement patch operation: ${(plan as BaseStatementPatchPlan).kind}`,
|
|
151
|
+
'AST_UNSUPPORTED_STATEMENT_OPERATION'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate that syntax remains valid after patching
|
|
156
|
+
const diagnostics = tempProject.getProgram().getSyntacticDiagnostics(sourceFile);
|
|
157
|
+
if (diagnostics.length > 0) {
|
|
158
|
+
const firstError = diagnostics[0]?.getMessageText();
|
|
159
|
+
const errorMsg = typeof firstError === 'string' ? firstError : firstError?.getMessageText();
|
|
160
|
+
throw new AbstentionError(
|
|
161
|
+
`Syntax error after applying statement patch: ${errorMsg ?? 'unknown syntax error'}`,
|
|
162
|
+
'AST_SYNTAX_ERROR'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const patchedSource = sourceFile.getFullText();
|
|
167
|
+
const diff = AstPatcher.generateDiff(sourceText, patchedSource, fileName);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
operation: plan.kind,
|
|
172
|
+
originalStatement: result.originalStatement,
|
|
173
|
+
replacementStatement: result.replacementStatement,
|
|
174
|
+
patchedSource,
|
|
175
|
+
diff,
|
|
176
|
+
importAdded,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Patches a test file on disk atomically.
|
|
182
|
+
*/
|
|
183
|
+
public patchStatementInFile(filePath: string, plan: StatementPatchPlan): StatementPatchResult {
|
|
184
|
+
const resolvedPath = path.resolve(filePath);
|
|
185
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
186
|
+
throw new AbstentionError(`Target file not found at ${resolvedPath}`, 'AST_FILE_NOT_FOUND');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sourceFile = this.project.getSourceFile(resolvedPath) ?? this.project.addSourceFileAtPath(resolvedPath);
|
|
190
|
+
const sourceText = sourceFile.getFullText();
|
|
191
|
+
const fileName = path.basename(resolvedPath);
|
|
192
|
+
const result = this.patchStatementInMemory(sourceText, plan, fileName);
|
|
193
|
+
|
|
194
|
+
const targetDir = path.dirname(resolvedPath);
|
|
195
|
+
const tempFile = path.join(
|
|
196
|
+
targetDir,
|
|
197
|
+
`.${path.basename(resolvedPath)}.${Date.now()}.${Math.random().toString(36).substring(2, 6)}.tmp`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
fs.writeFileSync(tempFile, result.patchedSource, 'utf8');
|
|
202
|
+
fs.renameSync(tempFile, resolvedPath);
|
|
203
|
+
sourceFile.refreshFromFileSystemSync();
|
|
204
|
+
return result;
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
if (fs.existsSync(tempFile)) {
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(tempFile);
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore cleanup failure
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Execution Handlers
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
private executeReplaceTimeout(
|
|
222
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
223
|
+
plan: ReplaceTimeoutPlan
|
|
224
|
+
): { originalStatement: string; replacementStatement: string } {
|
|
225
|
+
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
226
|
+
const text = stmt.getText();
|
|
227
|
+
return text.includes('waitForTimeout');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const targetStatement = this.selectSingleTargetStatement(candidateStatements, plan, 'waitForTimeout');
|
|
231
|
+
const originalStatement = targetStatement.getText();
|
|
232
|
+
const replacement = plan.replacementWaitStatement.trim().endsWith(';')
|
|
233
|
+
? plan.replacementWaitStatement.trim()
|
|
234
|
+
: `${plan.replacementWaitStatement.trim()};`;
|
|
235
|
+
|
|
236
|
+
targetStatement.replaceWithText(replacement);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
originalStatement,
|
|
240
|
+
replacementStatement: replacement,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private executeWrapActionWithResponse(
|
|
245
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
246
|
+
plan: WrapActionWithResponsePlan
|
|
247
|
+
): { originalStatement: string; replacementStatement: string } {
|
|
248
|
+
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
249
|
+
const text = stmt.getText();
|
|
250
|
+
if (plan.targetActionSubstring) {
|
|
251
|
+
return text.includes(plan.targetActionSubstring);
|
|
252
|
+
}
|
|
253
|
+
return (
|
|
254
|
+
text.includes('.click(') ||
|
|
255
|
+
text.includes('.fill(') ||
|
|
256
|
+
text.includes('.press(') ||
|
|
257
|
+
text.includes('.selectOption(') ||
|
|
258
|
+
text.includes('.check(')
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const targetStatement = this.selectSingleTargetStatement(
|
|
263
|
+
candidateStatements,
|
|
264
|
+
plan,
|
|
265
|
+
plan.targetActionSubstring ?? 'action'
|
|
266
|
+
);
|
|
267
|
+
const originalStatement = targetStatement.getText();
|
|
268
|
+
|
|
269
|
+
// Extract bare expression without leading await and trailing semicolon
|
|
270
|
+
let bareAction = originalStatement.trim();
|
|
271
|
+
if (bareAction.startsWith('await ')) {
|
|
272
|
+
bareAction = bareAction.substring(6).trim();
|
|
273
|
+
}
|
|
274
|
+
if (bareAction.endsWith(';')) {
|
|
275
|
+
bareAction = bareAction.substring(0, bareAction.length - 1).trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Auto-detect page variable
|
|
279
|
+
let pageVar = plan.pageVarName ?? 'page';
|
|
280
|
+
if (!plan.pageVarName) {
|
|
281
|
+
const pageMatch = bareAction.match(/(\b\w*[pP]age\w*)\./);
|
|
282
|
+
if (pageMatch && pageMatch[1]) {
|
|
283
|
+
pageVar = pageMatch[1];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const timeoutArg = plan.timeoutMs ? `, { timeout: ${plan.timeoutMs} }` : '';
|
|
288
|
+
const replacementStatement = `await Promise.all([\n ${pageVar}.waitForResponse(${plan.responsePredicate}${timeoutArg}),\n ${bareAction},\n]);`;
|
|
289
|
+
|
|
290
|
+
targetStatement.replaceWithText(replacementStatement);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
originalStatement,
|
|
294
|
+
replacementStatement,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private executeInsertReadinessCheck(
|
|
299
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
300
|
+
plan: InsertReadinessCheckPlan
|
|
301
|
+
): { originalStatement: string; replacementStatement: string } {
|
|
302
|
+
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
303
|
+
const text = stmt.getText();
|
|
304
|
+
if (plan.targetActionSubstring) {
|
|
305
|
+
return text.includes(plan.targetActionSubstring);
|
|
306
|
+
}
|
|
307
|
+
return text.includes('.click(') || text.includes('.fill(');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const targetStatement = this.selectSingleTargetStatement(
|
|
311
|
+
candidateStatements,
|
|
312
|
+
plan,
|
|
313
|
+
plan.targetActionSubstring ?? 'click/fill'
|
|
314
|
+
);
|
|
315
|
+
const originalStatement = targetStatement.getText();
|
|
316
|
+
const parent = targetStatement.getParent();
|
|
317
|
+
|
|
318
|
+
const readinessCode = plan.readinessStatement.trim().endsWith(';')
|
|
319
|
+
? plan.readinessStatement.trim()
|
|
320
|
+
: `${plan.readinessStatement.trim()};`;
|
|
321
|
+
|
|
322
|
+
if (Node.isBlock(parent)) {
|
|
323
|
+
const index = targetStatement.getChildIndex();
|
|
324
|
+
parent.insertStatements(index, readinessCode);
|
|
325
|
+
} else {
|
|
326
|
+
// Fallback: replace target statement with readiness + target
|
|
327
|
+
targetStatement.replaceWithText(`${readinessCode}\n${originalStatement}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
originalStatement,
|
|
332
|
+
replacementStatement: `${readinessCode}\n${originalStatement}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private executeReplaceInlineLocator(
|
|
337
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
338
|
+
plan: ReplaceInlineLocatorPlan
|
|
339
|
+
): { originalStatement: string; replacementStatement: string } {
|
|
340
|
+
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
341
|
+
return stmt.getText().includes(plan.oldLocatorText);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const targetStatement = this.selectSingleTargetStatement(
|
|
345
|
+
candidateStatements,
|
|
346
|
+
plan,
|
|
347
|
+
`'${plan.oldLocatorText}'`
|
|
348
|
+
);
|
|
349
|
+
const originalStatement = targetStatement.getText();
|
|
350
|
+
|
|
351
|
+
// AST-level node replacement
|
|
352
|
+
let replacedInAst = false;
|
|
353
|
+
const calls = targetStatement.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
354
|
+
for (const call of calls) {
|
|
355
|
+
if (call.getText().trim() === plan.oldLocatorText.trim()) {
|
|
356
|
+
call.replaceWithText(plan.newLocatorExpression);
|
|
357
|
+
replacedInAst = true;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!replacedInAst) {
|
|
363
|
+
targetStatement.forEachDescendant((node) => {
|
|
364
|
+
if (!replacedInAst && node.getText().trim() === plan.oldLocatorText.trim()) {
|
|
365
|
+
node.replaceWithText(plan.newLocatorExpression);
|
|
366
|
+
replacedInAst = true;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!replacedInAst) {
|
|
372
|
+
const patchedStatementText = originalStatement.replace(plan.oldLocatorText, plan.newLocatorExpression);
|
|
373
|
+
if (patchedStatementText === originalStatement) {
|
|
374
|
+
throw new AbstentionError(
|
|
375
|
+
`Failed to replace '${plan.oldLocatorText}' with '${plan.newLocatorExpression}'`,
|
|
376
|
+
'AST_REPLACEMENT_FAILED'
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
targetStatement.replaceWithText(patchedStatementText);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
originalStatement,
|
|
384
|
+
replacementStatement: targetStatement.getText(),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private executeReplaceStatement(
|
|
389
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
390
|
+
plan: ReplaceStatementPlan
|
|
391
|
+
): { originalStatement: string; replacementStatement: string } {
|
|
392
|
+
const candidateStatements = this.findStatements(sourceFile, plan.targetLine, (stmt) => {
|
|
393
|
+
if (plan.targetSubstring) {
|
|
394
|
+
return stmt.getText().includes(plan.targetSubstring);
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const targetStatement = this.selectSingleTargetStatement(
|
|
400
|
+
candidateStatements,
|
|
401
|
+
plan,
|
|
402
|
+
plan.targetSubstring ?? 'target'
|
|
403
|
+
);
|
|
404
|
+
const originalStatement = targetStatement.getText();
|
|
405
|
+
const replacement = plan.newStatement.trim().endsWith(';')
|
|
406
|
+
? plan.newStatement.trim()
|
|
407
|
+
: `${plan.newStatement.trim()};`;
|
|
408
|
+
|
|
409
|
+
targetStatement.replaceWithText(replacement);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
originalStatement,
|
|
413
|
+
replacementStatement: replacement,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private selectSingleTargetStatement(
|
|
418
|
+
candidates: Statement[],
|
|
419
|
+
plan: BaseStatementPatchPlan,
|
|
420
|
+
label: string
|
|
421
|
+
): Statement {
|
|
422
|
+
if (candidates.length === 0) {
|
|
423
|
+
throw new AbstentionError(
|
|
424
|
+
plan.targetLine
|
|
425
|
+
? `No ${label} statement found at line ${plan.targetLine}`
|
|
426
|
+
: `No ${label} statement found in source file`,
|
|
427
|
+
'AST_STATEMENT_NOT_FOUND'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (candidates.length > 1) {
|
|
431
|
+
throw new AbstentionError(
|
|
432
|
+
plan.targetLine
|
|
433
|
+
? `Ambiguous statement match on line ${plan.targetLine}: found ${candidates.length} matching ${label} statements. Provide a more specific targetSubstring or unique locator to disambiguate.`
|
|
434
|
+
: `Ambiguous statement match: found ${candidates.length} ${label} statements. Provide targetLine to specify which statement to transform.`,
|
|
435
|
+
'AST_AMBIGUOUS_STATEMENT_TARGET'
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const stmt = candidates[0]!;
|
|
439
|
+
this.assertNotAssertion(stmt);
|
|
440
|
+
return stmt;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Helper Methods
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
private findStatements(
|
|
448
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
449
|
+
targetLine?: number,
|
|
450
|
+
predicate?: (stmt: Statement) => boolean
|
|
451
|
+
): Statement[] {
|
|
452
|
+
const statements: Statement[] = [];
|
|
453
|
+
|
|
454
|
+
// Traverse all expression statements and variable statements in tests/blocks
|
|
455
|
+
const allStatements = [
|
|
456
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.ExpressionStatement),
|
|
457
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement),
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
for (const stmt of allStatements) {
|
|
461
|
+
// Exclude outer test wrapper calls (e.g. test('...', () => { ... }))
|
|
462
|
+
if (Node.isExpressionStatement(stmt)) {
|
|
463
|
+
const expr = stmt.getExpression();
|
|
464
|
+
if (Node.isCallExpression(expr)) {
|
|
465
|
+
const callText = expr.getExpression().getText();
|
|
466
|
+
if (
|
|
467
|
+
callText === 'test' ||
|
|
468
|
+
callText === 'test.describe' ||
|
|
469
|
+
callText === 'test.step' ||
|
|
470
|
+
callText === 'describe' ||
|
|
471
|
+
callText === 'it'
|
|
472
|
+
) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (targetLine !== undefined) {
|
|
479
|
+
const startLine = stmt.getStartLineNumber();
|
|
480
|
+
const endLine = stmt.getEndLineNumber();
|
|
481
|
+
if (targetLine < startLine || targetLine > endLine) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (predicate && !predicate(stmt)) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
statements.push(stmt);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return statements;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private assertNotAssertion(stmt: Statement): void {
|
|
497
|
+
const identifiers = AssertionHasher.discoverAssertionIdentifiers(stmt);
|
|
498
|
+
|
|
499
|
+
// Guard against removing or tampering with assertions (standard or aliased)
|
|
500
|
+
const callExpressions = stmt.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
501
|
+
for (const call of callExpressions) {
|
|
502
|
+
if (AssertionHasher.isAssertionCall(call, identifiers)) {
|
|
503
|
+
throw new AbstentionError(
|
|
504
|
+
'Modifying or removing test assertions via StatementPatcher is strictly prohibited',
|
|
505
|
+
'AST_ASSERTION_TAMPERING_PROHIBITED'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const trimmed = stmt.getText().trim();
|
|
511
|
+
for (const id of identifiers) {
|
|
512
|
+
if (trimmed.startsWith(`${id}(`) || trimmed.startsWith(`await ${id}(`)) {
|
|
513
|
+
throw new AbstentionError(
|
|
514
|
+
'Modifying or removing test assertions via StatementPatcher is strictly prohibited',
|
|
515
|
+
'AST_ASSERTION_TAMPERING_PROHIBITED'
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private ensurePlaywrightExpectImport(sourceFile: import('ts-morph').SourceFile): boolean {
|
|
522
|
+
const importDecls = sourceFile.getImportDeclarations();
|
|
523
|
+
const pwImport = importDecls.find((decl) => {
|
|
524
|
+
const spec = decl.getModuleSpecifierValue();
|
|
525
|
+
return spec === '@playwright/test';
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (pwImport) {
|
|
529
|
+
const namedImports = pwImport.getNamedImports().map((i) => i.getName());
|
|
530
|
+
if (!namedImports.includes('expect')) {
|
|
531
|
+
pwImport.addNamedImport('expect');
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// No @playwright/test import found; add one at the top of the file
|
|
538
|
+
sourceFile.insertImportDeclaration(0, {
|
|
539
|
+
moduleSpecifier: '@playwright/test',
|
|
540
|
+
namedImports: ['expect'],
|
|
541
|
+
});
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
}
|