eslint-plugin-playwright 0.17.0 → 0.19.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/README.md CHANGED
@@ -101,7 +101,7 @@ under the `playwright` key. It supports the following settings:
101
101
  the presence of at least one assertion per test case. This allows such rules
102
102
  to recognise custom assertion functions as valid assertions. The global
103
103
  setting applies to all modules. The
104
- [`expect-expect` rule accepts an option by the same name](./rules/expect-expect.md#additionalassertfunctionnames)
104
+ [`expect-expect` rule accepts an option by the same name](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md#additionalassertfunctionnames)
105
105
  to enable per-module configuration (.e.g, for module-specific custom assert
106
106
  functions).
107
107
 
@@ -173,3 +173,4 @@ command line option.\
173
173
  | | | | [require-top-level-describe](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `test.describe` block |
174
174
  | | 🔧 | | [require-soft-assertions](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md) | Require assertions to use `expect.soft()` |
175
175
  | ✔ | | | [valid-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md) | Enforce valid `expect()` usage |
176
+ | ✔ | 🔧 | | [valid-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md) | Enforce valid titles |
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ const prefer_web_first_assertions_1 = require("./rules/prefer-web-first-assertio
28
28
  const require_soft_assertions_1 = require("./rules/require-soft-assertions");
29
29
  const require_top_level_describe_1 = require("./rules/require-top-level-describe");
30
30
  const valid_expect_1 = require("./rules/valid-expect");
31
+ const valid_title_1 = require("./rules/valid-title");
31
32
  const index = {
32
33
  configs: {},
33
34
  rules: {
@@ -59,6 +60,7 @@ const index = {
59
60
  'require-soft-assertions': require_soft_assertions_1.default,
60
61
  'require-top-level-describe': require_top_level_describe_1.default,
61
62
  'valid-expect': valid_expect_1.default,
63
+ 'valid-title': valid_title_1.default,
62
64
  },
63
65
  };
64
66
  const sharedConfig = {
@@ -81,6 +83,7 @@ const sharedConfig = {
81
83
  'playwright/no-wait-for-timeout': 'warn',
82
84
  'playwright/prefer-web-first-assertions': 'error',
83
85
  'playwright/valid-expect': 'error',
86
+ 'playwright/valid-title': 'error',
84
87
  },
85
88
  };
86
89
  const legacyConfig = {
@@ -4,10 +4,11 @@ const ast_1 = require("../utils/ast");
4
4
  const misc_1 = require("../utils/misc");
5
5
  function isAssertionCall(node, additionalAssertFunctionNames) {
6
6
  return ((0, ast_1.isExpectCall)(node) ||
7
- additionalAssertFunctionNames.find((name) => (0, ast_1.isIdentifier)(node.callee, name)));
7
+ additionalAssertFunctionNames.find((name) => (0, ast_1.dig)(node.callee, name)));
8
8
  }
9
9
  exports.default = {
10
10
  create(context) {
11
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
11
12
  const unchecked = [];
12
13
  const additionalAssertFunctionNames = (0, misc_1.getAdditionalAssertFunctionNames)(context);
13
14
  function checkExpressions(nodes) {
@@ -21,11 +22,14 @@ exports.default = {
21
22
  }
22
23
  return {
23
24
  CallExpression(node) {
24
- if ((0, ast_1.isTest)(node, ['fixme', 'only', 'skip'])) {
25
+ if ((0, ast_1.isTestCall)(node, ['fixme', 'only', 'skip'])) {
25
26
  unchecked.push(node);
26
27
  }
27
28
  else if (isAssertionCall(node, additionalAssertFunctionNames)) {
28
- checkExpressions(context.getAncestors());
29
+ const ancestors = sourceCode.getAncestors
30
+ ? sourceCode.getAncestors(node)
31
+ : context.getAncestors();
32
+ checkExpressions(ancestors);
29
33
  }
30
34
  },
31
35
  'Program:exit'() {
@@ -5,7 +5,7 @@ exports.default = {
5
5
  create(context) {
6
6
  function checkConditional(node) {
7
7
  const call = (0, ast_1.findParent)(node, 'CallExpression');
8
- if (call && (0, ast_1.isTest)(call)) {
8
+ if (call && (0, ast_1.isTestCall)(call)) {
9
9
  context.report({ messageId: 'conditionalInTest', node });
10
10
  }
11
11
  }
@@ -5,7 +5,7 @@ exports.default = {
5
5
  create(context) {
6
6
  return {
7
7
  CallExpression(node) {
8
- if (((0, ast_1.isTest)(node) || (0, ast_1.isDescribeCall)(node)) &&
8
+ if (((0, ast_1.isTestCall)(node) || (0, ast_1.isDescribeCall)(node)) &&
9
9
  node.callee.type === 'MemberExpression' &&
10
10
  (0, ast_1.isPropertyAccessor)(node.callee, 'only')) {
11
11
  const { callee } = node;
@@ -5,11 +5,18 @@ exports.default = {
5
5
  create(context) {
6
6
  return {
7
7
  CallExpression(node) {
8
+ const options = context.options[0] || {};
9
+ const allowConditional = !!options.allowConditional;
8
10
  const { callee } = node;
9
11
  if (((0, ast_1.isTestIdentifier)(callee) || (0, ast_1.isDescribeCall)(node)) &&
10
12
  callee.type === 'MemberExpression' &&
11
13
  (0, ast_1.isPropertyAccessor)(callee, 'skip')) {
12
- const isHook = (0, ast_1.isTest)(node) || (0, ast_1.isDescribeCall)(node);
14
+ const isHook = (0, ast_1.isTestCall)(node) || (0, ast_1.isDescribeCall)(node);
15
+ // If allowConditional is enabled and it's not a test/describe hook,
16
+ // we ignore any `test.skip` calls that have no arguments.
17
+ if (!isHook && allowConditional && node.arguments.length) {
18
+ return;
19
+ }
13
20
  context.report({
14
21
  messageId: 'noSkippedTest',
15
22
  node: isHook ? callee.property : node,
@@ -43,6 +50,18 @@ exports.default = {
43
50
  noSkippedTest: 'Unexpected use of the `.skip()` annotation.',
44
51
  removeSkippedTestAnnotation: 'Remove the `.skip()` annotation.',
45
52
  },
53
+ schema: [
54
+ {
55
+ additionalProperties: false,
56
+ properties: {
57
+ allowConditional: {
58
+ default: false,
59
+ type: 'boolean',
60
+ },
61
+ },
62
+ type: 'object',
63
+ },
64
+ ],
46
65
  type: 'suggestion',
47
66
  },
48
67
  };
@@ -1,9 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const ast_1 = require("../utils/ast");
4
- function isString(node) {
5
- return node && ((0, ast_1.isStringLiteral)(node) || node.type === 'TemplateLiteral');
6
- }
7
4
  exports.default = {
8
5
  create(context) {
9
6
  const { allowedPrefixes, ignore, ignoreTopLevelDescribe } = {
@@ -17,7 +14,7 @@ exports.default = {
17
14
  CallExpression(node) {
18
15
  const method = (0, ast_1.isDescribeCall)(node)
19
16
  ? 'test.describe'
20
- : (0, ast_1.isTest)(node)
17
+ : (0, ast_1.isTestCall)(node)
21
18
  ? 'test'
22
19
  : null;
23
20
  if (method === 'test.describe') {
@@ -30,7 +27,7 @@ exports.default = {
30
27
  return;
31
28
  }
32
29
  const [title] = node.arguments;
33
- if (!isString(title)) {
30
+ if (!(0, ast_1.isStringNode)(title)) {
34
31
  return;
35
32
  }
36
33
  const description = (0, ast_1.getStringValue)(title);
@@ -28,7 +28,7 @@ exports.default = {
28
28
  const notModifier = expectCall.modifiers.find((node) => (0, ast_1.getStringValue)(node) === 'not');
29
29
  context.report({
30
30
  fix(fixer) {
31
- const sourceCode = context.getSourceCode();
31
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
32
32
  // We need to negate the expectation if the current expected
33
33
  // value is itself negated by the "not" modifier
34
34
  const addNotModifier = matcherArg.type === 'Literal' &&
@@ -26,7 +26,7 @@ exports.default = {
26
26
  }
27
27
  }
28
28
  else if (!describeCount) {
29
- if ((0, ast_1.isTest)(node)) {
29
+ if ((0, ast_1.isTestCall)(node)) {
30
30
  context.report({ messageId: 'unexpectedTest', node: node.callee });
31
31
  }
32
32
  else if ((0, ast_1.isTestHook)(node)) {
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const ast_1 = require("../utils/ast");
4
+ const doesBinaryExpressionContainStringNode = (binaryExp) => {
5
+ if ((0, ast_1.isStringNode)(binaryExp.right)) {
6
+ return true;
7
+ }
8
+ if (binaryExp.left.type === 'BinaryExpression') {
9
+ return doesBinaryExpressionContainStringNode(binaryExp.left);
10
+ }
11
+ return (0, ast_1.isStringNode)(binaryExp.left);
12
+ };
13
+ const quoteStringValue = (node) => node.type === 'TemplateLiteral'
14
+ ? `\`${node.quasis[0].value.raw}\``
15
+ : node.raw ?? '';
16
+ const compileMatcherPattern = (matcherMaybeWithMessage) => {
17
+ const [matcher, message] = Array.isArray(matcherMaybeWithMessage)
18
+ ? matcherMaybeWithMessage
19
+ : [matcherMaybeWithMessage];
20
+ return [new RegExp(matcher, 'u'), message];
21
+ };
22
+ const compileMatcherPatterns = (matchers) => {
23
+ if (typeof matchers === 'string' || Array.isArray(matchers)) {
24
+ const compiledMatcher = compileMatcherPattern(matchers);
25
+ return {
26
+ describe: compiledMatcher,
27
+ test: compiledMatcher,
28
+ };
29
+ }
30
+ return {
31
+ describe: matchers.describe
32
+ ? compileMatcherPattern(matchers.describe)
33
+ : null,
34
+ test: matchers.test ? compileMatcherPattern(matchers.test) : null,
35
+ };
36
+ };
37
+ const MatcherAndMessageSchema = {
38
+ additionalItems: false,
39
+ items: { type: 'string' },
40
+ maxItems: 2,
41
+ minItems: 1,
42
+ type: 'array',
43
+ };
44
+ exports.default = {
45
+ create(context) {
46
+ const opts = context.options?.[0] ?? {};
47
+ const { disallowedWords = [], ignoreSpaces = false, ignoreTypeOfDescribeName = false, ignoreTypeOfTestName = false, mustMatch, mustNotMatch, } = opts;
48
+ const disallowedWordsRegexp = new RegExp(`\\b(${disallowedWords.join('|')})\\b`, 'iu');
49
+ const mustNotMatchPatterns = compileMatcherPatterns(mustNotMatch ?? {});
50
+ const mustMatchPatterns = compileMatcherPatterns(mustMatch ?? {});
51
+ return {
52
+ CallExpression(node) {
53
+ const isDescribe = (0, ast_1.isDescribeCall)(node);
54
+ const isTest = (0, ast_1.isTestCall)(node);
55
+ if (!isDescribe && !isTest) {
56
+ return;
57
+ }
58
+ const [argument] = node.arguments;
59
+ if (!argument) {
60
+ return;
61
+ }
62
+ if (!(0, ast_1.isStringNode)(argument)) {
63
+ if (argument.type === 'BinaryExpression' &&
64
+ doesBinaryExpressionContainStringNode(argument)) {
65
+ return;
66
+ }
67
+ if (!((isDescribe && ignoreTypeOfDescribeName) ||
68
+ (isTest && ignoreTypeOfTestName)) &&
69
+ argument.type !== 'TemplateLiteral') {
70
+ context.report({
71
+ loc: argument.loc,
72
+ messageId: 'titleMustBeString',
73
+ });
74
+ }
75
+ return;
76
+ }
77
+ const title = (0, ast_1.getStringValue)(argument);
78
+ const functionName = isDescribe ? 'describe' : 'test';
79
+ if (!title) {
80
+ context.report({
81
+ data: { functionName },
82
+ messageId: 'emptyTitle',
83
+ node,
84
+ });
85
+ return;
86
+ }
87
+ if (disallowedWords.length > 0) {
88
+ const disallowedMatch = disallowedWordsRegexp.exec(title);
89
+ if (disallowedMatch) {
90
+ context.report({
91
+ data: { word: disallowedMatch[1] },
92
+ messageId: 'disallowedWord',
93
+ node: argument,
94
+ });
95
+ return;
96
+ }
97
+ }
98
+ if (ignoreSpaces === false && title.trim().length !== title.length) {
99
+ context.report({
100
+ fix: (fixer) => [
101
+ fixer.replaceTextRange(argument.range, quoteStringValue(argument)
102
+ .replace(/^([`'"]) +?/u, '$1')
103
+ .replace(/ +?([`'"])$/u, '$1')),
104
+ ],
105
+ messageId: 'accidentalSpace',
106
+ node: argument,
107
+ });
108
+ }
109
+ const [firstWord] = title.split(' ');
110
+ if (firstWord.toLowerCase() === functionName) {
111
+ context.report({
112
+ fix: (fixer) => [
113
+ fixer.replaceTextRange(argument.range, quoteStringValue(argument).replace(/^([`'"]).+? /u, '$1')),
114
+ ],
115
+ messageId: 'duplicatePrefix',
116
+ node: argument,
117
+ });
118
+ }
119
+ const [mustNotMatchPattern, mustNotMatchMessage] = mustNotMatchPatterns[functionName] ?? [];
120
+ if (mustNotMatchPattern && mustNotMatchPattern.test(title)) {
121
+ context.report({
122
+ data: {
123
+ functionName,
124
+ message: mustNotMatchMessage ?? '',
125
+ pattern: String(mustNotMatchPattern),
126
+ },
127
+ messageId: mustNotMatchMessage
128
+ ? 'mustNotMatchCustom'
129
+ : 'mustNotMatch',
130
+ node: argument,
131
+ });
132
+ return;
133
+ }
134
+ const [mustMatchPattern, mustMatchMessage] = mustMatchPatterns[functionName] ?? [];
135
+ if (mustMatchPattern && !mustMatchPattern.test(title)) {
136
+ context.report({
137
+ data: {
138
+ functionName,
139
+ message: mustMatchMessage ?? '',
140
+ pattern: String(mustMatchPattern),
141
+ },
142
+ messageId: mustMatchMessage ? 'mustMatchCustom' : 'mustMatch',
143
+ node: argument,
144
+ });
145
+ return;
146
+ }
147
+ },
148
+ };
149
+ },
150
+ meta: {
151
+ docs: {
152
+ category: 'Best Practices',
153
+ description: 'Enforce valid titles',
154
+ recommended: true,
155
+ url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md',
156
+ },
157
+ fixable: 'code',
158
+ messages: {
159
+ accidentalSpace: 'should not have leading or trailing spaces',
160
+ disallowedWord: '"{{ word }}" is not allowed in test titles',
161
+ duplicatePrefix: 'should not have duplicate prefix',
162
+ emptyTitle: '{{ functionName }} should not have an empty title',
163
+ mustMatch: '{{ functionName }} should match {{ pattern }}',
164
+ mustMatchCustom: '{{ message }}',
165
+ mustNotMatch: '{{ functionName }} should not match {{ pattern }}',
166
+ mustNotMatchCustom: '{{ message }}',
167
+ titleMustBeString: 'Title must be a string',
168
+ },
169
+ schema: [
170
+ {
171
+ additionalProperties: false,
172
+ patternProperties: {
173
+ [/^must(?:Not)?Match$/u.source]: {
174
+ oneOf: [
175
+ { type: 'string' },
176
+ MatcherAndMessageSchema,
177
+ {
178
+ additionalProperties: {
179
+ oneOf: [{ type: 'string' }, MatcherAndMessageSchema],
180
+ },
181
+ propertyNames: { enum: ['describe', 'test'] },
182
+ type: 'object',
183
+ },
184
+ ],
185
+ },
186
+ },
187
+ properties: {
188
+ disallowedWords: {
189
+ items: { type: 'string' },
190
+ type: 'array',
191
+ },
192
+ ignoreSpaces: {
193
+ default: false,
194
+ type: 'boolean',
195
+ },
196
+ ignoreTypeOfDescribeName: {
197
+ default: false,
198
+ type: 'boolean',
199
+ },
200
+ ignoreTypeOfTestName: {
201
+ default: false,
202
+ type: 'boolean',
203
+ },
204
+ },
205
+ type: 'object',
206
+ },
207
+ ],
208
+ type: 'suggestion',
209
+ },
210
+ };
package/lib/utils/ast.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isPageMethod = exports.getMatchers = exports.isExpectCall = exports.getExpectType = exports.isTestHook = exports.isTest = exports.findParent = exports.isDescribeCall = exports.isTestIdentifier = exports.isPropertyAccessor = exports.isBooleanLiteral = exports.isStringLiteral = exports.isIdentifier = exports.getRawValue = exports.getStringValue = void 0;
3
+ exports.isPageMethod = exports.dig = exports.getMatchers = exports.isExpectCall = exports.getExpectType = exports.isTestHook = exports.isTestCall = exports.findParent = exports.isDescribeCall = exports.isTestIdentifier = exports.isPropertyAccessor = exports.isStringNode = exports.isBooleanLiteral = exports.isStringLiteral = exports.isIdentifier = exports.getRawValue = exports.getStringValue = void 0;
4
4
  function getStringValue(node) {
5
5
  if (!node)
6
6
  return '';
@@ -28,6 +28,9 @@ function isLiteral(node, type, value) {
28
28
  ? typeof node.value === type
29
29
  : node.value === value));
30
30
  }
31
+ const isTemplateLiteral = (node, value) => node.type === 'TemplateLiteral' &&
32
+ node.quasis.length === 1 && // bail out if not simple
33
+ (value === undefined || node.quasis[0].value.raw === value);
31
34
  function isStringLiteral(node, value) {
32
35
  return isLiteral(node, 'string', value);
33
36
  }
@@ -36,6 +39,10 @@ function isBooleanLiteral(node, value) {
36
39
  return isLiteral(node, 'boolean', value);
37
40
  }
38
41
  exports.isBooleanLiteral = isBooleanLiteral;
42
+ function isStringNode(node) {
43
+ return node && (isStringLiteral(node) || isTemplateLiteral(node));
44
+ }
45
+ exports.isStringNode = isStringNode;
39
46
  function isPropertyAccessor(node, name) {
40
47
  return getStringValue(node.property) === name;
41
48
  }
@@ -76,7 +83,7 @@ function findParent(node, type) {
76
83
  : findParent(node.parent, type);
77
84
  }
78
85
  exports.findParent = findParent;
79
- function isTest(node, modifiers) {
86
+ function isTestCall(node, modifiers) {
80
87
  return (isTestIdentifier(node.callee) &&
81
88
  !isDescribeCall(node) &&
82
89
  (node.callee.type !== 'MemberExpression' ||
@@ -85,7 +92,7 @@ function isTest(node, modifiers) {
85
92
  node.arguments.length === 2 &&
86
93
  ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.arguments[1].type));
87
94
  }
88
- exports.isTest = isTest;
95
+ exports.isTestCall = isTestCall;
89
96
  const testHooks = new Set(['afterAll', 'afterEach', 'beforeAll', 'beforeEach']);
90
97
  function isTestHook(node) {
91
98
  return (node.callee.type === 'MemberExpression' &&
@@ -132,6 +139,7 @@ function dig(node, identifier) {
132
139
  ? isIdentifier(node, identifier)
133
140
  : false;
134
141
  }
142
+ exports.dig = dig;
135
143
  function isPageMethod(node, name) {
136
144
  return (node.callee.type === 'MemberExpression' &&
137
145
  dig(node.callee.object, /(^(page|frame)|(Page|Frame)$)/) &&
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-playwright",
3
3
  "description": "ESLint plugin for Playwright testing.",
4
- "version": "0.17.0",
4
+ "version": "0.19.0",
5
5
  "packageManager": "pnpm@8.8.0",
6
6
  "main": "lib/index.js",
7
7
  "repository": "https://github.com/playwright-community/eslint-plugin-playwright",