@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,221 @@
|
|
|
1
|
+
import { FN_TYPES, isExpectCall, memberName, resolveConstInit, unwrap, walk } from './ast-util.js';
|
|
2
|
+
const CHECKS = new Set([
|
|
3
|
+
'isVisible', 'isHidden', 'isEnabled', 'isDisabled', 'isChecked', 'isEditable', 'count',
|
|
4
|
+
'textContent', 'innerText', 'inputValue', 'getAttribute', 'allTextContents', 'allInnerTexts',
|
|
5
|
+
]);
|
|
6
|
+
const NEGATIVE_STATE_CHECKS = new Set(['isHidden', 'isDisabled']);
|
|
7
|
+
// Only actions that SUPPLY content count. A click-only fallback is the
|
|
8
|
+
// "make sure the menu/toggle is open, then assert" setup idiom (checked on
|
|
9
|
+
// rill, mattermost: all click-only hits were setup, not self-fulfilment;
|
|
10
|
+
// `press('Escape')` in an else branch was setup too, so press is excluded).
|
|
11
|
+
const ACTIONS = new Set(['fill', 'type', 'pressSequentially', 'selectOption', 'setInputFiles']);
|
|
12
|
+
export const noSelfFulfillingFallback = {
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'suggestion',
|
|
15
|
+
docs: { description: 'Disallow fallbacks that perform the expected state themselves before asserting it' },
|
|
16
|
+
messages: {
|
|
17
|
+
fallback: 'Self-fulfilling fallback: when the app does not produce this state, the test performs the actions itself and then asserts the result. The assertion can no longer detect the app failing to do it. Assert first and fail, or remove the fallback.',
|
|
18
|
+
},
|
|
19
|
+
schema: [
|
|
20
|
+
{
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: { testAliasNames: { items: { type: 'string' }, type: 'array' } },
|
|
23
|
+
type: 'object',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
const aliases = context.options[0]?.testAliasNames ?? [];
|
|
29
|
+
const testNames = new Set(['test', ...aliases]);
|
|
30
|
+
const text = (n) => context.sourceCode.getText(n).replace(/\s+/g, '');
|
|
31
|
+
function isTestCallback(fn) {
|
|
32
|
+
const call = fn.parent;
|
|
33
|
+
if (!call || call.type !== 'CallExpression' || !(call.arguments ?? []).includes(fn))
|
|
34
|
+
return false;
|
|
35
|
+
let c = call.callee;
|
|
36
|
+
if (c?.type === 'MemberExpression')
|
|
37
|
+
c = c.object?.type === 'MemberExpression' ? c.object.object : c.object;
|
|
38
|
+
return c?.type === 'Identifier' && !!c.name && testNames.has(c.name);
|
|
39
|
+
}
|
|
40
|
+
function inTest(n) {
|
|
41
|
+
for (let p = n.parent; p; p = p.parent)
|
|
42
|
+
if (FN_TYPES.has(p.type) && isTestCallback(p))
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/** Does the expression contain a state check (resolving one level of const)? */
|
|
47
|
+
function hasCheck(e, depth = 0) {
|
|
48
|
+
let found = false;
|
|
49
|
+
walk(e, (n) => {
|
|
50
|
+
if (found)
|
|
51
|
+
return false;
|
|
52
|
+
if (n.type === 'CallExpression' && CHECKS.has(memberName(n.callee) ?? ''))
|
|
53
|
+
found = true;
|
|
54
|
+
else if (n.type === 'MemberExpression' && !n.computed && n.property?.name === 'length')
|
|
55
|
+
found = true;
|
|
56
|
+
else if (n.type === 'Identifier' && depth < 2) {
|
|
57
|
+
const init = resolveConstInit(context, n);
|
|
58
|
+
if (init && hasCheck(init, depth + 1))
|
|
59
|
+
found = true;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
});
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
function isZeroish(n) {
|
|
66
|
+
return !!n && n.type === 'Literal' && (n.value === 0 || n.value === '' || n.value === false);
|
|
67
|
+
}
|
|
68
|
+
/** The test expression is true when the app did NOT produce the state. */
|
|
69
|
+
function negated(e, depth = 0) {
|
|
70
|
+
const n = unwrap(e);
|
|
71
|
+
if (!n || depth > 3)
|
|
72
|
+
return false;
|
|
73
|
+
if (n.type === 'UnaryExpression' && n.operator === '!')
|
|
74
|
+
return hasCheck(n.argument ?? undefined);
|
|
75
|
+
if (n.type === 'LogicalExpression')
|
|
76
|
+
return negated(n.left, depth + 1) || negated(n.right, depth + 1);
|
|
77
|
+
if (n.type === 'BinaryExpression') {
|
|
78
|
+
const op = n.operator;
|
|
79
|
+
const l = unwrap(n.left);
|
|
80
|
+
const r = unwrap(n.right);
|
|
81
|
+
if ((op === '===' || op === '==') && hasCheck(l) && isZeroish(r))
|
|
82
|
+
return true;
|
|
83
|
+
if ((op === '<' && hasCheck(l) && r?.type === 'Literal' && r.value === 1))
|
|
84
|
+
return true;
|
|
85
|
+
if ((op === '!==' || op === '!=') && hasCheck(l) && r?.type === 'Literal' && typeof r.value === 'string')
|
|
86
|
+
return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (n.type === 'CallExpression')
|
|
90
|
+
return NEGATIVE_STATE_CHECKS.has(memberName(n.callee) ?? '');
|
|
91
|
+
if (n.type === 'Identifier' && depth < 2) {
|
|
92
|
+
const init = resolveConstInit(context, n);
|
|
93
|
+
// `const hidden = await x.isHidden(); if (hidden)`
|
|
94
|
+
return init ? negated(init, depth + 1) : false;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
function positive(e) {
|
|
99
|
+
return hasCheck(e) && !negated(e);
|
|
100
|
+
}
|
|
101
|
+
/** Receiver texts of member calls and expect() subjects in a subtree. */
|
|
102
|
+
function keys(root, depth = 0) {
|
|
103
|
+
const out = new Set();
|
|
104
|
+
walk(root, (n) => {
|
|
105
|
+
if (n.type === 'CallExpression') {
|
|
106
|
+
if (isExpectCall(n)) {
|
|
107
|
+
const a = unwrap((n.arguments ?? [])[0]);
|
|
108
|
+
if (a)
|
|
109
|
+
addKey(out, a, depth);
|
|
110
|
+
}
|
|
111
|
+
else if (n.callee?.type === 'MemberExpression') {
|
|
112
|
+
addKey(out, n.callee.object, depth);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function addKey(out, n, depth) {
|
|
119
|
+
const u = unwrap(n);
|
|
120
|
+
if (!u)
|
|
121
|
+
return;
|
|
122
|
+
const t = text(u);
|
|
123
|
+
if (t === 'page' || t === 'this.page' || t === 'expect' || t === 'test' || t.length < 3)
|
|
124
|
+
return;
|
|
125
|
+
out.add(t);
|
|
126
|
+
if (u.type === 'Identifier' && depth < 2) {
|
|
127
|
+
const init = resolveConstInit(context, u);
|
|
128
|
+
if (init)
|
|
129
|
+
for (const k of keys(init, depth + 1))
|
|
130
|
+
out.add(k);
|
|
131
|
+
if (init)
|
|
132
|
+
addKey(out, init, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function overlap(a, b) {
|
|
136
|
+
for (const x of a)
|
|
137
|
+
for (const y of b)
|
|
138
|
+
if (x === y)
|
|
139
|
+
return true;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
function hasAction(branch) {
|
|
143
|
+
let has = false;
|
|
144
|
+
const targets = new Set();
|
|
145
|
+
walk(branch, (n) => {
|
|
146
|
+
if (n.type === 'CallExpression' && ACTIONS.has(memberName(n.callee) ?? '')) {
|
|
147
|
+
has = true;
|
|
148
|
+
addKey(targets, n.callee?.object, 0);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return { has, targets };
|
|
152
|
+
}
|
|
153
|
+
function expectsAfter(siblings, from) {
|
|
154
|
+
const found = [];
|
|
155
|
+
for (const s of siblings.slice(from + 1)) {
|
|
156
|
+
walk(s, (n) => {
|
|
157
|
+
if (isExpectCall(n))
|
|
158
|
+
found.push(n);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
function evaluate(stmt, siblings, idx) {
|
|
164
|
+
let fallback;
|
|
165
|
+
let condKeys = new Set();
|
|
166
|
+
if (stmt.type === 'IfStatement') {
|
|
167
|
+
const test = stmt.test;
|
|
168
|
+
if (negated(test))
|
|
169
|
+
fallback = stmt.consequent;
|
|
170
|
+
else if (positive(test) && stmt.alternate)
|
|
171
|
+
fallback = stmt.alternate;
|
|
172
|
+
if (!fallback)
|
|
173
|
+
return;
|
|
174
|
+
condKeys = keys(test);
|
|
175
|
+
// keys() only sees member-call receivers/expect subjects; a const-derived test needs its init too.
|
|
176
|
+
walk(test, (n) => {
|
|
177
|
+
if (n.type === 'Identifier') {
|
|
178
|
+
const init = resolveConstInit(context, n);
|
|
179
|
+
if (init)
|
|
180
|
+
for (const k of keys(init))
|
|
181
|
+
condKeys.add(k);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else if (stmt.type === 'TryStatement' && stmt.handler) {
|
|
186
|
+
let guarded = false;
|
|
187
|
+
walk(stmt.block, (n) => {
|
|
188
|
+
if (isExpectCall(n) || (n.type === 'CallExpression' && CHECKS.has(memberName(n.callee) ?? '')))
|
|
189
|
+
guarded = true;
|
|
190
|
+
});
|
|
191
|
+
if (!guarded)
|
|
192
|
+
return;
|
|
193
|
+
fallback = stmt.handler.body;
|
|
194
|
+
condKeys = keys(stmt.block);
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
return;
|
|
198
|
+
const act = hasAction(fallback);
|
|
199
|
+
if (!act.has)
|
|
200
|
+
return;
|
|
201
|
+
const expects = expectsAfter(siblings, idx);
|
|
202
|
+
if (expects.length === 0)
|
|
203
|
+
return;
|
|
204
|
+
const later = new Set();
|
|
205
|
+
for (const e of expects)
|
|
206
|
+
for (const k of keys(e))
|
|
207
|
+
later.add(k);
|
|
208
|
+
if (overlap(condKeys, later) || overlap(act.targets, later)) {
|
|
209
|
+
context.report({ node: stmt, messageId: 'fallback' });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function onBlock(raw) {
|
|
213
|
+
const block = raw;
|
|
214
|
+
if (!inTest(block))
|
|
215
|
+
return;
|
|
216
|
+
const body = (Array.isArray(block.body) ? block.body : []);
|
|
217
|
+
body.forEach((s, i) => evaluate(s, body, i));
|
|
218
|
+
}
|
|
219
|
+
return { BlockStatement: onBlock };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { TEST_ALIAS_SCHEMA, enclosingFunction, hookName, isDescribeCall, isInSerialScope, isSerialConfigureCall, isSerialDescribeCall, isTestDeclaration, propName, testNamesFromOptions, unwrap, } from './rule-util.js';
|
|
2
|
+
/**
|
|
3
|
+
* `pwscore/no-shared-mutable-state` (QAG-287). A module-level or
|
|
4
|
+
* describe-level `let`/`var` (or a `const` bound to an object/array
|
|
5
|
+
* literal, `new Map()` or `new Set()`) that one `test(...)` body assigns or
|
|
6
|
+
* mutates and a DIFFERENT `test(...)` body reads without writing it
|
|
7
|
+
* itself. Run alone, in another order or on another worker, the reader sees
|
|
8
|
+
* the initial value, so the suite is order-dependent.
|
|
9
|
+
*
|
|
10
|
+
* Not matched: variables in serial mode (`test.describe.serial`, or
|
|
11
|
+
* `test.describe.configure({ mode: 'serial' })` at file level or in an
|
|
12
|
+
* enclosing describe); assignment only inside `beforeEach`/`beforeAll`/`afterEach`
|
|
13
|
+
* (setup); a variable that any `beforeEach` assigns (it is reset before each
|
|
14
|
+
* test); a reader that also writes the variable itself (its own setup);
|
|
15
|
+
* variables declared inside a test.
|
|
16
|
+
* Mutation = assignment, `++`/`--`, member assignment (`v.x = 1`), or a
|
|
17
|
+
* call of `push/pop/shift/unshift/splice/set/add/delete/clear/sort/reverse`.
|
|
18
|
+
*
|
|
19
|
+
* Companion `pwscore/serial-mode-dependency` is report-only information:
|
|
20
|
+
* `test.describe.configure({ mode: 'serial' })` and `test.describe.serial()`
|
|
21
|
+
* declare that tests depend on order; not an error on its own.
|
|
22
|
+
*/
|
|
23
|
+
const MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'set', 'add', 'delete', 'clear', 'sort', 'reverse']);
|
|
24
|
+
export const noSharedMutableState = {
|
|
25
|
+
meta: {
|
|
26
|
+
type: 'suggestion',
|
|
27
|
+
docs: { description: 'Disallow state written in one test and read in another' },
|
|
28
|
+
messages: {
|
|
29
|
+
shared: '`{{name}}` is written in test "{{writer}}" and read in this test. Tests that share state depend on run order and break under retries or parallel workers. Create the state inside each test (or a fixture/beforeEach).',
|
|
30
|
+
},
|
|
31
|
+
schema: TEST_ALIAS_SCHEMA,
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
const testNames = testNamesFromOptions(context.options);
|
|
35
|
+
/** The test(...) call (and its title) whose callback contains `node`, if any. */
|
|
36
|
+
const owningTest = (node) => {
|
|
37
|
+
let fn = enclosingFunction(node);
|
|
38
|
+
while (fn) {
|
|
39
|
+
const call = fn.parent;
|
|
40
|
+
if (call?.type === 'CallExpression' && call.arguments.includes(fn)) {
|
|
41
|
+
if (isTestDeclaration(call, testNames))
|
|
42
|
+
return call;
|
|
43
|
+
if (hookName(call, testNames))
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
fn = enclosingFunction(fn);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
const owningHook = (node) => {
|
|
51
|
+
let fn = enclosingFunction(node);
|
|
52
|
+
while (fn) {
|
|
53
|
+
const call = fn.parent;
|
|
54
|
+
if (call?.type === 'CallExpression' && call.arguments.includes(fn)) {
|
|
55
|
+
const h = hookName(call, testNames);
|
|
56
|
+
if (h)
|
|
57
|
+
return h;
|
|
58
|
+
if (isTestDeclaration(call, testNames))
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
fn = enclosingFunction(fn);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
/** Declared directly at module level or directly in a describe callback. */
|
|
66
|
+
const isSharedScope = (decl) => {
|
|
67
|
+
const fn = enclosingFunction(decl);
|
|
68
|
+
if (!fn)
|
|
69
|
+
return true;
|
|
70
|
+
const call = fn.parent;
|
|
71
|
+
return call?.type === 'CallExpression' && call.arguments.includes(fn) && isDescribeCall(call, testNames);
|
|
72
|
+
};
|
|
73
|
+
const isMutableInit = (kind, init) => {
|
|
74
|
+
if (kind !== 'const')
|
|
75
|
+
return true;
|
|
76
|
+
const i = unwrap(init);
|
|
77
|
+
return (!!i &&
|
|
78
|
+
(i.type === 'ObjectExpression' ||
|
|
79
|
+
i.type === 'ArrayExpression' ||
|
|
80
|
+
(i.type === 'NewExpression' && i.callee?.type === 'Identifier' && (i.callee.name === 'Map' || i.callee.name === 'Set'))));
|
|
81
|
+
};
|
|
82
|
+
const titleOf = (call) => {
|
|
83
|
+
const t = call.arguments?.[0];
|
|
84
|
+
return t?.type === 'Literal' && typeof t.value === 'string' ? t.value : 'another test';
|
|
85
|
+
};
|
|
86
|
+
/** Is this reference a write/mutation of the variable? */
|
|
87
|
+
const isMutation = (id, ref) => {
|
|
88
|
+
if (ref.isWrite())
|
|
89
|
+
return true;
|
|
90
|
+
let child = id;
|
|
91
|
+
let parent = id.parent;
|
|
92
|
+
while (parent?.type === 'MemberExpression' && parent.object === child) {
|
|
93
|
+
const gp = parent.parent;
|
|
94
|
+
if (gp?.type === 'AssignmentExpression' && gp.left === parent)
|
|
95
|
+
return true;
|
|
96
|
+
if (gp?.type === 'UpdateExpression')
|
|
97
|
+
return true;
|
|
98
|
+
if (gp?.type === 'CallExpression' && gp.callee === parent) {
|
|
99
|
+
const name = propName(parent);
|
|
100
|
+
if (name && MUTATORS.has(name) && parent.object === id)
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
child = parent;
|
|
104
|
+
parent = gp;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
VariableDeclaration(node) {
|
|
110
|
+
if (!isSharedScope(node))
|
|
111
|
+
return;
|
|
112
|
+
for (const decl of node.declarations) {
|
|
113
|
+
if (decl.id?.type !== 'Identifier' || !isMutableInit(node.kind, decl.init))
|
|
114
|
+
continue;
|
|
115
|
+
const [variable] = context.sourceCode.getDeclaredVariables(decl);
|
|
116
|
+
if (!variable)
|
|
117
|
+
continue;
|
|
118
|
+
// Serial mode is Playwright's documented way to share state in order.
|
|
119
|
+
if (isInSerialScope(decl, testNames))
|
|
120
|
+
continue;
|
|
121
|
+
const writes = new Map(); // test call -> write node
|
|
122
|
+
const reads = new Map(); // test call -> first read identifier
|
|
123
|
+
let hookReset = false;
|
|
124
|
+
for (const ref of variable.references) {
|
|
125
|
+
const id = ref.identifier;
|
|
126
|
+
if (id === decl.id)
|
|
127
|
+
continue;
|
|
128
|
+
const test = owningTest(id);
|
|
129
|
+
const mutation = isMutation(id, ref);
|
|
130
|
+
if (!test) {
|
|
131
|
+
if (mutation && owningHook(id) === 'beforeEach')
|
|
132
|
+
hookReset = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (mutation) {
|
|
136
|
+
if (!writes.has(test))
|
|
137
|
+
writes.set(test, id);
|
|
138
|
+
}
|
|
139
|
+
else if (ref.isRead() && !reads.has(test)) {
|
|
140
|
+
reads.set(test, id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (hookReset || writes.size === 0)
|
|
144
|
+
continue;
|
|
145
|
+
for (const [test, id] of reads) {
|
|
146
|
+
if (writes.has(test))
|
|
147
|
+
continue;
|
|
148
|
+
// Serial mode orders the tests that share state: skip a writer/reader
|
|
149
|
+
// pair only when BOTH sit in a serial scope.
|
|
150
|
+
const readerSerial = isInSerialScope(test, testNames);
|
|
151
|
+
const writer = [...writes.keys()].find((w) => !(readerSerial && isInSerialScope(w, testNames)));
|
|
152
|
+
if (!writer)
|
|
153
|
+
continue;
|
|
154
|
+
context.report({
|
|
155
|
+
node: id,
|
|
156
|
+
messageId: 'shared',
|
|
157
|
+
data: { name: decl.id.name, writer: titleOf(writer) },
|
|
158
|
+
});
|
|
159
|
+
break; // one finding per variable
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
/** Report-only companion: serial mode declares order dependence. */
|
|
167
|
+
export const serialModeDependency = {
|
|
168
|
+
meta: {
|
|
169
|
+
type: 'suggestion',
|
|
170
|
+
docs: { description: 'Report serial-mode describe blocks (tests depend on order)' },
|
|
171
|
+
messages: {
|
|
172
|
+
serial: 'Serial mode: the tests in this scope depend on running in order, so one failure skips the rest and retries rerun the whole group. Information only.',
|
|
173
|
+
},
|
|
174
|
+
schema: TEST_ALIAS_SCHEMA,
|
|
175
|
+
},
|
|
176
|
+
create(context) {
|
|
177
|
+
const testNames = testNamesFromOptions(context.options);
|
|
178
|
+
return {
|
|
179
|
+
CallExpression(node) {
|
|
180
|
+
if (isSerialDescribeCall(node, testNames) || isSerialConfigureCall(node, testNames)) {
|
|
181
|
+
context.report({ node: node, messageId: 'serial' });
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An assertion whose failure is caught and discarded cannot fail the test.
|
|
3
|
+
*
|
|
4
|
+
* Matches:
|
|
5
|
+
* 1. `try { ... expect(x).matcher() ... } catch { ... }` where the catch
|
|
6
|
+
* body does not throw, call expect / test.fail / a fail-ish helper,
|
|
7
|
+
* return a value, or assign/mutate outer state (any of which may carry
|
|
8
|
+
* the failure to a later assertion). Skipped inside loops (retry idiom).
|
|
9
|
+
* 2. `await expect(x).matcher().catch(<fn that does not throw>)` as a
|
|
10
|
+
* statement (result discarded).
|
|
11
|
+
*/
|
|
12
|
+
import type { Rule } from 'eslint';
|
|
13
|
+
export declare const noSwallowedAssertion: Rule.RuleModule;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { FN_TYPES, memberName, parseMatcherCall, walk } from './ast-util.js';
|
|
2
|
+
const LOOPS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
|
|
3
|
+
const FAILISH = /fail|error|throw|assert|report|expect/i;
|
|
4
|
+
const LOGGERS = /^(console|logger|log|logging|consola|pino|winston|bunyan|debug)$/i;
|
|
5
|
+
/** `console.error(...)`, `logger.warn(...)`, `this.logger.error(...)`: logging reports, it does not fail. */
|
|
6
|
+
function isLoggerCall(callee) {
|
|
7
|
+
if (callee?.type !== 'MemberExpression')
|
|
8
|
+
return false;
|
|
9
|
+
let o = callee.object;
|
|
10
|
+
while (o) {
|
|
11
|
+
if (o.type === 'Identifier')
|
|
12
|
+
return LOGGERS.test(o.name ?? '');
|
|
13
|
+
if (o.type !== 'MemberExpression')
|
|
14
|
+
return false;
|
|
15
|
+
if (!o.computed && o.property?.type === 'Identifier' && LOGGERS.test(o.property.name ?? ''))
|
|
16
|
+
return true;
|
|
17
|
+
o = o.object;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
/** True when `body` can carry the failure onward: throw, expect, test.fail, return value, assignment, fail-ish call. */
|
|
22
|
+
function propagates(body) {
|
|
23
|
+
let ok = false;
|
|
24
|
+
walk(body, (n) => {
|
|
25
|
+
if (ok)
|
|
26
|
+
return false;
|
|
27
|
+
if (n.type === 'ThrowStatement')
|
|
28
|
+
ok = true;
|
|
29
|
+
else if (n.type === 'ReturnStatement' && n.argument)
|
|
30
|
+
ok = true;
|
|
31
|
+
else if (n.type === 'AssignmentExpression' || n.type === 'UpdateExpression')
|
|
32
|
+
ok = true;
|
|
33
|
+
else if (n.type === 'CallExpression') {
|
|
34
|
+
const c = n.callee;
|
|
35
|
+
if (isLoggerCall(c))
|
|
36
|
+
return undefined; // a log line is not a failure
|
|
37
|
+
const name = c?.type === 'Identifier' ? c.name : memberName(c);
|
|
38
|
+
if (c?.type === 'MemberExpression' && c.object?.type === 'Identifier' && c.object.name === 'expect')
|
|
39
|
+
ok = true;
|
|
40
|
+
else if (c?.type === 'MemberExpression' && memberName(c) === 'skip')
|
|
41
|
+
ok = true; // test.skip(...) in the catch: a deliberate, visible outcome
|
|
42
|
+
else if (name && FAILISH.test(name))
|
|
43
|
+
ok = true;
|
|
44
|
+
else if (name && ['push', 'add', 'set'].includes(name))
|
|
45
|
+
ok = true; // collects errors for later
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}, true);
|
|
49
|
+
return ok;
|
|
50
|
+
}
|
|
51
|
+
export const noSwallowedAssertion = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: { description: 'Disallow assertions whose failure is caught and discarded' },
|
|
55
|
+
messages: {
|
|
56
|
+
swallowed: 'This assertion is inside a try/catch that swallows its failure, so it can never fail the test. Rethrow in the catch, or drop the try.',
|
|
57
|
+
swallowedCatch: 'This assertion ends in .catch(...) that discards the failure, so it can never fail the test. Remove the .catch.',
|
|
58
|
+
},
|
|
59
|
+
schema: [],
|
|
60
|
+
},
|
|
61
|
+
create(context) {
|
|
62
|
+
return {
|
|
63
|
+
TryStatement(raw) {
|
|
64
|
+
const node = raw;
|
|
65
|
+
if (!node.handler)
|
|
66
|
+
return;
|
|
67
|
+
let hasExpect = false;
|
|
68
|
+
walk(node.block, (n) => {
|
|
69
|
+
if (n.type === 'CallExpression' && parseMatcherCall(n))
|
|
70
|
+
hasExpect = true;
|
|
71
|
+
});
|
|
72
|
+
if (!hasExpect)
|
|
73
|
+
return;
|
|
74
|
+
if (propagates(node.handler.body))
|
|
75
|
+
return;
|
|
76
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
77
|
+
if (LOOPS.has(p.type))
|
|
78
|
+
return;
|
|
79
|
+
if (FN_TYPES.has(p.type))
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
context.report({ node: raw, messageId: 'swallowed' });
|
|
83
|
+
},
|
|
84
|
+
CallExpression(raw) {
|
|
85
|
+
const node = raw;
|
|
86
|
+
if (memberName(node.callee) !== 'catch')
|
|
87
|
+
return;
|
|
88
|
+
const inner = node.callee?.object;
|
|
89
|
+
if (!inner || !parseMatcherCall(inner))
|
|
90
|
+
return;
|
|
91
|
+
const handler = (node.arguments ?? [])[0];
|
|
92
|
+
if (!handler)
|
|
93
|
+
return;
|
|
94
|
+
// Result must be discarded: `await expect(...).catch(...)` as a statement.
|
|
95
|
+
let up = node.parent;
|
|
96
|
+
if (up?.type === 'AwaitExpression')
|
|
97
|
+
up = up.parent;
|
|
98
|
+
if (up?.type !== 'ExpressionStatement')
|
|
99
|
+
return;
|
|
100
|
+
const isFn = FN_TYPES.has(handler.type);
|
|
101
|
+
const isConsole = handler.type === 'MemberExpression' && handler.object?.name === 'console';
|
|
102
|
+
if (!isFn && !isConsole)
|
|
103
|
+
return;
|
|
104
|
+
if (isFn) {
|
|
105
|
+
const body = handler.body;
|
|
106
|
+
if (body && body.type === 'BlockStatement' && propagates(body))
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
context.report({ node: raw, messageId: 'swallowedCatch' });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isPwReceiver, propName, unwrap, enclosingFunction } from './rule-util.js';
|
|
2
|
+
/**
|
|
3
|
+
* `pwscore/no-unawaited-action` (QAG-287). A Playwright action or
|
|
4
|
+
* navigation used as a bare expression statement, inside an async function
|
|
5
|
+
* (test, hook or helper), with no `await`, `return`, `void`, assignment or
|
|
6
|
+
* argument position: `page.goto(url);`, `page.getByRole('button').click();`,
|
|
7
|
+
* `const b = page.locator('#b'); b.fill('x');`.
|
|
8
|
+
*
|
|
9
|
+
* Matched shape: ExpressionStatement whose expression is exactly
|
|
10
|
+
* `<receiver>.<method>(...)`, where the receiver is syntactically a Page or
|
|
11
|
+
* Locator (see `isPwReceiver`) and `<method>` is an action/navigation/wait
|
|
12
|
+
* (list below, plus any `waitFor*`). Not matched: anything inside
|
|
13
|
+
* `Promise.all([...])`, assigned, returned, passed as an argument, chained
|
|
14
|
+
* into `.catch()`/`.then()`, or in a non-async function.
|
|
15
|
+
*
|
|
16
|
+
* Why not eslint-plugin-playwright's `missing-playwright-await` with its
|
|
17
|
+
* `includePageLocatorMethods` option: it matches the method NAME on any
|
|
18
|
+
* receiver (`el.click()`, `items.count()`, `$btn.check()` all count), which
|
|
19
|
+
* is not precise enough for a public score, and it also reports value
|
|
20
|
+
* getters. This rule requires a Page/Locator receiver.
|
|
21
|
+
*/
|
|
22
|
+
const ACTIONS = new Set([
|
|
23
|
+
'goto',
|
|
24
|
+
'reload',
|
|
25
|
+
'goBack',
|
|
26
|
+
'goForward',
|
|
27
|
+
'click',
|
|
28
|
+
'dblclick',
|
|
29
|
+
'fill',
|
|
30
|
+
'clear',
|
|
31
|
+
'press',
|
|
32
|
+
'pressSequentially',
|
|
33
|
+
'type',
|
|
34
|
+
'check',
|
|
35
|
+
'uncheck',
|
|
36
|
+
'setChecked',
|
|
37
|
+
'selectOption',
|
|
38
|
+
'hover',
|
|
39
|
+
'focus',
|
|
40
|
+
'tap',
|
|
41
|
+
'dragTo',
|
|
42
|
+
'dragAndDrop',
|
|
43
|
+
'setInputFiles',
|
|
44
|
+
'setContent',
|
|
45
|
+
'scrollIntoViewIfNeeded',
|
|
46
|
+
]);
|
|
47
|
+
export const noUnawaitedAction = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: 'problem',
|
|
50
|
+
docs: { description: 'Disallow Playwright actions/navigations that are not awaited' },
|
|
51
|
+
messages: {
|
|
52
|
+
unawaited: '`{{name}}()` returns a promise that is never awaited, so the test moves on before the action finishes (a flake and a swallowed failure). Add `await`.',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
return {
|
|
58
|
+
ExpressionStatement(node) {
|
|
59
|
+
const expr = node.expression;
|
|
60
|
+
if (!expr || expr.type !== 'CallExpression')
|
|
61
|
+
return;
|
|
62
|
+
const callee = unwrap(expr.callee);
|
|
63
|
+
if (callee?.type !== 'MemberExpression')
|
|
64
|
+
return;
|
|
65
|
+
const name = propName(callee);
|
|
66
|
+
if (!name || !(ACTIONS.has(name) || /^waitFor[A-Z]?/.test(name) || name === 'waitFor'))
|
|
67
|
+
return;
|
|
68
|
+
const fn = enclosingFunction(node);
|
|
69
|
+
if (!fn || !fn.async)
|
|
70
|
+
return;
|
|
71
|
+
if (!isPwReceiver(context, callee.object))
|
|
72
|
+
return;
|
|
73
|
+
context.report({ node: expr, messageId: 'unawaited', data: { name } });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Promise-returning Playwright call used without `await` inside
|
|
3
|
+
* `expect(...)`, with a matcher that is not a web-first (auto-awaiting)
|
|
4
|
+
* matcher. The assertion compares a Promise object, not the value.
|
|
5
|
+
*
|
|
6
|
+
* Matches `expect(<call>).[not.]<matcher>(...)` where <call> is not awaited,
|
|
7
|
+
* its method is one of innerText/innerHTML/textContent/inputValue/
|
|
8
|
+
* getAttribute/isVisible/isHidden/isEnabled/isDisabled/isChecked/isEditable/
|
|
9
|
+
* count/title/allInnerTexts/allTextContents/boundingBox/content, and the
|
|
10
|
+
* receiver is syntactically a Page/Locator (page, locator(), getBy*(), a
|
|
11
|
+
* const from one, `*Locator`/`*Page` names). Also flags an un-awaited such
|
|
12
|
+
* call as the argument of toBe/toEqual/toStrictEqual. Either position may be
|
|
13
|
+
* a const initialised (un-awaited) from such a call. Never flags
|
|
14
|
+
* `.resolves`/`.rejects` chains or web-first matchers.
|
|
15
|
+
* `playwright/prefer-web-first-assertions` hits on the same line/column are
|
|
16
|
+
* dropped by eslint-runner in favour of this message.
|
|
17
|
+
*/
|
|
18
|
+
import type { Rule } from 'eslint';
|
|
19
|
+
export declare const PROMISE_METHODS: Set<string>;
|
|
20
|
+
export declare const noUnawaitedExpectValue: Rule.RuleModule;
|