@qaguardian/playwright-score 2.2.0 → 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 +86 -0
- package/METHODOLOGY.md +41 -0
- 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/formatters/json.js +4 -1
- package/dist/formatters/markdown.d.ts +2 -0
- package/dist/formatters/markdown.js +15 -4
- 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 -28
- package/dist/index.js +17 -6
- package/dist/profiles.js +12 -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 +75 -7
- 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 +9 -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 +14 -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
|
|
@@ -925,13 +934,72 @@ const noSoftAssertionOnlyTest = {
|
|
|
925
934
|
};
|
|
926
935
|
},
|
|
927
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
|
+
}
|
|
928
1002
|
export const basePlugin = {
|
|
929
1003
|
meta: { name: 'pwscore-base', version: '0.1.0' },
|
|
930
|
-
rules:
|
|
931
|
-
'no-skipped-test-declaration': noSkippedTestDeclaration,
|
|
932
|
-
'no-timer-sleep': noTimerSleep,
|
|
933
|
-
'no-coordinate-click': noCoordinateClick,
|
|
934
|
-
'no-trivial-assertion': noTrivialAssertion,
|
|
935
|
-
'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
|
|
936
|
-
},
|
|
1004
|
+
rules: Object.fromEntries(Object.entries(rawRules).map(([name, rule]) => [name, contain(name, rule)])),
|
|
937
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
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TEST_ALIAS_SCHEMA, enclosingFunction, evalNumber, hookName, isDescribeCall, isTestDeclaration, propName, testNamesFromOptions, unwrap, } from './rule-util.js';
|
|
2
|
+
/**
|
|
3
|
+
* `pwscore/no-excessive-timeout` (QAG-287). A timeout so long that a real
|
|
4
|
+
* failure takes minutes to show and a slow app is silently tolerated.
|
|
5
|
+
* Matches (literals and simple arithmetic such as `2 * 60 * 1000`, numeric
|
|
6
|
+
* separators included):
|
|
7
|
+
* - `{ timeout: N }` with N > 60_000 passed to an action, wait or
|
|
8
|
+
* matcher call: `x.click/fill/goto/waitFor*(...)`, `expect(..).toXxx(..)`,
|
|
9
|
+
* `expect.poll(..)`
|
|
10
|
+
* - `test.setTimeout(N)` / `testInfo.setTimeout(N)` with N >= 300_000
|
|
11
|
+
* - `test.slow()` with no arguments written directly at file or describe
|
|
12
|
+
* level (a conditional `test.slow(cond, why)` or one inside a test is fine)
|
|
13
|
+
* Not matched: config-file timeouts, non-literal values, `timeout: 0`.
|
|
14
|
+
*/
|
|
15
|
+
const WAIT_METHODS = new Set([
|
|
16
|
+
'goto', 'reload', 'goBack', 'goForward', 'click', 'dblclick', 'fill', 'clear', 'press',
|
|
17
|
+
'pressSequentially', 'type', 'check', 'uncheck', 'setChecked', 'selectOption', 'hover',
|
|
18
|
+
'focus', 'tap', 'dragTo', 'dragAndDrop', 'setInputFiles', 'waitFor', 'poll', 'toPass',
|
|
19
|
+
]);
|
|
20
|
+
export const noExcessiveTimeout = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'suggestion',
|
|
23
|
+
docs: { description: 'Disallow very long literal timeouts' },
|
|
24
|
+
messages: {
|
|
25
|
+
long: 'A {{seconds}}s timeout means a failure takes that long to report and slowness is hidden. Fix the wait condition or the app instead of stretching the limit.',
|
|
26
|
+
longTest: '`setTimeout({{seconds}}s)` gives one test an extreme budget; split the test or fix the slow step.',
|
|
27
|
+
slow: 'Unconditional `test.slow()` triples every timeout in scope, hiding slowness. Apply it to the specific slow test with a reason, or fix the slowness.',
|
|
28
|
+
},
|
|
29
|
+
schema: TEST_ALIAS_SCHEMA,
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
const testNames = testNamesFromOptions(context.options);
|
|
33
|
+
const owners = new Set([...testNames, 'testInfo']);
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
const callee = unwrap(node.callee);
|
|
37
|
+
const isExpectRoot = callee?.type === 'Identifier' && callee.name === 'expect';
|
|
38
|
+
if (callee?.type !== 'MemberExpression' && !isExpectRoot)
|
|
39
|
+
return;
|
|
40
|
+
const name = callee.type === 'MemberExpression' ? propName(callee) : undefined;
|
|
41
|
+
// test.setTimeout(N) / testInfo.setTimeout(N)
|
|
42
|
+
if (name === 'setTimeout' && callee.object?.type === 'Identifier' && owners.has(callee.object.name)) {
|
|
43
|
+
const ms = evalNumber(node.arguments?.[0]);
|
|
44
|
+
if (ms !== undefined && ms >= 300_000) {
|
|
45
|
+
context.report({ node: node, messageId: 'longTest', data: { seconds: String(ms / 1000) } });
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// test.slow() at file/describe level
|
|
50
|
+
if (name === 'slow' && callee.object?.type === 'Identifier' && testNames.has(callee.object.name) && node.arguments.length === 0) {
|
|
51
|
+
const stmt = node.parent;
|
|
52
|
+
if (stmt?.type !== 'ExpressionStatement')
|
|
53
|
+
return;
|
|
54
|
+
const fn = enclosingFunction(node);
|
|
55
|
+
if (!fn) {
|
|
56
|
+
context.report({ node: node, messageId: 'slow' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const call = fn.parent;
|
|
60
|
+
if (call?.type === 'CallExpression' && isDescribeCall(call, testNames) && !hookName(call, testNames) && !isTestDeclaration(call, testNames)) {
|
|
61
|
+
context.report({ node: node, messageId: 'slow' });
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// { timeout: N } on an action / wait / matcher
|
|
66
|
+
if (!name || !(WAIT_METHODS.has(name) || /^waitFor[A-Z]/.test(name) || /^to[A-Z]/.test(name)))
|
|
67
|
+
return;
|
|
68
|
+
for (const arg of node.arguments ?? []) {
|
|
69
|
+
const obj = unwrap(arg);
|
|
70
|
+
if (obj?.type !== 'ObjectExpression')
|
|
71
|
+
continue;
|
|
72
|
+
for (const prop of obj.properties) {
|
|
73
|
+
if (prop.type !== 'Property' || prop.computed)
|
|
74
|
+
continue;
|
|
75
|
+
const key = prop.key?.name ?? prop.key?.value;
|
|
76
|
+
if (key !== 'timeout')
|
|
77
|
+
continue;
|
|
78
|
+
const ms = evalNumber(prop.value);
|
|
79
|
+
if (ms !== undefined && ms > 60_000) {
|
|
80
|
+
context.report({ node: prop, messageId: 'long', data: { seconds: String(ms / 1000) } });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `test.fixme(...)` is the "known broken, do not run" marker. Same split as
|
|
3
|
+
* pwscore/no-skipped-test-declaration:
|
|
4
|
+
*
|
|
5
|
+
* - FLAGGED: declaration `test.fixme('t', fn)` / `test.describe.fixme(...)`
|
|
6
|
+
* (last argument is a function, 2+ arguments), and the unconditional
|
|
7
|
+
* in-body forms `test.fixme()` (no arguments) and `test.fixme(true, ...)`.
|
|
8
|
+
* - CLEAN: conditional `test.fixme(browserName === 'webkit', 'why')`
|
|
9
|
+
* (any first argument that is not the literal `true`).
|
|
10
|
+
*
|
|
11
|
+
* `test` includes `test.extend()` aliases (testAliasNames option).
|
|
12
|
+
*/
|
|
13
|
+
import type { Rule } from 'eslint';
|
|
14
|
+
export declare const noFixmeTest: Rule.RuleModule;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FN_TYPES, memberName } from './ast-util.js';
|
|
2
|
+
function isFixmeCallee(callee, names) {
|
|
3
|
+
if (memberName(callee) !== 'fixme')
|
|
4
|
+
return false;
|
|
5
|
+
const obj = callee?.object;
|
|
6
|
+
if (obj?.type === 'Identifier' && obj.name && names.has(obj.name))
|
|
7
|
+
return true;
|
|
8
|
+
return (obj?.type === 'MemberExpression' &&
|
|
9
|
+
!obj.computed &&
|
|
10
|
+
obj.property?.name === 'describe' &&
|
|
11
|
+
obj.object?.type === 'Identifier' &&
|
|
12
|
+
!!obj.object.name &&
|
|
13
|
+
names.has(obj.object.name));
|
|
14
|
+
}
|
|
15
|
+
export const noFixmeTest = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
docs: { description: 'Disallow unconditional test.fixme(): a test marked broken never runs' },
|
|
19
|
+
messages: {
|
|
20
|
+
declaration: 'Unexpected test.fixme() declaration: this test never runs. Fix it or delete it.',
|
|
21
|
+
unconditional: 'Unconditional test.fixme() inside a test: everything after it never runs. Use test.fixme(condition, reason) or fix the test.',
|
|
22
|
+
},
|
|
23
|
+
schema: [
|
|
24
|
+
{
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: { testAliasNames: { items: { type: 'string' }, type: 'array' } },
|
|
27
|
+
type: 'object',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
const aliases = context.options[0]?.testAliasNames ?? [];
|
|
33
|
+
const names = new Set(['test', ...aliases]);
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(raw) {
|
|
36
|
+
const node = raw;
|
|
37
|
+
if (!isFixmeCallee(node.callee, names))
|
|
38
|
+
return;
|
|
39
|
+
const args = node.arguments ?? [];
|
|
40
|
+
const last = args[args.length - 1];
|
|
41
|
+
if (args.length >= 2 && last && FN_TYPES.has(last.type)) {
|
|
42
|
+
context.report({ node: raw, messageId: 'declaration' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (node.callee?.object?.type !== 'Identifier')
|
|
46
|
+
return; // describe.fixme() in-body form is not a thing
|
|
47
|
+
if (args.length === 0 || (args[0].type === 'Literal' && args[0].value === true)) {
|
|
48
|
+
context.report({ node: raw, messageId: 'unconditional' });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "If the app did not do it, the test does it itself, then asserts."
|
|
3
|
+
*
|
|
4
|
+
* Inside a test callback, for a statement S in a block that is
|
|
5
|
+
* - `if (<negated state check>) { ...actions... }`, or the `else` branch of
|
|
6
|
+
* `if (<state check>) {...} else { ...actions... }`, or
|
|
7
|
+
* - `try { ...expect/state check... } catch { ...actions... }`
|
|
8
|
+
* ALL of the following must hold:
|
|
9
|
+
* (a) the fallback branch (not descending into nested functions) calls a
|
|
10
|
+
* content-supplying Playwright action: fill, type,
|
|
11
|
+
* pressSequentially, selectOption, setInputFiles (click-only fallbacks
|
|
12
|
+
* are the common "ensure the menu/toggle is open" setup idiom and are
|
|
13
|
+
* not flagged);
|
|
14
|
+
* (b) a later statement in the same block contains an expect(...);
|
|
15
|
+
* (c) the later expect's text shares a locator/selector expression with the
|
|
16
|
+
* state check (or try block), or with the action targets. "Locator
|
|
17
|
+
* expression" = the receiver text of a member call, e.g.
|
|
18
|
+
* `page.getByRole('textbox')` or the variable `input`; the bare `page`
|
|
19
|
+
* never counts.
|
|
20
|
+
*
|
|
21
|
+
* A "state check" is a call to isVisible/isHidden/isEnabled/isChecked/count/
|
|
22
|
+
* textContent/innerText/inputValue/getAttribute (or a const boolean derived
|
|
23
|
+
* from one). "Negated" means: `!check`, `check === false`, `count() === 0`,
|
|
24
|
+
* `count() < 1`, `.length === 0`, `text !== '...'`, `isHidden()`/
|
|
25
|
+
* `isDisabled()`, or `||`/`&&` containing one. Positive-branch conditional
|
|
26
|
+
* actions (dismiss a banner if visible) are never flagged.
|
|
27
|
+
*/
|
|
28
|
+
import type { Rule } from 'eslint';
|
|
29
|
+
export declare const noSelfFulfillingFallback: Rule.RuleModule;
|