diff --git a/README.md b/README.md index 46643a26..2f5e1636 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ To enable this configuration use the `extends` property in your | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------ | | [await-async-query](docs/rules/await-async-query.md) | Enforce promises from async queries to be handled | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | -| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | | +| [await-fire-event](docs/rules/await-fire-event.md) | Enforce promises from fire event methods to be handled | ![vue-badge][] | | | [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | | | [no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | | | | [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | diff --git a/docs/rules/await-fire-event.md b/docs/rules/await-fire-event.md index 1f464de5..cff4509c 100644 --- a/docs/rules/await-fire-event.md +++ b/docs/rules/await-fire-event.md @@ -1,12 +1,17 @@ -# Enforce async fire event methods to be awaited (await-fire-event) +# Enforce promises from fire event methods to be handled (await-fire-event) -Ensure that promises returned by `fireEvent` methods are awaited +Ensure that promises returned by `fireEvent` methods are handled properly. ## Rule Details -This rule aims to prevent users from forgetting to await `fireEvent` -methods when they are async. +This rule aims to prevent users from forgetting to handle promise returned from `fireEvent` +methods. + +> ⚠️ `fireEvent` methods are async only on following Testing Library packages: +> +> - `@testing-library/vue` (supported by this plugin) +> - `@testing-library/svelte` (not supported yet by this plugin) Examples of **incorrect** code for this rule: @@ -15,6 +20,12 @@ fireEvent.click(getByText('Click me')); fireEvent.focus(getByLabelText('username')); fireEvent.blur(getByLabelText('username')); + +// wrap a fireEvent method within a function... +function triggerEvent() { + return fireEvent.click(button); +} +triggerEvent(); // ...but not handling promise from it is incorrect too ``` Examples of **correct** code for this rule: @@ -30,15 +41,24 @@ fireEvent.click(getByText('Click me')).then(() => { }); // return the promise within a function is correct too! -function clickMeRegularFn() { - return fireEvent.click(getByText('Click me')); -} const clickMeArrowFn = () => fireEvent.click(getByText('Click me')); + +// wrap a fireEvent method within a function... +function triggerEvent() { + return fireEvent.click(button); +} +await triggerEvent(); // ...and handling promise from it is correct also + +// using `Promise.all` or `Promise.allSettled` with an array of promises is valid +await Promise.all([ + fireEvent.focus(getByLabelText('username')), + fireEvent.blur(getByLabelText('username')), +]); ``` ## When Not To Use It -`fireEvent` methods are only async in Vue Testing Library so if you are using another Testing Library module, you shouldn't use this rule. +`fireEvent` methods are not async on all Testing Library packages. If you are not using Testing Library package with async fire event, you shouldn't use this rule. ## Further Reading diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts index 580a7cbb..f3066304 100644 --- a/lib/detect-testing-library-utils.ts +++ b/lib/detect-testing-library-utils.ts @@ -4,16 +4,15 @@ import { TSESTree, } from '@typescript-eslint/experimental-utils'; import { - getImportModuleName, getAssertNodeInfo, - isLiteral, + getImportModuleName, ImportModuleNode, isImportDeclaration, isImportNamespaceSpecifier, isImportSpecifier, + isLiteral, + isMemberExpression, isProperty, - isCallExpression, - isObjectPattern, } from './node-utils'; import { ABSENCE_MATCHERS, ASYNC_UTILS, PRESENCE_MATCHERS } from './utils'; @@ -54,6 +53,7 @@ export type DetectionHelpers = { isSyncQuery: (node: TSESTree.Identifier) => boolean; isAsyncQuery: (node: TSESTree.Identifier) => boolean; isAsyncUtil: (node: TSESTree.Identifier) => boolean; + isFireEventMethod: (node: TSESTree.Identifier) => boolean; isPresenceAssert: (node: TSESTree.MemberExpression) => boolean; isAbsenceAssert: (node: TSESTree.MemberExpression) => boolean; canReportErrors: () => boolean; @@ -67,6 +67,8 @@ export type DetectionHelpers = { const DEFAULT_FILENAME_PATTERN = '^.*\\.(test|spec)\\.[jt]sx?$'; +const FIRE_EVENT_NAME = 'fireEvent'; + /** * Enhances a given rule `create` with helpers to detect Testing Library utils. */ @@ -88,6 +90,15 @@ export function detectTestingLibraryUtils< context.settings['testing-library/filename-pattern'] ?? DEFAULT_FILENAME_PATTERN; + /** + * Determines whether aggressive reporting is enabled or not. + * + * Aggressive reporting is considered as enabled when: + * - custom module is not set (so we need to assume everything + * matching TL utils is related to TL no matter where it was imported from) + */ + const isAggressiveReportingEnabled = () => !customModule; + // Helpers for Testing Library detection. const getTestingLibraryImportNode: DetectionHelpers['getTestingLibraryImportNode'] = () => { return importedTestingLibraryNode; @@ -118,7 +129,7 @@ export function detectTestingLibraryUtils< * or custom module are imported. */ const isTestingLibraryImported: DetectionHelpers['isTestingLibraryImported'] = () => { - if (!customModule) { + if (isAggressiveReportingEnabled()) { return true; } @@ -176,6 +187,58 @@ export function detectTestingLibraryUtils< return ASYNC_UTILS.includes(node.name); }; + /** + * Determines whether a given node is fireEvent method or not + */ + const isFireEventMethod: DetectionHelpers['isFireEventMethod'] = (node) => { + const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + let fireEventUtilName: string | undefined; + + if (fireEventUtil) { + fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) + ? fireEventUtil.name + : fireEventUtil.local.name; + } else if (isAggressiveReportingEnabled()) { + fireEventUtilName = FIRE_EVENT_NAME; + } + + if (!fireEventUtilName) { + return false; + } + + const parentMemberExpression: + | TSESTree.MemberExpression + | undefined = isMemberExpression(node.parent) ? node.parent : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not fireEvent object itself + if ( + [fireEventUtilName, FIRE_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check fireEvent.click() usage + const regularCall = + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === fireEventUtilName; + + // check testingLibraryUtils.fireEvent.click() usage + const wildcardCall = + isMemberExpression(parentMemberExpression.object) && + ASTUtils.isIdentifier(parentMemberExpression.object.object) && + parentMemberExpression.object.object.name === fireEventUtilName && + ASTUtils.isIdentifier(parentMemberExpression.object.property) && + parentMemberExpression.object.property.name === FIRE_EVENT_NAME; + + return regularCall || wildcardCall; + }; + /** * Determines whether a given MemberExpression node is a presence assert * @@ -215,7 +278,8 @@ export function detectTestingLibraryUtils< }; /** - * Gets a string and verifies if it was imported/required by our custom module node + * Gets a string and verifies if it was imported/required by Testing Library + * related module. */ const findImportedUtilSpecifier: DetectionHelpers['findImportedUtilSpecifier'] = ( specifierName @@ -260,52 +324,24 @@ export function detectTestingLibraryUtils< }; /** * Takes a MemberExpression or an Identifier and verifies if its name comes from the import in TL - * @param node a MemberExpression (in "foo.property" it would be property) or an Identifier (it should be provided from a CallExpression, for example "foo()") + * @param node a MemberExpression (in "foo.property" it would be property) or an Identifier */ const isNodeComingFromTestingLibrary: DetectionHelpers['isNodeComingFromTestingLibrary'] = ( node: TSESTree.MemberExpression | TSESTree.Identifier ) => { - const importOrRequire = - getCustomModuleImportNode() ?? getTestingLibraryImportNode(); - if (!importOrRequire) { - return false; - } + let identifierName: string | undefined; + if (ASTUtils.isIdentifier(node)) { - if (isImportDeclaration(importOrRequire)) { - return importOrRequire.specifiers.some( - (s) => isImportSpecifier(s) && s.local.name === node.name - ); - } else { - return ( - ASTUtils.isVariableDeclarator(importOrRequire.parent) && - isObjectPattern(importOrRequire.parent.id) && - importOrRequire.parent.id.properties.some( - (p) => - isProperty(p) && - ASTUtils.isIdentifier(p.key) && - ASTUtils.isIdentifier(p.value) && - p.value.name === node.name - ) - ); - } - } else { - if (!ASTUtils.isIdentifier(node.object)) { - return false; - } - if (isImportDeclaration(importOrRequire)) { - return ( - isImportDeclaration(importOrRequire) && - isImportNamespaceSpecifier(importOrRequire.specifiers[0]) && - node.object.name === importOrRequire.specifiers[0].local.name - ); - } - return ( - isCallExpression(importOrRequire) && - ASTUtils.isVariableDeclarator(importOrRequire.parent) && - ASTUtils.isIdentifier(importOrRequire.parent.id) && - node.object.name === importOrRequire.parent.id.name - ); + identifierName = node.name; + } else if (ASTUtils.isIdentifier(node.object)) { + identifierName = node.object.name; + } + + if (!identifierName) { + return; } + + return !!findImportedUtilSpecifier(identifierName); }; const helpers = { @@ -321,6 +357,7 @@ export function detectTestingLibraryUtils< isSyncQuery, isAsyncQuery, isAsyncUtil, + isFireEventMethod, isPresenceAssert, isAbsenceAssert, canReportErrors, diff --git a/lib/node-utils.ts b/lib/node-utils.ts index 9e0168cb..fcbc2f14 100644 --- a/lib/node-utils.ts +++ b/lib/node-utils.ts @@ -27,7 +27,6 @@ const ValidLeftHandSideExpressions = [ AST_NODE_TYPES.ObjectExpression, AST_NODE_TYPES.ObjectPattern, AST_NODE_TYPES.Super, - AST_NODE_TYPES.TemplateLiteral, AST_NODE_TYPES.ThisExpression, AST_NODE_TYPES.TSNullKeyword, AST_NODE_TYPES.TaggedTemplateExpression, @@ -132,7 +131,7 @@ export function findClosestCallExpressionNode( return null; } - return findClosestCallExpressionNode(node.parent); + return findClosestCallExpressionNode(node.parent, shouldRestrictInnerScope); } export function findClosestCallNode( @@ -232,15 +231,22 @@ export function isPromiseAllSettled(node: TSESTree.CallExpression): boolean { ); } +/** + * Determines whether a given node belongs to handled Promise.all or Promise.allSettled + * array expression. + */ export function isPromisesArrayResolved(node: TSESTree.Node): boolean { - const parent = node.parent; + const closestCallExpression = findClosestCallExpressionNode(node, true); + + if (!closestCallExpression) { + return false; + } return ( - isCallExpression(parent) && - isArrayExpression(parent.parent) && - isCallExpression(parent.parent.parent) && - (isPromiseAll(parent.parent.parent) || - isPromiseAllSettled(parent.parent.parent)) + isArrayExpression(closestCallExpression.parent) && + isCallExpression(closestCallExpression.parent.parent) && + (isPromiseAll(closestCallExpression.parent.parent) || + isPromiseAllSettled(closestCallExpression.parent.parent)) ); } @@ -253,7 +259,7 @@ export function isPromisesArrayResolved(node: TSESTree.Node): boolean { * - it belongs to the `Promise.allSettled` method * - it's chained with the `then` method * - it's returned from a function - * - has `resolves` or `rejects` + * - has `resolves` or `rejects` jest methods */ export function isPromiseHandled(nodeIdentifier: TSESTree.Identifier): boolean { const closestCallExpressionNode = findClosestCallExpressionNode( diff --git a/lib/rules/await-async-utils.ts b/lib/rules/await-async-utils.ts index 75ff5384..c23a8877 100644 --- a/lib/rules/await-async-utils.ts +++ b/lib/rules/await-async-utils.ts @@ -74,7 +74,7 @@ export default createTestingLibraryRule({ ); if (references && references.length === 0) { - if (!isPromiseHandled(node as TSESTree.Identifier)) { + if (!isPromiseHandled(node)) { return context.report({ node, messageId: 'awaitAsyncUtil', diff --git a/lib/rules/await-fire-event.ts b/lib/rules/await-fire-event.ts index 84f7d877..c127112a 100644 --- a/lib/rules/await-fire-event.ts +++ b/lib/rules/await-fire-event.ts @@ -1,51 +1,106 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; import { - ESLintUtils, - TSESTree, - ASTUtils, -} from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; -import { isAwaited, hasChainedThen } from '../node-utils'; + findClosestCallExpressionNode, + getFunctionName, + getInnermostReturningFunction, + getVariableReferences, + isPromiseHandled, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'await-fire-event'; -export type MessageIds = 'awaitFireEvent'; +export type MessageIds = 'awaitFireEvent' | 'fireEventWrapper'; type Options = []; -export default ESLintUtils.RuleCreator(getDocsUrl)({ + +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', docs: { - description: 'Enforce async fire event methods to be awaited', + description: 'Enforce promises from fire event methods to be handled', category: 'Best Practices', recommended: false, }, messages: { - awaitFireEvent: 'async `fireEvent.{{ methodName }}` must be awaited', + awaitFireEvent: + 'Promise returned from `fireEvent.{{ methodName }}` must be handled', + fireEventWrapper: + 'Promise returned from `fireEvent.{{ wrapperName }}` wrapper over fire event method must be handled', }, fixable: null, schema: [], }, defaultOptions: [], - create: function (context) { + create: function (context, _, helpers) { + const functionWrappersNames: string[] = []; + + function reportUnhandledNode( + node: TSESTree.Identifier, + closestCallExpressionNode: TSESTree.CallExpression, + messageId: MessageIds = 'awaitFireEvent' + ): void { + if (!isPromiseHandled(node)) { + context.report({ + node: closestCallExpressionNode.callee, + messageId, + data: { name: node.name }, + }); + } + } + + function detectFireEventMethodWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); + + if (innerFunction) { + functionWrappersNames.push(getFunctionName(innerFunction)); + } + } + return { - 'CallExpression > MemberExpression > Identifier[name=fireEvent]'( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const fireEventMethodNode = memberExpression.property; - - if ( - ASTUtils.isIdentifier(fireEventMethodNode) && - !isAwaited(node.parent.parent.parent) && - !hasChainedThen(fireEventMethodNode.parent) - ) { - context.report({ - node: fireEventMethodNode, - messageId: 'awaitFireEvent', - data: { - methodName: fireEventMethodNode.name, - }, - }); + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (helpers.isFireEventMethod(node)) { + detectFireEventMethodWrapper(node); + + const closestCallExpression = findClosestCallExpressionNode( + node, + true + ); + + if (!closestCallExpression) { + return; + } + + const references = getVariableReferences( + context, + closestCallExpression.parent + ); + + if (references.length === 0) { + return reportUnhandledNode(node, closestCallExpression); + } else { + for (const reference of references) { + const referenceNode = reference.identifier as TSESTree.Identifier; + return reportUnhandledNode(referenceNode, closestCallExpression); + } + } + } else if (functionWrappersNames.includes(node.name)) { + // report promise returned from function wrapping fire event method + // previously detected + const closestCallExpression = findClosestCallExpressionNode( + node, + true + ); + + if (!closestCallExpression) { + return; + } + + return reportUnhandledNode( + node, + closestCallExpression, + 'fireEventWrapper' + ); } }, }; diff --git a/lib/rules/prefer-user-event.ts b/lib/rules/prefer-user-event.ts index 0e5fc4f4..7e4b8350 100644 --- a/lib/rules/prefer-user-event.ts +++ b/lib/rules/prefer-user-event.ts @@ -1,6 +1,6 @@ -import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; -import { isMemberExpression } from '../node-utils'; +import { findClosestCallExpressionNode } from '../node-utils'; export const RULE_NAME = 'prefer-user-event'; @@ -22,7 +22,7 @@ export const UserEventMethods = [ ] as const; type UserEventMethodsType = typeof UserEventMethods[number]; -// maps fireEvent methods to userEvent. Those not found here, do not have an equivalet (yet) +// maps fireEvent methods to userEvent. Those not found here, do not have an equivalent (yet) export const MappingToUserEvent: Record = { click: ['click', 'type', 'selectOptions', 'deselectOptions'], change: ['upload', 'type', 'clear', 'selectOptions', 'deselectOptions'], @@ -72,7 +72,7 @@ export default createTestingLibraryRule({ }, messages: { preferUserEvent: - 'Prefer using {{userEventMethods}} over {{fireEventMethod}}()', + 'Prefer using {{userEventMethods}} over fireEvent.{{fireEventMethod}}()', }, schema: [ { @@ -88,50 +88,34 @@ export default createTestingLibraryRule({ create(context, [options], helpers) { const { allowedMethods } = options; - const sourceCode = context.getSourceCode(); return { - ['CallExpression > MemberExpression'](node: TSESTree.MemberExpression) { - const util = helpers.findImportedUtilSpecifier('fireEvent'); - if (!util) { - // testing library was imported, but fireEvent was not imported + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (!helpers.isFireEventMethod(node)) { return; } - const fireEventAliasOrWildcard = ASTUtils.isIdentifier(util) - ? util.name - : util.local.name; - const fireEventUsed = - ASTUtils.isIdentifier(node.object) && - node.object.name === fireEventAliasOrWildcard; + const closestCallExpression = findClosestCallExpressionNode(node, true); - const fireEventFromWildcardUsed = - isMemberExpression(node.object) && - ASTUtils.isIdentifier(node.object.object) && - node.object.object.name === fireEventAliasOrWildcard && - ASTUtils.isIdentifier(node.object.property) && - node.object.property.name === 'fireEvent'; - - if (!fireEventUsed && !fireEventFromWildcardUsed) { - // fireEvent was imported but it was not used + if (!closestCallExpression) { return; } + const fireEventMethodName: string = node.name; + if ( - !ASTUtils.isIdentifier(node.property) || - !fireEventMappedMethods.includes(node.property.name) || - allowedMethods.includes(node.property.name) + !fireEventMappedMethods.includes(fireEventMethodName) || + allowedMethods.includes(fireEventMethodName) ) { - // the fire event does not have an equivalent in userEvent, or it's excluded return; } context.report({ - node, + node: closestCallExpression.callee, messageId: 'preferUserEvent', data: { - userEventMethods: buildErrorMessage(node.property.name), - fireEventMethod: sourceCode.getText(node), + userEventMethods: buildErrorMessage(fireEventMethodName), + fireEventMethod: fireEventMethodName, }, }); }, diff --git a/tests/lib/rules/await-fire-event.test.ts b/tests/lib/rules/await-fire-event.test.ts index dda5ed5f..2b62147a 100644 --- a/tests/lib/rules/await-fire-event.test.ts +++ b/tests/lib/rules/await-fire-event.test.ts @@ -3,88 +3,316 @@ import rule, { RULE_NAME } from '../../../lib/rules/await-fire-event'; const ruleTester = createRuleTester(); +const COMMON_FIRE_EVENT_METHODS: string[] = [ + 'click', + 'change', + 'focus', + 'blur', + 'keyDown', +]; + ruleTester.run(RULE_NAME, rule, { valid: [ - { - code: `fireEvent.click`, - }, - { - code: `async () => { - await fireEvent.click(getByText('Click me')) - } - `, - }, - { - code: `async () => { - await fireEvent.focus(getByLabelText('username')) - await fireEvent.blur(getByLabelText('username')) - } - `, - }, - { - code: `done => { - fireEvent.click(getByText('Click me')).then(() => { done() }) - } - `, - }, - { - code: `done => { - fireEvent.focus(getByLabelText('username')).then(() => { - fireEvent.blur(getByLabelText('username')).then(() => { done() }) + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('fire event method not called is valid', () => { + fireEvent.${fireEventMethod} + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise from fire event method is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await several promises from fire event methods is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise kept in a var from fire event method is valid', async () => { + const promise = fireEvent.${fireEventMethod}(getByLabelText('username')) + await promise + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('chain then method to promise from fire event method is valid', async (done) => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + .then(() => { done() }) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('chain then method to several promises from fire event methods is valid', async (done) => { + fireEvent.${fireEventMethod}(getByLabelText('username')).then(() => { + fireEvent.${fireEventMethod}(getByLabelText('username')).then(() => { done() }) }) - } - `, - }, - { - code: `() => { - return fireEvent.click(getByText('Click me')) - } - `, - }, - { - code: `() => fireEvent.click(getByText('Click me')) - `, - }, - { - code: `function clickUtil() { - doSomething() - return fireEvent.click(getByText('Click me')) - } - `, - }, + }) + `, + })), + `import { fireEvent } from '@testing-library/vue' + + test('fireEvent methods wrapped with Promise.all are valid', async () => { + await Promise.all([ + fireEvent.blur(getByText('Click me')), + fireEvent.click(getByText('Click me')), + ]) + }) + `, + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('return promise from fire event methods is valid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise returned from function wrapping fire event method is valid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + + await triggerEvent() + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + import { fireEvent } from 'somewhere-else' + test('unhandled promise from fire event not related to TL is valid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + test('await promise from fire event method imported from custom module is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + + // edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + ` + import { fireEvent } from 'test-utils' + test('edge case for innermost function without call expression', async () => { + function triggerEvent() { + doSomething() + return fireEvent.focus(getByLabelText('username')) + } + + const reassignedFunction = triggerEvent + }) + `, ], invalid: [ - { - code: `() => { - fireEvent.click(getByText('Click me')) - } + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise from fire event method is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 19 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent as testingLibraryFireEvent } from '@testing-library/vue' + test('unhandled promise from aliased fire event method is invalid', async () => { + testingLibraryFireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 33 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import * as testingLibrary from '@testing-library/vue' + test('unhandled promise from wildcard imported fire event method is invalid', async () => { + testingLibrary.fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 34 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('several unhandled promises from fire event methods is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + { + line: 5, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise from fire event method with aggressive reporting opted-out is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + test( + 'unhandled promise from fire event method imported from custom module with aggressive reporting opted-out is invalid', + () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) `, errors: [ { - column: 19, + line: 6, + column: 9, messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, }, ], - }, - { - code: `() => { - fireEvent.focus(getByLabelText('username')) - fireEvent.blur(getByLabelText('username')) - } + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + import { fireEvent } from '@testing-library/vue' + test( + 'unhandled promise from fire event method imported from default module with aggressive reporting opted-out is invalid', + () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) `, errors: [ { - line: 2, - column: 19, + line: 6, + column: 9, messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, }, + ], + })), + + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test( + 'unhandled promise from fire event method kept in a var is invalid', + () => { + const promise = fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ { - line: 3, - column: 19, + line: 6, + column: 25, messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise returned from function wrapping fire event method is invalid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + + triggerEvent() + }) + `, + errors: [ + { + line: 9, + column: 9, + messageId: 'fireEventWrapper', + data: { name: fireEventMethod }, }, ], - }, + })), ], }); diff --git a/tests/lib/rules/prefer-user-event.test.ts b/tests/lib/rules/prefer-user-event.test.ts index b1a4158a..171c1968 100644 --- a/tests/lib/rules/prefer-user-event.test.ts +++ b/tests/lib/rules/prefer-user-event.test.ts @@ -16,9 +16,7 @@ function createScenarioWithImport< T extends ValidTestCase | InvalidTestCase >(callback: (libraryModule: string, fireEventMethod: string) => T) { return LIBRARY_MODULES.reduce( - // can't find the right type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (acc: any, libraryModule) => + (acc: Array, libraryModule) => acc.concat( Object.keys(MappingToUserEvent).map((fireEventMethod) => callback(libraryModule, fireEventMethod) @@ -134,6 +132,17 @@ ruleTester.run(RULE_NAME, rule, { userEvent.${userEventMethod}(foo) `, })), + ...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({ + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // fireEvent method used but not imported from TL related module + // (aggressive reporting opted out) + import { fireEvent } from 'somewhere-else' + fireEvent.${fireEventMethod}(foo) + `, + })), ...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({ settings: { 'testing-library/module': 'test-utils', @@ -166,6 +175,15 @@ ruleTester.run(RULE_NAME, rule, { `, options: [{ allowedMethods: [fireEventMethod] }], })), + // edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + ` + import { fireEvent } from '@testing-library/react'; + test('edge case for no innermost function scope', () => { + const click = fireEvent.click + }) + `, ], invalid: [ ...createScenarioWithImport>( @@ -231,6 +249,15 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }], })), + ...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({ + code: ` + // same as previous group of test cases but without custom module set + // (aggressive reporting) + import { fireEvent } from 'test-utils' + fireEvent.${fireEventMethod}(foo) + `, + errors: [{ messageId: 'preferUserEvent', line: 5, column: 9 }], + })), ...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({ settings: { 'testing-library/module': 'test-utils', @@ -241,5 +268,26 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }], })), + { + code: ` // simple test to check error in detail + import { fireEvent } from '@testing-library/react' + + fireEvent.click(element) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + endLine: 4, + column: 7, + endColumn: 22, + data: { + userEventMethods: + 'userEvent.click(), userEvent.type() or userEvent.deselectOptions()', + fireEventMethod: 'click', + }, + }, + ], + }, ], });