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,612 +1,612 @@
|
|
|
1
|
-
import { Project, Node, SyntaxKind, CallExpression } from 'ts-morph';
|
|
2
|
-
import { UnauthorizedAstModificationError } from '../core/errors/engine-errors.js';
|
|
3
|
-
|
|
4
|
-
export interface DiffAllowlistTarget {
|
|
5
|
-
originalSource: string;
|
|
6
|
-
patchedSource: string;
|
|
7
|
-
targetClassName: string;
|
|
8
|
-
targetMemberName: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface DiffAllowlistResult {
|
|
12
|
-
isAllowed: boolean;
|
|
13
|
-
reason?: string | undefined;
|
|
14
|
-
modifiedMember?: string | undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class DiffAllowlistValidator {
|
|
18
|
-
private static readonly APPROVED_LOCATOR_METHODS = new Set([
|
|
19
|
-
'locator',
|
|
20
|
-
'frameLocator',
|
|
21
|
-
'getByRole',
|
|
22
|
-
'getByText',
|
|
23
|
-
'getByTestId',
|
|
24
|
-
'getByLabel',
|
|
25
|
-
'getByPlaceholder',
|
|
26
|
-
'getByAltText',
|
|
27
|
-
'getByTitle',
|
|
28
|
-
'filter',
|
|
29
|
-
'first',
|
|
30
|
-
'last',
|
|
31
|
-
'nth',
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Validates that ONLY the authorized locator member within the specified Page Object was modified.
|
|
36
|
-
* Compares the COMPLETE AST of the source file:
|
|
37
|
-
* - Prohibits modifying or adding imports.
|
|
38
|
-
* - Prohibits adding, removing, or modifying top-level functions, variables, interfaces, enums, type aliases.
|
|
39
|
-
* - Prohibits adding or removing class members (properties, methods, getters, setters, constructors).
|
|
40
|
-
* - Prohibits modifying class inheritance, decorators, or modifiers.
|
|
41
|
-
* - Validates that the replacement expression conforms strictly to the approved Playwright locator grammar.
|
|
42
|
-
*/
|
|
43
|
-
public static validate(target: DiffAllowlistTarget): DiffAllowlistResult {
|
|
44
|
-
const origProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
45
|
-
const patchProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
46
|
-
|
|
47
|
-
const origFile = origProject.createSourceFile('orig.ts', target.originalSource);
|
|
48
|
-
const patchFile = patchProject.createSourceFile('patched.ts', target.patchedSource);
|
|
49
|
-
|
|
50
|
-
// 1. Verify Imports: Exact match
|
|
51
|
-
const origImports = origFile.getImportDeclarations().map((i) => i.getText().trim());
|
|
52
|
-
const patchImports = patchFile.getImportDeclarations().map((i) => i.getText().trim());
|
|
53
|
-
if (JSON.stringify(origImports) !== JSON.stringify(patchImports)) {
|
|
54
|
-
return {
|
|
55
|
-
isAllowed: false,
|
|
56
|
-
reason: 'Diff allowlist violated: Import declarations were modified, added, or deleted.',
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// 2. Verify Top-Level Non-Class Declarations (Functions, Variables, Interfaces, Type Aliases, Enums)
|
|
61
|
-
const origFunctions = origFile.getFunctions().map((f) => f.getText().trim());
|
|
62
|
-
const patchFunctions = patchFile.getFunctions().map((f) => f.getText().trim());
|
|
63
|
-
if (JSON.stringify(origFunctions) !== JSON.stringify(patchFunctions)) {
|
|
64
|
-
return {
|
|
65
|
-
isAllowed: false,
|
|
66
|
-
reason: 'Diff allowlist violated: Top-level function declarations were added, removed, or modified.',
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const origInterfaces = origFile.getInterfaces().map((i) => i.getText().trim());
|
|
71
|
-
const patchInterfaces = patchFile.getInterfaces().map((i) => i.getText().trim());
|
|
72
|
-
if (JSON.stringify(origInterfaces) !== JSON.stringify(patchInterfaces)) {
|
|
73
|
-
return {
|
|
74
|
-
isAllowed: false,
|
|
75
|
-
reason: 'Diff allowlist violated: Interface declarations were altered.',
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const origTypeAliases = origFile.getTypeAliases().map((t) => t.getText().trim());
|
|
80
|
-
const patchTypeAliases = patchFile.getTypeAliases().map((t) => t.getText().trim());
|
|
81
|
-
if (JSON.stringify(origTypeAliases) !== JSON.stringify(patchTypeAliases)) {
|
|
82
|
-
return {
|
|
83
|
-
isAllowed: false,
|
|
84
|
-
reason: 'Diff allowlist violated: Type alias declarations were altered.',
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const origEnums = origFile.getEnums().map((e) => e.getText().trim());
|
|
89
|
-
const patchEnums = patchFile.getEnums().map((e) => e.getText().trim());
|
|
90
|
-
if (JSON.stringify(origEnums) !== JSON.stringify(patchEnums)) {
|
|
91
|
-
return {
|
|
92
|
-
isAllowed: false,
|
|
93
|
-
reason: 'Diff allowlist violated: Enum declarations were altered.',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 3. Verify Classes: Exact count and names
|
|
98
|
-
const origClasses = origFile.getClasses();
|
|
99
|
-
const patchClasses = patchFile.getClasses();
|
|
100
|
-
if (origClasses.length !== patchClasses.length) {
|
|
101
|
-
return {
|
|
102
|
-
isAllowed: false,
|
|
103
|
-
reason: `Diff allowlist violated: Class structure or declarations were altered (Class count changed ${origClasses.length} -> ${patchClasses.length}).`,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
for (let idx = 0; idx < origClasses.length; idx++) {
|
|
108
|
-
if (origClasses[idx]?.getName() !== patchClasses[idx]?.getName()) {
|
|
109
|
-
return {
|
|
110
|
-
isAllowed: false,
|
|
111
|
-
reason: 'Diff allowlist violated: Class structure or declarations were altered.',
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 4. Locate Target Class
|
|
117
|
-
const origClass = origFile.getClass(target.targetClassName);
|
|
118
|
-
const patchClass = patchFile.getClass(target.targetClassName);
|
|
119
|
-
|
|
120
|
-
if (!origClass || !patchClass) {
|
|
121
|
-
// Check top-level constant pattern if class is not used
|
|
122
|
-
const origVar = origFile.getVariableDeclaration(target.targetMemberName);
|
|
123
|
-
const patchVar = patchFile.getVariableDeclaration(target.targetMemberName);
|
|
124
|
-
if (origVar && patchVar) {
|
|
125
|
-
const replacement = patchVar.getInitializer()?.getText() ?? '';
|
|
126
|
-
const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(replacement);
|
|
127
|
-
if (!grammarCheck.isValid) {
|
|
128
|
-
return { isAllowed: false, reason: grammarCheck.reason };
|
|
129
|
-
}
|
|
130
|
-
return { isAllowed: true, modifiedMember: target.targetMemberName };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
isAllowed: false,
|
|
135
|
-
reason: `Target class '${target.targetClassName}' not found in source AST.`,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 5. Verify Target Class Structure & Members
|
|
140
|
-
// Total member counts must match
|
|
141
|
-
if (origClass.getProperties().length !== patchClass.getProperties().length) {
|
|
142
|
-
return {
|
|
143
|
-
isAllowed: false,
|
|
144
|
-
reason: `Diff allowlist violated: Class property count changed (${origClass.getProperties().length} -> ${patchClass.getProperties().length}). Unauthorized property added or removed.`,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (origClass.getMethods().length !== patchClass.getMethods().length) {
|
|
149
|
-
return {
|
|
150
|
-
isAllowed: false,
|
|
151
|
-
reason: `Diff allowlist violated: Class method count changed (${origClass.getMethods().length} -> ${patchClass.getMethods().length}). Unauthorized method added or removed.`,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (origClass.getGetAccessors().length !== patchClass.getGetAccessors().length) {
|
|
156
|
-
return {
|
|
157
|
-
isAllowed: false,
|
|
158
|
-
reason: `Diff allowlist violated: Getter accessor count changed in class '${target.targetClassName}'.`,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (origClass.getSetAccessors().length !== patchClass.getSetAccessors().length) {
|
|
163
|
-
return {
|
|
164
|
-
isAllowed: false,
|
|
165
|
-
reason: `Diff allowlist violated: Setter accessor count changed in class '${target.targetClassName}'.`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (origClass.getConstructors().length !== patchClass.getConstructors().length) {
|
|
170
|
-
return {
|
|
171
|
-
isAllowed: false,
|
|
172
|
-
reason: `Diff allowlist violated: Constructor count changed in class '${target.targetClassName}'.`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Check heritage, modifiers, decorators
|
|
177
|
-
if (origClass.getExtends()?.getText() !== patchClass.getExtends()?.getText()) {
|
|
178
|
-
return {
|
|
179
|
-
isAllowed: false,
|
|
180
|
-
reason: `Diff allowlist violated: Class 'extends' heritage was altered.`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 6. Verify all other properties are unchanged
|
|
185
|
-
for (const prop of origClass.getProperties()) {
|
|
186
|
-
const name = prop.getName();
|
|
187
|
-
if (name !== target.targetMemberName) {
|
|
188
|
-
const patchProp = patchClass.getProperty(name);
|
|
189
|
-
if (!patchProp || patchProp.getText().trim() !== prop.getText().trim()) {
|
|
190
|
-
return {
|
|
191
|
-
isAllowed: false,
|
|
192
|
-
reason: `Diff allowlist violated: Unauthorized change to property '${name}' in class '${target.targetClassName}'.`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 7. Verify all other methods are unchanged
|
|
199
|
-
for (const method of origClass.getMethods()) {
|
|
200
|
-
const name = method.getName();
|
|
201
|
-
if (name !== target.targetMemberName) {
|
|
202
|
-
const patchMethod = patchClass.getMethod(name);
|
|
203
|
-
if (!patchMethod || patchMethod.getText().trim() !== method.getText().trim()) {
|
|
204
|
-
return {
|
|
205
|
-
isAllowed: false,
|
|
206
|
-
reason: `Diff allowlist violated: Unauthorized change to method '${name}' in class '${target.targetClassName}'.`,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// 8. Extract and validate replacement expression
|
|
213
|
-
const patchProp = patchClass.getProperty(target.targetMemberName);
|
|
214
|
-
const patchGetter = patchClass.getGetAccessor(target.targetMemberName);
|
|
215
|
-
const patchMethod = patchClass.getMethod(target.targetMemberName);
|
|
216
|
-
let locatorText = '';
|
|
217
|
-
|
|
218
|
-
if (patchProp && patchProp.getInitializer()) {
|
|
219
|
-
locatorText = patchProp.getInitializer()!.getText();
|
|
220
|
-
} else if (patchGetter) {
|
|
221
|
-
const returns = patchGetter.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
222
|
-
locatorText = returns[0]?.getExpression()?.getText() ?? '';
|
|
223
|
-
} else if (patchMethod) {
|
|
224
|
-
const returns = patchMethod.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
225
|
-
locatorText = returns[0]?.getExpression()?.getText() ?? '';
|
|
226
|
-
} else {
|
|
227
|
-
// Check constructor assignments
|
|
228
|
-
for (const ctor of patchClass.getConstructors()) {
|
|
229
|
-
for (const stmt of ctor.getStatements()) {
|
|
230
|
-
if (Node.isExpressionStatement(stmt)) {
|
|
231
|
-
const expr = stmt.getExpression();
|
|
232
|
-
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getKind() === SyntaxKind.EqualsToken) {
|
|
233
|
-
if (expr.getLeft().getText() === `this.${target.targetMemberName}`) {
|
|
234
|
-
locatorText = expr.getRight().getText();
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (!locatorText) {
|
|
244
|
-
return {
|
|
245
|
-
isAllowed: false,
|
|
246
|
-
reason: `Diff allowlist violated: Target member '${target.targetMemberName}' has no replacement expression.`,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// 9. Semantic AST Locator Grammar Validation
|
|
251
|
-
const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(locatorText);
|
|
252
|
-
if (!grammarCheck.isValid) {
|
|
253
|
-
return {
|
|
254
|
-
isAllowed: false,
|
|
255
|
-
reason: grammarCheck.reason,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
isAllowed: true,
|
|
261
|
-
modifiedMember: target.targetMemberName,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private static readonly APPROVED_OPTION_KEYS = new Set([
|
|
266
|
-
'name',
|
|
267
|
-
'exact',
|
|
268
|
-
'hasText',
|
|
269
|
-
'hasNotText',
|
|
270
|
-
'has',
|
|
271
|
-
'hasNot',
|
|
272
|
-
'timeout',
|
|
273
|
-
'checked',
|
|
274
|
-
'disabled',
|
|
275
|
-
'expanded',
|
|
276
|
-
'includeHidden',
|
|
277
|
-
'level',
|
|
278
|
-
'pressed',
|
|
279
|
-
'selected',
|
|
280
|
-
]);
|
|
281
|
-
|
|
282
|
-
private static isLiteralValue(node: Node): boolean {
|
|
283
|
-
return (
|
|
284
|
-
Node.isStringLiteral(node) ||
|
|
285
|
-
Node.isNumericLiteral(node) ||
|
|
286
|
-
node.getKind() === SyntaxKind.TrueKeyword ||
|
|
287
|
-
node.getKind() === SyntaxKind.FalseKeyword ||
|
|
288
|
-
Node.isRegularExpressionLiteral(node)
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Recursively validates an argument node passed into a locator method call.
|
|
294
|
-
* Strictly disallows identifiers (process.env), arbitrary function calls,
|
|
295
|
-
* arrow functions, dynamic property accesses, and executable code.
|
|
296
|
-
*/
|
|
297
|
-
private static validateArgumentNode(arg: Node): { isValid: boolean; reason?: string } {
|
|
298
|
-
if (DiffAllowlistValidator.isLiteralValue(arg)) {
|
|
299
|
-
return { isValid: true };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (Node.isObjectLiteralExpression(arg)) {
|
|
303
|
-
for (const prop of arg.getProperties()) {
|
|
304
|
-
if (!Node.isPropertyAssignment(prop)) {
|
|
305
|
-
return {
|
|
306
|
-
isValid: false,
|
|
307
|
-
reason: `Disallowed object property structure: only standard key-value assignments are allowed, received ${prop.getKindName()}`,
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const keyName = prop.getName();
|
|
312
|
-
if (!DiffAllowlistValidator.APPROVED_OPTION_KEYS.has(keyName)) {
|
|
313
|
-
return {
|
|
314
|
-
isValid: false,
|
|
315
|
-
reason: `Disallowed option key '${keyName}'. Only approved Playwright locator options are allowed.`,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const val = prop.getInitializer();
|
|
320
|
-
if (!val) {
|
|
321
|
-
return { isValid: false, reason: `Missing value for option key '${keyName}'` };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (keyName === 'has' || keyName === 'hasNot') {
|
|
325
|
-
// Recursive locator call validation for nested locators
|
|
326
|
-
if (!Node.isCallExpression(val)) {
|
|
327
|
-
return {
|
|
328
|
-
isValid: false,
|
|
329
|
-
reason: `Option '${keyName}' must be a CallExpression resolving to a locator, received ${val.getKindName()}`,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
const nestedCheck = DiffAllowlistValidator.validateLocatorCallChain(val);
|
|
333
|
-
if (!nestedCheck.isValid) return nestedCheck;
|
|
334
|
-
} else {
|
|
335
|
-
// All other options must be literal values
|
|
336
|
-
if (!DiffAllowlistValidator.isLiteralValue(val)) {
|
|
337
|
-
return {
|
|
338
|
-
isValid: false,
|
|
339
|
-
reason: `Disallowed argument in option '${keyName}': ${val.getKindName()} ('${val.getText()}'). Dynamic expressions, environment variables, and function calls are prohibited.`,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return { isValid: true };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return {
|
|
348
|
-
isValid: false,
|
|
349
|
-
reason: `Disallowed argument expression: ${arg.getKindName()} ('${arg.getText()}'). Only literals and safe option objects are permitted.`,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Validates a locator call chain expression recursively, including all arguments.
|
|
355
|
-
*/
|
|
356
|
-
private static validateLocatorCallChain(callExpr: CallExpression): { isValid: boolean; reason?: string } {
|
|
357
|
-
let current: Node = callExpr;
|
|
358
|
-
while (Node.isCallExpression(current)) {
|
|
359
|
-
const expr = current.getExpression();
|
|
360
|
-
if (Node.isPropertyAccessExpression(expr)) {
|
|
361
|
-
const methodName = expr.getName();
|
|
362
|
-
if (!DiffAllowlistValidator.APPROVED_LOCATOR_METHODS.has(methodName)) {
|
|
363
|
-
return {
|
|
364
|
-
isValid: false,
|
|
365
|
-
reason: `Replacement expression contains disallowed statements: disallowed method call '${methodName}'. Only Playwright locator methods are permitted.`,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Recursively validate every argument passed to this method
|
|
370
|
-
for (const arg of current.getArguments()) {
|
|
371
|
-
const argCheck = DiffAllowlistValidator.validateArgumentNode(arg);
|
|
372
|
-
if (!argCheck.isValid) {
|
|
373
|
-
return {
|
|
374
|
-
isValid: false,
|
|
375
|
-
reason: `Replacement expression contains disallowed statements: ${argCheck.reason}`,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
current = expr.getExpression();
|
|
381
|
-
} else {
|
|
382
|
-
break;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const rootText = current.getText().trim();
|
|
387
|
-
const approvedRoots = new Set(['this.page', 'page', 'this', 'this._page', 'this.browserPage']);
|
|
388
|
-
if (!approvedRoots.has(rootText)) {
|
|
389
|
-
return {
|
|
390
|
-
isValid: false,
|
|
391
|
-
reason: `Replacement expression contains disallowed statements: locator chain root must be 'this.page' or 'page', received '${rootText}'`,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return { isValid: true };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Validates that a replacement expression is strictly a safe Playwright locator call AST.
|
|
400
|
-
* Rejects arbitrary statements, process calls, functions, or unapproved methods.
|
|
401
|
-
*/
|
|
402
|
-
public static validateLocatorGrammar(expressionText: string): { isValid: boolean; reason?: string } {
|
|
403
|
-
// 1. Basic sanitization: No statements separated by semicolons
|
|
404
|
-
const trimmed = expressionText.trim().replace(/;$/, '');
|
|
405
|
-
if (trimmed.includes(';')) {
|
|
406
|
-
return {
|
|
407
|
-
isValid: false,
|
|
408
|
-
reason: `Replacement expression contains disallowed statements: multiple statements in '${expressionText}'`,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// 2. Reject obvious dangerous keywords upfront
|
|
413
|
-
const lower = trimmed.toLowerCase();
|
|
414
|
-
if (
|
|
415
|
-
lower.includes('process.') ||
|
|
416
|
-
lower.includes('require(') ||
|
|
417
|
-
lower.includes('import(') ||
|
|
418
|
-
lower.includes('eval(') ||
|
|
419
|
-
lower.includes('exec(') ||
|
|
420
|
-
lower.includes('spawn(')
|
|
421
|
-
) {
|
|
422
|
-
return {
|
|
423
|
-
isValid: false,
|
|
424
|
-
reason: `Replacement expression contains disallowed statements: arbitrary code execution detected in '${expressionText}'`,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// 3. Parse into AST expression
|
|
429
|
-
const tempProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
430
|
-
const tempFile = tempProject.createSourceFile('temp_expr.ts', `const _x = ${trimmed};`);
|
|
431
|
-
const varDecl = tempFile.getVariableDeclaration('_x');
|
|
432
|
-
const initializer = varDecl?.getInitializer();
|
|
433
|
-
|
|
434
|
-
if (!initializer) {
|
|
435
|
-
return {
|
|
436
|
-
isValid: false,
|
|
437
|
-
reason: `Replacement expression contains disallowed statements: failed to parse '${expressionText}'`,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// String literals are valid for locator constants (e.g. '[data-test=submit]')
|
|
442
|
-
if (Node.isStringLiteral(initializer)) {
|
|
443
|
-
return { isValid: true };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Must be a CallExpression or PropertyAccessExpression resolving to a locator
|
|
447
|
-
if (!Node.isCallExpression(initializer)) {
|
|
448
|
-
return {
|
|
449
|
-
isValid: false,
|
|
450
|
-
reason: `Replacement expression contains disallowed statements: must be a CallExpression (e.g. this.page.getByRole(...)), received ${initializer.getKindName()}`,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Validate the call chain and its arguments recursively
|
|
455
|
-
return DiffAllowlistValidator.validateLocatorCallChain(initializer);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Asserts diff allowlist compliance, throwing UnauthorizedAstModificationError if violated.
|
|
460
|
-
*/
|
|
461
|
-
public static assertAllowed(target: DiffAllowlistTarget): void {
|
|
462
|
-
const result = DiffAllowlistValidator.validate(target);
|
|
463
|
-
if (!result.isAllowed) {
|
|
464
|
-
throw new UnauthorizedAstModificationError(result.reason ?? 'Unknown violation');
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
export interface StatementDiffAllowlistTarget {
|
|
470
|
-
originalSource: string;
|
|
471
|
-
patchedSource: string;
|
|
472
|
-
operation?: string | undefined;
|
|
473
|
-
targetLine?: number | undefined;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
export interface StatementDiffAllowlistResult {
|
|
477
|
-
isAllowed: boolean;
|
|
478
|
-
reason?: string | undefined;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export class StatementDiffAllowlistValidator {
|
|
482
|
-
private static readonly DISALLOWED_SECURITY_TOKENS = [
|
|
483
|
-
'process.env',
|
|
484
|
-
'process.exit',
|
|
485
|
-
'child_process',
|
|
486
|
-
'require(',
|
|
487
|
-
'eval(',
|
|
488
|
-
'exec(',
|
|
489
|
-
'spawn(',
|
|
490
|
-
'fs.',
|
|
491
|
-
'Function(',
|
|
492
|
-
];
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Validates that ONLY approved statement-level modifications (timing synchronizations,
|
|
496
|
-
* event-driven wait replacements, readiness checks, or inline locator repairs) were applied to the test spec AST.
|
|
497
|
-
*
|
|
498
|
-
* Guarantees:
|
|
499
|
-
* - No arbitrary code execution or dangerous APIs.
|
|
500
|
-
* - No unauthorized third-party module imports.
|
|
501
|
-
* - Top-level declarations (functions, classes, interfaces, types) remain invariant.
|
|
502
|
-
* - Test block structure and test descriptions remain invariant.
|
|
503
|
-
*/
|
|
504
|
-
public static validate(target: StatementDiffAllowlistTarget): StatementDiffAllowlistResult {
|
|
505
|
-
// 1. Prohibit dangerous tokens
|
|
506
|
-
for (const token of StatementDiffAllowlistValidator.DISALLOWED_SECURITY_TOKENS) {
|
|
507
|
-
if (target.patchedSource.includes(token) && !target.originalSource.includes(token)) {
|
|
508
|
-
return {
|
|
509
|
-
isAllowed: false,
|
|
510
|
-
reason: `Statement diff allowlist violated: Prohibited security token '${token}' introduced.`,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const origProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
516
|
-
const patchProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
517
|
-
|
|
518
|
-
const origFile = origProject.createSourceFile('orig.spec.ts', target.originalSource);
|
|
519
|
-
const patchFile = patchProject.createSourceFile('patched.spec.ts', target.patchedSource);
|
|
520
|
-
|
|
521
|
-
// 2. Verify Imports: Only allow adding expect from @playwright/test
|
|
522
|
-
const origImports = origFile.getImportDeclarations();
|
|
523
|
-
const patchImports = patchFile.getImportDeclarations();
|
|
524
|
-
|
|
525
|
-
for (const patchImp of patchImports) {
|
|
526
|
-
const spec = patchImp.getModuleSpecifierValue();
|
|
527
|
-
const origImp = origImports.find((i) => i.getModuleSpecifierValue() === spec);
|
|
528
|
-
if (!origImp) {
|
|
529
|
-
// Only @playwright/test import may be added
|
|
530
|
-
if (spec !== '@playwright/test') {
|
|
531
|
-
return {
|
|
532
|
-
isAllowed: false,
|
|
533
|
-
reason: `Statement diff allowlist violated: Unauthorized new import of module '${spec}'.`,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
} else {
|
|
537
|
-
// If module exists, ensure no unauthorized named imports were added
|
|
538
|
-
const origNamed = new Set(origImp.getNamedImports().map((n) => n.getName()));
|
|
539
|
-
for (const named of patchImp.getNamedImports()) {
|
|
540
|
-
const name = named.getName();
|
|
541
|
-
if (!origNamed.has(name) && name !== 'expect') {
|
|
542
|
-
return {
|
|
543
|
-
isAllowed: false,
|
|
544
|
-
reason: `Statement diff allowlist violated: Unauthorized import '${name}' added from '${spec}'.`,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// 3. Prohibit adding or removing top-level declarations
|
|
552
|
-
if (origFile.getFunctions().length !== patchFile.getFunctions().length) {
|
|
553
|
-
return {
|
|
554
|
-
isAllowed: false,
|
|
555
|
-
reason: 'Statement diff allowlist violated: Top-level function declarations modified or added.',
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
if (origFile.getClasses().length !== patchFile.getClasses().length) {
|
|
559
|
-
return {
|
|
560
|
-
isAllowed: false,
|
|
561
|
-
reason: 'Statement diff allowlist violated: Top-level class declarations modified or added.',
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
if (origFile.getInterfaces().length !== patchFile.getInterfaces().length) {
|
|
565
|
-
return {
|
|
566
|
-
isAllowed: false,
|
|
567
|
-
reason: 'Statement diff allowlist violated: Top-level interface declarations modified or added.',
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
if (origFile.getTypeAliases().length !== patchFile.getTypeAliases().length) {
|
|
571
|
-
return {
|
|
572
|
-
isAllowed: false,
|
|
573
|
-
reason: 'Statement diff allowlist violated: Top-level type aliases modified or added.',
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// 4. Verify test blocks: Count and descriptions of test() and test.describe() must match
|
|
578
|
-
const extractTestCalls = (sourceFile: import('ts-morph').SourceFile) => {
|
|
579
|
-
const calls: string[] = [];
|
|
580
|
-
sourceFile.forEachDescendant((node) => {
|
|
581
|
-
if (Node.isCallExpression(node)) {
|
|
582
|
-
const text = node.getExpression().getText();
|
|
583
|
-
if (text === 'test' || text === 'test.describe' || text === 'test.only' || text === 'test.skip') {
|
|
584
|
-
const firstArg = node.getArguments()[0];
|
|
585
|
-
calls.push(`${text}:${firstArg ? firstArg.getText() : ''}`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
});
|
|
589
|
-
return calls;
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const origTestCalls = extractTestCalls(origFile);
|
|
593
|
-
const patchTestCalls = extractTestCalls(patchFile);
|
|
594
|
-
if (JSON.stringify(origTestCalls) !== JSON.stringify(patchTestCalls)) {
|
|
595
|
-
return {
|
|
596
|
-
isAllowed: false,
|
|
597
|
-
reason: 'Statement diff allowlist violated: Test suite structure, test names, or test block declarations altered.',
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
return {
|
|
602
|
-
isAllowed: true,
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
public static assertAllowed(target: StatementDiffAllowlistTarget): void {
|
|
607
|
-
const res = StatementDiffAllowlistValidator.validate(target);
|
|
608
|
-
if (!res.isAllowed) {
|
|
609
|
-
throw new UnauthorizedAstModificationError(res.reason ?? 'Unknown violation');
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}
|
|
1
|
+
import { Project, Node, SyntaxKind, CallExpression } from 'ts-morph';
|
|
2
|
+
import { UnauthorizedAstModificationError } from '../core/errors/engine-errors.js';
|
|
3
|
+
|
|
4
|
+
export interface DiffAllowlistTarget {
|
|
5
|
+
originalSource: string;
|
|
6
|
+
patchedSource: string;
|
|
7
|
+
targetClassName: string;
|
|
8
|
+
targetMemberName: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DiffAllowlistResult {
|
|
12
|
+
isAllowed: boolean;
|
|
13
|
+
reason?: string | undefined;
|
|
14
|
+
modifiedMember?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DiffAllowlistValidator {
|
|
18
|
+
private static readonly APPROVED_LOCATOR_METHODS = new Set([
|
|
19
|
+
'locator',
|
|
20
|
+
'frameLocator',
|
|
21
|
+
'getByRole',
|
|
22
|
+
'getByText',
|
|
23
|
+
'getByTestId',
|
|
24
|
+
'getByLabel',
|
|
25
|
+
'getByPlaceholder',
|
|
26
|
+
'getByAltText',
|
|
27
|
+
'getByTitle',
|
|
28
|
+
'filter',
|
|
29
|
+
'first',
|
|
30
|
+
'last',
|
|
31
|
+
'nth',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validates that ONLY the authorized locator member within the specified Page Object was modified.
|
|
36
|
+
* Compares the COMPLETE AST of the source file:
|
|
37
|
+
* - Prohibits modifying or adding imports.
|
|
38
|
+
* - Prohibits adding, removing, or modifying top-level functions, variables, interfaces, enums, type aliases.
|
|
39
|
+
* - Prohibits adding or removing class members (properties, methods, getters, setters, constructors).
|
|
40
|
+
* - Prohibits modifying class inheritance, decorators, or modifiers.
|
|
41
|
+
* - Validates that the replacement expression conforms strictly to the approved Playwright locator grammar.
|
|
42
|
+
*/
|
|
43
|
+
public static validate(target: DiffAllowlistTarget): DiffAllowlistResult {
|
|
44
|
+
const origProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
45
|
+
const patchProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
46
|
+
|
|
47
|
+
const origFile = origProject.createSourceFile('orig.ts', target.originalSource);
|
|
48
|
+
const patchFile = patchProject.createSourceFile('patched.ts', target.patchedSource);
|
|
49
|
+
|
|
50
|
+
// 1. Verify Imports: Exact match
|
|
51
|
+
const origImports = origFile.getImportDeclarations().map((i) => i.getText().trim());
|
|
52
|
+
const patchImports = patchFile.getImportDeclarations().map((i) => i.getText().trim());
|
|
53
|
+
if (JSON.stringify(origImports) !== JSON.stringify(patchImports)) {
|
|
54
|
+
return {
|
|
55
|
+
isAllowed: false,
|
|
56
|
+
reason: 'Diff allowlist violated: Import declarations were modified, added, or deleted.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Verify Top-Level Non-Class Declarations (Functions, Variables, Interfaces, Type Aliases, Enums)
|
|
61
|
+
const origFunctions = origFile.getFunctions().map((f) => f.getText().trim());
|
|
62
|
+
const patchFunctions = patchFile.getFunctions().map((f) => f.getText().trim());
|
|
63
|
+
if (JSON.stringify(origFunctions) !== JSON.stringify(patchFunctions)) {
|
|
64
|
+
return {
|
|
65
|
+
isAllowed: false,
|
|
66
|
+
reason: 'Diff allowlist violated: Top-level function declarations were added, removed, or modified.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const origInterfaces = origFile.getInterfaces().map((i) => i.getText().trim());
|
|
71
|
+
const patchInterfaces = patchFile.getInterfaces().map((i) => i.getText().trim());
|
|
72
|
+
if (JSON.stringify(origInterfaces) !== JSON.stringify(patchInterfaces)) {
|
|
73
|
+
return {
|
|
74
|
+
isAllowed: false,
|
|
75
|
+
reason: 'Diff allowlist violated: Interface declarations were altered.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const origTypeAliases = origFile.getTypeAliases().map((t) => t.getText().trim());
|
|
80
|
+
const patchTypeAliases = patchFile.getTypeAliases().map((t) => t.getText().trim());
|
|
81
|
+
if (JSON.stringify(origTypeAliases) !== JSON.stringify(patchTypeAliases)) {
|
|
82
|
+
return {
|
|
83
|
+
isAllowed: false,
|
|
84
|
+
reason: 'Diff allowlist violated: Type alias declarations were altered.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const origEnums = origFile.getEnums().map((e) => e.getText().trim());
|
|
89
|
+
const patchEnums = patchFile.getEnums().map((e) => e.getText().trim());
|
|
90
|
+
if (JSON.stringify(origEnums) !== JSON.stringify(patchEnums)) {
|
|
91
|
+
return {
|
|
92
|
+
isAllowed: false,
|
|
93
|
+
reason: 'Diff allowlist violated: Enum declarations were altered.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Verify Classes: Exact count and names
|
|
98
|
+
const origClasses = origFile.getClasses();
|
|
99
|
+
const patchClasses = patchFile.getClasses();
|
|
100
|
+
if (origClasses.length !== patchClasses.length) {
|
|
101
|
+
return {
|
|
102
|
+
isAllowed: false,
|
|
103
|
+
reason: `Diff allowlist violated: Class structure or declarations were altered (Class count changed ${origClasses.length} -> ${patchClasses.length}).`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (let idx = 0; idx < origClasses.length; idx++) {
|
|
108
|
+
if (origClasses[idx]?.getName() !== patchClasses[idx]?.getName()) {
|
|
109
|
+
return {
|
|
110
|
+
isAllowed: false,
|
|
111
|
+
reason: 'Diff allowlist violated: Class structure or declarations were altered.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Locate Target Class
|
|
117
|
+
const origClass = origFile.getClass(target.targetClassName);
|
|
118
|
+
const patchClass = patchFile.getClass(target.targetClassName);
|
|
119
|
+
|
|
120
|
+
if (!origClass || !patchClass) {
|
|
121
|
+
// Check top-level constant pattern if class is not used
|
|
122
|
+
const origVar = origFile.getVariableDeclaration(target.targetMemberName);
|
|
123
|
+
const patchVar = patchFile.getVariableDeclaration(target.targetMemberName);
|
|
124
|
+
if (origVar && patchVar) {
|
|
125
|
+
const replacement = patchVar.getInitializer()?.getText() ?? '';
|
|
126
|
+
const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(replacement);
|
|
127
|
+
if (!grammarCheck.isValid) {
|
|
128
|
+
return { isAllowed: false, reason: grammarCheck.reason };
|
|
129
|
+
}
|
|
130
|
+
return { isAllowed: true, modifiedMember: target.targetMemberName };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
isAllowed: false,
|
|
135
|
+
reason: `Target class '${target.targetClassName}' not found in source AST.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 5. Verify Target Class Structure & Members
|
|
140
|
+
// Total member counts must match
|
|
141
|
+
if (origClass.getProperties().length !== patchClass.getProperties().length) {
|
|
142
|
+
return {
|
|
143
|
+
isAllowed: false,
|
|
144
|
+
reason: `Diff allowlist violated: Class property count changed (${origClass.getProperties().length} -> ${patchClass.getProperties().length}). Unauthorized property added or removed.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (origClass.getMethods().length !== patchClass.getMethods().length) {
|
|
149
|
+
return {
|
|
150
|
+
isAllowed: false,
|
|
151
|
+
reason: `Diff allowlist violated: Class method count changed (${origClass.getMethods().length} -> ${patchClass.getMethods().length}). Unauthorized method added or removed.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (origClass.getGetAccessors().length !== patchClass.getGetAccessors().length) {
|
|
156
|
+
return {
|
|
157
|
+
isAllowed: false,
|
|
158
|
+
reason: `Diff allowlist violated: Getter accessor count changed in class '${target.targetClassName}'.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (origClass.getSetAccessors().length !== patchClass.getSetAccessors().length) {
|
|
163
|
+
return {
|
|
164
|
+
isAllowed: false,
|
|
165
|
+
reason: `Diff allowlist violated: Setter accessor count changed in class '${target.targetClassName}'.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (origClass.getConstructors().length !== patchClass.getConstructors().length) {
|
|
170
|
+
return {
|
|
171
|
+
isAllowed: false,
|
|
172
|
+
reason: `Diff allowlist violated: Constructor count changed in class '${target.targetClassName}'.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check heritage, modifiers, decorators
|
|
177
|
+
if (origClass.getExtends()?.getText() !== patchClass.getExtends()?.getText()) {
|
|
178
|
+
return {
|
|
179
|
+
isAllowed: false,
|
|
180
|
+
reason: `Diff allowlist violated: Class 'extends' heritage was altered.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 6. Verify all other properties are unchanged
|
|
185
|
+
for (const prop of origClass.getProperties()) {
|
|
186
|
+
const name = prop.getName();
|
|
187
|
+
if (name !== target.targetMemberName) {
|
|
188
|
+
const patchProp = patchClass.getProperty(name);
|
|
189
|
+
if (!patchProp || patchProp.getText().trim() !== prop.getText().trim()) {
|
|
190
|
+
return {
|
|
191
|
+
isAllowed: false,
|
|
192
|
+
reason: `Diff allowlist violated: Unauthorized change to property '${name}' in class '${target.targetClassName}'.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 7. Verify all other methods are unchanged
|
|
199
|
+
for (const method of origClass.getMethods()) {
|
|
200
|
+
const name = method.getName();
|
|
201
|
+
if (name !== target.targetMemberName) {
|
|
202
|
+
const patchMethod = patchClass.getMethod(name);
|
|
203
|
+
if (!patchMethod || patchMethod.getText().trim() !== method.getText().trim()) {
|
|
204
|
+
return {
|
|
205
|
+
isAllowed: false,
|
|
206
|
+
reason: `Diff allowlist violated: Unauthorized change to method '${name}' in class '${target.targetClassName}'.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 8. Extract and validate replacement expression
|
|
213
|
+
const patchProp = patchClass.getProperty(target.targetMemberName);
|
|
214
|
+
const patchGetter = patchClass.getGetAccessor(target.targetMemberName);
|
|
215
|
+
const patchMethod = patchClass.getMethod(target.targetMemberName);
|
|
216
|
+
let locatorText = '';
|
|
217
|
+
|
|
218
|
+
if (patchProp && patchProp.getInitializer()) {
|
|
219
|
+
locatorText = patchProp.getInitializer()!.getText();
|
|
220
|
+
} else if (patchGetter) {
|
|
221
|
+
const returns = patchGetter.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
222
|
+
locatorText = returns[0]?.getExpression()?.getText() ?? '';
|
|
223
|
+
} else if (patchMethod) {
|
|
224
|
+
const returns = patchMethod.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
225
|
+
locatorText = returns[0]?.getExpression()?.getText() ?? '';
|
|
226
|
+
} else {
|
|
227
|
+
// Check constructor assignments
|
|
228
|
+
for (const ctor of patchClass.getConstructors()) {
|
|
229
|
+
for (const stmt of ctor.getStatements()) {
|
|
230
|
+
if (Node.isExpressionStatement(stmt)) {
|
|
231
|
+
const expr = stmt.getExpression();
|
|
232
|
+
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getKind() === SyntaxKind.EqualsToken) {
|
|
233
|
+
if (expr.getLeft().getText() === `this.${target.targetMemberName}`) {
|
|
234
|
+
locatorText = expr.getRight().getText();
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!locatorText) {
|
|
244
|
+
return {
|
|
245
|
+
isAllowed: false,
|
|
246
|
+
reason: `Diff allowlist violated: Target member '${target.targetMemberName}' has no replacement expression.`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 9. Semantic AST Locator Grammar Validation
|
|
251
|
+
const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(locatorText);
|
|
252
|
+
if (!grammarCheck.isValid) {
|
|
253
|
+
return {
|
|
254
|
+
isAllowed: false,
|
|
255
|
+
reason: grammarCheck.reason,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
isAllowed: true,
|
|
261
|
+
modifiedMember: target.targetMemberName,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private static readonly APPROVED_OPTION_KEYS = new Set([
|
|
266
|
+
'name',
|
|
267
|
+
'exact',
|
|
268
|
+
'hasText',
|
|
269
|
+
'hasNotText',
|
|
270
|
+
'has',
|
|
271
|
+
'hasNot',
|
|
272
|
+
'timeout',
|
|
273
|
+
'checked',
|
|
274
|
+
'disabled',
|
|
275
|
+
'expanded',
|
|
276
|
+
'includeHidden',
|
|
277
|
+
'level',
|
|
278
|
+
'pressed',
|
|
279
|
+
'selected',
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
private static isLiteralValue(node: Node): boolean {
|
|
283
|
+
return (
|
|
284
|
+
Node.isStringLiteral(node) ||
|
|
285
|
+
Node.isNumericLiteral(node) ||
|
|
286
|
+
node.getKind() === SyntaxKind.TrueKeyword ||
|
|
287
|
+
node.getKind() === SyntaxKind.FalseKeyword ||
|
|
288
|
+
Node.isRegularExpressionLiteral(node)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Recursively validates an argument node passed into a locator method call.
|
|
294
|
+
* Strictly disallows identifiers (process.env), arbitrary function calls,
|
|
295
|
+
* arrow functions, dynamic property accesses, and executable code.
|
|
296
|
+
*/
|
|
297
|
+
private static validateArgumentNode(arg: Node): { isValid: boolean; reason?: string } {
|
|
298
|
+
if (DiffAllowlistValidator.isLiteralValue(arg)) {
|
|
299
|
+
return { isValid: true };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (Node.isObjectLiteralExpression(arg)) {
|
|
303
|
+
for (const prop of arg.getProperties()) {
|
|
304
|
+
if (!Node.isPropertyAssignment(prop)) {
|
|
305
|
+
return {
|
|
306
|
+
isValid: false,
|
|
307
|
+
reason: `Disallowed object property structure: only standard key-value assignments are allowed, received ${prop.getKindName()}`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const keyName = prop.getName();
|
|
312
|
+
if (!DiffAllowlistValidator.APPROVED_OPTION_KEYS.has(keyName)) {
|
|
313
|
+
return {
|
|
314
|
+
isValid: false,
|
|
315
|
+
reason: `Disallowed option key '${keyName}'. Only approved Playwright locator options are allowed.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const val = prop.getInitializer();
|
|
320
|
+
if (!val) {
|
|
321
|
+
return { isValid: false, reason: `Missing value for option key '${keyName}'` };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (keyName === 'has' || keyName === 'hasNot') {
|
|
325
|
+
// Recursive locator call validation for nested locators
|
|
326
|
+
if (!Node.isCallExpression(val)) {
|
|
327
|
+
return {
|
|
328
|
+
isValid: false,
|
|
329
|
+
reason: `Option '${keyName}' must be a CallExpression resolving to a locator, received ${val.getKindName()}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const nestedCheck = DiffAllowlistValidator.validateLocatorCallChain(val);
|
|
333
|
+
if (!nestedCheck.isValid) return nestedCheck;
|
|
334
|
+
} else {
|
|
335
|
+
// All other options must be literal values
|
|
336
|
+
if (!DiffAllowlistValidator.isLiteralValue(val)) {
|
|
337
|
+
return {
|
|
338
|
+
isValid: false,
|
|
339
|
+
reason: `Disallowed argument in option '${keyName}': ${val.getKindName()} ('${val.getText()}'). Dynamic expressions, environment variables, and function calls are prohibited.`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { isValid: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
isValid: false,
|
|
349
|
+
reason: `Disallowed argument expression: ${arg.getKindName()} ('${arg.getText()}'). Only literals and safe option objects are permitted.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Validates a locator call chain expression recursively, including all arguments.
|
|
355
|
+
*/
|
|
356
|
+
private static validateLocatorCallChain(callExpr: CallExpression): { isValid: boolean; reason?: string } {
|
|
357
|
+
let current: Node = callExpr;
|
|
358
|
+
while (Node.isCallExpression(current)) {
|
|
359
|
+
const expr = current.getExpression();
|
|
360
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
361
|
+
const methodName = expr.getName();
|
|
362
|
+
if (!DiffAllowlistValidator.APPROVED_LOCATOR_METHODS.has(methodName)) {
|
|
363
|
+
return {
|
|
364
|
+
isValid: false,
|
|
365
|
+
reason: `Replacement expression contains disallowed statements: disallowed method call '${methodName}'. Only Playwright locator methods are permitted.`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Recursively validate every argument passed to this method
|
|
370
|
+
for (const arg of current.getArguments()) {
|
|
371
|
+
const argCheck = DiffAllowlistValidator.validateArgumentNode(arg);
|
|
372
|
+
if (!argCheck.isValid) {
|
|
373
|
+
return {
|
|
374
|
+
isValid: false,
|
|
375
|
+
reason: `Replacement expression contains disallowed statements: ${argCheck.reason}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
current = expr.getExpression();
|
|
381
|
+
} else {
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const rootText = current.getText().trim();
|
|
387
|
+
const approvedRoots = new Set(['this.page', 'page', 'this', 'this._page', 'this.browserPage']);
|
|
388
|
+
if (!approvedRoots.has(rootText)) {
|
|
389
|
+
return {
|
|
390
|
+
isValid: false,
|
|
391
|
+
reason: `Replacement expression contains disallowed statements: locator chain root must be 'this.page' or 'page', received '${rootText}'`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { isValid: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Validates that a replacement expression is strictly a safe Playwright locator call AST.
|
|
400
|
+
* Rejects arbitrary statements, process calls, functions, or unapproved methods.
|
|
401
|
+
*/
|
|
402
|
+
public static validateLocatorGrammar(expressionText: string): { isValid: boolean; reason?: string } {
|
|
403
|
+
// 1. Basic sanitization: No statements separated by semicolons
|
|
404
|
+
const trimmed = expressionText.trim().replace(/;$/, '');
|
|
405
|
+
if (trimmed.includes(';')) {
|
|
406
|
+
return {
|
|
407
|
+
isValid: false,
|
|
408
|
+
reason: `Replacement expression contains disallowed statements: multiple statements in '${expressionText}'`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 2. Reject obvious dangerous keywords upfront
|
|
413
|
+
const lower = trimmed.toLowerCase();
|
|
414
|
+
if (
|
|
415
|
+
lower.includes('process.') ||
|
|
416
|
+
lower.includes('require(') ||
|
|
417
|
+
lower.includes('import(') ||
|
|
418
|
+
lower.includes('eval(') ||
|
|
419
|
+
lower.includes('exec(') ||
|
|
420
|
+
lower.includes('spawn(')
|
|
421
|
+
) {
|
|
422
|
+
return {
|
|
423
|
+
isValid: false,
|
|
424
|
+
reason: `Replacement expression contains disallowed statements: arbitrary code execution detected in '${expressionText}'`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 3. Parse into AST expression
|
|
429
|
+
const tempProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
430
|
+
const tempFile = tempProject.createSourceFile('temp_expr.ts', `const _x = ${trimmed};`);
|
|
431
|
+
const varDecl = tempFile.getVariableDeclaration('_x');
|
|
432
|
+
const initializer = varDecl?.getInitializer();
|
|
433
|
+
|
|
434
|
+
if (!initializer) {
|
|
435
|
+
return {
|
|
436
|
+
isValid: false,
|
|
437
|
+
reason: `Replacement expression contains disallowed statements: failed to parse '${expressionText}'`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// String literals are valid for locator constants (e.g. '[data-test=submit]')
|
|
442
|
+
if (Node.isStringLiteral(initializer)) {
|
|
443
|
+
return { isValid: true };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Must be a CallExpression or PropertyAccessExpression resolving to a locator
|
|
447
|
+
if (!Node.isCallExpression(initializer)) {
|
|
448
|
+
return {
|
|
449
|
+
isValid: false,
|
|
450
|
+
reason: `Replacement expression contains disallowed statements: must be a CallExpression (e.g. this.page.getByRole(...)), received ${initializer.getKindName()}`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Validate the call chain and its arguments recursively
|
|
455
|
+
return DiffAllowlistValidator.validateLocatorCallChain(initializer);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Asserts diff allowlist compliance, throwing UnauthorizedAstModificationError if violated.
|
|
460
|
+
*/
|
|
461
|
+
public static assertAllowed(target: DiffAllowlistTarget): void {
|
|
462
|
+
const result = DiffAllowlistValidator.validate(target);
|
|
463
|
+
if (!result.isAllowed) {
|
|
464
|
+
throw new UnauthorizedAstModificationError(result.reason ?? 'Unknown violation');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface StatementDiffAllowlistTarget {
|
|
470
|
+
originalSource: string;
|
|
471
|
+
patchedSource: string;
|
|
472
|
+
operation?: string | undefined;
|
|
473
|
+
targetLine?: number | undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export interface StatementDiffAllowlistResult {
|
|
477
|
+
isAllowed: boolean;
|
|
478
|
+
reason?: string | undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export class StatementDiffAllowlistValidator {
|
|
482
|
+
private static readonly DISALLOWED_SECURITY_TOKENS = [
|
|
483
|
+
'process.env',
|
|
484
|
+
'process.exit',
|
|
485
|
+
'child_process',
|
|
486
|
+
'require(',
|
|
487
|
+
'eval(',
|
|
488
|
+
'exec(',
|
|
489
|
+
'spawn(',
|
|
490
|
+
'fs.',
|
|
491
|
+
'Function(',
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Validates that ONLY approved statement-level modifications (timing synchronizations,
|
|
496
|
+
* event-driven wait replacements, readiness checks, or inline locator repairs) were applied to the test spec AST.
|
|
497
|
+
*
|
|
498
|
+
* Guarantees:
|
|
499
|
+
* - No arbitrary code execution or dangerous APIs.
|
|
500
|
+
* - No unauthorized third-party module imports.
|
|
501
|
+
* - Top-level declarations (functions, classes, interfaces, types) remain invariant.
|
|
502
|
+
* - Test block structure and test descriptions remain invariant.
|
|
503
|
+
*/
|
|
504
|
+
public static validate(target: StatementDiffAllowlistTarget): StatementDiffAllowlistResult {
|
|
505
|
+
// 1. Prohibit dangerous tokens
|
|
506
|
+
for (const token of StatementDiffAllowlistValidator.DISALLOWED_SECURITY_TOKENS) {
|
|
507
|
+
if (target.patchedSource.includes(token) && !target.originalSource.includes(token)) {
|
|
508
|
+
return {
|
|
509
|
+
isAllowed: false,
|
|
510
|
+
reason: `Statement diff allowlist violated: Prohibited security token '${token}' introduced.`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const origProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
516
|
+
const patchProject = new Project({ skipAddingFilesFromTsConfig: true });
|
|
517
|
+
|
|
518
|
+
const origFile = origProject.createSourceFile('orig.spec.ts', target.originalSource);
|
|
519
|
+
const patchFile = patchProject.createSourceFile('patched.spec.ts', target.patchedSource);
|
|
520
|
+
|
|
521
|
+
// 2. Verify Imports: Only allow adding expect from @playwright/test
|
|
522
|
+
const origImports = origFile.getImportDeclarations();
|
|
523
|
+
const patchImports = patchFile.getImportDeclarations();
|
|
524
|
+
|
|
525
|
+
for (const patchImp of patchImports) {
|
|
526
|
+
const spec = patchImp.getModuleSpecifierValue();
|
|
527
|
+
const origImp = origImports.find((i) => i.getModuleSpecifierValue() === spec);
|
|
528
|
+
if (!origImp) {
|
|
529
|
+
// Only @playwright/test import may be added
|
|
530
|
+
if (spec !== '@playwright/test') {
|
|
531
|
+
return {
|
|
532
|
+
isAllowed: false,
|
|
533
|
+
reason: `Statement diff allowlist violated: Unauthorized new import of module '${spec}'.`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
// If module exists, ensure no unauthorized named imports were added
|
|
538
|
+
const origNamed = new Set(origImp.getNamedImports().map((n) => n.getName()));
|
|
539
|
+
for (const named of patchImp.getNamedImports()) {
|
|
540
|
+
const name = named.getName();
|
|
541
|
+
if (!origNamed.has(name) && name !== 'expect') {
|
|
542
|
+
return {
|
|
543
|
+
isAllowed: false,
|
|
544
|
+
reason: `Statement diff allowlist violated: Unauthorized import '${name}' added from '${spec}'.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// 3. Prohibit adding or removing top-level declarations
|
|
552
|
+
if (origFile.getFunctions().length !== patchFile.getFunctions().length) {
|
|
553
|
+
return {
|
|
554
|
+
isAllowed: false,
|
|
555
|
+
reason: 'Statement diff allowlist violated: Top-level function declarations modified or added.',
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (origFile.getClasses().length !== patchFile.getClasses().length) {
|
|
559
|
+
return {
|
|
560
|
+
isAllowed: false,
|
|
561
|
+
reason: 'Statement diff allowlist violated: Top-level class declarations modified or added.',
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (origFile.getInterfaces().length !== patchFile.getInterfaces().length) {
|
|
565
|
+
return {
|
|
566
|
+
isAllowed: false,
|
|
567
|
+
reason: 'Statement diff allowlist violated: Top-level interface declarations modified or added.',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (origFile.getTypeAliases().length !== patchFile.getTypeAliases().length) {
|
|
571
|
+
return {
|
|
572
|
+
isAllowed: false,
|
|
573
|
+
reason: 'Statement diff allowlist violated: Top-level type aliases modified or added.',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 4. Verify test blocks: Count and descriptions of test() and test.describe() must match
|
|
578
|
+
const extractTestCalls = (sourceFile: import('ts-morph').SourceFile) => {
|
|
579
|
+
const calls: string[] = [];
|
|
580
|
+
sourceFile.forEachDescendant((node) => {
|
|
581
|
+
if (Node.isCallExpression(node)) {
|
|
582
|
+
const text = node.getExpression().getText();
|
|
583
|
+
if (text === 'test' || text === 'test.describe' || text === 'test.only' || text === 'test.skip') {
|
|
584
|
+
const firstArg = node.getArguments()[0];
|
|
585
|
+
calls.push(`${text}:${firstArg ? firstArg.getText() : ''}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
return calls;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const origTestCalls = extractTestCalls(origFile);
|
|
593
|
+
const patchTestCalls = extractTestCalls(patchFile);
|
|
594
|
+
if (JSON.stringify(origTestCalls) !== JSON.stringify(patchTestCalls)) {
|
|
595
|
+
return {
|
|
596
|
+
isAllowed: false,
|
|
597
|
+
reason: 'Statement diff allowlist violated: Test suite structure, test names, or test block declarations altered.',
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
isAllowed: true,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
public static assertAllowed(target: StatementDiffAllowlistTarget): void {
|
|
607
|
+
const res = StatementDiffAllowlistValidator.validate(target);
|
|
608
|
+
if (!res.isAllowed) {
|
|
609
|
+
throw new UnauthorizedAstModificationError(res.reason ?? 'Unknown violation');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|