diff --git a/README.md b/README.md index 2a4ffe2..d866843 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ export default [ | [require-to-throw-message](docs/rules/require-to-throw-message.md) | require toThrow() to be called with an error message | | 🌐 | | | | | [require-top-level-describe](docs/rules/require-top-level-describe.md) | enforce that all tests are in a top-level describe | | 🌐 | | | | | [valid-describe-callback](docs/rules/valid-describe-callback.md) | enforce valid describe callback | ✅ | | | | | -| [valid-expect](docs/rules/valid-expect.md) | enforce valid `expect()` usage | ✅ | | | | | +| [valid-expect](docs/rules/valid-expect.md) | enforce valid `expect()` usage | ✅ | | 🔧 | | | | [valid-title](docs/rules/valid-title.md) | enforce valid titles | ✅ | | 🔧 | | | diff --git a/docs/rules/valid-expect.md b/docs/rules/valid-expect.md index 83bea29..a988397 100644 --- a/docs/rules/valid-expect.md +++ b/docs/rules/valid-expect.md @@ -2,6 +2,8 @@ 💼 This rule is enabled in the ✅ `recommended` config. +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + This rule triggers a warning if `expect` is called with no argument or with more than one argument. You change that behavior by setting the `minArgs` and `maxArgs` options. @@ -32,7 +34,7 @@ This rule triggers a warning if `expect` is called with no argument or with more - Default: `[]` - ```js + ```js { "vitest/valid-expect": ["error", { "asyncMatchers": ["toBeResolved", "toBeRejected"] @@ -42,8 +44,8 @@ This rule triggers a warning if `expect` is called with no argument or with more avoid using asyncMatchers with `expect`: - - + + 3. `minArgs` - Type: `number` diff --git a/src/rules/valid-expect.ts b/src/rules/valid-expect.ts index 25b5f1a..6c8841e 100644 --- a/src/rules/valid-expect.ts +++ b/src/rules/valid-expect.ts @@ -1,5 +1,5 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' -import { createEslintRule, getAccessorValue, isSupportedAccessor } from '../utils' +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' +import { createEslintRule, FunctionExpression, getAccessorValue, isFunction, isSupportedAccessor } from '../utils' import { parseVitestFnCallWithReason } from '../utils/parse-vitest-fn-call' import { ModifierName } from '../utils/types' @@ -44,6 +44,21 @@ const getPromiseCallExpressionNode = (node: TSESTree.Node) => { const promiseArrayExceptionKey = ({ start, end }: TSESTree.SourceLocation) => `${start.line}:${start.column}-${end.line}:${end.column}` +const getNormalizeFunctionExpression = ( + functionExpression: FunctionExpression, +): + | TSESTree.PropertyComputedName + | TSESTree.PropertyNonComputedName + | FunctionExpression => { + if ( + functionExpression.parent.type === AST_NODE_TYPES.Property && + functionExpression.type === AST_NODE_TYPES.FunctionExpression + ) + return functionExpression.parent; + + return functionExpression; +}; + function getParentIfThenified(node: TSESTree.Node): TSESTree.Node { const grandParentNode = node.parent?.parent @@ -65,6 +80,15 @@ const findPromiseCallExpressionNode = (node: TSESTree.Node) => ? getPromiseCallExpressionNode(node.parent) : null +const findFirstFunctionExpression = ({ + parent, +}: TSESTree.Node): FunctionExpression | null => { + if (!parent) + return null; + + return isFunction(parent) ? parent : findFirstFunctionExpression(parent); +}; + const isAcceptableReturnNode = ( node: TSESTree.Node, allowReturn: boolean @@ -110,6 +134,7 @@ export default createEslintRule<[ 'Promises which return async assertions must be awaited{{orReturned}}' }, type: 'suggestion', + fixable: "code", schema: [ { type: 'object', @@ -143,6 +168,13 @@ export default createEslintRule<[ }], create: (context, [{ alwaysAwait, asyncMatchers = defaultAsyncMatchers, minArgs = 1, maxArgs = 1 }]) => { const arrayExceptions = new Set() + const descriptors: Array<{ + node: TSESTree.Node; + messageId: Extract< + MESSAGE_IDS, + 'asyncMustBeAwaited' | 'promisesWithAsyncAssertionsMustBeAwaited' + >; + }> = []; const pushPromiseArrayException = (loc: TSESTree.SourceLocation) => arrayExceptions.add(promiseArrayExceptionKey(loc)) @@ -293,19 +325,64 @@ export default createEslintRule<[ if (finalNode.parent && !isAcceptableReturnNode(finalNode.parent, !alwaysAwait) && !promiseArrayExceptionExists(finalNode.loc)) { - context.report({ - loc: finalNode.loc, - data: { orReturned }, + descriptors.push({ messageId: finalNode === targetNode ? 'asyncMustBeAwaited' : 'promisesWithAsyncAssertionsMustBeAwaited', - node + node: finalNode }) if (isParentArrayExpression) pushPromiseArrayException(finalNode.loc) } - } + }, + 'Program:exit'() { + const fixes: TSESLint.RuleFix[] = []; + + descriptors.forEach(({ node, messageId }, index) => { + const orReturned = alwaysAwait ? '' : ' or returned'; + + context.report({ + loc: node.loc, + data: { orReturned }, + messageId, + node, + fix(fixer) { + const functionExpression = findFirstFunctionExpression(node); + + if (!functionExpression) + return null; + + const foundAsyncFixer = fixes.some(fix => fix.text === 'async '); + + if (!functionExpression.async && !foundAsyncFixer) { + const targetFunction = + getNormalizeFunctionExpression(functionExpression); + + fixes.push(fixer.insertTextBefore(targetFunction, 'async ')); + } + + const returnStatement = + node.parent?.type === AST_NODE_TYPES.ReturnStatement + ? node.parent + : null; + + if (alwaysAwait && returnStatement) { + const sourceCodeText = + context.sourceCode.getText(returnStatement); + const replacedText = sourceCodeText.replace('return', 'await'); + + fixes.push(fixer.replaceText(returnStatement, replacedText)); + } + else { + fixes.push(fixer.insertTextBefore(node, 'await ')); + } + + return index === descriptors.length - 1 ? fixes : null; + }, + }); + }); + }, } } }) diff --git a/tests/valid-expect.test.ts b/tests/valid-expect.test.ts index 9bf6d1b..ff339cf 100644 --- a/tests/valid-expect.test.ts +++ b/tests/valid-expect.test.ts @@ -429,6 +429,15 @@ ruleTester.run(RULE_NAME, rule, { ? expect(obj).toBe(true) : expect(obj).resolves.not.toThrow(); } + }); + `, + output: ` + expect.extend({ + async toResolve(obj) { + this.isNot + ? expect(obj).toBe(true) + : await expect(obj).resolves.not.toThrow(); + } }); `, errors: [ @@ -447,6 +456,15 @@ ruleTester.run(RULE_NAME, rule, { ? expect(obj).resolves.not.toThrow() : expect(obj).toBe(true); } + }); + `, + output: ` + expect.extend({ + async toResolve(obj) { + this.isNot + ? await expect(obj).resolves.not.toThrow() + : expect(obj).toBe(true); + } }); `, errors: [ @@ -459,6 +477,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).resolves.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).resolves.toBeDefined(); });', errors: [ { column: 30, @@ -470,6 +489,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).toResolve(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).toResolve(); });', errors: [ { messageId: 'asyncMustBeAwaited', @@ -481,6 +501,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).toResolve(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).toResolve(); });', options: [{ asyncMatchers: undefined }], errors: [ { @@ -493,6 +514,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).toReject(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).toReject(); });', errors: [ { messageId: 'asyncMustBeAwaited', @@ -504,6 +526,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).not.toReject(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).not.toReject(); });', errors: [ { messageId: 'asyncMustBeAwaited', @@ -515,6 +538,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).resolves.not.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).resolves.not.toBeDefined(); });', errors: [ { column: 30, @@ -526,6 +550,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).rejects.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).rejects.toBeDefined(); });', errors: [ { column: 30, @@ -537,6 +562,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.resolve(2)).rejects.not.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).rejects.not.toBeDefined(); });', errors: [ { column: 30, @@ -548,6 +574,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", async () => { expect(Promise.resolve(2)).resolves.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).resolves.toBeDefined(); });', errors: [ { column: 36, @@ -559,6 +586,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", async () => { expect(Promise.resolve(2)).resolves.not.toBeDefined(); });', + output: 'test("valid-expect", async () => { await expect(Promise.resolve(2)).resolves.not.toBeDefined(); });', errors: [ { column: 36, @@ -570,6 +598,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.reject(2)).toRejectWith(2); });', + output: 'test("valid-expect", async () => { await expect(Promise.reject(2)).toRejectWith(2); });', options: [{ asyncMatchers: ['toRejectWith'] }], errors: [ { @@ -581,6 +610,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: 'test("valid-expect", () => { expect(Promise.reject(2)).rejects.toBe(2); });', + output: 'test("valid-expect", async () => { await expect(Promise.reject(2)).rejects.toBe(2); });', options: [{ asyncMatchers: ['toRejectWith'] }], errors: [ { @@ -596,6 +626,12 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(2)).resolves.not.toBeDefined(); expect(Promise.resolve(1)).rejects.toBeDefined(); }); + `, + output: ` + test("valid-expect", async () => { + await expect(Promise.resolve(2)).resolves.not.toBeDefined(); + await expect(Promise.resolve(1)).rejects.toBeDefined(); + }); `, errors: [ { @@ -620,6 +656,12 @@ ruleTester.run(RULE_NAME, rule, { await expect(Promise.resolve(2)).resolves.not.toBeDefined(); expect(Promise.resolve(1)).rejects.toBeDefined(); }); + `, + output: ` + test("valid-expect", async () => { + await expect(Promise.resolve(2)).resolves.not.toBeDefined(); + await expect(Promise.resolve(1)).rejects.toBeDefined(); + }); `, errors: [ { @@ -637,6 +679,12 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(2)).resolves.not.toBeDefined(); return expect(Promise.resolve(1)).rejects.toBeDefined(); }); + `, + output: ` + test("valid-expect", async () => { + await expect(Promise.resolve(2)).resolves.not.toBeDefined(); + await expect(Promise.resolve(1)).rejects.toBeDefined(); + }); `, options: [{ alwaysAwait: true }], errors: [ @@ -660,6 +708,12 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(2)).resolves.not.toBeDefined(); return expect(Promise.resolve(1)).rejects.toBeDefined(); }); + `, + output: ` + test("valid-expect", async () => { + await expect(Promise.resolve(2)).resolves.not.toBeDefined(); + return expect(Promise.resolve(1)).rejects.toBeDefined(); + }); `, errors: [ { @@ -676,6 +730,11 @@ ruleTester.run(RULE_NAME, rule, { test("valid-expect", () => { Promise.x(expect(Promise.resolve(2)).resolves.not.toBeDefined()); }); + `, + output: ` + test("valid-expect", async () => { + await Promise.x(expect(Promise.resolve(2)).resolves.not.toBeDefined()); + }); `, errors: [ { @@ -691,6 +750,11 @@ ruleTester.run(RULE_NAME, rule, { code: ` test("valid-expect", () => { Promise.resolve(expect(Promise.resolve(2)).resolves.not.toBeDefined()); + }); + `, + output: ` + test("valid-expect", async () => { + await Promise.resolve(expect(Promise.resolve(2)).resolves.not.toBeDefined()); }); `, options: [{ alwaysAwait: true }], @@ -712,6 +776,14 @@ ruleTester.run(RULE_NAME, rule, { ]); }); `, + output: ` + test("valid-expect", async () => { + await Promise.all([ + expect(Promise.resolve(2)).resolves.not.toBeDefined(), + expect(Promise.resolve(3)).resolves.not.toBeDefined(), + ]); + }); + `, errors: [ { line: 3, @@ -731,6 +803,13 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(3)).resolves.not.toBeDefined(), ]); });`, + output: ` + test("valid-expect", async () => { + await Promise.x([ + expect(Promise.resolve(2)).resolves.not.toBeDefined(), + expect(Promise.resolve(3)).resolves.not.toBeDefined(), + ]); + });`, errors: [ { line: 3, @@ -750,6 +829,14 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(3)).resolves.not.toBeDefined(), ] }); + `, + output: ` + test("valid-expect", async () => { + const assertions = [ + await expect(Promise.resolve(2)).resolves.not.toBeDefined(), + await expect(Promise.resolve(3)).resolves.not.toBeDefined(), + ] + }); `, errors: [ { @@ -777,6 +864,14 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(2)).toResolve(), expect(Promise.resolve(3)).toReject(), ] + }); + `, + output: ` + test("valid-expect", async () => { + const assertions = [ + await expect(Promise.resolve(2)).toResolve(), + await expect(Promise.resolve(3)).toReject(), + ] }); `, errors: [ @@ -802,6 +897,14 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(3)).resolves.toReject(), ] }); + `, + output: ` + test("valid-expect", async () => { + const assertions = [ + await expect(Promise.resolve(2)).not.toResolve(), + await expect(Promise.resolve(3)).resolves.toReject(), + ] + }); `, errors: [ { @@ -835,6 +938,13 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(2)).resolves.toBe(1); }); }); + `, + output: ` + test("valid-expect", () => { + return expect(functionReturningAPromise()).resolves.toEqual(1).then(async () => { + await expect(Promise.resolve(2)).resolves.toBe(1); + }); + }); `, errors: [ { @@ -855,6 +965,14 @@ ruleTester.run(RULE_NAME, rule, { expect(Promise.resolve(4)).resolves.toBe(4); }); }); + `, + output: ` + test("valid-expect", () => { + return expect(functionReturningAPromise()).resolves.toEqual(1).then(async () => { + await expect(Promise.resolve(2)).resolves.toBe(1); + await expect(Promise.resolve(4)).resolves.toBe(4); + }); + }); `, errors: [ {