@qaguardian/playwright-score 2.1.1 → 2.3.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/CHANGELOG.md +159 -0
- package/METHODOLOGY.md +42 -1
- package/README.md +1 -1
- package/dist/cli.js +61 -5
- package/dist/confidence.d.ts +30 -0
- package/dist/confidence.js +67 -0
- package/dist/eslint-runner.js +18 -0
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/json.js +4 -1
- package/dist/formatters/markdown.d.ts +2 -0
- package/dist/formatters/markdown.js +17 -5
- package/dist/formatters/report-css.d.ts +2 -0
- package/dist/formatters/report-css.js +68 -0
- package/dist/formatters/report.d.ts +20 -0
- package/dist/formatters/report.js +279 -0
- package/dist/formatters/text.d.ts +6 -1
- package/dist/formatters/text.js +73 -27
- package/dist/index.js +21 -8
- package/dist/metrics.js +33 -2
- package/dist/playwright-api-names.d.ts +10 -0
- package/dist/playwright-api-names.js +35 -0
- package/dist/profiles.js +14 -0
- package/dist/report-model.d.ts +52 -0
- package/dist/report-model.js +162 -0
- package/dist/report-source.d.ts +11 -0
- package/dist/report-source.js +96 -0
- package/dist/rule-copy.d.ts +14 -0
- package/dist/rule-copy.js +215 -0
- package/dist/rules/ast-util.d.ts +60 -0
- package/dist/rules/ast-util.js +140 -0
- package/dist/rules/base-plugin.d.ts +19 -5
- package/dist/rules/base-plugin.js +245 -8
- package/dist/rules/no-brittle-selector.d.ts +3 -0
- package/dist/rules/no-brittle-selector.js +150 -0
- package/dist/rules/no-excessive-timeout.d.ts +2 -0
- package/dist/rules/no-excessive-timeout.js +87 -0
- package/dist/rules/no-fixme-test.d.ts +14 -0
- package/dist/rules/no-fixme-test.js +53 -0
- package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
- package/dist/rules/no-self-fulfilling-fallback.js +221 -0
- package/dist/rules/no-shared-mutable-state.d.ts +4 -0
- package/dist/rules/no-shared-mutable-state.js +186 -0
- package/dist/rules/no-swallowed-assertion.d.ts +13 -0
- package/dist/rules/no-swallowed-assertion.js +113 -0
- package/dist/rules/no-unawaited-action.d.ts +2 -0
- package/dist/rules/no-unawaited-action.js +77 -0
- package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
- package/dist/rules/no-unawaited-expect-value.js +46 -0
- package/dist/rules/no-weak-assertion.d.ts +19 -0
- package/dist/rules/no-weak-assertion.js +82 -0
- package/dist/rules/rule-util.d.ts +56 -0
- package/dist/rules/rule-util.js +253 -0
- package/dist/score-engine.d.ts +5 -0
- package/dist/score-engine.js +10 -9
- package/dist/suite-name.d.ts +11 -0
- package/dist/suite-name.js +36 -0
- package/dist/test-aliases.js +144 -3
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export const FN_TYPES = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration']);
|
|
2
|
+
/** Walk a subtree. `visit` returns false to skip that node's children. Never descends into nested functions unless `intoFunctions`. */
|
|
3
|
+
export function walk(node, visit, intoFunctions = false) {
|
|
4
|
+
if (!node || typeof node !== 'object')
|
|
5
|
+
return;
|
|
6
|
+
if (Array.isArray(node)) {
|
|
7
|
+
for (const item of node)
|
|
8
|
+
walk(item, visit, intoFunctions);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const rec = node;
|
|
12
|
+
if (typeof rec.type !== 'string')
|
|
13
|
+
return;
|
|
14
|
+
if (visit(node) === false)
|
|
15
|
+
return;
|
|
16
|
+
for (const key of Object.keys(rec)) {
|
|
17
|
+
if (key === 'parent')
|
|
18
|
+
continue;
|
|
19
|
+
const v = rec[key];
|
|
20
|
+
if (!v || typeof v !== 'object')
|
|
21
|
+
continue;
|
|
22
|
+
const child = v;
|
|
23
|
+
if (!intoFunctions && !Array.isArray(v) && child.type && FN_TYPES.has(child.type))
|
|
24
|
+
continue;
|
|
25
|
+
walk(v, visit, intoFunctions);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function unwrap(node) {
|
|
29
|
+
let n = node ?? undefined;
|
|
30
|
+
while (n &&
|
|
31
|
+
(n.type === 'AwaitExpression' ||
|
|
32
|
+
n.type === 'TSNonNullExpression' ||
|
|
33
|
+
n.type === 'ChainExpression' ||
|
|
34
|
+
n.type === 'TSAsExpression' ||
|
|
35
|
+
n.type === 'ParenthesizedExpression')) {
|
|
36
|
+
n = (n.type === 'AwaitExpression' ? n.argument : (n.argument ?? n.expression)) ?? undefined;
|
|
37
|
+
}
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
export function memberName(callee) {
|
|
41
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
42
|
+
return undefined;
|
|
43
|
+
return callee.property?.type === 'Identifier' ? callee.property.name : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** `expect(...)`, `expect.soft(...)`, `expect.poll(...)`. */
|
|
46
|
+
export function isExpectCall(node) {
|
|
47
|
+
if (!node || node.type !== 'CallExpression')
|
|
48
|
+
return false;
|
|
49
|
+
const c = node.callee;
|
|
50
|
+
if (c?.type === 'Identifier' && c.name === 'expect')
|
|
51
|
+
return true;
|
|
52
|
+
return (c?.type === 'MemberExpression' &&
|
|
53
|
+
!c.computed &&
|
|
54
|
+
c.object?.type === 'Identifier' &&
|
|
55
|
+
c.object.name === 'expect' &&
|
|
56
|
+
(c.property?.name === 'soft' || c.property?.name === 'poll'));
|
|
57
|
+
}
|
|
58
|
+
/** If `call` is `expect(x).[not.]matcher(...)`, describe it. */
|
|
59
|
+
export function parseMatcherCall(call) {
|
|
60
|
+
if (call.type !== 'CallExpression')
|
|
61
|
+
return undefined;
|
|
62
|
+
const matcher = memberName(call.callee);
|
|
63
|
+
if (!matcher)
|
|
64
|
+
return undefined;
|
|
65
|
+
const modifiers = [];
|
|
66
|
+
let obj = call.callee?.object;
|
|
67
|
+
while (obj && obj.type === 'MemberExpression' && !obj.computed && obj.property?.type === 'Identifier') {
|
|
68
|
+
modifiers.push(obj.property.name ?? '');
|
|
69
|
+
obj = obj.object;
|
|
70
|
+
}
|
|
71
|
+
if (!obj || !isExpectCall(obj))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!modifiers.every((m) => m === 'not' || m === 'resolves' || m === 'rejects'))
|
|
74
|
+
return undefined;
|
|
75
|
+
return { expectCall: obj, matcher, modifiers, call };
|
|
76
|
+
}
|
|
77
|
+
/** Scope-resolved `const x = <init>` initialiser for an identifier, if any. */
|
|
78
|
+
export function resolveConstInit(context, id) {
|
|
79
|
+
if (id.type !== 'Identifier' || !id.name)
|
|
80
|
+
return undefined;
|
|
81
|
+
let scope = context.sourceCode.getScope(id);
|
|
82
|
+
while (scope) {
|
|
83
|
+
const v = scope.set.get(id.name);
|
|
84
|
+
if (v) {
|
|
85
|
+
const def = v.defs[0];
|
|
86
|
+
if (!def || def.type !== 'Variable')
|
|
87
|
+
return undefined;
|
|
88
|
+
if (def.parent?.type === 'VariableDeclaration' && def.parent.kind !== 'const')
|
|
89
|
+
return undefined;
|
|
90
|
+
return def.node.id?.type === 'Identifier' ? (def.node.init ?? undefined) : undefined;
|
|
91
|
+
}
|
|
92
|
+
scope = scope.upper;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const CHAIN_OK = new Set(['first', 'last', 'nth', 'filter', 'locator', 'and', 'or', 'frameLocator', 'contentFrame']);
|
|
97
|
+
/** Syntactic Locator: `page.locator(..)`, `x.getBy*(..)`, those chained with first/last/nth/filter/..., or a const initialised from one. */
|
|
98
|
+
export function isLocatorExpr(context, node, depth = 0) {
|
|
99
|
+
const n = unwrap(node);
|
|
100
|
+
if (!n || depth > 3)
|
|
101
|
+
return false;
|
|
102
|
+
if (n.type === 'Identifier') {
|
|
103
|
+
const init = resolveConstInit(context, n);
|
|
104
|
+
return init ? isLocatorExpr(context, init, depth + 1) : false;
|
|
105
|
+
}
|
|
106
|
+
if (n.type !== 'CallExpression')
|
|
107
|
+
return false;
|
|
108
|
+
const name = memberName(n.callee);
|
|
109
|
+
if (!name)
|
|
110
|
+
return false;
|
|
111
|
+
if (name === 'locator' || name.startsWith('getBy'))
|
|
112
|
+
return true;
|
|
113
|
+
if (CHAIN_OK.has(name))
|
|
114
|
+
return isLocatorExpr(context, n.callee?.object, depth + 1);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
/** Receiver is plausibly a Playwright Page/Locator/Frame (syntactic). */
|
|
118
|
+
export function isPlaywrightReceiver(context, node, depth = 0) {
|
|
119
|
+
const n = unwrap(node);
|
|
120
|
+
if (!n || depth > 3)
|
|
121
|
+
return false;
|
|
122
|
+
if (isLocatorExpr(context, n))
|
|
123
|
+
return true;
|
|
124
|
+
if (n.type === 'Identifier') {
|
|
125
|
+
return n.name === 'page' || n.name === 'frame' || /[a-z]Locator$/.test(n.name ?? '') || /^(locator|page)$/i.test(n.name ?? '');
|
|
126
|
+
}
|
|
127
|
+
if (n.type === 'MemberExpression' && !n.computed) {
|
|
128
|
+
return n.property?.name === 'page' && n.object?.type === 'ThisExpression';
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
export function nearestFunction(node) {
|
|
133
|
+
let p = node.parent;
|
|
134
|
+
while (p) {
|
|
135
|
+
if (FN_TYPES.has(p.type))
|
|
136
|
+
return p;
|
|
137
|
+
p = p.parent;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
@@ -3,16 +3,30 @@
|
|
|
3
3
|
* style — always active.
|
|
4
4
|
*/
|
|
5
5
|
import type { Rule } from 'eslint';
|
|
6
|
+
/**
|
|
7
|
+
* Rule crashes are contained: an exception thrown while a rule builds its
|
|
8
|
+
* listeners or runs one on some unusual syntax must not abort the run for
|
|
9
|
+
* the whole suite. The rule simply reports nothing for that node. Crashes are
|
|
10
|
+
* recorded so tests can assert there are none.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ruleCrashes: Array<{
|
|
13
|
+
rule: string;
|
|
14
|
+
error: unknown;
|
|
15
|
+
}>;
|
|
16
|
+
/** Clear the crash log; scorePaths calls this at the start of every run. */
|
|
17
|
+
export declare function resetRuleCrashes(): void;
|
|
18
|
+
/** Crashes of the current run, one entry per rule, in first-crash order. */
|
|
19
|
+
export declare function summarizeRuleCrashes(): Array<{
|
|
20
|
+
rule: string;
|
|
21
|
+
count: number;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function contain(name: string, rule: Rule.RuleModule): Rule.RuleModule;
|
|
6
24
|
export declare const basePlugin: {
|
|
7
25
|
meta: {
|
|
8
26
|
name: string;
|
|
9
27
|
version: string;
|
|
10
28
|
};
|
|
11
29
|
rules: {
|
|
12
|
-
|
|
13
|
-
'no-timer-sleep': Rule.RuleModule;
|
|
14
|
-
'no-coordinate-click': Rule.RuleModule;
|
|
15
|
-
'no-trivial-assertion': Rule.RuleModule;
|
|
16
|
-
'no-soft-assertion-only-test': Rule.RuleModule;
|
|
30
|
+
[k: string]: Rule.RuleModule;
|
|
17
31
|
};
|
|
18
32
|
};
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { noUnawaitedAction } from './no-unawaited-action.js';
|
|
2
|
+
import { noBrittleSelector } from './no-brittle-selector.js';
|
|
3
|
+
import { noSharedMutableState, serialModeDependency } from './no-shared-mutable-state.js';
|
|
4
|
+
import { noExcessiveTimeout } from './no-excessive-timeout.js';
|
|
5
|
+
import { noFixmeTest } from './no-fixme-test.js';
|
|
6
|
+
import { noSwallowedAssertion } from './no-swallowed-assertion.js';
|
|
7
|
+
import { noWeakAssertion } from './no-weak-assertion.js';
|
|
8
|
+
import { noUnawaitedExpectValue } from './no-unawaited-expect-value.js';
|
|
9
|
+
import { noSelfFulfillingFallback } from './no-self-fulfilling-fallback.js';
|
|
1
10
|
/**
|
|
2
11
|
* `testNames` is `'test'` plus any `test.extend(...)`-derived fixture
|
|
3
12
|
* aliases detected by test-aliases.ts (see eslint-runner.ts, which feeds
|
|
@@ -175,6 +184,64 @@ function callsResolve(fn, resolveName) {
|
|
|
175
184
|
call.callee?.type === 'Identifier' &&
|
|
176
185
|
call.callee.name === resolveName);
|
|
177
186
|
}
|
|
187
|
+
const LOOP_TYPES = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
|
|
188
|
+
/**
|
|
189
|
+
* A retry/backoff pause inside a plain helper function (an API retry loop,
|
|
190
|
+
* `for (...) { try { await call() } catch { await sleep(500) } }`) is not a
|
|
191
|
+
* test waiting on the UI: it paces calls to a service. Only that shape is
|
|
192
|
+
* exempt: the sleep sits inside a catch clause, or inside a loop whose body
|
|
193
|
+
* contains a try/catch, and the enclosing function is *defined* (function
|
|
194
|
+
* declaration, `const f = () => ...`, class method) and is not itself nested
|
|
195
|
+
* in a callback handed to a call (a test, hook, describe or step body). A
|
|
196
|
+
* hand-rolled polling wait (`while (await x.isVisible()) sleep()`), a sleep
|
|
197
|
+
* directly in a test body or hook, a helper declared inside a test callback
|
|
198
|
+
* and a bare `sleep()` helper are all still flagged. `from` is the ancestor
|
|
199
|
+
* index to start climbing from (the Promise executor's own parent).
|
|
200
|
+
*/
|
|
201
|
+
function isRetryBackoffInHelper(ancestors, from) {
|
|
202
|
+
let retryShape = false;
|
|
203
|
+
let helperFound = false;
|
|
204
|
+
for (let i = from; i >= 0; i--) {
|
|
205
|
+
const anc = ancestors[i];
|
|
206
|
+
if (!helperFound) {
|
|
207
|
+
if (anc.type === 'CatchClause')
|
|
208
|
+
retryShape = true;
|
|
209
|
+
if (LOOP_TYPES.has(anc.type) && containsTryCatch(anc.body))
|
|
210
|
+
retryShape = true;
|
|
211
|
+
}
|
|
212
|
+
if (!FUNCTION_TYPES.has(anc.type) && anc.type !== 'FunctionDeclaration')
|
|
213
|
+
continue;
|
|
214
|
+
const parent = ancestors[i - 1];
|
|
215
|
+
if (parent?.type === 'CallExpression' || parent?.type === 'NewExpression') {
|
|
216
|
+
// Callback handed to a call (test body, hook, describe, .then, step):
|
|
217
|
+
// either the sleep is not in a free-standing helper at all, or the
|
|
218
|
+
// helper lives inside such a callback. Not exempt either way.
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (helperFound)
|
|
222
|
+
continue; // a function around the helper: keep checking the callback chain
|
|
223
|
+
if (anc.type === 'FunctionDeclaration' ||
|
|
224
|
+
parent?.type === 'MethodDefinition' ||
|
|
225
|
+
parent?.type === 'PropertyDefinition' ||
|
|
226
|
+
(parent?.type === 'VariableDeclarator' && parent.init === anc)) {
|
|
227
|
+
if (!retryShape)
|
|
228
|
+
return false;
|
|
229
|
+
helperFound = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return helperFound;
|
|
233
|
+
}
|
|
234
|
+
function containsTryCatch(root) {
|
|
235
|
+
let found = false;
|
|
236
|
+
walkNode(root, (n) => {
|
|
237
|
+
if (n.type !== 'TryStatement' || !n.handler)
|
|
238
|
+
return;
|
|
239
|
+
// An empty `try {}` is not a retry shape.
|
|
240
|
+
if ((n.block?.body ?? []).length > 0)
|
|
241
|
+
found = true;
|
|
242
|
+
});
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
178
245
|
/**
|
|
179
246
|
* `await new Promise((resolve) => setTimeout(resolve, ms))` is the same
|
|
180
247
|
* hard-coded sleep as `page.waitForTimeout(ms)` (already flagged, as an
|
|
@@ -289,7 +356,7 @@ const noTimerSleep = {
|
|
|
289
356
|
firstArg.name === resolveParam.name;
|
|
290
357
|
const resolveName = resolveParam?.type === 'Identifier' ? resolveParam.name : undefined;
|
|
291
358
|
const isWrappedResolve = typeof resolveName === 'string' && callsResolve(firstArg, resolveName);
|
|
292
|
-
if (isDirectResolve || isWrappedResolve) {
|
|
359
|
+
if ((isDirectResolve || isWrappedResolve) && !isRetryBackoffInHelper(ancestors, i - 1)) {
|
|
293
360
|
context.report({ node: node, messageId: 'sleep' });
|
|
294
361
|
}
|
|
295
362
|
}
|
|
@@ -517,8 +584,119 @@ function collectConstBindings(root) {
|
|
|
517
584
|
bindings.set(d.id.name, value);
|
|
518
585
|
}
|
|
519
586
|
});
|
|
587
|
+
// A `const` array is still mutable: `const errors = []; page.on('pageerror',
|
|
588
|
+
// (e) => errors.push(e)); expect(errors).toEqual([])` is a real assertion
|
|
589
|
+
// (the listener fills the array), not `expect([]).toEqual([])`. Only keep
|
|
590
|
+
// an array binding when every reference to it is the plain subject of an
|
|
591
|
+
// expect(...) call; any other use (method call, passed along, spread,
|
|
592
|
+
// reassigned element) might mutate it, so it is not a constant.
|
|
593
|
+
for (const [name, value] of [...bindings]) {
|
|
594
|
+
if (value.ok && Array.isArray(value.value) && !isOnlyReadAsExpectSubject(root, name)) {
|
|
595
|
+
bindings.delete(name);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
520
598
|
return bindings;
|
|
521
599
|
}
|
|
600
|
+
const MUTATING_METHODS = new Set([
|
|
601
|
+
'push', 'unshift', 'splice', 'pop', 'shift', 'sort', 'reverse', 'fill', 'copyWithin',
|
|
602
|
+
]);
|
|
603
|
+
/**
|
|
604
|
+
* True when no reference to `name` under `root` could change the array:
|
|
605
|
+
* every reference is a declaration, the subject of expect(...), a `.length`
|
|
606
|
+
* or index read, or the receiver of a non-mutating method. A reference that
|
|
607
|
+
* is a call argument (it may be handed to code that fills it), the
|
|
608
|
+
* receiver of a mutating method, an assignment/update target, or any other
|
|
609
|
+
* use that aliases the array (returned, assigned on, spread, put in an
|
|
610
|
+
* object) counts as possibly mutating, wherever it sits — including inside
|
|
611
|
+
* a closure such as a listener. Same-named properties (`obj.name`,
|
|
612
|
+
* `{ name: 1 }`) are not references. Shadowing is not resolved: a shadowing
|
|
613
|
+
* binding is read like the outer one, which can only keep a finding quiet.
|
|
614
|
+
*/
|
|
615
|
+
function isOnlyReadAsExpectSubject(root, name) {
|
|
616
|
+
let ok = true;
|
|
617
|
+
const visit = (node, stack) => {
|
|
618
|
+
if (!ok || !node || typeof node !== 'object')
|
|
619
|
+
return;
|
|
620
|
+
if (Array.isArray(node)) {
|
|
621
|
+
for (const item of node)
|
|
622
|
+
visit(item, stack);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const rec = node;
|
|
626
|
+
if (typeof rec.type !== 'string')
|
|
627
|
+
return;
|
|
628
|
+
const n = node;
|
|
629
|
+
if (n.type === 'Identifier' && n.name === name) {
|
|
630
|
+
if (referenceMayMutate(n, stack))
|
|
631
|
+
ok = false;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const next = [...stack, n];
|
|
635
|
+
for (const key of Object.keys(rec)) {
|
|
636
|
+
if (key === 'parent')
|
|
637
|
+
continue;
|
|
638
|
+
const v = rec[key];
|
|
639
|
+
if (v && typeof v === 'object')
|
|
640
|
+
visit(v, next);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
visit(root, []);
|
|
644
|
+
return ok;
|
|
645
|
+
}
|
|
646
|
+
/** Is the member expression `m` itself written, deleted or the receiver of a mutating method? */
|
|
647
|
+
function memberMutates(m, above) {
|
|
648
|
+
if (above.type === 'AssignmentExpression' && above.left === m)
|
|
649
|
+
return true;
|
|
650
|
+
if (above.type === 'UpdateExpression')
|
|
651
|
+
return true;
|
|
652
|
+
if (above.type === 'UnaryExpression' && above.operator === 'delete')
|
|
653
|
+
return true;
|
|
654
|
+
const prop = m.property;
|
|
655
|
+
return (!m.computed &&
|
|
656
|
+
prop?.type === 'Identifier' &&
|
|
657
|
+
!!prop.name &&
|
|
658
|
+
MUTATING_METHODS.has(prop.name) &&
|
|
659
|
+
above.type === 'CallExpression' &&
|
|
660
|
+
above.callee === m);
|
|
661
|
+
}
|
|
662
|
+
function referenceMayMutate(id, stack) {
|
|
663
|
+
const parent = stack[stack.length - 1];
|
|
664
|
+
const grand = stack[stack.length - 2];
|
|
665
|
+
if (!parent)
|
|
666
|
+
return true;
|
|
667
|
+
switch (parent.type) {
|
|
668
|
+
case 'VariableDeclarator':
|
|
669
|
+
return parent.id !== id; // `const b = expected` aliases it
|
|
670
|
+
case 'CallExpression':
|
|
671
|
+
if (parent.arguments?.[0] === id && isExpectCallNode(parent))
|
|
672
|
+
return false;
|
|
673
|
+
return parent.callee !== id || false; // an argument, or the array called
|
|
674
|
+
case 'MemberExpression': {
|
|
675
|
+
if (parent.object !== id)
|
|
676
|
+
return false; // `obj.expected`: a property name, not this binding
|
|
677
|
+
// Mutation of the member itself (`a.x = 1`, `a.push(1)`), then, for a
|
|
678
|
+
// computed access chain (`rows[0].push(9)`, `rows[0][1] = 2`), of
|
|
679
|
+
// the element it reaches: the inner array is part of the asserted one.
|
|
680
|
+
let member = parent;
|
|
681
|
+
for (let up = stack.length - 2; up >= 0; up--) {
|
|
682
|
+
const above = stack[up];
|
|
683
|
+
if (memberMutates(member, above))
|
|
684
|
+
return true;
|
|
685
|
+
if (!member.computed || above.type !== 'MemberExpression' || above.object !== member)
|
|
686
|
+
break;
|
|
687
|
+
member = above;
|
|
688
|
+
}
|
|
689
|
+
return false; // `.length`, `[0]`, `.map(...)`, ... are reads
|
|
690
|
+
}
|
|
691
|
+
case 'Property':
|
|
692
|
+
case 'PropertyDefinition':
|
|
693
|
+
// Non-computed key position is a name, not a reference; a value (or
|
|
694
|
+
// shorthand) puts the array inside an object.
|
|
695
|
+
return !((parent.key === id || false) && !parent.computed && !parent.shorthand);
|
|
696
|
+
default:
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
522
700
|
/**
|
|
523
701
|
* Climbs from `node` to the body of the nearest enclosing function (the
|
|
524
702
|
* test/hook callback in the common case) — the scope `noTrivialAssertion`
|
|
@@ -756,13 +934,72 @@ const noSoftAssertionOnlyTest = {
|
|
|
756
934
|
};
|
|
757
935
|
},
|
|
758
936
|
};
|
|
937
|
+
const rawRules = {
|
|
938
|
+
'no-skipped-test-declaration': noSkippedTestDeclaration,
|
|
939
|
+
'no-timer-sleep': noTimerSleep,
|
|
940
|
+
'no-coordinate-click': noCoordinateClick,
|
|
941
|
+
'no-trivial-assertion': noTrivialAssertion,
|
|
942
|
+
'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
|
|
943
|
+
'no-unawaited-action': noUnawaitedAction,
|
|
944
|
+
'no-brittle-selector': noBrittleSelector,
|
|
945
|
+
'no-shared-mutable-state': noSharedMutableState,
|
|
946
|
+
'serial-mode-dependency': serialModeDependency,
|
|
947
|
+
'no-excessive-timeout': noExcessiveTimeout,
|
|
948
|
+
'no-fixme-test': noFixmeTest,
|
|
949
|
+
'no-swallowed-assertion': noSwallowedAssertion,
|
|
950
|
+
'no-weak-assertion': noWeakAssertion,
|
|
951
|
+
'no-unawaited-expect-value': noUnawaitedExpectValue,
|
|
952
|
+
'no-self-fulfilling-fallback': noSelfFulfillingFallback,
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* Rule crashes are contained: an exception thrown while a rule builds its
|
|
956
|
+
* listeners or runs one on some unusual syntax must not abort the run for
|
|
957
|
+
* the whole suite. The rule simply reports nothing for that node. Crashes are
|
|
958
|
+
* recorded so tests can assert there are none.
|
|
959
|
+
*/
|
|
960
|
+
export const ruleCrashes = [];
|
|
961
|
+
/** Clear the crash log; scorePaths calls this at the start of every run. */
|
|
962
|
+
export function resetRuleCrashes() {
|
|
963
|
+
ruleCrashes.length = 0;
|
|
964
|
+
}
|
|
965
|
+
/** Crashes of the current run, one entry per rule, in first-crash order. */
|
|
966
|
+
export function summarizeRuleCrashes() {
|
|
967
|
+
const counts = new Map();
|
|
968
|
+
for (const c of ruleCrashes)
|
|
969
|
+
counts.set(c.rule, (counts.get(c.rule) ?? 0) + 1);
|
|
970
|
+
return [...counts].map(([rule, count]) => ({ rule, count }));
|
|
971
|
+
}
|
|
972
|
+
export function contain(name, rule) {
|
|
973
|
+
return {
|
|
974
|
+
...rule,
|
|
975
|
+
create(context) {
|
|
976
|
+
let listeners;
|
|
977
|
+
try {
|
|
978
|
+
listeners = rule.create(context);
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
ruleCrashes.push({ rule: name, error });
|
|
982
|
+
return {};
|
|
983
|
+
}
|
|
984
|
+
const safe = {};
|
|
985
|
+
for (const [key, fn] of Object.entries(listeners)) {
|
|
986
|
+
if (typeof fn !== 'function')
|
|
987
|
+
continue;
|
|
988
|
+
safe[key] = (...args) => {
|
|
989
|
+
try {
|
|
990
|
+
return fn(...args);
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
ruleCrashes.push({ rule: name, error });
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return safe;
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
759
1002
|
export const basePlugin = {
|
|
760
1003
|
meta: { name: 'pwscore-base', version: '0.1.0' },
|
|
761
|
-
rules:
|
|
762
|
-
'no-skipped-test-declaration': noSkippedTestDeclaration,
|
|
763
|
-
'no-timer-sleep': noTimerSleep,
|
|
764
|
-
'no-coordinate-click': noCoordinateClick,
|
|
765
|
-
'no-trivial-assertion': noTrivialAssertion,
|
|
766
|
-
'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
|
|
767
|
-
},
|
|
1004
|
+
rules: Object.fromEntries(Object.entries(rawRules).map(([name, rule]) => [name, contain(name, rule)])),
|
|
768
1005
|
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { isPwReceiver, propName, staticString, unwrap } from './rule-util.js';
|
|
2
|
+
/**
|
|
3
|
+
* `pwscore/no-brittle-selector` (QAG-287). Selector strings that encode
|
|
4
|
+
* page structure instead of what the user sees. Matches, on the first
|
|
5
|
+
* argument of `.locator(...)`, `.frameLocator(...)` and the legacy
|
|
6
|
+
* selector-taking page methods (`page.click('..')`, `page.$('..')`, ...)
|
|
7
|
+
* when it is a fully static string:
|
|
8
|
+
* - XPath: `xpath=...`, or a string starting with `//` or `(//`
|
|
9
|
+
* - `:nth-child(` / `:nth-of-type(`
|
|
10
|
+
* - a CSS chain with 4 or more combinators (`>`, `+`, `~`, descendant
|
|
11
|
+
* space) outside brackets/parentheses/quotes
|
|
12
|
+
* - generated class names: `.css-1a2b3c`, `.sc-bdfBwQ`, `.jss123`,
|
|
13
|
+
* `.MuiBox-root-12`, `.makeStyles-root-12`
|
|
14
|
+
* and `.nth(n)` / `.first()` / `.last()` applied directly to a raw
|
|
15
|
+
* `.locator('<string>')` (a `data-testid`/`role=`/`text=` selector string
|
|
16
|
+
* is exempt: it names something stable).
|
|
17
|
+
*
|
|
18
|
+
* Reported as warning but report-only (evidence, not score): the locators
|
|
19
|
+
* dimension already counts every raw `.locator()`/legacy selector call in
|
|
20
|
+
* its native/raw ratio; scoring these as well would charge the same call
|
|
21
|
+
* twice. The finding says why the raw call is fragile.
|
|
22
|
+
*/
|
|
23
|
+
const SELECTOR_METHODS = new Set(['locator', 'frameLocator']);
|
|
24
|
+
const LEGACY = new Set([
|
|
25
|
+
'click', 'dblclick', 'fill', 'type', 'press', 'check', 'uncheck', 'hover', 'focus',
|
|
26
|
+
'selectOption', 'textContent', 'innerText', 'innerHTML', 'getAttribute', 'inputValue',
|
|
27
|
+
'isVisible', 'isChecked', 'isEnabled', 'isDisabled', 'isHidden', 'waitForSelector', '$', '$$',
|
|
28
|
+
'setInputFiles', 'dispatchEvent', 'tap',
|
|
29
|
+
]);
|
|
30
|
+
function countCombinators(css) {
|
|
31
|
+
let depthParen = 0;
|
|
32
|
+
let depthBracket = 0;
|
|
33
|
+
let quote = '';
|
|
34
|
+
let count = 0;
|
|
35
|
+
let pendingSpace = false;
|
|
36
|
+
let sawCompound = false;
|
|
37
|
+
for (let i = 0; i < css.length; i++) {
|
|
38
|
+
const ch = css[i];
|
|
39
|
+
if (quote) {
|
|
40
|
+
if (ch === '\\')
|
|
41
|
+
i++;
|
|
42
|
+
else if (ch === quote)
|
|
43
|
+
quote = '';
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"' || ch === "'") {
|
|
47
|
+
quote = ch;
|
|
48
|
+
sawCompound = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === '(') {
|
|
52
|
+
depthParen++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === ')') {
|
|
56
|
+
depthParen = Math.max(0, depthParen - 1);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '[') {
|
|
60
|
+
depthBracket++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === ']') {
|
|
64
|
+
depthBracket = Math.max(0, depthBracket - 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (depthParen || depthBracket)
|
|
68
|
+
continue;
|
|
69
|
+
if (ch === ',')
|
|
70
|
+
return 0; // selector list: do not guess
|
|
71
|
+
if (ch === '>' || ch === '+' || ch === '~') {
|
|
72
|
+
if (sawCompound)
|
|
73
|
+
count++;
|
|
74
|
+
sawCompound = false;
|
|
75
|
+
pendingSpace = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (/\s/.test(ch)) {
|
|
79
|
+
if (sawCompound)
|
|
80
|
+
pendingSpace = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (pendingSpace) {
|
|
84
|
+
count++;
|
|
85
|
+
pendingSpace = false;
|
|
86
|
+
}
|
|
87
|
+
sawCompound = true;
|
|
88
|
+
}
|
|
89
|
+
return count;
|
|
90
|
+
}
|
|
91
|
+
export function brittleReason(sel) {
|
|
92
|
+
const s = sel.trim();
|
|
93
|
+
if (/^xpath=/i.test(s) || s.startsWith('//') || s.startsWith('(//'))
|
|
94
|
+
return 'an XPath selector';
|
|
95
|
+
if (/:nth-(child|of-type|last-child|last-of-type)\(/.test(s))
|
|
96
|
+
return 'a positional :nth-child/:nth-of-type selector';
|
|
97
|
+
if (/\.css-(?=[a-z0-9]*\d)[a-z0-9]{5,}\b/.test(s) || /\.sc-(?=[A-Za-z0-9]*[A-Z0-9])[A-Za-z0-9]{5,}/.test(s) ||
|
|
98
|
+
/\.jss\d+/.test(s) || /\.Mui[A-Za-z]+-[A-Za-z]+-\d+/.test(s) || /\.makeStyles-[A-Za-z]+-\d+/.test(s)) {
|
|
99
|
+
return 'a generated class name that changes between builds';
|
|
100
|
+
}
|
|
101
|
+
// Only plain CSS gets the combinator count (not text=/role=/>> chains).
|
|
102
|
+
if (!/^[a-z-]+=/i.test(s) || /^css=/i.test(s)) {
|
|
103
|
+
const css = s.replace(/^css=/i, '');
|
|
104
|
+
if (!css.includes('>>') && countCombinators(css) >= 4)
|
|
105
|
+
return 'a long CSS chain (4+ combinators) tied to the page structure';
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
export const noBrittleSelector = {
|
|
110
|
+
meta: {
|
|
111
|
+
type: 'suggestion',
|
|
112
|
+
docs: { description: 'Disallow selectors that encode page structure' },
|
|
113
|
+
messages: {
|
|
114
|
+
brittle: 'Brittle selector: {{reason}}. It breaks when markup is reorganised; prefer getByRole/getByLabel/getByTestId.',
|
|
115
|
+
positional: '`.{{method}}()` on a raw `.locator(...)` picks an element by its position, which changes with the data or layout. Narrow the locator instead (role, label, test id, filter).',
|
|
116
|
+
},
|
|
117
|
+
schema: [],
|
|
118
|
+
},
|
|
119
|
+
create(context) {
|
|
120
|
+
return {
|
|
121
|
+
CallExpression(node) {
|
|
122
|
+
const callee = unwrap(node.callee);
|
|
123
|
+
if (callee?.type !== 'MemberExpression')
|
|
124
|
+
return;
|
|
125
|
+
const name = propName(callee);
|
|
126
|
+
if (!name)
|
|
127
|
+
return;
|
|
128
|
+
if (SELECTOR_METHODS.has(name) || (LEGACY.has(name) && isPwReceiver(context, callee.object))) {
|
|
129
|
+
const sel = staticString(node.arguments?.[0]);
|
|
130
|
+
if (sel !== undefined) {
|
|
131
|
+
const reason = brittleReason(sel);
|
|
132
|
+
if (reason)
|
|
133
|
+
context.report({ node: node, messageId: 'brittle', data: { reason } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (name === 'first' || name === 'last' || name === 'nth') {
|
|
137
|
+
const inner = unwrap(callee.object);
|
|
138
|
+
if (inner?.type === 'CallExpression' && inner.callee?.type === 'MemberExpression' && propName(inner.callee) === 'locator') {
|
|
139
|
+
const sel = staticString(inner.arguments?.[0]);
|
|
140
|
+
if (sel === undefined)
|
|
141
|
+
return;
|
|
142
|
+
if (/data-(testid|test-id|test|cy|qa|pw)|^role=|^text=|^internal:|^id=|^testid=/i.test(sel.trim()))
|
|
143
|
+
return;
|
|
144
|
+
context.report({ node: node, messageId: 'positional', data: { method: name } });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|