diff --git a/docs/rules/prefer-user-event.md b/docs/rules/prefer-user-event.md index dda1aca0..f2e0210e 100644 --- a/docs/rules/prefer-user-event.md +++ b/docs/rules/prefer-user-event.md @@ -29,6 +29,14 @@ fireEventAliased.click(node); import * as dom from '@testing-library/dom'; // or const dom = require(@testing-library/dom'); dom.fireEvent.click(node); + +// using fireEvent as a function +import * as dom from '@testing-library/dom'; +dom.fireEvent(node, dom.createEvent('click', node)); + +import { fireEvent, createEvent } from '@testing-library/dom'; +const clickEvent = createEvent.click(node); +fireEvent(node, clickEvent); ``` Examples of **correct** code for this rule: @@ -48,6 +56,9 @@ fireEvent.cut(node); import * as dom from '@testing-library/dom'; // or const dom = require('@testing-library/dom'); dom.fireEvent.cut(node); + +import { fireEvent, createEvent } from '@testing-library/dom'; +fireEvent(node, createEvent('cut', node)); ``` #### Options diff --git a/lib/create-testing-library-rule/detect-testing-library-utils.ts b/lib/create-testing-library-rule/detect-testing-library-utils.ts index c62b9578..f811dec7 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -77,6 +77,9 @@ type IsAsyncUtilFn = ( type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; +type IsCreateEventUtil = ( + node: TSESTree.CallExpression | TSESTree.Identifier +) => boolean; type IsRenderVariableDeclaratorFn = ( node: TSESTree.VariableDeclarator ) => boolean; @@ -115,6 +118,7 @@ export interface DetectionHelpers { isFireEventMethod: IsFireEventMethodFn; isUserEventMethod: IsUserEventMethodFn; isRenderUtil: IsRenderUtilFn; + isCreateEventUtil: IsCreateEventUtil; isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; isDebugUtil: IsDebugUtilFn; isActUtil: (node: TSESTree.Identifier) => boolean; @@ -128,6 +132,7 @@ export interface DetectionHelpers { const USER_EVENT_PACKAGE = '@testing-library/user-event'; const REACT_DOM_TEST_UTILS_PACKAGE = 'react-dom/test-utils'; const FIRE_EVENT_NAME = 'fireEvent'; +const CREATE_EVENT_NAME = 'createEvent'; const USER_EVENT_NAME = 'userEvent'; const RENDER_NAME = 'render'; @@ -471,6 +476,7 @@ export function detectTestingLibraryUtils< /** * Determines whether a given node is fireEvent method or not */ + // eslint-disable-next-line complexity const isFireEventMethod: IsFireEventMethodFn = (node) => { const fireEventUtil = findImportedTestingLibraryUtilSpecifier(FIRE_EVENT_NAME); @@ -493,33 +499,54 @@ export function detectTestingLibraryUtils< ? node.parent : undefined; - if (!parentMemberExpression) { + const parentCallExpression: TSESTree.CallExpression | undefined = + node.parent && isCallExpression(node.parent) ? node.parent : undefined; + + if (!parentMemberExpression && !parentCallExpression) { 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('method', node) usage + if (parentCallExpression) { + return [fireEventUtilName, FIRE_EVENT_NAME].includes(node.name); } + // we know it's defined at this point, but TS seems to think it is not + // so here I'm enforcing it once in order to avoid using "!" operator every time + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const definedParentMemberExpression = parentMemberExpression!; + // check fireEvent.click() usage const regularCall = - ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === fireEventUtilName; + ASTUtils.isIdentifier(definedParentMemberExpression.object) && + isCallExpression(definedParentMemberExpression.parent) && + definedParentMemberExpression.object.name === fireEventUtilName && + node.name !== FIRE_EVENT_NAME && + node.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; + isMemberExpression(definedParentMemberExpression.object) && + ASTUtils.isIdentifier(definedParentMemberExpression.object.object) && + definedParentMemberExpression.object.object.name === + fireEventUtilName && + ASTUtils.isIdentifier(definedParentMemberExpression.object.property) && + definedParentMemberExpression.object.property.name === + FIRE_EVENT_NAME && + node.name !== FIRE_EVENT_NAME && + node.name !== fireEventUtilName; + + // check testingLibraryUtils.fireEvent('click') + const wildcardCallWithCallExpression = + ASTUtils.isIdentifier(definedParentMemberExpression.object) && + definedParentMemberExpression.object.name === fireEventUtilName && + ASTUtils.isIdentifier(definedParentMemberExpression.property) && + definedParentMemberExpression.property.name === FIRE_EVENT_NAME && + !isMemberExpression(definedParentMemberExpression.parent) && + node.name === FIRE_EVENT_NAME && + node.name !== fireEventUtilName; + + return regularCall || wildcardCall || wildcardCallWithCallExpression; }; const isUserEventMethod: IsUserEventMethodFn = (node) => { @@ -595,6 +622,40 @@ export function detectTestingLibraryUtils< } ); + const isCreateEventUtil: IsCreateEventUtil = (node) => { + const isCreateEventCallback = ( + identifierNodeName: string, + originalNodeName?: string + ) => [identifierNodeName, originalNodeName].includes(CREATE_EVENT_NAME); + if ( + isCallExpression(node) && + isMemberExpression(node.callee) && + ASTUtils.isIdentifier(node.callee.object) + ) { + return isPotentialTestingLibraryFunction( + node.callee.object, + isCreateEventCallback + ); + } + + if ( + isCallExpression(node) && + isMemberExpression(node.callee) && + isMemberExpression(node.callee.object) && + ASTUtils.isIdentifier(node.callee.object.property) + ) { + return isPotentialTestingLibraryFunction( + node.callee.object.property, + isCreateEventCallback + ); + } + const identifier = getDeepestIdentifierNode(node); + return isPotentialTestingLibraryFunction( + identifier, + isCreateEventCallback + ); + }; + const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { if (!node.init) { return false; @@ -712,7 +773,8 @@ export function detectTestingLibraryUtils< isRenderUtil(node) || isFireEventMethod(node) || isUserEventMethod(node) || - isActUtil(node) + isActUtil(node) || + isCreateEventUtil(node) ); }; @@ -906,6 +968,7 @@ export function detectTestingLibraryUtils< isFireEventMethod, isUserEventMethod, isRenderUtil, + isCreateEventUtil, isRenderVariableDeclarator, isDebugUtil, isActUtil, diff --git a/lib/rules/prefer-user-event.ts b/lib/rules/prefer-user-event.ts index b6b82543..dda81565 100644 --- a/lib/rules/prefer-user-event.ts +++ b/lib/rules/prefer-user-event.ts @@ -1,7 +1,11 @@ -import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; -import { findClosestCallExpressionNode } from '../node-utils'; +import { + findClosestCallExpressionNode, + isCallExpression, + isMemberExpression, +} from '../node-utils'; export const RULE_NAME = 'prefer-user-event'; @@ -91,28 +95,68 @@ export default createTestingLibraryRule({ create(context, [options], helpers) { const { allowedMethods } = options; - + const createEventVariables: Record = {}; + + const isfireEventMethodAllowed = (methodName: string) => + !fireEventMappedMethods.includes(methodName) || + allowedMethods.includes(methodName); + + const getFireEventMethodName = ( + callExpressionNode: TSESTree.CallExpression, + node: TSESTree.Identifier + ) => { + if ( + !ASTUtils.isIdentifier(callExpressionNode.callee) && + !isMemberExpression(callExpressionNode.callee) + ) { + return node.name; + } + const secondArgument = callExpressionNode.arguments[1]; + if ( + ASTUtils.isIdentifier(secondArgument) && + createEventVariables[secondArgument.name] !== undefined + ) { + return createEventVariables[secondArgument.name]; + } + if ( + !isCallExpression(secondArgument) || + !helpers.isCreateEventUtil(secondArgument) + ) { + return node.name; + } + if (ASTUtils.isIdentifier(secondArgument.callee)) { + // createEvent('click', foo) + return (secondArgument.arguments[0] as TSESTree.Literal) + .value as string; + } + // createEvent.click(foo) + return ( + (secondArgument.callee as TSESTree.MemberExpression) + .property as TSESTree.Identifier + ).name; + }; return { 'CallExpression Identifier'(node: TSESTree.Identifier) { if (!helpers.isFireEventMethod(node)) { return; } - const closestCallExpression = findClosestCallExpressionNode(node, true); if (!closestCallExpression) { return; } - const fireEventMethodName: string = node.name; + const fireEventMethodName = getFireEventMethodName( + closestCallExpression, + node + ); if ( - !fireEventMappedMethods.includes(fireEventMethodName) || - allowedMethods.includes(fireEventMethodName) + !fireEventMethodName || + isfireEventMethodAllowed(fireEventMethodName) ) { return; } - context.report({ node: closestCallExpression.callee, messageId: 'preferUserEvent', @@ -122,6 +166,29 @@ export default createTestingLibraryRule({ }, }); }, + + VariableDeclarator(node: TSESTree.VariableDeclarator) { + if ( + !isCallExpression(node.init) || + !helpers.isCreateEventUtil(node.init) || + !ASTUtils.isIdentifier(node.id) + ) { + return; + } + let fireEventMethodName = ''; + if ( + isMemberExpression(node.init.callee) && + ASTUtils.isIdentifier(node.init.callee.property) + ) { + fireEventMethodName = node.init.callee.property.name; + } else { + fireEventMethodName = (node.init.arguments[0] as TSESTree.Literal) + .value as string; + } + if (!isfireEventMethodAllowed(fireEventMethodName)) { + createEventVariables[node.id.name] = fireEventMethodName; + } + }, }; }, }); diff --git a/tests/lib/rules/prefer-user-event.test.ts b/tests/lib/rules/prefer-user-event.test.ts index 4b195f70..ececbc06 100644 --- a/tests/lib/rules/prefer-user-event.test.ts +++ b/tests/lib/rules/prefer-user-event.test.ts @@ -204,6 +204,67 @@ ruleTester.run(RULE_NAME, rule, { const click = fireEvent.click }) `, + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + const event = createEvent.${fireEventMethod}(node) + fireEvent(node, event) + `, + options: [{ allowedMethods: [fireEventMethod] }], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased, createEvent } from 'test-utils' + fireEventAliased(node, createEvent.${fireEventMethod}(node)) + `, + options: [{ allowedMethods: [fireEventMethod] }], + })), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + const event = createEvent.drop(node) + fireEvent(node, event) + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + const event = createEvent('drop', node) + fireEvent(node, event) + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased, createEvent as createEventAliased } from 'test-utils' + const event = createEventAliased.drop(node) + fireEventAliased(node, event) + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased, createEvent as createEventAliased } from 'test-utils' + const event = createEventAliased('drop', node) + fireEventAliased(node, event) + `, + }, ], invalid: [ ...createScenarioWithImport>( @@ -411,5 +472,137 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + + fireEvent(node, createEvent('${fireEventMethod}', node)) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + + fireEvent(node, createEvent.${fireEventMethod}(node)) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent, createEvent } from 'test-utils' + const event = createEvent.${fireEventMethod}(node) + fireEvent(node, event) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased, createEvent as createEventAliased } from 'test-utils' + const eventValid = createEventAliased.drop(node) + fireEventAliased(node, eventValid) + const eventInvalid = createEventAliased.${fireEventMethod}(node) + fireEventAliased(node, eventInvalid) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 6, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import * as dom from 'test-utils' + const eventValid = dom.createEvent.drop(node) + dom.fireEvent(node, eventValid) + const eventInvalid = dom.createEvent.${fireEventMethod}(node) + dom.fireEvent(node, eventInvalid) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 6, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import * as dom from 'test-utils' + // valid event + dom.fireEvent(node, dom.createEvent.drop(node)) + // invalid event + dom.fireEvent(node, dom.createEvent.${fireEventMethod}(node)) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 6, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod, + }, + } as const, + ], + })), ], });