Skip to content

Commit 9d61ebd

Browse files
Catch & fix out-of-order assertion arguments (#307)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent d5b5fbf commit 9d61ebd

File tree

4 files changed

+497
-18
lines changed

4 files changed

+497
-18
lines changed

docs/rules/assertion-arguments.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Enforces passing the right number of arguments to assertion methods like `t.is()
66

77
Assertion messages are optional arguments that can be given to any assertion call to improve the error message, should the assertion fail.
88

9+
This rule also attempts to enforce passing actual values before expected values. If exactly one of the first two arguments to a two-argument assertion is a static expression such as `{a: 1}`, then the static expression must come second. (`t.regex()` and `t.notRegex()` are excluded from this check, because either their `contents` argument or their `regex` argument could plausibly be the actual or expected value.) If the argument to a one-argument assertion is a binary relation such as `'static' === dynamic`, a similar check is performed on its left- and right-hand sides. Errors of these kinds are usually fixable.
10+
911
## Fail
1012

1113
```js

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"dependencies": {
3333
"deep-strict-equal": "^0.2.0",
3434
"enhance-visitors": "^1.0.0",
35+
"eslint-utils": "^2.1.0",
3536
"espree": "^7.1.0",
3637
"espurify": "^2.0.1",
3738
"import-modules": "^2.0.0",

rules/assertion-arguments.js

Lines changed: 208 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
'use strict';
22
const {visitIf} = require('enhance-visitors');
3+
const {getStaticValue, isOpeningParenToken, isCommaToken} = require('eslint-utils');
34
const util = require('../util');
45
const createAvaRule = require('../create-ava-rule');
56

67
const expectedNbArguments = {
8+
assert: {
9+
min: 1,
10+
max: 2
11+
},
712
deepEqual: {
813
min: 2,
914
max: 3
@@ -44,6 +49,10 @@ const expectedNbArguments = {
4449
min: 1,
4550
max: 2
4651
},
52+
notThrowsAsync: {
53+
min: 1,
54+
max: 2
55+
},
4756
pass: {
4857
min: 0,
4958
max: 1
@@ -72,6 +81,10 @@ const expectedNbArguments = {
7281
min: 1,
7382
max: 3
7483
},
84+
throwsAsync: {
85+
min: 1,
86+
max: 3
87+
},
7588
true: {
7689
min: 1,
7790
max: 2
@@ -86,6 +99,111 @@ const expectedNbArguments = {
8699
}
87100
};
88101

102+
const actualExpectedAssertions = new Set([
103+
'deepEqual',
104+
'is',
105+
'like',
106+
'not',
107+
'notDeepEqual',
108+
'throws',
109+
'throwsAsync'
110+
]);
111+
112+
const relationalActualExpectedAssertions = new Set([
113+
'assert',
114+
'truthy',
115+
'falsy',
116+
'true',
117+
'false'
118+
]);
119+
120+
const comparisonOperators = new Map([
121+
['>', '<'],
122+
['>=', '<='],
123+
['==', '=='],
124+
['===', '==='],
125+
['!=', '!='],
126+
['!==', '!=='],
127+
['<=', '>='],
128+
['<', '>']
129+
]);
130+
131+
const flipOperator = operator => comparisonOperators.get(operator);
132+
133+
function isStatic(node) {
134+
const staticValue = getStaticValue(node);
135+
return staticValue !== null && typeof staticValue.value !== 'function';
136+
}
137+
138+
function * sourceRangesOfArguments(sourceCode, callExpression) {
139+
const openingParen = sourceCode.getTokenAfter(
140+
callExpression.callee,
141+
{filter: token => isOpeningParenToken(token)}
142+
);
143+
144+
const closingParen = sourceCode.getLastToken(callExpression);
145+
146+
for (const [index, argument] of callExpression.arguments.entries()) {
147+
const previousToken = index === 0 ?
148+
openingParen :
149+
sourceCode.getTokenBefore(
150+
argument,
151+
{filter: token => isCommaToken(token)}
152+
);
153+
154+
const nextToken = index === callExpression.arguments.length - 1 ?
155+
closingParen :
156+
sourceCode.getTokenAfter(
157+
argument,
158+
{filter: token => isCommaToken(token)}
159+
);
160+
161+
const firstToken = sourceCode.getTokenAfter(
162+
previousToken,
163+
{includeComments: true}
164+
);
165+
166+
const lastToken = sourceCode.getTokenBefore(
167+
nextToken,
168+
{includeComments: true}
169+
);
170+
171+
yield [firstToken.range[0], lastToken.range[1]];
172+
}
173+
}
174+
175+
function sourceOfBinaryExpressionComponents(sourceCode, node) {
176+
const {operator, left, right} = node;
177+
178+
const operatorToken = sourceCode.getFirstTokenBetween(
179+
left,
180+
right,
181+
{filter: token => token.value === operator}
182+
);
183+
184+
const previousToken = sourceCode.getTokenBefore(node);
185+
const nextToken = sourceCode.getTokenAfter(node);
186+
187+
const leftRange = [
188+
sourceCode.getTokenAfter(previousToken, {includeComments: true}).range[0],
189+
sourceCode.getTokenBefore(operatorToken, {includeComments: true}).range[1]
190+
];
191+
192+
const rightRange = [
193+
sourceCode.getTokenAfter(operatorToken, {includeComments: true}).range[0],
194+
sourceCode.getTokenBefore(nextToken, {includeComments: true}).range[1]
195+
];
196+
197+
return [leftRange, operatorToken, rightRange];
198+
}
199+
200+
function noComments(sourceCode, ...nodes) {
201+
return nodes.every(node => {
202+
const {leading, trailing} = sourceCode.getComments(node);
203+
return leading.length === 0 && trailing.length === 0;
204+
});
205+
}
206+
89207
const create = context => {
90208
const ava = createAvaRule();
91209
const options = context.options[0] || {};
@@ -141,19 +259,102 @@ const create = context => {
141259
report(node, `Not enough arguments. Expected at least ${nArgs.min}.`);
142260
} else if (node.arguments.length > nArgs.max) {
143261
report(node, `Too many arguments. Expected at most ${nArgs.max}.`);
144-
} else if (enforcesMessage && nArgs.min !== nArgs.max) {
145-
const hasMessage = gottenArgs === nArgs.max;
262+
} else {
263+
if (enforcesMessage && nArgs.min !== nArgs.max) {
264+
const hasMessage = gottenArgs === nArgs.max;
146265

147-
if (!hasMessage && shouldHaveMessage) {
148-
report(node, 'Expected an assertion message, but found none.');
149-
} else if (hasMessage && !shouldHaveMessage) {
150-
report(node, 'Expected no assertion message, but found one.');
266+
if (!hasMessage && shouldHaveMessage) {
267+
report(node, 'Expected an assertion message, but found none.');
268+
} else if (hasMessage && !shouldHaveMessage) {
269+
report(node, 'Expected no assertion message, but found one.');
270+
}
151271
}
272+
273+
checkArgumentOrder({node, assertion: members[0], context});
152274
}
153275
})
154276
});
155277
};
156278

279+
function checkArgumentOrder({node, assertion, context}) {
280+
const [first, second] = node.arguments;
281+
if (actualExpectedAssertions.has(assertion) && second) {
282+
const [leftNode, rightNode] = [first, second];
283+
if (isStatic(leftNode) && !isStatic(rightNode)) {
284+
context.report(
285+
makeOutOfOrder2ArgumentReport({node, leftNode, rightNode, context})
286+
);
287+
}
288+
} else if (
289+
relationalActualExpectedAssertions.has(assertion) &&
290+
first &&
291+
first.type === 'BinaryExpression' &&
292+
comparisonOperators.has(first.operator)
293+
) {
294+
const [leftNode, rightNode] = [first.left, first.right];
295+
if (isStatic(leftNode) && !isStatic(rightNode)) {
296+
context.report(
297+
makeOutOfOrder1ArgumentReport({node: first, leftNode, rightNode, context})
298+
);
299+
}
300+
}
301+
}
302+
303+
function makeOutOfOrder2ArgumentReport({node, leftNode, rightNode, context}) {
304+
const sourceCode = context.getSourceCode();
305+
const [leftRange, rightRange] = sourceRangesOfArguments(sourceCode, node);
306+
const report = {
307+
message: 'Expected values should come after actual values.',
308+
loc: {
309+
start: sourceCode.getLocFromIndex(leftRange[0]),
310+
end: sourceCode.getLocFromIndex(rightRange[1])
311+
}
312+
};
313+
314+
if (noComments(sourceCode, leftNode, rightNode)) {
315+
report.fix = fixer => {
316+
const leftText = sourceCode.getText().slice(...leftRange);
317+
const rightText = sourceCode.getText().slice(...rightRange);
318+
return [
319+
fixer.replaceTextRange(leftRange, rightText),
320+
fixer.replaceTextRange(rightRange, leftText)
321+
];
322+
};
323+
}
324+
325+
return report;
326+
}
327+
328+
function makeOutOfOrder1ArgumentReport({node, leftNode, rightNode, context}) {
329+
const sourceCode = context.getSourceCode();
330+
const [
331+
leftRange,
332+
operatorToken,
333+
rightRange
334+
] = sourceOfBinaryExpressionComponents(sourceCode, node);
335+
const report = {
336+
message: 'Expected values should come after actual values.',
337+
loc: {
338+
start: sourceCode.getLocFromIndex(leftRange[0]),
339+
end: sourceCode.getLocFromIndex(rightRange[1])
340+
}
341+
};
342+
343+
if (noComments(sourceCode, leftNode, rightNode, node)) {
344+
report.fix = fixer => {
345+
const leftText = sourceCode.getText().slice(...leftRange);
346+
const rightText = sourceCode.getText().slice(...rightRange);
347+
return [
348+
fixer.replaceTextRange(leftRange, rightText),
349+
fixer.replaceText(operatorToken, flipOperator(node.operator)),
350+
fixer.replaceTextRange(rightRange, leftText)
351+
];
352+
};
353+
}
354+
355+
return report;
356+
}
357+
157358
const schema = [{
158359
type: 'object',
159360
properties: {
@@ -170,6 +371,7 @@ const schema = [{
170371
module.exports = {
171372
create,
172373
meta: {
374+
fixable: 'code',
173375
docs: {
174376
url: util.getDocsUrl(__filename)
175377
},

0 commit comments

Comments
 (0)