1 | 'use strict';
|
2 |
|
3 | const {visitIf} = require('enhance-visitors');
|
4 | const {getStaticValue, isOpeningParenToken, isCommaToken, findVariable} = require('eslint-utils');
|
5 | const util = require('../util');
|
6 | const createAvaRule = require('../create-ava-rule');
|
7 |
|
8 | const expectedNbArguments = {
|
9 | assert: {
|
10 | min: 1,
|
11 | max: 2,
|
12 | },
|
13 | deepEqual: {
|
14 | min: 2,
|
15 | max: 3,
|
16 | },
|
17 | fail: {
|
18 | min: 0,
|
19 | max: 1,
|
20 | },
|
21 | false: {
|
22 | min: 1,
|
23 | max: 2,
|
24 | },
|
25 | falsy: {
|
26 | min: 1,
|
27 | max: 2,
|
28 | },
|
29 | ifError: {
|
30 | min: 1,
|
31 | max: 2,
|
32 | },
|
33 | is: {
|
34 | min: 2,
|
35 | max: 3,
|
36 | },
|
37 | like: {
|
38 | min: 2,
|
39 | max: 3,
|
40 | },
|
41 | not: {
|
42 | min: 2,
|
43 | max: 3,
|
44 | },
|
45 | notDeepEqual: {
|
46 | min: 2,
|
47 | max: 3,
|
48 | },
|
49 | notThrows: {
|
50 | min: 1,
|
51 | max: 2,
|
52 | },
|
53 | notThrowsAsync: {
|
54 | min: 1,
|
55 | max: 2,
|
56 | },
|
57 | pass: {
|
58 | min: 0,
|
59 | max: 1,
|
60 | },
|
61 | plan: {
|
62 | min: 1,
|
63 | max: 1,
|
64 | },
|
65 | regex: {
|
66 | min: 2,
|
67 | max: 3,
|
68 | },
|
69 | notRegex: {
|
70 | min: 2,
|
71 | max: 3,
|
72 | },
|
73 | snapshot: {
|
74 | min: 1,
|
75 | max: 2,
|
76 | },
|
77 | teardown: {
|
78 | min: 1,
|
79 | max: 1,
|
80 | },
|
81 | throws: {
|
82 | min: 1,
|
83 | max: 3,
|
84 | },
|
85 | throwsAsync: {
|
86 | min: 1,
|
87 | max: 3,
|
88 | },
|
89 | true: {
|
90 | min: 1,
|
91 | max: 2,
|
92 | },
|
93 | truthy: {
|
94 | min: 1,
|
95 | max: 2,
|
96 | },
|
97 | timeout: {
|
98 | min: 1,
|
99 | max: 2,
|
100 | },
|
101 | };
|
102 |
|
103 | const actualExpectedAssertions = new Set([
|
104 | 'deepEqual',
|
105 | 'is',
|
106 | 'like',
|
107 | 'not',
|
108 | 'notDeepEqual',
|
109 | 'throws',
|
110 | 'throwsAsync',
|
111 | ]);
|
112 |
|
113 | const relationalActualExpectedAssertions = new Set([
|
114 | 'assert',
|
115 | 'truthy',
|
116 | 'falsy',
|
117 | 'true',
|
118 | 'false',
|
119 | ]);
|
120 |
|
121 | const comparisonOperators = new Map([
|
122 | ['>', '<'],
|
123 | ['>=', '<='],
|
124 | ['==', '=='],
|
125 | ['===', '==='],
|
126 | ['!=', '!='],
|
127 | ['!==', '!=='],
|
128 | ['<=', '>='],
|
129 | ['<', '>'],
|
130 | ]);
|
131 |
|
132 | const flipOperator = operator => comparisonOperators.get(operator);
|
133 |
|
134 | function isStatic(node) {
|
135 | const staticValue = getStaticValue(node);
|
136 | return staticValue !== null && typeof staticValue.value !== 'function';
|
137 | }
|
138 |
|
139 | function * sourceRangesOfArguments(sourceCode, callExpression) {
|
140 | const openingParen = sourceCode.getTokenAfter(
|
141 | callExpression.callee,
|
142 | {filter: token => isOpeningParenToken(token)},
|
143 | );
|
144 |
|
145 | const closingParen = sourceCode.getLastToken(callExpression);
|
146 |
|
147 | for (const [index, argument] of callExpression.arguments.entries()) {
|
148 | const previousToken = index === 0
|
149 | ? openingParen
|
150 | : sourceCode.getTokenBefore(
|
151 | argument,
|
152 | {filter: token => isCommaToken(token)},
|
153 | );
|
154 |
|
155 | const nextToken = index === callExpression.arguments.length - 1
|
156 | ? closingParen
|
157 | : sourceCode.getTokenAfter(
|
158 | argument,
|
159 | {filter: token => isCommaToken(token)},
|
160 | );
|
161 |
|
162 | const firstToken = sourceCode.getTokenAfter(
|
163 | previousToken,
|
164 | {includeComments: true},
|
165 | );
|
166 |
|
167 | const lastToken = sourceCode.getTokenBefore(
|
168 | nextToken,
|
169 | {includeComments: true},
|
170 | );
|
171 |
|
172 | yield [firstToken.range[0], lastToken.range[1]];
|
173 | }
|
174 | }
|
175 |
|
176 | function sourceOfBinaryExpressionComponents(sourceCode, node) {
|
177 | const {operator, left, right} = node;
|
178 |
|
179 | const operatorToken = sourceCode.getFirstTokenBetween(
|
180 | left,
|
181 | right,
|
182 | {filter: token => token.value === operator},
|
183 | );
|
184 |
|
185 | const previousToken = sourceCode.getTokenBefore(node);
|
186 | const nextToken = sourceCode.getTokenAfter(node);
|
187 |
|
188 | const leftRange = [
|
189 | sourceCode.getTokenAfter(previousToken, {includeComments: true}).range[0],
|
190 | sourceCode.getTokenBefore(operatorToken, {includeComments: true}).range[1],
|
191 | ];
|
192 |
|
193 | const rightRange = [
|
194 | sourceCode.getTokenAfter(operatorToken, {includeComments: true}).range[0],
|
195 | sourceCode.getTokenBefore(nextToken, {includeComments: true}).range[1],
|
196 | ];
|
197 |
|
198 | return [leftRange, operatorToken, rightRange];
|
199 | }
|
200 |
|
201 | function noComments(sourceCode, ...nodes) {
|
202 | return nodes.every(node => sourceCode.getCommentsBefore(node).length === 0 && sourceCode.getCommentsAfter(node).length === 0);
|
203 | }
|
204 |
|
205 | function isString(node) {
|
206 | const {type} = node;
|
207 | return type === 'TemplateLiteral'
|
208 | || type === 'TaggedTemplateExpression'
|
209 | || (type === 'Literal' && typeof node.value === 'string');
|
210 | }
|
211 |
|
212 | const create = context => {
|
213 | const ava = createAvaRule();
|
214 | const options = context.options[0] ?? {};
|
215 | const enforcesMessage = Boolean(options.message);
|
216 | const shouldHaveMessage = options.message !== 'never';
|
217 |
|
218 | function report(node, message) {
|
219 | context.report({node, message});
|
220 | }
|
221 |
|
222 | return ava.merge({
|
223 | CallExpression: visitIf([
|
224 | ava.isInTestFile,
|
225 | ava.isInTestNode,
|
226 | ])(node => {
|
227 | const {callee} = node;
|
228 |
|
229 | if (
|
230 | callee.type !== 'MemberExpression'
|
231 | || !callee.property
|
232 | || util.getNameOfRootNodeObject(callee) !== 't'
|
233 | || util.isPropertyUnderContext(callee)
|
234 | ) {
|
235 | return;
|
236 | }
|
237 |
|
238 | const gottenArguments = node.arguments.length;
|
239 | const firstNonSkipMember = util.getMembers(callee).find(name => name !== 'skip');
|
240 |
|
241 | if (firstNonSkipMember === 'end') {
|
242 | if (gottenArguments > 1) {
|
243 | report(node, 'Too many arguments. Expected at most 1.');
|
244 | }
|
245 |
|
246 | return;
|
247 | }
|
248 |
|
249 | if (firstNonSkipMember === 'try') {
|
250 | if (gottenArguments < 1) {
|
251 | report(node, 'Not enough arguments. Expected at least 1.');
|
252 | }
|
253 |
|
254 | return;
|
255 | }
|
256 |
|
257 | const nArguments = expectedNbArguments[firstNonSkipMember];
|
258 |
|
259 | if (!nArguments) {
|
260 | return;
|
261 | }
|
262 |
|
263 | if (gottenArguments < nArguments.min) {
|
264 | report(node, `Not enough arguments. Expected at least ${nArguments.min}.`);
|
265 | } else if (node.arguments.length > nArguments.max) {
|
266 | report(node, `Too many arguments. Expected at most ${nArguments.max}.`);
|
267 | } else {
|
268 | if (enforcesMessage && nArguments.min !== nArguments.max) {
|
269 | const hasMessage = gottenArguments === nArguments.max;
|
270 |
|
271 | if (!hasMessage && shouldHaveMessage) {
|
272 | report(node, 'Expected an assertion message, but found none.');
|
273 | } else if (hasMessage && !shouldHaveMessage) {
|
274 | report(node, 'Expected no assertion message, but found one.');
|
275 | }
|
276 | }
|
277 |
|
278 | checkArgumentOrder({node, assertion: firstNonSkipMember, context});
|
279 | }
|
280 |
|
281 | if (gottenArguments === nArguments.max && nArguments.min !== nArguments.max) {
|
282 | let lastArgument = node.arguments.at(-1);
|
283 |
|
284 | if (lastArgument.type === 'Identifier') {
|
285 | const variable = findVariable(context.sourceCode.getScope(node), lastArgument);
|
286 | let value;
|
287 | for (const reference of variable.references) {
|
288 | value = reference.writeExpr ?? value;
|
289 | }
|
290 |
|
291 | lastArgument = value;
|
292 | }
|
293 |
|
294 | if (!isString(lastArgument)) {
|
295 | report(node, 'Assertion message should be a string.');
|
296 | }
|
297 | }
|
298 | }),
|
299 | });
|
300 | };
|
301 |
|
302 | function checkArgumentOrder({node, assertion, context}) {
|
303 | const [first, second] = node.arguments;
|
304 | if (actualExpectedAssertions.has(assertion) && second) {
|
305 | const [leftNode, rightNode] = [first, second];
|
306 | if (isStatic(leftNode) && !isStatic(rightNode)) {
|
307 | context.report(
|
308 | makeOutOfOrder2ArgumentReport({
|
309 | node,
|
310 | leftNode,
|
311 | rightNode,
|
312 | context,
|
313 | }),
|
314 | );
|
315 | }
|
316 | } else if (
|
317 | relationalActualExpectedAssertions.has(assertion)
|
318 | && first
|
319 | && first.type === 'BinaryExpression'
|
320 | && comparisonOperators.has(first.operator)
|
321 | ) {
|
322 | const [leftNode, rightNode] = [first.left, first.right];
|
323 | if (isStatic(leftNode) && !isStatic(rightNode)) {
|
324 | context.report(
|
325 | makeOutOfOrder1ArgumentReport({
|
326 | node: first,
|
327 | leftNode,
|
328 | rightNode,
|
329 | context,
|
330 | }),
|
331 | );
|
332 | }
|
333 | }
|
334 | }
|
335 |
|
336 | function makeOutOfOrder2ArgumentReport({node, leftNode, rightNode, context}) {
|
337 | const sourceCode = context.getSourceCode();
|
338 | const [leftRange, rightRange] = sourceRangesOfArguments(sourceCode, node);
|
339 | const report = {
|
340 | message: 'Expected values should come after actual values.',
|
341 | loc: {
|
342 | start: sourceCode.getLocFromIndex(leftRange[0]),
|
343 | end: sourceCode.getLocFromIndex(rightRange[1]),
|
344 | },
|
345 | };
|
346 |
|
347 | if (noComments(sourceCode, leftNode, rightNode)) {
|
348 | report.fix = fixer => {
|
349 | const leftText = sourceCode.getText().slice(...leftRange);
|
350 | const rightText = sourceCode.getText().slice(...rightRange);
|
351 | return [
|
352 | fixer.replaceTextRange(leftRange, rightText),
|
353 | fixer.replaceTextRange(rightRange, leftText),
|
354 | ];
|
355 | };
|
356 | }
|
357 |
|
358 | return report;
|
359 | }
|
360 |
|
361 | function makeOutOfOrder1ArgumentReport({node, leftNode, rightNode, context}) {
|
362 | const sourceCode = context.getSourceCode();
|
363 | const [
|
364 | leftRange,
|
365 | operatorToken,
|
366 | rightRange,
|
367 | ] = sourceOfBinaryExpressionComponents(sourceCode, node);
|
368 | const report = {
|
369 | message: 'Expected values should come after actual values.',
|
370 | loc: {
|
371 | start: sourceCode.getLocFromIndex(leftRange[0]),
|
372 | end: sourceCode.getLocFromIndex(rightRange[1]),
|
373 | },
|
374 | };
|
375 |
|
376 | if (noComments(sourceCode, leftNode, rightNode, node)) {
|
377 | report.fix = fixer => {
|
378 | const leftText = sourceCode.getText().slice(...leftRange);
|
379 | const rightText = sourceCode.getText().slice(...rightRange);
|
380 | return [
|
381 | fixer.replaceTextRange(leftRange, rightText),
|
382 | fixer.replaceText(operatorToken, flipOperator(node.operator)),
|
383 | fixer.replaceTextRange(rightRange, leftText),
|
384 | ];
|
385 | };
|
386 | }
|
387 |
|
388 | return report;
|
389 | }
|
390 |
|
391 | const schema = [{
|
392 | type: 'object',
|
393 | properties: {
|
394 | message: {
|
395 | enum: [
|
396 | 'always',
|
397 | 'never',
|
398 | ],
|
399 | default: undefined,
|
400 | },
|
401 | },
|
402 | }];
|
403 |
|
404 | module.exports = {
|
405 | create,
|
406 | meta: {
|
407 | type: 'problem',
|
408 | docs: {
|
409 | description: 'Enforce passing correct arguments to assertions.',
|
410 | url: util.getDocsUrl(__filename),
|
411 | },
|
412 | fixable: 'code',
|
413 | schema,
|
414 | },
|
415 | };
|