@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,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;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isPlaywrightReceiver, memberName, parseMatcherCall, resolveConstInit } from './ast-util.js';
|
|
2
|
+
export const PROMISE_METHODS = new Set([
|
|
3
|
+
'innerText', 'innerHTML', 'textContent', 'inputValue', 'getAttribute', 'isVisible', 'isHidden',
|
|
4
|
+
'isEnabled', 'isDisabled', 'isChecked', 'isEditable', 'count', 'title', 'allInnerTexts',
|
|
5
|
+
'allTextContents', 'boundingBox', 'content',
|
|
6
|
+
]);
|
|
7
|
+
const WEB_FIRST = /^(toHave|toBe(Visible|Hidden|Enabled|Disabled|Checked|Editable|Empty|Focused|Attached|InViewport|OK)$|toContainText$|toMatchAriaSnapshot$|toPass$|toMatchSnapshot$|toHaveScreenshot$)/;
|
|
8
|
+
const VALUE_ARG_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']);
|
|
9
|
+
export const noUnawaitedExpectValue = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: { description: 'Disallow un-awaited Promise-returning Playwright calls inside expect()' },
|
|
13
|
+
messages: {
|
|
14
|
+
missingAwait: 'Missing await: {{call}}() returns a Promise, and this matcher compares the Promise itself, not the value (so it always passes or always fails). Add await, or use a web-first assertion on the locator.',
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
},
|
|
18
|
+
create(context) {
|
|
19
|
+
function bareCall(n0) {
|
|
20
|
+
// `const t = page.title(); expect(t)...`: a const initialised from an un-awaited call.
|
|
21
|
+
const n = n0?.type === 'Identifier' ? resolveConstInit(context, n0) : n0;
|
|
22
|
+
if (!n || n.type !== 'CallExpression')
|
|
23
|
+
return undefined; // AwaitExpression etc. are not bare
|
|
24
|
+
const name = memberName(n.callee);
|
|
25
|
+
if (!name || !PROMISE_METHODS.has(name))
|
|
26
|
+
return undefined;
|
|
27
|
+
return isPlaywrightReceiver(context, n.callee?.object) ? name : undefined;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
CallExpression(raw) {
|
|
31
|
+
const node = raw;
|
|
32
|
+
const m = parseMatcherCall(node);
|
|
33
|
+
if (!m || m.modifiers.some((x) => x === 'resolves' || x === 'rejects'))
|
|
34
|
+
return;
|
|
35
|
+
if (WEB_FIRST.test(m.matcher))
|
|
36
|
+
return;
|
|
37
|
+
const isPoll = m.expectCall.callee?.type === 'MemberExpression' && m.expectCall.callee.property?.name === 'poll';
|
|
38
|
+
const subject = (m.expectCall.arguments ?? [])[0];
|
|
39
|
+
const hit = (!isPoll && bareCall(subject)) ||
|
|
40
|
+
(VALUE_ARG_MATCHERS.has(m.matcher) ? bareCall((node.arguments ?? [])[0]) : undefined);
|
|
41
|
+
if (hit)
|
|
42
|
+
context.report({ node: raw, messageId: 'missingAwait', data: { call: hit } });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assertions that cannot fail, or assert almost nothing. Exact shapes:
|
|
3
|
+
*
|
|
4
|
+
* 1. `expect(<count>).toBeGreaterThanOrEqual(0)` / `.toBeGreaterThan(-1)`
|
|
5
|
+
* (no `.not`), where <count> is `.length`, `[await] x.count()`, a const
|
|
6
|
+
* initialised from one of those, or `expect.poll(() => x.count())`.
|
|
7
|
+
* 2. `expect(<Locator>).toBeTruthy()` / `.toBeDefined()` / `.not.toBeNull()`
|
|
8
|
+
* / `.not.toBeUndefined()`; <Locator> is syntactic only: `page.locator()`,
|
|
9
|
+
* `x.getBy*()`, those chained with first/last/nth/filter/locator, or a
|
|
10
|
+
* const initialised from one. A Locator object is always truthy.
|
|
11
|
+
* 3. `expect(await x.isVisible()).toBeDefined()` (also isHidden, isEnabled,
|
|
12
|
+
* isDisabled, isChecked, isEditable, count, innerText, innerHTML,
|
|
13
|
+
* inputValue, title; textContent/getAttribute only for toBeDefined):
|
|
14
|
+
* these always return a value, so "defined" proves nothing.
|
|
15
|
+
*
|
|
16
|
+
* `toBeTruthy()` on any other value is NOT flagged.
|
|
17
|
+
*/
|
|
18
|
+
import type { Rule } from 'eslint';
|
|
19
|
+
export declare const noWeakAssertion: Rule.RuleModule;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { FN_TYPES, isLocatorExpr, memberName, parseMatcherCall, resolveConstInit, unwrap } from './ast-util.js';
|
|
2
|
+
const ALWAYS_VALUE = new Set([
|
|
3
|
+
'isVisible', 'isHidden', 'isEnabled', 'isDisabled', 'isChecked', 'isEditable',
|
|
4
|
+
'count', 'innerText', 'innerHTML', 'inputValue', 'title',
|
|
5
|
+
]);
|
|
6
|
+
const MAYBE_NULL = new Set(['textContent', 'getAttribute']); // null is "defined"
|
|
7
|
+
function isCountish(context, node, depth = 0) {
|
|
8
|
+
const n = unwrap(node);
|
|
9
|
+
if (!n || depth > 2)
|
|
10
|
+
return false;
|
|
11
|
+
if (n.type === 'MemberExpression' && !n.computed && n.property?.name === 'length')
|
|
12
|
+
return true;
|
|
13
|
+
if (n.type === 'CallExpression' && memberName(n.callee) === 'count')
|
|
14
|
+
return true;
|
|
15
|
+
if (n.type === 'Identifier') {
|
|
16
|
+
const init = resolveConstInit(context, n);
|
|
17
|
+
return init ? isCountish(context, init, depth + 1) : false;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
function negativeLiteral(arg, want) {
|
|
22
|
+
if (!arg)
|
|
23
|
+
return false;
|
|
24
|
+
if (arg.type === 'Literal')
|
|
25
|
+
return arg.value === want;
|
|
26
|
+
return arg.type === 'UnaryExpression' && arg.operator === '-' && arg.argument?.type === 'Literal' && -arg.argument.value === want;
|
|
27
|
+
}
|
|
28
|
+
export const noWeakAssertion = {
|
|
29
|
+
meta: {
|
|
30
|
+
type: 'problem',
|
|
31
|
+
docs: { description: 'Disallow assertions that cannot fail or assert almost nothing' },
|
|
32
|
+
messages: {
|
|
33
|
+
count: 'A count is never negative, so this assertion cannot fail. Assert the expected count, or use expect(locator).toHaveCount(n).',
|
|
34
|
+
locator: 'A Locator object is always truthy/defined, so this assertion cannot fail. Use a web-first assertion such as expect(locator).toBeVisible().',
|
|
35
|
+
defined: 'This call always returns a value, so asserting it is defined cannot fail. Assert the value itself, or use a web-first assertion.',
|
|
36
|
+
},
|
|
37
|
+
schema: [],
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
return {
|
|
41
|
+
CallExpression(raw) {
|
|
42
|
+
const node = raw;
|
|
43
|
+
const m = parseMatcherCall(node);
|
|
44
|
+
if (!m || m.modifiers.some((x) => x === 'resolves' || x === 'rejects'))
|
|
45
|
+
return;
|
|
46
|
+
const not = m.modifiers.includes('not');
|
|
47
|
+
const arg0 = (m.expectCall.arguments ?? [])[0];
|
|
48
|
+
const isPoll = m.expectCall.callee?.type === 'MemberExpression' && m.expectCall.callee.property?.name === 'poll';
|
|
49
|
+
// 1. count >= 0
|
|
50
|
+
if (!not && ((m.matcher === 'toBeGreaterThanOrEqual' && negativeLiteral((node.arguments ?? [])[0], 0)) ||
|
|
51
|
+
(m.matcher === 'toBeGreaterThan' && negativeLiteral((node.arguments ?? [])[0], -1)))) {
|
|
52
|
+
let subject = arg0;
|
|
53
|
+
if (isPoll && subject && FN_TYPES.has(subject.type) && subject.type === 'ArrowFunctionExpression' && subject.body.type !== 'BlockStatement') {
|
|
54
|
+
subject = subject.body;
|
|
55
|
+
}
|
|
56
|
+
if (isCountish(context, subject))
|
|
57
|
+
context.report({ node: raw, messageId: 'count' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// 2. Locator truthy
|
|
61
|
+
const truthy = (!not && (m.matcher === 'toBeTruthy' || m.matcher === 'toBeDefined')) ||
|
|
62
|
+
(not && (m.matcher === 'toBeNull' || m.matcher === 'toBeUndefined'));
|
|
63
|
+
if (truthy && !isPoll) {
|
|
64
|
+
if (isLocatorExpr(context, arg0)) {
|
|
65
|
+
context.report({ node: raw, messageId: 'locator' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// 3. value-returning call asserted "defined"
|
|
69
|
+
const subj = unwrap(arg0);
|
|
70
|
+
const name = subj?.type === 'CallExpression' ? memberName(subj.callee) : undefined;
|
|
71
|
+
if (name) {
|
|
72
|
+
const definedCheck = (!not && m.matcher === 'toBeDefined') || (not && m.matcher === 'toBeUndefined');
|
|
73
|
+
const nullCheck = not && m.matcher === 'toBeNull';
|
|
74
|
+
if ((definedCheck && (ALWAYS_VALUE.has(name) || MAYBE_NULL.has(name))) || (nullCheck && ALWAYS_VALUE.has(name))) {
|
|
75
|
+
context.report({ node: raw, messageId: 'defined' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small AST helpers shared by the QAG-287 flake-risk rules (one rule per
|
|
3
|
+
* file). Nodes are typed loosely on purpose: the parser may produce
|
|
4
|
+
* TypeScript-ESTree node types that the plain ESTree typings do not model.
|
|
5
|
+
*/
|
|
6
|
+
export type AnyNode = any;
|
|
7
|
+
/** Strips `x!`, `x as T` and optional-chain wrappers. */
|
|
8
|
+
export declare function unwrap(node: AnyNode): AnyNode;
|
|
9
|
+
/** Name of a non-computed member property (or string-literal computed one). */
|
|
10
|
+
export declare function propName(member: AnyNode): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* True when the expression is *syntactically* a Playwright Page or Locator:
|
|
13
|
+
* the identifier `page`, a `.page` property (`this.page`, `fixtures.page`),
|
|
14
|
+
* a locator-producing call chain on one of those (`page.getByRole(..)`,
|
|
15
|
+
* `page.locator(..).first()`), or an identifier bound once by `const` to
|
|
16
|
+
* such an expression. Anything else (a parameter called `el`, a jQuery
|
|
17
|
+
* wrapper, an array) is not a receiver, so a method that happens to share a
|
|
18
|
+
* Playwright name on another object is never matched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isPwReceiver(context: {
|
|
21
|
+
sourceCode: AnyNode;
|
|
22
|
+
}, expr: AnyNode, depth?: number): boolean;
|
|
23
|
+
/** Nearest enclosing function node, or undefined at module level. */
|
|
24
|
+
export declare function enclosingFunction(node: AnyNode): AnyNode;
|
|
25
|
+
/** Evaluates a numeric literal / simple arithmetic expression, else undefined. */
|
|
26
|
+
export declare function evalNumber(node: AnyNode): number | undefined;
|
|
27
|
+
/** Static string value of a literal or an expression-free template literal. */
|
|
28
|
+
export declare function staticString(node: AnyNode): string | undefined;
|
|
29
|
+
/** `test(...)`, `test.only(...)`, `it(...)` — a test declaration call (not describe/hook). */
|
|
30
|
+
export declare function isTestDeclaration(call: AnyNode, testNames: ReadonlySet<string>): boolean;
|
|
31
|
+
export declare function hookName(call: AnyNode, testNames: ReadonlySet<string>): string | undefined;
|
|
32
|
+
/** `test.describe(...)`, `describe(...)`, `test.describe.serial/parallel/only(...)`. */
|
|
33
|
+
export declare function isDescribeCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
|
|
34
|
+
/** `test.describe.serial(...)` call. */
|
|
35
|
+
export declare function isSerialDescribeCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
|
|
36
|
+
/** `test.describe.configure({ mode: 'serial' })` call. */
|
|
37
|
+
export declare function isSerialConfigureCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Is `node` in a scope declared serial: the file has a top-level
|
|
40
|
+
* `test.describe.configure({ mode: 'serial' })`, or any enclosing describe is
|
|
41
|
+
* `test.describe.serial(...)` or configures serial mode in its own body.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isInSerialScope(node: AnyNode, testNames: ReadonlySet<string>): boolean;
|
|
44
|
+
export declare function testNamesFromOptions(options: unknown[]): Set<string>;
|
|
45
|
+
export declare const TEST_ALIAS_SCHEMA: {
|
|
46
|
+
type: "object";
|
|
47
|
+
properties: {
|
|
48
|
+
testAliasNames: {
|
|
49
|
+
items: {
|
|
50
|
+
type: "string";
|
|
51
|
+
};
|
|
52
|
+
type: "array";
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
additionalProperties: boolean;
|
|
56
|
+
}[];
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/** Strips `x!`, `x as T` and optional-chain wrappers. */
|
|
2
|
+
export function unwrap(node) {
|
|
3
|
+
let n = node;
|
|
4
|
+
while (n &&
|
|
5
|
+
(n.type === 'TSNonNullExpression' ||
|
|
6
|
+
n.type === 'ChainExpression' ||
|
|
7
|
+
n.type === 'TSAsExpression' ||
|
|
8
|
+
n.type === 'TSSatisfiesExpression' ||
|
|
9
|
+
n.type === 'TSTypeAssertion')) {
|
|
10
|
+
n = n.expression;
|
|
11
|
+
}
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
/** Name of a non-computed member property (or string-literal computed one). */
|
|
15
|
+
export function propName(member) {
|
|
16
|
+
if (!member || member.type !== 'MemberExpression')
|
|
17
|
+
return undefined;
|
|
18
|
+
const p = member.property;
|
|
19
|
+
if (!member.computed && p?.type === 'Identifier')
|
|
20
|
+
return p.name;
|
|
21
|
+
if (member.computed && p?.type === 'Literal' && typeof p.value === 'string')
|
|
22
|
+
return p.value;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const LOCATOR_PRODUCERS = new Set([
|
|
26
|
+
'locator',
|
|
27
|
+
'getByRole',
|
|
28
|
+
'getByText',
|
|
29
|
+
'getByLabel',
|
|
30
|
+
'getByPlaceholder',
|
|
31
|
+
'getByAltText',
|
|
32
|
+
'getByTitle',
|
|
33
|
+
'getByTestId',
|
|
34
|
+
'first',
|
|
35
|
+
'last',
|
|
36
|
+
'nth',
|
|
37
|
+
'filter',
|
|
38
|
+
'and',
|
|
39
|
+
'or',
|
|
40
|
+
'frameLocator',
|
|
41
|
+
'contentFrame',
|
|
42
|
+
]);
|
|
43
|
+
function findConstInit(context, node, name) {
|
|
44
|
+
let scope = context.sourceCode.getScope(node);
|
|
45
|
+
while (scope) {
|
|
46
|
+
const v = scope.set?.get(name);
|
|
47
|
+
if (v) {
|
|
48
|
+
const defs = v.defs ?? [];
|
|
49
|
+
if (defs.length !== 1)
|
|
50
|
+
return undefined;
|
|
51
|
+
const def = defs[0];
|
|
52
|
+
if (def.type !== 'Variable' || def.parent?.kind !== 'const')
|
|
53
|
+
return undefined;
|
|
54
|
+
return def.node?.init;
|
|
55
|
+
}
|
|
56
|
+
scope = scope.upper;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True when the expression is *syntactically* a Playwright Page or Locator:
|
|
62
|
+
* the identifier `page`, a `.page` property (`this.page`, `fixtures.page`),
|
|
63
|
+
* a locator-producing call chain on one of those (`page.getByRole(..)`,
|
|
64
|
+
* `page.locator(..).first()`), or an identifier bound once by `const` to
|
|
65
|
+
* such an expression. Anything else (a parameter called `el`, a jQuery
|
|
66
|
+
* wrapper, an array) is not a receiver, so a method that happens to share a
|
|
67
|
+
* Playwright name on another object is never matched.
|
|
68
|
+
*/
|
|
69
|
+
export function isPwReceiver(context, expr, depth = 0) {
|
|
70
|
+
const n = unwrap(expr);
|
|
71
|
+
if (!n || depth > 4)
|
|
72
|
+
return false;
|
|
73
|
+
if (n.type === 'Identifier') {
|
|
74
|
+
if (n.name === 'page')
|
|
75
|
+
return true;
|
|
76
|
+
const init = findConstInit(context, n, n.name);
|
|
77
|
+
return init ? isPwReceiver(context, init, depth + 1) : false;
|
|
78
|
+
}
|
|
79
|
+
if (n.type === 'MemberExpression') {
|
|
80
|
+
return propName(n) === 'page';
|
|
81
|
+
}
|
|
82
|
+
if (n.type === 'CallExpression' && n.callee?.type === 'MemberExpression') {
|
|
83
|
+
const name = propName(n.callee);
|
|
84
|
+
return !!name && LOCATOR_PRODUCERS.has(name) && isPwReceiver(context, n.callee.object, depth + 1);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/** Nearest enclosing function node, or undefined at module level. */
|
|
89
|
+
export function enclosingFunction(node) {
|
|
90
|
+
let p = node?.parent;
|
|
91
|
+
while (p) {
|
|
92
|
+
if (p.type === 'FunctionDeclaration' ||
|
|
93
|
+
p.type === 'FunctionExpression' ||
|
|
94
|
+
p.type === 'ArrowFunctionExpression') {
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
p = p.parent;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Evaluates a numeric literal / simple arithmetic expression, else undefined. */
|
|
102
|
+
export function evalNumber(node) {
|
|
103
|
+
const n = unwrap(node);
|
|
104
|
+
if (!n)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (n.type === 'Literal' && typeof n.value === 'number')
|
|
107
|
+
return n.value;
|
|
108
|
+
if (n.type === 'UnaryExpression' && (n.operator === '-' || n.operator === '+')) {
|
|
109
|
+
const v = evalNumber(n.argument);
|
|
110
|
+
return v === undefined ? undefined : n.operator === '-' ? -v : v;
|
|
111
|
+
}
|
|
112
|
+
if (n.type === 'BinaryExpression' && ['+', '-', '*', '/', '**'].includes(n.operator)) {
|
|
113
|
+
const a = evalNumber(n.left);
|
|
114
|
+
const b = evalNumber(n.right);
|
|
115
|
+
if (a === undefined || b === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
switch (n.operator) {
|
|
118
|
+
case '+':
|
|
119
|
+
return a + b;
|
|
120
|
+
case '-':
|
|
121
|
+
return a - b;
|
|
122
|
+
case '*':
|
|
123
|
+
return a * b;
|
|
124
|
+
case '/':
|
|
125
|
+
return b === 0 ? undefined : a / b;
|
|
126
|
+
default:
|
|
127
|
+
return a ** b;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Static string value of a literal or an expression-free template literal. */
|
|
133
|
+
export function staticString(node) {
|
|
134
|
+
const n = unwrap(node);
|
|
135
|
+
if (!n)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (n.type === 'Literal' && typeof n.value === 'string')
|
|
138
|
+
return n.value;
|
|
139
|
+
if (n.type === 'TemplateLiteral' && n.expressions.length === 0 && n.quasis.length === 1) {
|
|
140
|
+
return n.quasis[0].value.cooked ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
/** `test(...)`, `test.only(...)`, `it(...)` — a test declaration call (not describe/hook). */
|
|
145
|
+
export function isTestDeclaration(call, testNames) {
|
|
146
|
+
if (call?.type !== 'CallExpression')
|
|
147
|
+
return false;
|
|
148
|
+
const c = call.callee;
|
|
149
|
+
if (c?.type === 'Identifier')
|
|
150
|
+
return testNames.has(c.name) || c.name === 'it';
|
|
151
|
+
if (c?.type === 'MemberExpression' && c.object?.type === 'Identifier') {
|
|
152
|
+
const m = propName(c);
|
|
153
|
+
return (testNames.has(c.object.name) || c.object.name === 'it') && (m === 'only' || m === 'fail' || m === 'fixme');
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const HOOKS = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']);
|
|
158
|
+
export function hookName(call, testNames) {
|
|
159
|
+
const c = call?.type === 'CallExpression' ? call.callee : undefined;
|
|
160
|
+
if (c?.type === 'MemberExpression' && c.object?.type === 'Identifier' && testNames.has(c.object.name)) {
|
|
161
|
+
const m = propName(c);
|
|
162
|
+
if (m && HOOKS.has(m))
|
|
163
|
+
return m;
|
|
164
|
+
}
|
|
165
|
+
if (c?.type === 'Identifier' && HOOKS.has(c.name))
|
|
166
|
+
return c.name;
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** `test.describe(...)`, `describe(...)`, `test.describe.serial/parallel/only(...)`. */
|
|
170
|
+
export function isDescribeCall(call, testNames) {
|
|
171
|
+
if (call?.type !== 'CallExpression')
|
|
172
|
+
return false;
|
|
173
|
+
const c = call.callee;
|
|
174
|
+
if (c?.type === 'Identifier')
|
|
175
|
+
return c.name === 'describe';
|
|
176
|
+
if (c?.type !== 'MemberExpression')
|
|
177
|
+
return false;
|
|
178
|
+
const m = propName(c);
|
|
179
|
+
const o = unwrap(c.object);
|
|
180
|
+
if (m === 'describe')
|
|
181
|
+
return o?.type === 'Identifier' && (testNames.has(o.name) || o.name === 'it');
|
|
182
|
+
if (m === 'serial' || m === 'parallel' || m === 'only' || m === 'skip' || m === 'fixme') {
|
|
183
|
+
return (o?.type === 'MemberExpression' &&
|
|
184
|
+
propName(o) === 'describe' &&
|
|
185
|
+
o.object?.type === 'Identifier' &&
|
|
186
|
+
testNames.has(o.object.name));
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
/** `test.describe.serial(...)` call. */
|
|
191
|
+
export function isSerialDescribeCall(call, testNames) {
|
|
192
|
+
if (call?.type !== 'CallExpression')
|
|
193
|
+
return false;
|
|
194
|
+
const c = unwrap(call.callee);
|
|
195
|
+
if (c?.type !== 'MemberExpression' || propName(c) !== 'serial')
|
|
196
|
+
return false;
|
|
197
|
+
const o = unwrap(c.object);
|
|
198
|
+
return o?.type === 'MemberExpression' && propName(o) === 'describe' && o.object?.type === 'Identifier' && testNames.has(o.object.name);
|
|
199
|
+
}
|
|
200
|
+
/** `test.describe.configure({ mode: 'serial' })` call. */
|
|
201
|
+
export function isSerialConfigureCall(call, testNames) {
|
|
202
|
+
if (call?.type !== 'CallExpression')
|
|
203
|
+
return false;
|
|
204
|
+
const c = unwrap(call.callee);
|
|
205
|
+
if (c?.type !== 'MemberExpression' || propName(c) !== 'configure')
|
|
206
|
+
return false;
|
|
207
|
+
const o = unwrap(c.object);
|
|
208
|
+
if (!(o?.type === 'MemberExpression' && propName(o) === 'describe' && o.object?.type === 'Identifier' && testNames.has(o.object.name)))
|
|
209
|
+
return false;
|
|
210
|
+
const arg = unwrap(call.arguments?.[0]);
|
|
211
|
+
if (arg?.type !== 'ObjectExpression')
|
|
212
|
+
return false;
|
|
213
|
+
return arg.properties.some((p) => p.type === 'Property' && (p.key?.name ?? p.key?.value) === 'mode' && p.value?.type === 'Literal' && p.value.value === 'serial');
|
|
214
|
+
}
|
|
215
|
+
/** Does this statement list directly contain `test.describe.configure({ mode: 'serial' })`? */
|
|
216
|
+
function bodyHasSerialConfigure(body, testNames) {
|
|
217
|
+
return (body ?? []).some((st) => {
|
|
218
|
+
if (st?.type !== 'ExpressionStatement')
|
|
219
|
+
return false;
|
|
220
|
+
const e = unwrap(st.expression);
|
|
221
|
+
return isSerialConfigureCall(e?.type === 'AwaitExpression' ? unwrap(e.argument) : e, testNames);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Is `node` in a scope declared serial: the file has a top-level
|
|
226
|
+
* `test.describe.configure({ mode: 'serial' })`, or any enclosing describe is
|
|
227
|
+
* `test.describe.serial(...)` or configures serial mode in its own body.
|
|
228
|
+
*/
|
|
229
|
+
export function isInSerialScope(node, testNames) {
|
|
230
|
+
for (let p = node?.parent; p; p = p.parent) {
|
|
231
|
+
if (p.type === 'Program')
|
|
232
|
+
return bodyHasSerialConfigure(p.body, testNames);
|
|
233
|
+
if ((p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') && p.parent?.type === 'CallExpression' && p.parent.arguments.includes(p)) {
|
|
234
|
+
const call = p.parent;
|
|
235
|
+
if (isSerialDescribeCall(call, testNames))
|
|
236
|
+
return true;
|
|
237
|
+
if (isDescribeCall(call, testNames) && p.body?.type === 'BlockStatement' && bodyHasSerialConfigure(p.body.body, testNames))
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
export function testNamesFromOptions(options) {
|
|
244
|
+
const names = options[0]?.testAliasNames ?? [];
|
|
245
|
+
return new Set(['test', ...names]);
|
|
246
|
+
}
|
|
247
|
+
export const TEST_ALIAS_SCHEMA = [
|
|
248
|
+
{
|
|
249
|
+
type: 'object',
|
|
250
|
+
properties: { testAliasNames: { items: { type: 'string' }, type: 'array' } },
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
];
|