@scantrix/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/astConfigParser.js +308 -0
- package/dist/astRuleHelpers.js +1451 -0
- package/dist/auditConfig.js +81 -0
- package/dist/ciExtractor.js +327 -0
- package/dist/cli.js +156 -0
- package/dist/configExtractor.js +261 -0
- package/dist/cypressExtractor.js +217 -0
- package/dist/diffTracker.js +310 -0
- package/dist/report.js +1904 -0
- package/dist/sarifFormatter.js +88 -0
- package/dist/scanResult.js +45 -0
- package/dist/scanner.js +3519 -0
- package/dist/scoring.js +206 -0
- package/dist/sinks/index.js +29 -0
- package/dist/sinks/jsonSink.js +28 -0
- package/dist/sinks/types.js +2 -0
- package/docs/high-res-icon.svg +26 -0
- package/docs/scantrix-logo-light.svg +64 -0
- package/docs/scantrix-logo.svg +64 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findVariableShadowing = findVariableShadowing;
|
|
7
|
+
exports.findCustomAssertionWrappers = findCustomAssertionWrappers;
|
|
8
|
+
exports.findManualContextInTestBodies = findManualContextInTestBodies;
|
|
9
|
+
exports.findTestDataMutations = findTestDataMutations;
|
|
10
|
+
exports.findMissingPlaywrightAwaits = findMissingPlaywrightAwaits;
|
|
11
|
+
exports.extractRelativeImportBindings = extractRelativeImportBindings;
|
|
12
|
+
exports.findSleepWrapperExports = findSleepWrapperExports;
|
|
13
|
+
exports.findClassSleepMethodExports = findClassSleepMethodExports;
|
|
14
|
+
exports.findDisguisedHardWaitCalls = findDisguisedHardWaitCalls;
|
|
15
|
+
exports.findUnguardedRetrievalMethods = findUnguardedRetrievalMethods;
|
|
16
|
+
exports.findUnguardedRetrievalMethodsInFunctions = findUnguardedRetrievalMethodsInFunctions;
|
|
17
|
+
exports.analyzePomClassStructure = analyzePomClassStructure;
|
|
18
|
+
exports.findLargeInlineDataLiterals = findLargeInlineDataLiterals;
|
|
19
|
+
/**
|
|
20
|
+
* AST-based rule helpers for analytical scanner rules.
|
|
21
|
+
*
|
|
22
|
+
* Uses the TypeScript Compiler API (ts.createSourceFile) for accurate
|
|
23
|
+
* scope-aware detection that regex cannot reliably achieve.
|
|
24
|
+
*/
|
|
25
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
26
|
+
// ─── Shared Utilities ────────────────────────────────────────────────
|
|
27
|
+
function parseSource(filePath, content) {
|
|
28
|
+
return typescript_1.default.createSourceFile(filePath, content, typescript_1.default.ScriptTarget.Latest, true);
|
|
29
|
+
}
|
|
30
|
+
/** Check if a CallExpression matches `obj.method()` */
|
|
31
|
+
function isMethodCall(node, obj, method) {
|
|
32
|
+
const expr = node.expression;
|
|
33
|
+
if (!typescript_1.default.isPropertyAccessExpression(expr))
|
|
34
|
+
return false;
|
|
35
|
+
if (expr.name.text !== method)
|
|
36
|
+
return false;
|
|
37
|
+
if (typescript_1.default.isIdentifier(expr.expression)) {
|
|
38
|
+
return expr.expression.text === obj;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Check if a CallExpression matches `obj.method()` OR just `func()` */
|
|
43
|
+
function isCallTo(node, name) {
|
|
44
|
+
if (typescript_1.default.isIdentifier(node.expression)) {
|
|
45
|
+
return node.expression.text === name;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** Get the root identifier from a PropertyAccessExpression chain */
|
|
50
|
+
function getRootIdentifier(node) {
|
|
51
|
+
if (typescript_1.default.isIdentifier(node))
|
|
52
|
+
return node.text;
|
|
53
|
+
if (typescript_1.default.isPropertyAccessExpression(node)) {
|
|
54
|
+
return getRootIdentifier(node.expression);
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/** Get 1-based line number from AST position */
|
|
59
|
+
function lineOf(sf, pos) {
|
|
60
|
+
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
61
|
+
}
|
|
62
|
+
/** Get the declaration keyword text for a variable declaration */
|
|
63
|
+
function declKeyword(decl) {
|
|
64
|
+
const list = decl.parent;
|
|
65
|
+
if (!typescript_1.default.isVariableDeclarationList(list))
|
|
66
|
+
return "unknown";
|
|
67
|
+
if (list.flags & typescript_1.default.NodeFlags.Const)
|
|
68
|
+
return "const";
|
|
69
|
+
if (list.flags & typescript_1.default.NodeFlags.Let)
|
|
70
|
+
return "let";
|
|
71
|
+
return "var";
|
|
72
|
+
}
|
|
73
|
+
// ─── PW-STABILITY-007: Variable Shadowing Between Test Scopes ────────
|
|
74
|
+
/**
|
|
75
|
+
* Detect variable shadowing between describe-scope and nested
|
|
76
|
+
* test/hook callbacks. Outer `let`/`var` declarations that are
|
|
77
|
+
* shadowed by inner `const`/`let`/`var` of the same name cause
|
|
78
|
+
* the outer to stay undefined — runtime crash in afterAll.
|
|
79
|
+
*/
|
|
80
|
+
function findVariableShadowing(filePath, content) {
|
|
81
|
+
const sf = parseSource(filePath, content);
|
|
82
|
+
const results = [];
|
|
83
|
+
function visit(node) {
|
|
84
|
+
// Find test.describe(..., callback)
|
|
85
|
+
if (typescript_1.default.isCallExpression(node) &&
|
|
86
|
+
isMethodCall(node, "test", "describe")) {
|
|
87
|
+
const callback = node.arguments[1];
|
|
88
|
+
if (callback &&
|
|
89
|
+
(typescript_1.default.isArrowFunction(callback) ||
|
|
90
|
+
typescript_1.default.isFunctionExpression(callback)) &&
|
|
91
|
+
callback.body &&
|
|
92
|
+
typescript_1.default.isBlock(callback.body)) {
|
|
93
|
+
analyzeDescribeBlock(sf, callback.body, results);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
typescript_1.default.forEachChild(node, visit);
|
|
97
|
+
}
|
|
98
|
+
visit(sf);
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
function analyzeDescribeBlock(sf, block, results) {
|
|
102
|
+
// Collect top-level let/var declarations in describe scope
|
|
103
|
+
const outerVars = new Map();
|
|
104
|
+
for (const stmt of block.statements) {
|
|
105
|
+
if (typescript_1.default.isVariableStatement(stmt)) {
|
|
106
|
+
const keyword = declKeyword(stmt.declarationList.declarations[0]);
|
|
107
|
+
if (keyword === "let" || keyword === "var") {
|
|
108
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
109
|
+
if (typescript_1.default.isIdentifier(decl.name)) {
|
|
110
|
+
const name = decl.name.text;
|
|
111
|
+
// Skip intentionally unused vars (prefixed with _)
|
|
112
|
+
if (name.startsWith("_"))
|
|
113
|
+
continue;
|
|
114
|
+
outerVars.set(name, {
|
|
115
|
+
line: lineOf(sf, decl.getStart()),
|
|
116
|
+
kind: keyword,
|
|
117
|
+
decl,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (outerVars.size === 0)
|
|
125
|
+
return;
|
|
126
|
+
// Walk nested test/hook callbacks for shadowing declarations
|
|
127
|
+
for (const stmt of block.statements) {
|
|
128
|
+
if (!typescript_1.default.isExpressionStatement(stmt))
|
|
129
|
+
continue;
|
|
130
|
+
const expr = stmt.expression;
|
|
131
|
+
if (!typescript_1.default.isCallExpression(expr))
|
|
132
|
+
continue;
|
|
133
|
+
// Match test(), test.only(), test.beforeAll(), test.afterAll(), etc.
|
|
134
|
+
const callee = expr.expression;
|
|
135
|
+
const isTestLikeCall = isCallTo(expr, "test") ||
|
|
136
|
+
(typescript_1.default.isPropertyAccessExpression(callee) &&
|
|
137
|
+
typescript_1.default.isIdentifier(callee.expression) &&
|
|
138
|
+
callee.expression.text === "test");
|
|
139
|
+
if (!isTestLikeCall)
|
|
140
|
+
continue;
|
|
141
|
+
// Find the callback argument (last arg that is a function)
|
|
142
|
+
for (const arg of expr.arguments) {
|
|
143
|
+
if ((typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) &&
|
|
144
|
+
arg.body &&
|
|
145
|
+
typescript_1.default.isBlock(arg.body)) {
|
|
146
|
+
collectShadowingInBlock(sf, arg.body, outerVars, results);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function collectShadowingInBlock(sf, block, outerVars, results) {
|
|
152
|
+
function walk(node) {
|
|
153
|
+
if (typescript_1.default.isVariableDeclaration(node) && typescript_1.default.isIdentifier(node.name)) {
|
|
154
|
+
const name = node.name.text;
|
|
155
|
+
const outer = outerVars.get(name);
|
|
156
|
+
if (outer) {
|
|
157
|
+
const innerKind = declKeyword(node);
|
|
158
|
+
results.push({
|
|
159
|
+
file: sf.fileName,
|
|
160
|
+
outerLine: outer.line,
|
|
161
|
+
innerLine: lineOf(sf, node.getStart()),
|
|
162
|
+
name,
|
|
163
|
+
outerKind: outer.kind,
|
|
164
|
+
innerKind,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
typescript_1.default.forEachChild(node, walk);
|
|
169
|
+
}
|
|
170
|
+
walk(block);
|
|
171
|
+
}
|
|
172
|
+
// ─── PW-FLAKE-007: Custom Assertion Wrappers ─────────────────────────
|
|
173
|
+
const ASSERTION_NAME_RE = /^(compare|assert|verify|validate|check|ensure|confirm)/i;
|
|
174
|
+
/**
|
|
175
|
+
* Detect custom assertion wrappers imported from relative paths
|
|
176
|
+
* that bypass Playwright's auto-retrying expect().
|
|
177
|
+
*/
|
|
178
|
+
function findCustomAssertionWrappers(filePath, content) {
|
|
179
|
+
const sf = parseSource(filePath, content);
|
|
180
|
+
// 1. Collect assertion-like imports from relative paths
|
|
181
|
+
const assertionBindings = new Map(); // name → module path
|
|
182
|
+
for (const stmt of sf.statements) {
|
|
183
|
+
if (!typescript_1.default.isImportDeclaration(stmt))
|
|
184
|
+
continue;
|
|
185
|
+
const specifier = stmt.moduleSpecifier;
|
|
186
|
+
if (!typescript_1.default.isStringLiteral(specifier))
|
|
187
|
+
continue;
|
|
188
|
+
const modulePath = specifier.text;
|
|
189
|
+
// Only relative imports
|
|
190
|
+
if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
|
|
191
|
+
continue;
|
|
192
|
+
if (stmt.importClause) {
|
|
193
|
+
// Named imports: import { compareFoo } from './utils'
|
|
194
|
+
const namedBindings = stmt.importClause.namedBindings;
|
|
195
|
+
if (namedBindings && typescript_1.default.isNamedImports(namedBindings)) {
|
|
196
|
+
for (const el of namedBindings.elements) {
|
|
197
|
+
const name = el.name.text;
|
|
198
|
+
if (ASSERTION_NAME_RE.test(name)) {
|
|
199
|
+
assertionBindings.set(name, modulePath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Default import: import compareFoo from './utils'
|
|
204
|
+
if (stmt.importClause.name) {
|
|
205
|
+
const name = stmt.importClause.name.text;
|
|
206
|
+
if (ASSERTION_NAME_RE.test(name)) {
|
|
207
|
+
assertionBindings.set(name, modulePath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (assertionBindings.size === 0)
|
|
213
|
+
return [];
|
|
214
|
+
// 2. Count calls to each assertion binding AND expect() calls AND test() blocks
|
|
215
|
+
const callCounts = new Map();
|
|
216
|
+
let expectCount = 0;
|
|
217
|
+
let testCount = 0;
|
|
218
|
+
function walk(node) {
|
|
219
|
+
if (typescript_1.default.isCallExpression(node)) {
|
|
220
|
+
// Count expect()
|
|
221
|
+
if (typescript_1.default.isIdentifier(node.expression) && node.expression.text === "expect") {
|
|
222
|
+
expectCount++;
|
|
223
|
+
}
|
|
224
|
+
// Count test() / test.only()
|
|
225
|
+
if (isCallTo(node, "test") || isMethodCall(node, "test", "only")) {
|
|
226
|
+
testCount++;
|
|
227
|
+
}
|
|
228
|
+
// Count assertion binding calls
|
|
229
|
+
if (typescript_1.default.isIdentifier(node.expression)) {
|
|
230
|
+
const name = node.expression.text;
|
|
231
|
+
if (assertionBindings.has(name)) {
|
|
232
|
+
callCounts.set(name, (callCounts.get(name) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Also check await expressions: await compareFoo(...)
|
|
236
|
+
}
|
|
237
|
+
typescript_1.default.forEachChild(node, walk);
|
|
238
|
+
}
|
|
239
|
+
walk(sf);
|
|
240
|
+
// 3. Flag if custom calls ≥ 2 AND (expect count < test count OR custom calls > expect count)
|
|
241
|
+
const results = [];
|
|
242
|
+
for (const [name, modulePath] of assertionBindings) {
|
|
243
|
+
const count = callCounts.get(name) ?? 0;
|
|
244
|
+
if (count >= 2 && (expectCount < testCount || count > expectCount)) {
|
|
245
|
+
results.push({
|
|
246
|
+
file: filePath,
|
|
247
|
+
importName: name,
|
|
248
|
+
importPath: modulePath,
|
|
249
|
+
callCount: count,
|
|
250
|
+
expectCount,
|
|
251
|
+
testCount,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
// ─── PW-STABILITY-008: Manual Browser Context in Test Bodies ─────────
|
|
258
|
+
/**
|
|
259
|
+
* Check if a node is inside a VariableDeclaration within the given boundary.
|
|
260
|
+
* Used to distinguish locally-declared contexts (const/let/var inside test body)
|
|
261
|
+
* from assignments to outer-scoped variables (describe-level, accessible to afterAll).
|
|
262
|
+
*/
|
|
263
|
+
function isLocallyDeclared(node, boundary) {
|
|
264
|
+
let current = node.parent;
|
|
265
|
+
while (current && current !== boundary) {
|
|
266
|
+
if (typescript_1.default.isVariableDeclaration(current))
|
|
267
|
+
return true;
|
|
268
|
+
current = current.parent;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Detect `browser.newContext()` calls inside test() callbacks.
|
|
274
|
+
* Fixtures handle lifecycle automatically (including failure scenarios).
|
|
275
|
+
*
|
|
276
|
+
* Only flags calls where the context is locally declared (const/let/var)
|
|
277
|
+
* inside the test body. Assignments to outer-scoped variables (declared in
|
|
278
|
+
* describe scope) are skipped because afterAll/afterEach can access them
|
|
279
|
+
* for cleanup.
|
|
280
|
+
*/
|
|
281
|
+
function findManualContextInTestBodies(filePath, content) {
|
|
282
|
+
const sf = parseSource(filePath, content);
|
|
283
|
+
const results = [];
|
|
284
|
+
function visit(node) {
|
|
285
|
+
if (!typescript_1.default.isCallExpression(node)) {
|
|
286
|
+
typescript_1.default.forEachChild(node, visit);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Match test(...) or test.only(...)
|
|
290
|
+
const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only");
|
|
291
|
+
if (!isTestCall) {
|
|
292
|
+
typescript_1.default.forEachChild(node, visit);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Skip hooks: test.beforeAll, test.afterAll, test.beforeEach, test.afterEach
|
|
296
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
|
|
297
|
+
const method = node.expression.name.text;
|
|
298
|
+
if (method === "beforeAll" ||
|
|
299
|
+
method === "afterAll" ||
|
|
300
|
+
method === "beforeEach" ||
|
|
301
|
+
method === "afterEach" ||
|
|
302
|
+
method === "describe") {
|
|
303
|
+
typescript_1.default.forEachChild(node, visit);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Extract test name from 1st argument
|
|
308
|
+
let testName = "(unnamed)";
|
|
309
|
+
if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
|
|
310
|
+
testName = node.arguments[0].text;
|
|
311
|
+
}
|
|
312
|
+
// Find the callback (2nd or last arg that is a function)
|
|
313
|
+
let callback;
|
|
314
|
+
for (const arg of node.arguments) {
|
|
315
|
+
if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
|
|
316
|
+
callback = arg;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
|
|
320
|
+
typescript_1.default.forEachChild(node, visit);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Count browser.newContext() and request.newContext() calls in the callback body.
|
|
324
|
+
// Only count calls where the result is locally declared (const/let/var) inside
|
|
325
|
+
// the test body. If assigned to an outer-scoped variable (describe level),
|
|
326
|
+
// afterAll/afterEach can clean it up — skip those.
|
|
327
|
+
const bodyBlock = callback.body;
|
|
328
|
+
let newContextCount = 0;
|
|
329
|
+
let firstLine = 0;
|
|
330
|
+
function walkBody(n) {
|
|
331
|
+
if (typescript_1.default.isCallExpression(n) &&
|
|
332
|
+
typescript_1.default.isPropertyAccessExpression(n.expression) &&
|
|
333
|
+
n.expression.name.text === "newContext" &&
|
|
334
|
+
typescript_1.default.isIdentifier(n.expression.expression) &&
|
|
335
|
+
(n.expression.expression.text === "browser" ||
|
|
336
|
+
n.expression.expression.text === "request")) {
|
|
337
|
+
if (isLocallyDeclared(n, bodyBlock)) {
|
|
338
|
+
newContextCount++;
|
|
339
|
+
if (firstLine === 0) {
|
|
340
|
+
firstLine = lineOf(sf, n.getStart());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
typescript_1.default.forEachChild(n, walkBody);
|
|
345
|
+
}
|
|
346
|
+
walkBody(bodyBlock);
|
|
347
|
+
if (newContextCount > 0) {
|
|
348
|
+
results.push({
|
|
349
|
+
file: filePath,
|
|
350
|
+
line: firstLine,
|
|
351
|
+
testName,
|
|
352
|
+
count: newContextCount,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Don't recurse into the test callback again (already walked)
|
|
356
|
+
}
|
|
357
|
+
visit(sf);
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
360
|
+
// ─── ARCH-012: Mutating Imported Test Data Objects ───────────────────
|
|
361
|
+
const DATA_PATH_RE = /(?:data|fixture|constant|config)/i;
|
|
362
|
+
/**
|
|
363
|
+
* Detect mutations of imported test-data objects (namespace or named imports
|
|
364
|
+
* from relative paths). Mutating shared module-level objects breaks
|
|
365
|
+
* parallel execution.
|
|
366
|
+
*/
|
|
367
|
+
function findTestDataMutations(filePath, content) {
|
|
368
|
+
const sf = parseSource(filePath, content);
|
|
369
|
+
// 1. Collect import bindings
|
|
370
|
+
const importBindings = new Map(); // binding name → module path
|
|
371
|
+
for (const stmt of sf.statements) {
|
|
372
|
+
if (!typescript_1.default.isImportDeclaration(stmt))
|
|
373
|
+
continue;
|
|
374
|
+
const specifier = stmt.moduleSpecifier;
|
|
375
|
+
if (!typescript_1.default.isStringLiteral(specifier))
|
|
376
|
+
continue;
|
|
377
|
+
const modulePath = specifier.text;
|
|
378
|
+
// Only relative imports
|
|
379
|
+
if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
|
|
380
|
+
continue;
|
|
381
|
+
if (stmt.importClause) {
|
|
382
|
+
const namedBindings = stmt.importClause.namedBindings;
|
|
383
|
+
// Namespace imports: import * as testData from './data'
|
|
384
|
+
if (namedBindings && typescript_1.default.isNamespaceImport(namedBindings)) {
|
|
385
|
+
importBindings.set(namedBindings.name.text, modulePath);
|
|
386
|
+
}
|
|
387
|
+
// Named imports from data-like paths
|
|
388
|
+
if (namedBindings &&
|
|
389
|
+
typescript_1.default.isNamedImports(namedBindings) &&
|
|
390
|
+
DATA_PATH_RE.test(modulePath)) {
|
|
391
|
+
for (const el of namedBindings.elements) {
|
|
392
|
+
importBindings.set(el.name.text, modulePath);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Default import from data-like paths
|
|
396
|
+
if (stmt.importClause.name && DATA_PATH_RE.test(modulePath)) {
|
|
397
|
+
importBindings.set(stmt.importClause.name.text, modulePath);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (importBindings.size === 0)
|
|
402
|
+
return [];
|
|
403
|
+
// 2. Find assignments where left side is a property of an imported binding
|
|
404
|
+
const mutations = new Map();
|
|
405
|
+
function walk(node) {
|
|
406
|
+
if (typescript_1.default.isBinaryExpression(node) &&
|
|
407
|
+
node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken) {
|
|
408
|
+
const left = node.left;
|
|
409
|
+
if (typescript_1.default.isPropertyAccessExpression(left) || typescript_1.default.isElementAccessExpression(left)) {
|
|
410
|
+
const root = getRootIdentifier(left);
|
|
411
|
+
if (root && importBindings.has(root)) {
|
|
412
|
+
const entry = mutations.get(root) ?? {
|
|
413
|
+
importPath: importBindings.get(root),
|
|
414
|
+
lines: [],
|
|
415
|
+
snippets: [],
|
|
416
|
+
};
|
|
417
|
+
entry.lines.push(lineOf(sf, node.getStart()));
|
|
418
|
+
const lineText = content
|
|
419
|
+
.split(/\r?\n/)[lineOf(sf, node.getStart()) - 1]?.trim()
|
|
420
|
+
.slice(0, 120);
|
|
421
|
+
entry.snippets.push(lineText ?? "");
|
|
422
|
+
mutations.set(root, entry);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
typescript_1.default.forEachChild(node, walk);
|
|
427
|
+
}
|
|
428
|
+
walk(sf);
|
|
429
|
+
// 3. Build results
|
|
430
|
+
const results = [];
|
|
431
|
+
for (const [binding, data] of mutations) {
|
|
432
|
+
results.push({
|
|
433
|
+
file: filePath,
|
|
434
|
+
line: data.lines[0],
|
|
435
|
+
bindingName: binding,
|
|
436
|
+
importPath: data.importPath,
|
|
437
|
+
mutationCount: data.lines.length,
|
|
438
|
+
snippet: `${data.lines.length} mutation(s) of '${binding}' from ${data.importPath}`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return results;
|
|
442
|
+
}
|
|
443
|
+
// ─── PW-FLAKE-008: Missing await on Async Playwright APIs ───────────
|
|
444
|
+
const ASYNC_MATCHERS = new Set([
|
|
445
|
+
"toBeAttached", "toBeChecked", "toBeDisabled", "toBeEditable",
|
|
446
|
+
"toBeEmpty", "toBeEnabled", "toBeFocused", "toBeHidden",
|
|
447
|
+
"toBeInViewport", "toBeVisible", "toContainText",
|
|
448
|
+
"toHaveAccessibleDescription", "toHaveAccessibleName",
|
|
449
|
+
"toHaveAttribute", "toHaveCSS", "toHaveClass", "toHaveCount",
|
|
450
|
+
"toHaveId", "toHaveJSProperty", "toHaveRole", "toHaveScreenshot",
|
|
451
|
+
"toHaveText", "toHaveTitle", "toHaveURL", "toHaveValue",
|
|
452
|
+
"toHaveValues", "toPass",
|
|
453
|
+
]);
|
|
454
|
+
/**
|
|
455
|
+
* Walk a call-expression chain and check whether the root is
|
|
456
|
+
* `expect(...)`, `expect.soft(...)`, or `expect.poll(...)`.
|
|
457
|
+
* Returns the kind if found, undefined otherwise.
|
|
458
|
+
*/
|
|
459
|
+
function getExpectChainKind(node) {
|
|
460
|
+
// Walk the expression to find the root call
|
|
461
|
+
let current = node.expression;
|
|
462
|
+
// Peel off PropertyAccessExpression layers: e.g. expect(...).not.toBeVisible
|
|
463
|
+
while (typescript_1.default.isPropertyAccessExpression(current)) {
|
|
464
|
+
current = current.expression;
|
|
465
|
+
}
|
|
466
|
+
// Now `current` should be the root call: expect(...) / expect.soft(...) / expect.poll(...)
|
|
467
|
+
if (!typescript_1.default.isCallExpression(current))
|
|
468
|
+
return undefined;
|
|
469
|
+
const callee = current.expression;
|
|
470
|
+
// expect(...)
|
|
471
|
+
if (typescript_1.default.isIdentifier(callee) && callee.text === "expect") {
|
|
472
|
+
return "expect";
|
|
473
|
+
}
|
|
474
|
+
// expect.soft(...) or expect.poll(...)
|
|
475
|
+
if (typescript_1.default.isPropertyAccessExpression(callee) &&
|
|
476
|
+
typescript_1.default.isIdentifier(callee.expression) &&
|
|
477
|
+
callee.expression.text === "expect") {
|
|
478
|
+
const method = callee.name.text;
|
|
479
|
+
if (method === "soft")
|
|
480
|
+
return "expect.soft";
|
|
481
|
+
if (method === "poll")
|
|
482
|
+
return "expect.poll";
|
|
483
|
+
}
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Get the final method name from a call expression's property chain.
|
|
488
|
+
* e.g. `expect(x).not.toBeVisible()` → "toBeVisible"
|
|
489
|
+
*/
|
|
490
|
+
function getFinalMethodName(node) {
|
|
491
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
|
|
492
|
+
return node.expression.name.text;
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
/** Check if a CallExpression is `test.step(...)` */
|
|
497
|
+
function isTestStepCall(node) {
|
|
498
|
+
return isMethodCall(node, "test", "step");
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Detect missing `await` on async Playwright APIs inside test() callbacks.
|
|
502
|
+
*
|
|
503
|
+
* Catches:
|
|
504
|
+
* - `expect(locator).toBeVisible()` (and all 28 async matchers)
|
|
505
|
+
* - `expect.soft(locator).toBeVisible()` (soft assertion variants)
|
|
506
|
+
* - `expect.poll(() => ...).toBe(...)` (poll chains)
|
|
507
|
+
* - `test.step("name", async () => { ... })` (unawaited steps)
|
|
508
|
+
* - Negated forms: `expect(x).not.toBeVisible()`
|
|
509
|
+
*/
|
|
510
|
+
function findMissingPlaywrightAwaits(filePath, content) {
|
|
511
|
+
const sf = parseSource(filePath, content);
|
|
512
|
+
const results = [];
|
|
513
|
+
function visit(node) {
|
|
514
|
+
if (!typescript_1.default.isCallExpression(node)) {
|
|
515
|
+
typescript_1.default.forEachChild(node, visit);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// Match test(...) or test.only(...)
|
|
519
|
+
const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only");
|
|
520
|
+
if (!isTestCall) {
|
|
521
|
+
typescript_1.default.forEachChild(node, visit);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// Skip hooks and describe
|
|
525
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
|
|
526
|
+
const method = node.expression.name.text;
|
|
527
|
+
if (method === "beforeAll" ||
|
|
528
|
+
method === "afterAll" ||
|
|
529
|
+
method === "beforeEach" ||
|
|
530
|
+
method === "afterEach" ||
|
|
531
|
+
method === "describe") {
|
|
532
|
+
typescript_1.default.forEachChild(node, visit);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Extract test name
|
|
537
|
+
let testName = "(unnamed)";
|
|
538
|
+
if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
|
|
539
|
+
testName = node.arguments[0].text;
|
|
540
|
+
}
|
|
541
|
+
// Find the callback
|
|
542
|
+
let callback;
|
|
543
|
+
for (const arg of node.arguments) {
|
|
544
|
+
if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
|
|
545
|
+
callback = arg;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
|
|
549
|
+
typescript_1.default.forEachChild(node, visit);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Walk the callback body for unawaited async patterns
|
|
553
|
+
function walkBody(n) {
|
|
554
|
+
if (typescript_1.default.isExpressionStatement(n)) {
|
|
555
|
+
const expr = n.expression;
|
|
556
|
+
// If the expression is an AwaitExpression, it's properly awaited — skip
|
|
557
|
+
if (typescript_1.default.isAwaitExpression(expr)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (typescript_1.default.isCallExpression(expr)) {
|
|
561
|
+
// Check for unawaited test.step(...)
|
|
562
|
+
if (isTestStepCall(expr)) {
|
|
563
|
+
results.push({
|
|
564
|
+
file: filePath,
|
|
565
|
+
line: lineOf(sf, n.getStart()),
|
|
566
|
+
testName,
|
|
567
|
+
snippet: "test.step(...)",
|
|
568
|
+
});
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// Check for unawaited expect chain with async matcher
|
|
572
|
+
const methodName = getFinalMethodName(expr);
|
|
573
|
+
if (methodName) {
|
|
574
|
+
const chainKind = getExpectChainKind(expr);
|
|
575
|
+
if (chainKind === "expect.poll") {
|
|
576
|
+
// Any matcher on expect.poll() is async
|
|
577
|
+
results.push({
|
|
578
|
+
file: filePath,
|
|
579
|
+
line: lineOf(sf, n.getStart()),
|
|
580
|
+
testName,
|
|
581
|
+
snippet: `expect.poll(...).${methodName}(...)`,
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if ((chainKind === "expect" || chainKind === "expect.soft") &&
|
|
586
|
+
ASYNC_MATCHERS.has(methodName)) {
|
|
587
|
+
const prefix = chainKind === "expect.soft"
|
|
588
|
+
? "expect.soft(...)"
|
|
589
|
+
: "expect(...)";
|
|
590
|
+
results.push({
|
|
591
|
+
file: filePath,
|
|
592
|
+
line: lineOf(sf, n.getStart()),
|
|
593
|
+
testName,
|
|
594
|
+
snippet: `${prefix}.${methodName}(...)`,
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
typescript_1.default.forEachChild(n, walkBody);
|
|
602
|
+
}
|
|
603
|
+
walkBody(callback.body);
|
|
604
|
+
// Don't recurse into the test callback again
|
|
605
|
+
}
|
|
606
|
+
visit(sf);
|
|
607
|
+
return results;
|
|
608
|
+
}
|
|
609
|
+
// ─── PW-FLAKE-001 Enhancement: Disguised Hard Wait Detection ─────────
|
|
610
|
+
const RETRY_INTENT_KEYWORDS = ["retry", "attempt", "backoff", "polling", "reconnect"];
|
|
611
|
+
/**
|
|
612
|
+
* Extract all named/default import bindings from relative paths.
|
|
613
|
+
* Skips namespace imports (`import * as ...`) and non-relative imports.
|
|
614
|
+
*/
|
|
615
|
+
function extractRelativeImportBindings(filePath, content) {
|
|
616
|
+
const sf = parseSource(filePath, content);
|
|
617
|
+
const bindings = [];
|
|
618
|
+
for (const stmt of sf.statements) {
|
|
619
|
+
if (!typescript_1.default.isImportDeclaration(stmt))
|
|
620
|
+
continue;
|
|
621
|
+
const specifier = stmt.moduleSpecifier;
|
|
622
|
+
if (!typescript_1.default.isStringLiteral(specifier))
|
|
623
|
+
continue;
|
|
624
|
+
const modulePath = specifier.text;
|
|
625
|
+
if (!modulePath.startsWith("./") && !modulePath.startsWith("../"))
|
|
626
|
+
continue;
|
|
627
|
+
if (!stmt.importClause)
|
|
628
|
+
continue;
|
|
629
|
+
// Default import
|
|
630
|
+
if (stmt.importClause.name) {
|
|
631
|
+
bindings.push({
|
|
632
|
+
localName: stmt.importClause.name.text,
|
|
633
|
+
originalName: "default",
|
|
634
|
+
modulePath,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
const namedBindings = stmt.importClause.namedBindings;
|
|
638
|
+
if (!namedBindings)
|
|
639
|
+
continue;
|
|
640
|
+
// Skip namespace imports (import * as ...)
|
|
641
|
+
if (typescript_1.default.isNamespaceImport(namedBindings))
|
|
642
|
+
continue;
|
|
643
|
+
// Named imports
|
|
644
|
+
if (typescript_1.default.isNamedImports(namedBindings)) {
|
|
645
|
+
for (const el of namedBindings.elements) {
|
|
646
|
+
bindings.push({
|
|
647
|
+
localName: el.name.text,
|
|
648
|
+
originalName: el.propertyName ? el.propertyName.text : el.name.text,
|
|
649
|
+
modulePath,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return bindings;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Check whether a function body contains a sleep pattern:
|
|
658
|
+
* - `new Promise(... => setTimeout(...))`
|
|
659
|
+
* - `waitForTimeout(...)` as a property access call
|
|
660
|
+
* - Direct `setTimeout(...)` call
|
|
661
|
+
*/
|
|
662
|
+
function bodyContainsSleepPattern(body) {
|
|
663
|
+
let found = false;
|
|
664
|
+
function walk(node) {
|
|
665
|
+
if (found)
|
|
666
|
+
return;
|
|
667
|
+
if (typescript_1.default.isCallExpression(node)) {
|
|
668
|
+
// Direct setTimeout(...)
|
|
669
|
+
if (typescript_1.default.isIdentifier(node.expression) && node.expression.text === "setTimeout") {
|
|
670
|
+
found = true;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// *.waitForTimeout(...)
|
|
674
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
|
|
675
|
+
node.expression.name.text === "waitForTimeout") {
|
|
676
|
+
found = true;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// *.waitForNetworkIdle(...)
|
|
680
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
|
|
681
|
+
node.expression.name.text === "waitForNetworkIdle") {
|
|
682
|
+
found = true;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
// *.waitForLoadState('networkidle')
|
|
686
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression) &&
|
|
687
|
+
node.expression.name.text === "waitForLoadState" &&
|
|
688
|
+
node.arguments.length > 0 &&
|
|
689
|
+
typescript_1.default.isStringLiteral(node.arguments[0]) &&
|
|
690
|
+
node.arguments[0].text === "networkidle") {
|
|
691
|
+
found = true;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// new Promise(... => setTimeout(...))
|
|
696
|
+
if (typescript_1.default.isNewExpression(node) &&
|
|
697
|
+
typescript_1.default.isIdentifier(node.expression) &&
|
|
698
|
+
node.expression.text === "Promise") {
|
|
699
|
+
const args = node.arguments;
|
|
700
|
+
if (args && args.length > 0) {
|
|
701
|
+
function walkForSetTimeout(n) {
|
|
702
|
+
if (found)
|
|
703
|
+
return;
|
|
704
|
+
if (typescript_1.default.isCallExpression(n) &&
|
|
705
|
+
typescript_1.default.isIdentifier(n.expression) &&
|
|
706
|
+
n.expression.text === "setTimeout") {
|
|
707
|
+
found = true;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
typescript_1.default.forEachChild(n, walkForSetTimeout);
|
|
711
|
+
}
|
|
712
|
+
for (const arg of args) {
|
|
713
|
+
walkForSetTimeout(arg);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
typescript_1.default.forEachChild(node, walk);
|
|
719
|
+
}
|
|
720
|
+
walk(body);
|
|
721
|
+
return found;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Check whether a function body contains a loop with retry-intent identifiers.
|
|
725
|
+
* Used to suppress false positives on retry/polling wrappers.
|
|
726
|
+
*/
|
|
727
|
+
function bodyHasRetryLoop(body, sf) {
|
|
728
|
+
let hasLoop = false;
|
|
729
|
+
let hasRetryIntent = false;
|
|
730
|
+
function walk(node) {
|
|
731
|
+
if (hasLoop && hasRetryIntent)
|
|
732
|
+
return;
|
|
733
|
+
if (typescript_1.default.isWhileStatement(node) ||
|
|
734
|
+
typescript_1.default.isForStatement(node) ||
|
|
735
|
+
typescript_1.default.isForInStatement(node) ||
|
|
736
|
+
typescript_1.default.isForOfStatement(node) ||
|
|
737
|
+
typescript_1.default.isDoStatement(node)) {
|
|
738
|
+
hasLoop = true;
|
|
739
|
+
}
|
|
740
|
+
if (typescript_1.default.isIdentifier(node)) {
|
|
741
|
+
const name = node.text.toLowerCase();
|
|
742
|
+
if (RETRY_INTENT_KEYWORDS.some((kw) => name.includes(kw))) {
|
|
743
|
+
hasRetryIntent = true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (typescript_1.default.isStringLiteral(node)) {
|
|
747
|
+
const text = node.text.toLowerCase();
|
|
748
|
+
if (RETRY_INTENT_KEYWORDS.some((kw) => text.includes(kw))) {
|
|
749
|
+
hasRetryIntent = true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
typescript_1.default.forEachChild(node, walk);
|
|
753
|
+
}
|
|
754
|
+
walk(body);
|
|
755
|
+
return hasLoop && hasRetryIntent;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Parse a utility module and return names of exported functions whose body
|
|
759
|
+
* is a sleep wrapper (Promise-setTimeout, waitForTimeout, direct setTimeout).
|
|
760
|
+
* Excludes functions with retry/polling loop patterns (false-positive filter).
|
|
761
|
+
*/
|
|
762
|
+
function findSleepWrapperExports(filePath, content) {
|
|
763
|
+
const sf = parseSource(filePath, content);
|
|
764
|
+
// 1. Collect all module-level function bodies by name
|
|
765
|
+
const funcBodies = new Map();
|
|
766
|
+
// 2. Collect exported names
|
|
767
|
+
const exportedNames = new Set();
|
|
768
|
+
for (const stmt of sf.statements) {
|
|
769
|
+
// Function declarations: function foo() { ... }
|
|
770
|
+
if (typescript_1.default.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
|
|
771
|
+
funcBodies.set(stmt.name.text, stmt.body);
|
|
772
|
+
if (stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword)) {
|
|
773
|
+
exportedNames.add(stmt.name.text);
|
|
774
|
+
}
|
|
775
|
+
if (stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.DefaultKeyword)) {
|
|
776
|
+
exportedNames.add(stmt.name.text);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Variable declarations: const foo = async () => { ... }
|
|
780
|
+
if (typescript_1.default.isVariableStatement(stmt)) {
|
|
781
|
+
const isExported = stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword);
|
|
782
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
783
|
+
if (!typescript_1.default.isIdentifier(decl.name) || !decl.initializer)
|
|
784
|
+
continue;
|
|
785
|
+
const init = decl.initializer;
|
|
786
|
+
if ((typescript_1.default.isArrowFunction(init) || typescript_1.default.isFunctionExpression(init)) &&
|
|
787
|
+
init.body) {
|
|
788
|
+
funcBodies.set(decl.name.text, init.body);
|
|
789
|
+
if (isExported) {
|
|
790
|
+
exportedNames.add(decl.name.text);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// export { name1, name2 }
|
|
796
|
+
if (typescript_1.default.isExportDeclaration(stmt) &&
|
|
797
|
+
stmt.exportClause &&
|
|
798
|
+
typescript_1.default.isNamedExports(stmt.exportClause)) {
|
|
799
|
+
for (const el of stmt.exportClause.elements) {
|
|
800
|
+
const localName = el.propertyName ? el.propertyName.text : el.name.text;
|
|
801
|
+
exportedNames.add(localName);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// export default identifier
|
|
805
|
+
if (typescript_1.default.isExportAssignment(stmt) && typescript_1.default.isIdentifier(stmt.expression)) {
|
|
806
|
+
exportedNames.add(stmt.expression.text);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// 3. For each exported function, check if body is a sleep wrapper
|
|
810
|
+
const results = [];
|
|
811
|
+
for (const name of exportedNames) {
|
|
812
|
+
const body = funcBodies.get(name);
|
|
813
|
+
if (!body)
|
|
814
|
+
continue;
|
|
815
|
+
if (bodyHasRetryLoop(body, sf))
|
|
816
|
+
continue;
|
|
817
|
+
if (bodyContainsSleepPattern(body)) {
|
|
818
|
+
results.push(name);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return results;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Parse a utility module and return exported classes that contain methods
|
|
825
|
+
* whose body is a sleep wrapper (Promise-setTimeout, waitForTimeout, direct setTimeout).
|
|
826
|
+
* Excludes methods with retry/polling loop patterns (false-positive filter).
|
|
827
|
+
*/
|
|
828
|
+
function findClassSleepMethodExports(filePath, content) {
|
|
829
|
+
const sf = parseSource(filePath, content);
|
|
830
|
+
// Collect names exported via `export { ClassName }` or `export default ident`
|
|
831
|
+
const reExportedNames = new Set();
|
|
832
|
+
const defaultExportedNames = new Set();
|
|
833
|
+
for (const stmt of sf.statements) {
|
|
834
|
+
if (typescript_1.default.isExportDeclaration(stmt) &&
|
|
835
|
+
stmt.exportClause &&
|
|
836
|
+
typescript_1.default.isNamedExports(stmt.exportClause)) {
|
|
837
|
+
for (const el of stmt.exportClause.elements) {
|
|
838
|
+
const localName = el.propertyName ? el.propertyName.text : el.name.text;
|
|
839
|
+
reExportedNames.add(localName);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (typescript_1.default.isExportAssignment(stmt) && typescript_1.default.isIdentifier(stmt.expression)) {
|
|
843
|
+
defaultExportedNames.add(stmt.expression.text);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const results = [];
|
|
847
|
+
for (const stmt of sf.statements) {
|
|
848
|
+
if (!typescript_1.default.isClassDeclaration(stmt))
|
|
849
|
+
continue;
|
|
850
|
+
// Determine if the class is exported
|
|
851
|
+
const hasExportModifier = stmt.modifiers?.some((m) => m.kind === typescript_1.default.SyntaxKind.ExportKeyword);
|
|
852
|
+
const className = stmt.name?.text;
|
|
853
|
+
const isExported = hasExportModifier ||
|
|
854
|
+
(className !== undefined && reExportedNames.has(className)) ||
|
|
855
|
+
(className !== undefined && defaultExportedNames.has(className));
|
|
856
|
+
if (!isExported)
|
|
857
|
+
continue;
|
|
858
|
+
const sleepMethods = [];
|
|
859
|
+
for (const member of stmt.members) {
|
|
860
|
+
if (!typescript_1.default.isMethodDeclaration(member))
|
|
861
|
+
continue;
|
|
862
|
+
if (!member.body)
|
|
863
|
+
continue;
|
|
864
|
+
if (!member.name || !typescript_1.default.isIdentifier(member.name))
|
|
865
|
+
continue;
|
|
866
|
+
if (bodyHasRetryLoop(member.body, sf))
|
|
867
|
+
continue;
|
|
868
|
+
if (bodyContainsSleepPattern(member.body)) {
|
|
869
|
+
sleepMethods.push(member.name.text);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (sleepMethods.length > 0) {
|
|
873
|
+
results.push({
|
|
874
|
+
className: className ?? "default",
|
|
875
|
+
methodNames: sleepMethods,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return results;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Given a test file and a map of wrapper binding names → import paths,
|
|
883
|
+
* find call sites of those wrappers. Returns line numbers and function names.
|
|
884
|
+
*/
|
|
885
|
+
function findDisguisedHardWaitCalls(filePath, content, wrapperBindings, sleepMethodBindings) {
|
|
886
|
+
if (wrapperBindings.size === 0 && (!sleepMethodBindings || sleepMethodBindings.size === 0))
|
|
887
|
+
return [];
|
|
888
|
+
const sf = parseSource(filePath, content);
|
|
889
|
+
const results = [];
|
|
890
|
+
function visit(node) {
|
|
891
|
+
if (typescript_1.default.isCallExpression(node)) {
|
|
892
|
+
// Direct function call: delay(2000)
|
|
893
|
+
if (typescript_1.default.isIdentifier(node.expression)) {
|
|
894
|
+
const name = node.expression.text;
|
|
895
|
+
const importPath = wrapperBindings.get(name);
|
|
896
|
+
if (importPath) {
|
|
897
|
+
results.push({
|
|
898
|
+
file: filePath,
|
|
899
|
+
line: lineOf(sf, node.getStart()),
|
|
900
|
+
calledFunction: name,
|
|
901
|
+
importPath,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Property access call: instance.delay(2000) or this.delay(2000)
|
|
906
|
+
if (sleepMethodBindings &&
|
|
907
|
+
sleepMethodBindings.size > 0 &&
|
|
908
|
+
typescript_1.default.isPropertyAccessExpression(node.expression)) {
|
|
909
|
+
const methodName = node.expression.name.text;
|
|
910
|
+
const importPath = sleepMethodBindings.get(methodName);
|
|
911
|
+
if (importPath) {
|
|
912
|
+
results.push({
|
|
913
|
+
file: filePath,
|
|
914
|
+
line: lineOf(sf, node.getStart()),
|
|
915
|
+
calledFunction: methodName,
|
|
916
|
+
importPath,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
typescript_1.default.forEachChild(node, visit);
|
|
922
|
+
}
|
|
923
|
+
visit(sf);
|
|
924
|
+
return results;
|
|
925
|
+
}
|
|
926
|
+
// ─── PW-FLAKE-010: Retrieval Methods Without Content Guard ────────────
|
|
927
|
+
const RETRIEVAL_METHODS = new Set([
|
|
928
|
+
"innerText", "textContent", "allInnerTexts",
|
|
929
|
+
"allTextContents", "innerHTML", "inputValue",
|
|
930
|
+
]);
|
|
931
|
+
const CONTENT_GUARD_MATCHERS = new Set([
|
|
932
|
+
"toHaveText", "toContainText", "toBeEmpty", "toBeVisible",
|
|
933
|
+
]);
|
|
934
|
+
/**
|
|
935
|
+
* Check whether a CallExpression is a content-guard assertion.
|
|
936
|
+
*
|
|
937
|
+
* Content guards are:
|
|
938
|
+
* 1. `expect()` chains whose final matcher is one of `toHaveText`,
|
|
939
|
+
* `toContainText`, `toBeEmpty`, `toBeVisible`, or `toPass` — including
|
|
940
|
+
* negated forms (`.not.toHaveText(...)`) and `.resolves`/`.rejects`.
|
|
941
|
+
* 2. Direct `locator.waitFor()` calls — these wait for the element to reach
|
|
942
|
+
* an actionable state (default: "visible"), ensuring content is ready.
|
|
943
|
+
*/
|
|
944
|
+
function isContentGuard(node) {
|
|
945
|
+
if (!typescript_1.default.isCallExpression(node))
|
|
946
|
+
return false;
|
|
947
|
+
const methodName = getFinalMethodName(node);
|
|
948
|
+
if (!methodName)
|
|
949
|
+
return false;
|
|
950
|
+
// locator.waitFor() is a guard (waits for "visible" state by default)
|
|
951
|
+
if (methodName === "waitFor") {
|
|
952
|
+
// Accept if it's NOT inside an expect() chain — i.e. it's a direct locator call
|
|
953
|
+
const kind = getExpectChainKind(node);
|
|
954
|
+
if (kind === undefined)
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
// toPass() is a guard regardless of chain root
|
|
958
|
+
if (methodName === "toPass") {
|
|
959
|
+
const kind = getExpectChainKind(node);
|
|
960
|
+
return kind !== undefined;
|
|
961
|
+
}
|
|
962
|
+
if (!CONTENT_GUARD_MATCHERS.has(methodName))
|
|
963
|
+
return false;
|
|
964
|
+
const kind = getExpectChainKind(node);
|
|
965
|
+
return kind !== undefined;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Check if a CallExpression is a retrieval method call: `.innerText()`, etc.
|
|
969
|
+
* Returns the method name if matched, undefined otherwise.
|
|
970
|
+
*/
|
|
971
|
+
function getRetrievalMethodName(node) {
|
|
972
|
+
if (!typescript_1.default.isPropertyAccessExpression(node.expression))
|
|
973
|
+
return undefined;
|
|
974
|
+
const name = node.expression.name.text;
|
|
975
|
+
return RETRIEVAL_METHODS.has(name) ? name : undefined;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Detect retrieval method calls (innerText, textContent, innerHTML, etc.)
|
|
979
|
+
* that are NOT preceded by a content guard within the same test body.
|
|
980
|
+
*
|
|
981
|
+
* A content guard is an expect() assertion with toHaveText, toContainText,
|
|
982
|
+
* toBeEmpty, or toPass that targets the same locator (or any locator within
|
|
983
|
+
* the preceding statements). The AST approach lets us check the actual
|
|
984
|
+
* statement ordering within the test callback body rather than relying on
|
|
985
|
+
* a fragile line-window regex heuristic.
|
|
986
|
+
*/
|
|
987
|
+
function findUnguardedRetrievalMethods(filePath, content) {
|
|
988
|
+
const sf = parseSource(filePath, content);
|
|
989
|
+
const results = [];
|
|
990
|
+
function visit(node) {
|
|
991
|
+
if (!typescript_1.default.isCallExpression(node)) {
|
|
992
|
+
typescript_1.default.forEachChild(node, visit);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
// Match test(...), test.only(...), test.slow(...)
|
|
996
|
+
const isTestCall = isCallTo(node, "test") || isMethodCall(node, "test", "only") ||
|
|
997
|
+
isMethodCall(node, "test", "slow");
|
|
998
|
+
if (!isTestCall) {
|
|
999
|
+
typescript_1.default.forEachChild(node, visit);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// Skip hooks and describe
|
|
1003
|
+
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
|
|
1004
|
+
const method = node.expression.name.text;
|
|
1005
|
+
if (method === "beforeAll" || method === "afterAll" ||
|
|
1006
|
+
method === "beforeEach" || method === "afterEach" ||
|
|
1007
|
+
method === "describe") {
|
|
1008
|
+
typescript_1.default.forEachChild(node, visit);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// Extract test name
|
|
1013
|
+
let testName = "(unnamed)";
|
|
1014
|
+
if (node.arguments.length > 0 && typescript_1.default.isStringLiteral(node.arguments[0])) {
|
|
1015
|
+
testName = node.arguments[0].text;
|
|
1016
|
+
}
|
|
1017
|
+
// Find the callback
|
|
1018
|
+
let callback;
|
|
1019
|
+
for (const arg of node.arguments) {
|
|
1020
|
+
if (typescript_1.default.isArrowFunction(arg) || typescript_1.default.isFunctionExpression(arg)) {
|
|
1021
|
+
callback = arg;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (!callback || !callback.body || !typescript_1.default.isBlock(callback.body)) {
|
|
1025
|
+
typescript_1.default.forEachChild(node, visit);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
// Collect statements in order; track which statements contain content guards
|
|
1029
|
+
const stmts = callback.body.statements;
|
|
1030
|
+
const guardSeen = new Set(); // statement indices that contain guards
|
|
1031
|
+
// First pass: identify which statements contain content guards
|
|
1032
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
1033
|
+
if (stmtContainsContentGuard(stmts[i])) {
|
|
1034
|
+
guardSeen.add(i);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// Second pass: find retrieval method calls and check for preceding guards
|
|
1038
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
1039
|
+
collectRetrievalCalls(stmts[i], sf).forEach((call) => {
|
|
1040
|
+
// Check if any of the preceding statements (within 5 statements) is a guard
|
|
1041
|
+
const windowStart = Math.max(0, i - 5);
|
|
1042
|
+
let guarded = false;
|
|
1043
|
+
for (let j = windowStart; j < i; j++) {
|
|
1044
|
+
if (guardSeen.has(j)) {
|
|
1045
|
+
guarded = true;
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// If the statement itself contains a guard, treat the retrieval as guarded
|
|
1050
|
+
if (guardSeen.has(i))
|
|
1051
|
+
guarded = true;
|
|
1052
|
+
if (!guarded) {
|
|
1053
|
+
results.push({
|
|
1054
|
+
file: filePath,
|
|
1055
|
+
line: call.line,
|
|
1056
|
+
testName,
|
|
1057
|
+
snippet: `.${call.method}()`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
visit(sf);
|
|
1064
|
+
return results;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Scan non-test files (POMs, page classes, fixtures) for retrieval method
|
|
1068
|
+
* calls without a preceding content guard in the same function body.
|
|
1069
|
+
*
|
|
1070
|
+
* Walks every function, method, and arrow-function body in the file.
|
|
1071
|
+
* Uses the same 5-statement guard window as the test-scoped variant.
|
|
1072
|
+
*/
|
|
1073
|
+
function findUnguardedRetrievalMethodsInFunctions(filePath, content) {
|
|
1074
|
+
const sf = parseSource(filePath, content);
|
|
1075
|
+
const results = [];
|
|
1076
|
+
function visit(node) {
|
|
1077
|
+
// Skip arrow/function expressions that are arguments to expect() chains —
|
|
1078
|
+
// these are assertion/polling callbacks where retrieval is intentional.
|
|
1079
|
+
if ((typescript_1.default.isArrowFunction(node) || typescript_1.default.isFunctionExpression(node)) &&
|
|
1080
|
+
typescript_1.default.isCallExpression(node.parent)) {
|
|
1081
|
+
const parentCall = node.parent;
|
|
1082
|
+
// Direct expect(..., callback) or expect.poll(callback)
|
|
1083
|
+
const chainKind = getExpectChainKind(parentCall);
|
|
1084
|
+
if (chainKind !== undefined)
|
|
1085
|
+
return;
|
|
1086
|
+
// Callback is an argument to expect() itself: expect(async () => { ... })
|
|
1087
|
+
if (typescript_1.default.isIdentifier(parentCall.expression) &&
|
|
1088
|
+
parentCall.expression.text === "expect")
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// Match any function-like body: methods, arrow functions, function declarations/expressions
|
|
1092
|
+
let body;
|
|
1093
|
+
let scopeName = "(anonymous)";
|
|
1094
|
+
if (typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isFunctionExpression(node)) {
|
|
1095
|
+
body = node.body && typescript_1.default.isBlock(node.body) ? node.body : undefined;
|
|
1096
|
+
scopeName = node.name?.text ?? "(anonymous)";
|
|
1097
|
+
}
|
|
1098
|
+
else if (typescript_1.default.isMethodDeclaration(node)) {
|
|
1099
|
+
body = node.body && typescript_1.default.isBlock(node.body) ? node.body : undefined;
|
|
1100
|
+
if (typescript_1.default.isIdentifier(node.name))
|
|
1101
|
+
scopeName = node.name.text;
|
|
1102
|
+
else if (typescript_1.default.isStringLiteral(node.name))
|
|
1103
|
+
scopeName = node.name.text;
|
|
1104
|
+
}
|
|
1105
|
+
else if (typescript_1.default.isArrowFunction(node) && node.body && typescript_1.default.isBlock(node.body)) {
|
|
1106
|
+
body = node.body;
|
|
1107
|
+
// Try to get name from parent variable declaration
|
|
1108
|
+
if (typescript_1.default.isVariableDeclaration(node.parent) && typescript_1.default.isIdentifier(node.parent.name)) {
|
|
1109
|
+
scopeName = node.parent.name.text;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (body) {
|
|
1113
|
+
const stmts = body.statements;
|
|
1114
|
+
const guardSeen = new Set();
|
|
1115
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
1116
|
+
if (stmtContainsContentGuard(stmts[i])) {
|
|
1117
|
+
guardSeen.add(i);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
1121
|
+
collectRetrievalCalls(stmts[i], sf).forEach((call) => {
|
|
1122
|
+
const windowStart = Math.max(0, i - 5);
|
|
1123
|
+
let guarded = false;
|
|
1124
|
+
for (let j = windowStart; j < i; j++) {
|
|
1125
|
+
if (guardSeen.has(j)) {
|
|
1126
|
+
guarded = true;
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (guardSeen.has(i))
|
|
1131
|
+
guarded = true;
|
|
1132
|
+
if (!guarded) {
|
|
1133
|
+
results.push({
|
|
1134
|
+
file: filePath,
|
|
1135
|
+
line: call.line,
|
|
1136
|
+
testName: scopeName,
|
|
1137
|
+
snippet: `.${call.method}()`,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
typescript_1.default.forEachChild(node, visit);
|
|
1144
|
+
}
|
|
1145
|
+
visit(sf);
|
|
1146
|
+
return results;
|
|
1147
|
+
}
|
|
1148
|
+
/** Check if any node within a statement subtree contains a content guard. */
|
|
1149
|
+
function stmtContainsContentGuard(node) {
|
|
1150
|
+
if (isContentGuard(node))
|
|
1151
|
+
return true;
|
|
1152
|
+
let found = false;
|
|
1153
|
+
function walk(n) {
|
|
1154
|
+
if (found)
|
|
1155
|
+
return;
|
|
1156
|
+
if (isContentGuard(n)) {
|
|
1157
|
+
found = true;
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
typescript_1.default.forEachChild(n, walk);
|
|
1161
|
+
}
|
|
1162
|
+
typescript_1.default.forEachChild(node, walk);
|
|
1163
|
+
return found;
|
|
1164
|
+
}
|
|
1165
|
+
/** Collect all retrieval method calls from a statement subtree.
|
|
1166
|
+
* Skips nested arrow/function expressions (callbacks) since those
|
|
1167
|
+
* run in a different execution context (e.g., expect.poll callbacks). */
|
|
1168
|
+
function collectRetrievalCalls(node, sf) {
|
|
1169
|
+
const calls = [];
|
|
1170
|
+
function walk(n) {
|
|
1171
|
+
// Don't descend into nested callbacks — those are separate contexts
|
|
1172
|
+
if (typescript_1.default.isArrowFunction(n) || typescript_1.default.isFunctionExpression(n))
|
|
1173
|
+
return;
|
|
1174
|
+
if (typescript_1.default.isCallExpression(n)) {
|
|
1175
|
+
const method = getRetrievalMethodName(n);
|
|
1176
|
+
if (method) {
|
|
1177
|
+
calls.push({ line: lineOf(sf, n.getStart()), method });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
typescript_1.default.forEachChild(n, walk);
|
|
1181
|
+
}
|
|
1182
|
+
walk(node);
|
|
1183
|
+
return calls;
|
|
1184
|
+
}
|
|
1185
|
+
// ─── ARCH-002: AST-Based POM "Locator Bag" Detection ─────────────────
|
|
1186
|
+
/** Locator-related Playwright page method prefixes. */
|
|
1187
|
+
const LOCATOR_PAGE_METHODS = new Set([
|
|
1188
|
+
"locator",
|
|
1189
|
+
"getByRole",
|
|
1190
|
+
"getByText",
|
|
1191
|
+
"getByLabel",
|
|
1192
|
+
"getByPlaceholder",
|
|
1193
|
+
"getByAltText",
|
|
1194
|
+
"getByTitle",
|
|
1195
|
+
"getByTestId",
|
|
1196
|
+
]);
|
|
1197
|
+
function isLocatorInit(expr) {
|
|
1198
|
+
// Matches: (this.)page.locator() / (this.)page.getBy*()
|
|
1199
|
+
if (!typescript_1.default.isCallExpression(expr))
|
|
1200
|
+
return false;
|
|
1201
|
+
const callee = expr.expression;
|
|
1202
|
+
if (!typescript_1.default.isPropertyAccessExpression(callee))
|
|
1203
|
+
return false;
|
|
1204
|
+
const methodName = callee.name.text;
|
|
1205
|
+
if (!LOCATOR_PAGE_METHODS.has(methodName))
|
|
1206
|
+
return false;
|
|
1207
|
+
// The object should be `page` or `this.page`
|
|
1208
|
+
const obj = callee.expression;
|
|
1209
|
+
if (typescript_1.default.isIdentifier(obj) && obj.text === "page")
|
|
1210
|
+
return true;
|
|
1211
|
+
if (typescript_1.default.isPropertyAccessExpression(obj) &&
|
|
1212
|
+
obj.name.text === "page" &&
|
|
1213
|
+
obj.expression.kind === typescript_1.default.SyntaxKind.ThisKeyword)
|
|
1214
|
+
return true;
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
function hasModifier(node, kind) {
|
|
1218
|
+
const mods = typescript_1.default.canHaveModifiers(node) ? typescript_1.default.getModifiers(node) : undefined;
|
|
1219
|
+
return mods?.some((m) => m.kind === kind) ?? false;
|
|
1220
|
+
}
|
|
1221
|
+
function isPrivateOrProtected(node) {
|
|
1222
|
+
return (hasModifier(node, typescript_1.default.SyntaxKind.PrivateKeyword) ||
|
|
1223
|
+
hasModifier(node, typescript_1.default.SyntaxKind.ProtectedKeyword));
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Analyze a POM file's class structure to determine if it is a "locator bag":
|
|
1227
|
+
* a class with many public locators and few/no action methods that use them.
|
|
1228
|
+
*
|
|
1229
|
+
* Returns null if the file does not contain a class declaration.
|
|
1230
|
+
*/
|
|
1231
|
+
function analyzePomClassStructure(filePath, content) {
|
|
1232
|
+
const sf = parseSource(filePath, content);
|
|
1233
|
+
// Find the first class declaration
|
|
1234
|
+
let classDecl;
|
|
1235
|
+
function findClass(node) {
|
|
1236
|
+
if (classDecl)
|
|
1237
|
+
return;
|
|
1238
|
+
if (typescript_1.default.isClassDeclaration(node)) {
|
|
1239
|
+
classDecl = node;
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
typescript_1.default.forEachChild(node, findClass);
|
|
1243
|
+
}
|
|
1244
|
+
findClass(sf);
|
|
1245
|
+
if (!classDecl)
|
|
1246
|
+
return null;
|
|
1247
|
+
const className = classDecl.name?.text ?? "(anonymous)";
|
|
1248
|
+
let extendsClass = null;
|
|
1249
|
+
if (classDecl.heritageClauses) {
|
|
1250
|
+
for (const clause of classDecl.heritageClauses) {
|
|
1251
|
+
if (clause.token === typescript_1.default.SyntaxKind.ExtendsKeyword && clause.types.length > 0) {
|
|
1252
|
+
const baseExpr = clause.types[0].expression;
|
|
1253
|
+
if (typescript_1.default.isIdentifier(baseExpr)) {
|
|
1254
|
+
extendsClass = baseExpr.text;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
// Phase 1: Discover public locator properties
|
|
1260
|
+
const locatorNames = [];
|
|
1261
|
+
// Map property names to their declarations for modifier checking
|
|
1262
|
+
const propertyDecls = new Map();
|
|
1263
|
+
for (const member of classDecl.members) {
|
|
1264
|
+
if (typescript_1.default.isPropertyDeclaration(member) && typescript_1.default.isIdentifier(member.name)) {
|
|
1265
|
+
propertyDecls.set(member.name.text, member);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
// 1a: Property declarations with locator initializers
|
|
1269
|
+
for (const member of classDecl.members) {
|
|
1270
|
+
if (typescript_1.default.isPropertyDeclaration(member) && typescript_1.default.isIdentifier(member.name)) {
|
|
1271
|
+
if (isPrivateOrProtected(member))
|
|
1272
|
+
continue;
|
|
1273
|
+
if (member.initializer && isLocatorInit(member.initializer)) {
|
|
1274
|
+
locatorNames.push(member.name.text);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
// 1b: Constructor-assigned locators: this.foo = (this.)page.locator/getBy*()
|
|
1279
|
+
for (const member of classDecl.members) {
|
|
1280
|
+
if (!typescript_1.default.isConstructorDeclaration(member) || !member.body)
|
|
1281
|
+
continue;
|
|
1282
|
+
for (const stmt of member.body.statements) {
|
|
1283
|
+
if (!typescript_1.default.isExpressionStatement(stmt))
|
|
1284
|
+
continue;
|
|
1285
|
+
const expr = stmt.expression;
|
|
1286
|
+
if (!typescript_1.default.isBinaryExpression(expr) || expr.operatorToken.kind !== typescript_1.default.SyntaxKind.EqualsToken)
|
|
1287
|
+
continue;
|
|
1288
|
+
// LHS: this.foo
|
|
1289
|
+
const lhs = expr.left;
|
|
1290
|
+
if (!typescript_1.default.isPropertyAccessExpression(lhs) ||
|
|
1291
|
+
lhs.expression.kind !== typescript_1.default.SyntaxKind.ThisKeyword ||
|
|
1292
|
+
!typescript_1.default.isIdentifier(lhs.name))
|
|
1293
|
+
continue;
|
|
1294
|
+
const propName = lhs.name.text;
|
|
1295
|
+
// Skip if already found via property declaration
|
|
1296
|
+
if (locatorNames.includes(propName))
|
|
1297
|
+
continue;
|
|
1298
|
+
// Check the RHS is a locator init
|
|
1299
|
+
if (!isLocatorInit(expr.right))
|
|
1300
|
+
continue;
|
|
1301
|
+
// Check if the property declaration has private/protected
|
|
1302
|
+
const propDecl = propertyDecls.get(propName);
|
|
1303
|
+
if (propDecl && isPrivateOrProtected(propDecl))
|
|
1304
|
+
continue;
|
|
1305
|
+
locatorNames.push(propName);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const publicLocatorCount = locatorNames.length;
|
|
1309
|
+
const locatorNameSet = new Set(locatorNames);
|
|
1310
|
+
// Phase 2: Count action methods that reference locators via this.<locatorName>
|
|
1311
|
+
const actionMethodNames = [];
|
|
1312
|
+
let totalMethodCount = 0;
|
|
1313
|
+
for (const member of classDecl.members) {
|
|
1314
|
+
// Skip getters and setters — they're separate AST node types
|
|
1315
|
+
if (typescript_1.default.isGetAccessorDeclaration(member))
|
|
1316
|
+
continue;
|
|
1317
|
+
if (typescript_1.default.isSetAccessorDeclaration(member))
|
|
1318
|
+
continue;
|
|
1319
|
+
if (!typescript_1.default.isMethodDeclaration(member))
|
|
1320
|
+
continue;
|
|
1321
|
+
if (!typescript_1.default.isIdentifier(member.name))
|
|
1322
|
+
continue;
|
|
1323
|
+
// Skip constructor (already handled above; methods only)
|
|
1324
|
+
const methodName = member.name.text;
|
|
1325
|
+
if (methodName === "constructor")
|
|
1326
|
+
continue;
|
|
1327
|
+
totalMethodCount++;
|
|
1328
|
+
// Check if method body references this.<locatorName>
|
|
1329
|
+
if (!member.body)
|
|
1330
|
+
continue;
|
|
1331
|
+
let referencesLocator = false;
|
|
1332
|
+
function checkLocatorRef(node) {
|
|
1333
|
+
if (referencesLocator)
|
|
1334
|
+
return;
|
|
1335
|
+
if (typescript_1.default.isPropertyAccessExpression(node) &&
|
|
1336
|
+
node.expression.kind === typescript_1.default.SyntaxKind.ThisKeyword &&
|
|
1337
|
+
typescript_1.default.isIdentifier(node.name) &&
|
|
1338
|
+
locatorNameSet.has(node.name.text)) {
|
|
1339
|
+
referencesLocator = true;
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
typescript_1.default.forEachChild(node, checkLocatorRef);
|
|
1343
|
+
}
|
|
1344
|
+
checkLocatorRef(member.body);
|
|
1345
|
+
if (referencesLocator) {
|
|
1346
|
+
actionMethodNames.push(methodName);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
const actionMethodCount = actionMethodNames.length;
|
|
1350
|
+
// A POM is a "locator bag" when it has many public locators
|
|
1351
|
+
// and fewer than 30% of them have wrapping action methods.
|
|
1352
|
+
const isLocatorBag = publicLocatorCount > 5 &&
|
|
1353
|
+
(publicLocatorCount === 0 || actionMethodCount / publicLocatorCount < 0.3);
|
|
1354
|
+
return {
|
|
1355
|
+
className,
|
|
1356
|
+
extendsClass,
|
|
1357
|
+
publicLocatorCount,
|
|
1358
|
+
locatorNames,
|
|
1359
|
+
actionMethodCount,
|
|
1360
|
+
actionMethodNames,
|
|
1361
|
+
totalMethodCount,
|
|
1362
|
+
isLocatorBag,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
// ─── ARCH-008: Large Inline Data Literals ────────────────────────────
|
|
1366
|
+
/**
|
|
1367
|
+
* Check whether an ObjectLiteralExpression or ArrayLiteralExpression is
|
|
1368
|
+
* "data-like": mostly primitives / nested object-arrays, no function or
|
|
1369
|
+
* class values. Returns false if any element is an arrow function,
|
|
1370
|
+
* function expression, or class expression.
|
|
1371
|
+
*/
|
|
1372
|
+
function isDataLike(node) {
|
|
1373
|
+
const children = typescript_1.default.isObjectLiteralExpression(node)
|
|
1374
|
+
? node.properties
|
|
1375
|
+
: node.elements;
|
|
1376
|
+
for (const child of children) {
|
|
1377
|
+
if (typescript_1.default.isArrowFunction(child) || typescript_1.default.isFunctionExpression(child) || typescript_1.default.isClassExpression(child)) {
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
if (typescript_1.default.isPropertyAssignment(child)) {
|
|
1381
|
+
const init = child.initializer;
|
|
1382
|
+
if (typescript_1.default.isArrowFunction(init) || typescript_1.default.isFunctionExpression(init) || typescript_1.default.isClassExpression(init)) {
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (typescript_1.default.isSpreadAssignment(child) || typescript_1.default.isSpreadElement(child)) {
|
|
1387
|
+
// Spread from an import (e.g. ...basePayload) is fine — skip deep check
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Infer a symbol name for the variable/property that holds the literal.
|
|
1395
|
+
* Walks up through parents: VariableDeclaration → name, PropertyAssignment → name,
|
|
1396
|
+
* ExportAssignment → "default", otherwise "(anonymous)".
|
|
1397
|
+
*/
|
|
1398
|
+
function inferSymbolName(node) {
|
|
1399
|
+
const parent = node.parent;
|
|
1400
|
+
if (!parent)
|
|
1401
|
+
return "(anonymous)";
|
|
1402
|
+
if (typescript_1.default.isVariableDeclaration(parent) && typescript_1.default.isIdentifier(parent.name)) {
|
|
1403
|
+
return parent.name.text;
|
|
1404
|
+
}
|
|
1405
|
+
if (typescript_1.default.isPropertyAssignment(parent) && typescript_1.default.isIdentifier(parent.name)) {
|
|
1406
|
+
return parent.name.text;
|
|
1407
|
+
}
|
|
1408
|
+
if (typescript_1.default.isPropertyAssignment(parent) && typescript_1.default.isStringLiteral(parent.name)) {
|
|
1409
|
+
return parent.name.text;
|
|
1410
|
+
}
|
|
1411
|
+
if (typescript_1.default.isExportAssignment(parent)) {
|
|
1412
|
+
return "default";
|
|
1413
|
+
}
|
|
1414
|
+
// `as const` satisfies: unwrap one level
|
|
1415
|
+
if (typescript_1.default.isAsExpression(parent) || typescript_1.default.isSatisfiesExpression(parent)) {
|
|
1416
|
+
return inferSymbolName(parent);
|
|
1417
|
+
}
|
|
1418
|
+
return "(anonymous)";
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Find large inline object/array literals (≥ threshold lines) in a source file.
|
|
1422
|
+
* Only counts ObjectLiteralExpression and ArrayLiteralExpression nodes that are
|
|
1423
|
+
* data-like (no function values). Ignores function bodies, class bodies, type
|
|
1424
|
+
* declarations, and import/export blocks.
|
|
1425
|
+
*/
|
|
1426
|
+
function findLargeInlineDataLiterals(filePath, content, threshold = 50) {
|
|
1427
|
+
const sf = parseSource(filePath, content);
|
|
1428
|
+
const results = [];
|
|
1429
|
+
function visit(node) {
|
|
1430
|
+
// Only inspect object and array literal expressions
|
|
1431
|
+
if ((typescript_1.default.isObjectLiteralExpression(node) || typescript_1.default.isArrayLiteralExpression(node)) &&
|
|
1432
|
+
isDataLike(node)) {
|
|
1433
|
+
const startLine = lineOf(sf, node.getStart());
|
|
1434
|
+
const endLine = lineOf(sf, node.getEnd());
|
|
1435
|
+
const lineCount = endLine - startLine + 1;
|
|
1436
|
+
if (lineCount >= threshold) {
|
|
1437
|
+
results.push({
|
|
1438
|
+
file: filePath,
|
|
1439
|
+
line: startLine,
|
|
1440
|
+
endLine,
|
|
1441
|
+
lineCount,
|
|
1442
|
+
symbolName: inferSymbolName(node),
|
|
1443
|
+
nodeType: typescript_1.default.isObjectLiteralExpression(node) ? "object" : "array",
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
typescript_1.default.forEachChild(node, visit);
|
|
1448
|
+
}
|
|
1449
|
+
visit(sf);
|
|
1450
|
+
return results;
|
|
1451
|
+
}
|