From c878215ee251b00c74f1929ecbbba7665c847772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Tue, 27 Apr 2021 14:13:39 +0200 Subject: [PATCH 1/4] feat(createTestingLibraryRule): add detectionOptions config --- .../detect-testing-library-utils.ts | 917 ++++++++++++++++++ .../index.ts} | 34 +- lib/detect-testing-library-utils.ts | 913 ----------------- 3 files changed, 936 insertions(+), 928 deletions(-) create mode 100644 lib/create-testing-library-rule/detect-testing-library-utils.ts rename lib/{create-testing-library-rule.ts => create-testing-library-rule/index.ts} (58%) delete mode 100644 lib/detect-testing-library-utils.ts diff --git a/lib/create-testing-library-rule/detect-testing-library-utils.ts b/lib/create-testing-library-rule/detect-testing-library-utils.ts new file mode 100644 index 00000000..d632e585 --- /dev/null +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -0,0 +1,917 @@ +import { + ASTUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; + +import { + getAssertNodeInfo, + getDeepestIdentifierNode, + getImportModuleName, + getPropertyIdentifierNode, + getReferenceNode, + hasImportMatch, + ImportModuleNode, + isImportDeclaration, + isImportDefaultSpecifier, + isImportNamespaceSpecifier, + isImportSpecifier, + isLiteral, + isMemberExpression, + isObjectPattern, + isProperty, +} from '../node-utils'; +import { + ABSENCE_MATCHERS, + ALL_QUERIES_COMBINATIONS, + ASYNC_UTILS, + PRESENCE_MATCHERS, +} from '../utils'; + +const SETTING_OPTION_OFF = 'off' as const; + +export type TestingLibrarySettings = { + 'testing-library/utils-module'?: string | typeof SETTING_OPTION_OFF; + 'testing-library/custom-renders'?: string[] | typeof SETTING_OPTION_OFF; + 'testing-library/custom-queries'?: string[] | typeof SETTING_OPTION_OFF; +}; + +export type TestingLibraryContext< + TOptions extends readonly unknown[], + TMessageIds extends string +> = Readonly< + TSESLint.RuleContext & { + settings: TestingLibrarySettings; + } +>; + +export type EnhancedRuleCreate< + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +> = ( + context: TestingLibraryContext, + optionsWithDefault: Readonly, + detectionHelpers: Readonly +) => TRuleListener; + +// Helpers methods +type GetTestingLibraryImportNodeFn = () => ImportModuleNode | null; +type GetCustomModuleImportNodeFn = () => ImportModuleNode | null; +type GetTestingLibraryImportNameFn = () => string | undefined; +type GetCustomModuleImportNameFn = () => string | undefined; +type IsTestingLibraryImportedFn = (isStrict?: boolean) => boolean; +type IsGetQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsQueryQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsFindQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsSyncQueryFn = (node: TSESTree.Identifier) => boolean; +type IsAsyncQueryFn = (node: TSESTree.Identifier) => boolean; +type IsQueryFn = (node: TSESTree.Identifier) => boolean; +type IsCustomQueryFn = (node: TSESTree.Identifier) => boolean; +type IsBuiltInQueryFn = (node: TSESTree.Identifier) => boolean; +type IsAsyncUtilFn = ( + node: TSESTree.Identifier, + validNames?: readonly typeof ASYNC_UTILS[number][] +) => boolean; +type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; +type IsRenderVariableDeclaratorFn = ( + node: TSESTree.VariableDeclarator +) => boolean; +type IsDebugUtilFn = (identifierNode: TSESTree.Identifier) => boolean; +type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean; +type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean; +type CanReportErrorsFn = () => boolean; +type FindImportedUtilSpecifierFn = ( + specifierName: string +) => TSESTree.ImportClause | TSESTree.Identifier | undefined; +type IsNodeComingFromTestingLibraryFn = ( + node: TSESTree.MemberExpression | TSESTree.Identifier +) => boolean; + +export interface DetectionHelpers { + getTestingLibraryImportNode: GetTestingLibraryImportNodeFn; + getCustomModuleImportNode: GetCustomModuleImportNodeFn; + getTestingLibraryImportName: GetTestingLibraryImportNameFn; + getCustomModuleImportName: GetCustomModuleImportNameFn; + isTestingLibraryImported: IsTestingLibraryImportedFn; + isGetQueryVariant: IsGetQueryVariantFn; + isQueryQueryVariant: IsQueryQueryVariantFn; + isFindQueryVariant: IsFindQueryVariantFn; + isSyncQuery: IsSyncQueryFn; + isAsyncQuery: IsAsyncQueryFn; + isQuery: IsQueryFn; + isCustomQuery: IsCustomQueryFn; + isBuiltInQuery: IsBuiltInQueryFn; + isAsyncUtil: IsAsyncUtilFn; + isFireEventUtil: (node: TSESTree.Identifier) => boolean; + isUserEventUtil: (node: TSESTree.Identifier) => boolean; + isFireEventMethod: IsFireEventMethodFn; + isUserEventMethod: IsUserEventMethodFn; + isRenderUtil: IsRenderUtilFn; + isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; + isDebugUtil: IsDebugUtilFn; + isPresenceAssert: IsPresenceAssertFn; + isAbsenceAssert: IsAbsenceAssertFn; + canReportErrors: CanReportErrorsFn; + findImportedUtilSpecifier: FindImportedUtilSpecifierFn; + isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; +} + +const USER_EVENT_PACKAGE = '@testing-library/user-event'; +const FIRE_EVENT_NAME = 'fireEvent'; +const USER_EVENT_NAME = 'userEvent'; +const RENDER_NAME = 'render'; + +export type DetectionOptions = { + /** + * If true, force `detectTestingLibraryUtils` to skip `canReportErrors` + * so it doesn't opt-out rule listener. + * + * Useful when some rule apply to files other than testing ones + * (e.g. `consistent-data-testid`) + */ + skipRuleReportingCheck: boolean; +}; + +/** + * Enhances a given rule `create` with helpers to detect Testing Library utils. + */ +export const detectTestingLibraryUtils = < + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +>( + ruleCreate: EnhancedRuleCreate, + { skipRuleReportingCheck = false }: Partial = {} +) => ( + context: TestingLibraryContext, + optionsWithDefault: Readonly +): TSESLint.RuleListener => { + let importedTestingLibraryNode: ImportModuleNode | null = null; + let importedCustomModuleNode: ImportModuleNode | null = null; + let importedUserEventLibraryNode: ImportModuleNode | null = null; + + // Init options based on shared ESLint settings + const customModuleSetting = context.settings['testing-library/utils-module']; + const customRendersSetting = + context.settings['testing-library/custom-renders']; + const customQueriesSetting = + context.settings['testing-library/custom-queries']; + + /** + * Small method to extract common checks to determine whether a node is + * related to Testing Library or not. + * + * To determine whether a node is a valid Testing Library util, there are + * two conditions to match: + * - it's named in a particular way (decided by given callback) + * - it's imported from valid Testing Library module (depends on aggressive + * reporting) + */ + function isTestingLibraryUtil( + node: TSESTree.Identifier, + isUtilCallback: ( + identifierNodeName: string, + originalNodeName?: string + ) => boolean + ): boolean { + if (!node) { + return false; + } + + const referenceNode = getReferenceNode(node); + const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); + + if (!referenceNodeIdentifier) { + return false; + } + + const importedUtilSpecifier = getImportedUtilSpecifier( + referenceNodeIdentifier + ); + + const originalNodeName = + isImportSpecifier(importedUtilSpecifier) && + importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name + ? importedUtilSpecifier.imported.name + : undefined; + + if (!isUtilCallback(node.name, originalNodeName)) { + return false; + } + + if (isAggressiveModuleReportingEnabled()) { + return true; + } + + return isNodeComingFromTestingLibrary(referenceNodeIdentifier); + } + + /** + * Determines whether aggressive module reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * module is not set, so we need to assume everything matching Testing + * Library utils is related to Testing Library no matter from where module + * they are coming from. Otherwise, this aggressive reporting mechanism is + * opted-out in favour to report only those utils coming from Testing + * Library package or custom module set up on settings. + */ + const isAggressiveModuleReportingEnabled = () => !customModuleSetting; + + /** + * Determines whether aggressive render reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * renders are not set, so we need to assume every method containing + * "render" is a valid Testing Library `render`. Otherwise, this aggressive + * reporting mechanism is opted-out in favour to report only `render` or + * names set up on custom renders setting. + */ + const isAggressiveRenderReportingEnabled = (): boolean => { + const isSwitchedOff = customRendersSetting === SETTING_OPTION_OFF; + const hasCustomOptions = + Array.isArray(customRendersSetting) && customRendersSetting.length > 0; + + return !isSwitchedOff && !hasCustomOptions; + }; + + /** + * Determines whether Aggressive Reporting for queries is enabled or not. + * + * This Aggressive Reporting mechanism is considered as enabled when custom-queries setting is not set, + * so the plugin needs to report both built-in and custom queries. + * Otherwise, this Aggressive Reporting mechanism is opted-out in favour of reporting only built-in queries + those + * indicated in custom-queries setting. + */ + const isAggressiveQueryReportingEnabled = (): boolean => { + const isSwitchedOff = customQueriesSetting === SETTING_OPTION_OFF; + const hasCustomOptions = + Array.isArray(customQueriesSetting) && customQueriesSetting.length > 0; + + return !isSwitchedOff && !hasCustomOptions; + }; + + const getCustomModule = (): string | undefined => { + if ( + !isAggressiveModuleReportingEnabled() && + customModuleSetting !== SETTING_OPTION_OFF + ) { + return customModuleSetting; + } + return undefined; + }; + + const getCustomRenders = (): string[] => { + if ( + !isAggressiveRenderReportingEnabled() && + customRendersSetting !== SETTING_OPTION_OFF + ) { + return customRendersSetting as string[]; + } + + return []; + }; + + const getCustomQueries = (): string[] => { + if ( + !isAggressiveQueryReportingEnabled() && + customQueriesSetting !== SETTING_OPTION_OFF + ) { + return customQueriesSetting as string[]; + } + + return []; + }; + + // Helpers for Testing Library detection. + const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => { + return importedTestingLibraryNode; + }; + + const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => { + return importedCustomModuleNode; + }; + + const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => { + return getImportModuleName(importedTestingLibraryNode); + }; + + const getCustomModuleImportName: GetCustomModuleImportNameFn = () => { + return getImportModuleName(importedCustomModuleNode); + }; + + /** + * Determines whether Testing Library utils are imported or not for + * current file being analyzed. + * + * By default, it is ALWAYS considered as imported. This is what we call + * "aggressive reporting" so we don't miss TL utils reexported from + * custom modules. + * + * However, there is a setting to customize the module where TL utils can + * be imported from: "testing-library/utils-module". If this setting is enabled, + * then this method will return `true` ONLY IF a testing-library package + * or custom module are imported. + */ + const isTestingLibraryImported: IsTestingLibraryImportedFn = ( + isStrict = false + ) => { + const isSomeModuleImported = + !!importedTestingLibraryNode || !!importedCustomModuleNode; + + return ( + (!isStrict && isAggressiveModuleReportingEnabled()) || + isSomeModuleImported + ); + }; + + /** + * Determines whether a given node is a reportable query, + * either a built-in or a custom one. + * + * Depending on Aggressive Query Reporting setting, custom queries will be + * reportable or not. + */ + const isQuery: IsQueryFn = (node) => { + const hasQueryPattern = /^(get|query|find)(All)?By.+$/.test(node.name); + if (!hasQueryPattern) { + return false; + } + + if (isAggressiveQueryReportingEnabled()) { + return true; + } + + const customQueries = getCustomQueries(); + const isBuiltInQuery = ALL_QUERIES_COMBINATIONS.includes(node.name); + const isReportableCustomQuery = customQueries.some((pattern) => + new RegExp(pattern).test(node.name) + ); + return isBuiltInQuery || isReportableCustomQuery; + }; + + /** + * Determines whether a given node is `get*` query variant or not. + */ + const isGetQueryVariant: IsGetQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('get'); + }; + + /** + * Determines whether a given node is `query*` query variant or not. + */ + const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('query'); + }; + + /** + * Determines whether a given node is `find*` query variant or not. + */ + const isFindQueryVariant: IsFindQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('find'); + }; + + /** + * Determines whether a given node is sync query or not. + */ + const isSyncQuery: IsSyncQueryFn = (node) => { + return isGetQueryVariant(node) || isQueryQueryVariant(node); + }; + + /** + * Determines whether a given node is async query or not. + */ + const isAsyncQuery: IsAsyncQueryFn = (node) => { + return isFindQueryVariant(node); + }; + + const isCustomQuery: IsCustomQueryFn = (node) => { + return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name); + }; + + const isBuiltInQuery = (node: TSESTree.Identifier): boolean => { + return isQuery(node) && ALL_QUERIES_COMBINATIONS.includes(node.name); + }; + + /** + * Determines whether a given node is a valid async util or not. + * + * A node will be interpreted as a valid async util based on two conditions: + * the name matches with some Testing Library async util, and the node is + * coming from Testing Library module. + * + * The latter depends on Aggressive module reporting: + * if enabled, then it doesn't matter from where the given node was imported + * from as it will be considered part of Testing Library. + * Otherwise, it means `custom-module` has been set up, so only those nodes + * coming from Testing Library will be considered as valid. + */ + const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return ( + (validNames as string[]).includes(identifierNodeName) || + (!!originalNodeName && + (validNames as string[]).includes(originalNodeName)) + ); + } + ); + }; + + /** + * Determines whether a given node is fireEvent util itself or not. + * + * Not to be confused with {@link isFireEventMethod} + */ + const isFireEventUtil = (node: TSESTree.Identifier): boolean => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName].includes('fireEvent'); + } + ); + }; + + /** + * Determines whether a given node is userEvent util itself or not. + * + * Not to be confused with {@link isUserEventMethod} + */ + const isUserEventUtil = (node: TSESTree.Identifier): boolean => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } + + if (!userEventName) { + return false; + } + + return node.name === userEventName; + }; + + /** + * Determines whether a given node is fireEvent method or not + */ + const isFireEventMethod: IsFireEventMethodFn = (node) => { + const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + let fireEventUtilName: string | undefined; + + if (fireEventUtil) { + fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) + ? fireEventUtil.name + : fireEventUtil.local.name; + } else if (isAggressiveModuleReportingEnabled()) { + fireEventUtilName = FIRE_EVENT_NAME; + } + + if (!fireEventUtilName) { + return false; + } + + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && 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; + }; + + const isUserEventMethod: IsUserEventMethodFn = (node) => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } + + if (!userEventName) { + return false; + } + + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && isMemberExpression(node.parent) ? node.parent : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not userEvent object itself + if ( + [userEventName, USER_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check userEvent.click() usage + return ( + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventName + ); + }; + + /** + * Determines whether a given node is a valid render util or not. + * + * A node will be interpreted as a valid render based on two conditions: + * the name matches with a valid "render" option, and the node is coming + * from Testing Library module. This depends on: + * + * - Aggressive render reporting: if enabled, then every node name + * containing "render" will be assumed as Testing Library render util. + * Otherwise, it means `custom-modules` has been set up, so only those nodes + * named as "render" or some of the `custom-modules` options will be + * considered as Testing Library render util. + * - Aggressive module reporting: if enabled, then it doesn't matter from + * where the given node was imported from as it will be considered part of + * Testing Library. Otherwise, it means `custom-module` has been set up, so + * only those nodes coming from Testing Library will be considered as valid. + */ + const isRenderUtil: IsRenderUtilFn = (node) => + isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => { + if (isAggressiveRenderReportingEnabled()) { + return identifierNodeName.toLowerCase().includes(RENDER_NAME); + } + + return [RENDER_NAME, ...getCustomRenders()].some( + (validRenderName) => + validRenderName === identifierNodeName || + (Boolean(originalNodeName) && validRenderName === originalNodeName) + ); + }); + + const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { + if (!node.init) { + return false; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); + + if (!initIdentifierNode) { + return false; + } + + return isRenderUtil(initIdentifierNode); + }; + + const isDebugUtil: IsDebugUtilFn = (identifierNode) => { + const isBuiltInConsole = + isMemberExpression(identifierNode.parent) && + ASTUtils.isIdentifier(identifierNode.parent.object) && + identifierNode.parent.object.name === 'console'; + + return ( + !isBuiltInConsole && + isTestingLibraryUtil( + identifierNode, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName] + .filter(Boolean) + .includes('debug'); + } + ) + ); + }; + + /** + * Determines whether a given MemberExpression node is a presence assert + * + * Presence asserts could have shape of: + * - expect(element).toBeInTheDocument() + * - expect(element).not.toBeNull() + */ + const isPresenceAssert: IsPresenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? ABSENCE_MATCHERS.includes(matcher) + : PRESENCE_MATCHERS.includes(matcher); + }; + + /** + * Determines whether a given MemberExpression node is an absence assert + * + * Absence asserts could have shape of: + * - expect(element).toBeNull() + * - expect(element).not.toBeInTheDocument() + */ + const isAbsenceAssert: IsAbsenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? PRESENCE_MATCHERS.includes(matcher) + : ABSENCE_MATCHERS.includes(matcher); + }; + + /** + * Gets a string and verifies if it was imported/required by Testing Library + * related module. + */ + const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( + specifierName + ) => { + const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); + + if (!node) { + return undefined; + } + + if (isImportDeclaration(node)) { + const namedExport = node.specifiers.find((n) => { + return ( + isImportSpecifier(n) && + [n.imported.name, n.local.name].includes(specifierName) + ); + }); + + // it is "import { foo [as alias] } from 'baz'"" + if (namedExport) { + return namedExport; + } + + // it could be "import * as rtl from 'baz'" + return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); + } else { + if (!ASTUtils.isVariableDeclarator(node.parent)) { + return undefined; + } + const requireNode = node.parent; + + if (ASTUtils.isIdentifier(requireNode.id)) { + // this is const rtl = require('foo') + return requireNode.id; + } + + // this should be const { something } = require('foo') + if (!isObjectPattern(requireNode.id)) { + return undefined; + } + + const property = requireNode.id.properties.find( + (n) => + isProperty(n) && + ASTUtils.isIdentifier(n.key) && + n.key.name === specifierName + ); + if (!property) { + return undefined; + } + return (property as TSESTree.Property).key as TSESTree.Identifier; + } + }; + + const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { + if (!importedUserEventLibraryNode) { + return null; + } + + if (isImportDeclaration(importedUserEventLibraryNode)) { + const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( + (specifier) => isImportDefaultSpecifier(specifier) + ); + + if (userEventIdentifier) { + return userEventIdentifier.local; + } + } else { + if (!ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent)) { + return null; + } + + const requireNode = importedUserEventLibraryNode.parent; + if (!ASTUtils.isIdentifier(requireNode.id)) { + return null; + } + + return requireNode.id; + } + + return null; + }; + + const getImportedUtilSpecifier = ( + node: TSESTree.MemberExpression | TSESTree.Identifier + ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; + + if (!identifierName) { + return undefined; + } + + return findImportedUtilSpecifier(identifierName); + }; + + /** + * Determines if file inspected meets all conditions to be reported by rules or not. + */ + const canReportErrors: CanReportErrorsFn = () => { + return skipRuleReportingCheck || isTestingLibraryImported(); + }; + + /** + * Determines whether a node is imported from a valid Testing Library module + * + * This method will try to find any import matching the given node name, + * and also make sure the name is a valid match in case it's been renamed. + */ + const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( + node + ) => { + const importNode = getImportedUtilSpecifier(node); + + if (!importNode) { + return false; + } + + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; + + if (!identifierName) { + return false; + } + + return hasImportMatch(importNode, identifierName); + }; + + const helpers: DetectionHelpers = { + getTestingLibraryImportNode, + getCustomModuleImportNode, + getTestingLibraryImportName, + getCustomModuleImportName, + isTestingLibraryImported, + isGetQueryVariant, + isQueryQueryVariant, + isFindQueryVariant, + isSyncQuery, + isAsyncQuery, + isQuery, + isCustomQuery, + isBuiltInQuery, + isAsyncUtil, + isFireEventUtil, + isUserEventUtil, + isFireEventMethod, + isUserEventMethod, + isRenderUtil, + isRenderVariableDeclarator, + isDebugUtil, + isPresenceAssert, + isAbsenceAssert, + canReportErrors, + findImportedUtilSpecifier, + isNodeComingFromTestingLibrary, + }; + + // Instructions for Testing Library detection. + const detectionInstructions: TSESLint.RuleListener = { + /** + * This ImportDeclaration rule listener will check if Testing Library related + * modules are imported. Since imports happen first thing in a file, it's + * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule` + * since they will have corresponding value already updated when reporting other + * parts of the file. + */ + ImportDeclaration(node: TSESTree.ImportDeclaration) { + // check only if testing library import not found yet so we avoid + // to override importedTestingLibraryNode after it's found + if ( + !importedTestingLibraryNode && + /testing-library/g.test(node.source.value as string) + ) { + importedTestingLibraryNode = node; + } + + // check only if custom module import not found yet so we avoid + // to override importedCustomModuleNode after it's found + const customModule = getCustomModule(); + if ( + customModule && + !importedCustomModuleNode && + String(node.source.value).endsWith(customModule) + ) { + importedCustomModuleNode = node; + } + + // check only if user-event import not found yet so we avoid + // to override importedUserEventLibraryNode after it's found + if ( + !importedUserEventLibraryNode && + String(node.source.value) === USER_EVENT_PACKAGE + ) { + importedUserEventLibraryNode = node; + } + }, + + // Check if Testing Library related modules are loaded with required. + [`CallExpression > Identifier[name="require"]`](node: TSESTree.Identifier) { + const callExpression = node.parent as TSESTree.CallExpression; + const { arguments: args } = callExpression; + + if ( + !importedTestingLibraryNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + /testing-library/g.test(arg.value) + ) + ) { + importedTestingLibraryNode = callExpression; + } + + const customModule = getCustomModule(); + if ( + !importedCustomModuleNode && + args.some( + (arg) => + customModule && + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value.endsWith(customModule) + ) + ) { + importedCustomModuleNode = callExpression; + } + + if ( + !importedCustomModuleNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value === USER_EVENT_PACKAGE + ) + ) { + importedUserEventLibraryNode = callExpression; + } + }, + }; + + // update given rule to inject Testing Library detection + const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); + const enhancedRuleInstructions: TSESLint.RuleListener = {}; + + const allKeys = new Set( + Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)) + ); + + // Iterate over ALL instructions keys so we can override original rule instructions + // to prevent their execution if conditions to report errors are not met. + allKeys.forEach((instruction) => { + enhancedRuleInstructions[instruction] = (node) => { + if (instruction in detectionInstructions) { + detectionInstructions[instruction]?.(node); + } + + if (canReportErrors() && ruleInstructions[instruction]) { + return ruleInstructions[instruction]?.(node); + } + }; + }); + + return enhancedRuleInstructions; +}; diff --git a/lib/create-testing-library-rule.ts b/lib/create-testing-library-rule/index.ts similarity index 58% rename from lib/create-testing-library-rule.ts rename to lib/create-testing-library-rule/index.ts index 96f47d66..88b2654c 100644 --- a/lib/create-testing-library-rule.ts +++ b/lib/create-testing-library-rule/index.ts @@ -1,6 +1,9 @@ import { ESLintUtils, TSESLint } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from './utils'; + +import { getDocsUrl } from '../utils'; + import { + DetectionOptions, detectTestingLibraryUtils, EnhancedRuleCreate, } from './detect-testing-library-utils'; @@ -11,24 +14,25 @@ type CreateRuleMeta = { docs: CreateRuleMetaDocs; } & Omit, 'docs'>; -export function createTestingLibraryRule< +export const createTestingLibraryRule = < TOptions extends readonly unknown[], TMessageIds extends string, TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener ->( - config: Readonly<{ - name: string; - meta: CreateRuleMeta; - defaultOptions: Readonly; - create: EnhancedRuleCreate; - }> -): TSESLint.RuleModule { - const { create, ...remainingConfig } = config; - - return ESLintUtils.RuleCreator(getDocsUrl)({ +>({ + create, + detectionOptions = {}, + ...remainingConfig +}: Readonly<{ + name: string; + meta: CreateRuleMeta; + defaultOptions: Readonly; + detectionOptions?: Partial; + create: EnhancedRuleCreate; +}>): TSESLint.RuleModule => + ESLintUtils.RuleCreator(getDocsUrl)({ ...remainingConfig, create: detectTestingLibraryUtils( - create + create, + detectionOptions ), }); -} diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts deleted file mode 100644 index 0c81fa6e..00000000 --- a/lib/detect-testing-library-utils.ts +++ /dev/null @@ -1,913 +0,0 @@ -import { - ASTUtils, - TSESLint, - TSESTree, -} from '@typescript-eslint/experimental-utils'; -import { - getAssertNodeInfo, - getDeepestIdentifierNode, - getImportModuleName, - getPropertyIdentifierNode, - getReferenceNode, - hasImportMatch, - ImportModuleNode, - isImportDeclaration, - isImportDefaultSpecifier, - isImportNamespaceSpecifier, - isImportSpecifier, - isLiteral, - isMemberExpression, - isObjectPattern, - isProperty, -} from './node-utils'; -import { - ABSENCE_MATCHERS, - ALL_QUERIES_COMBINATIONS, - ASYNC_UTILS, - PRESENCE_MATCHERS, -} from './utils'; - -const SETTING_OPTION_OFF = 'off' as const; - -export type TestingLibrarySettings = { - 'testing-library/utils-module'?: string | typeof SETTING_OPTION_OFF; - 'testing-library/custom-renders'?: string[] | typeof SETTING_OPTION_OFF; - 'testing-library/custom-queries'?: string[] | typeof SETTING_OPTION_OFF; -}; - -export type TestingLibraryContext< - TOptions extends readonly unknown[], - TMessageIds extends string -> = Readonly< - TSESLint.RuleContext & { - settings: TestingLibrarySettings; - } ->; - -export type EnhancedRuleCreate< - TOptions extends readonly unknown[], - TMessageIds extends string, - TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener -> = ( - context: TestingLibraryContext, - optionsWithDefault: Readonly, - detectionHelpers: Readonly -) => TRuleListener; - -// Helpers methods -type GetTestingLibraryImportNodeFn = () => ImportModuleNode | null; -type GetCustomModuleImportNodeFn = () => ImportModuleNode | null; -type GetTestingLibraryImportNameFn = () => string | undefined; -type GetCustomModuleImportNameFn = () => string | undefined; -type IsTestingLibraryImportedFn = (isStrict?: boolean) => boolean; -type IsGetQueryVariantFn = (node: TSESTree.Identifier) => boolean; -type IsQueryQueryVariantFn = (node: TSESTree.Identifier) => boolean; -type IsFindQueryVariantFn = (node: TSESTree.Identifier) => boolean; -type IsSyncQueryFn = (node: TSESTree.Identifier) => boolean; -type IsAsyncQueryFn = (node: TSESTree.Identifier) => boolean; -type IsQueryFn = (node: TSESTree.Identifier) => boolean; -type IsCustomQueryFn = (node: TSESTree.Identifier) => boolean; -type IsBuiltInQueryFn = (node: TSESTree.Identifier) => boolean; -type IsAsyncUtilFn = ( - node: TSESTree.Identifier, - validNames?: readonly typeof ASYNC_UTILS[number][] -) => boolean; -type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; -type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; -type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; -type IsRenderVariableDeclaratorFn = ( - node: TSESTree.VariableDeclarator -) => boolean; -type IsDebugUtilFn = (identifierNode: TSESTree.Identifier) => boolean; -type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean; -type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean; -type CanReportErrorsFn = () => boolean; -type FindImportedUtilSpecifierFn = ( - specifierName: string -) => TSESTree.ImportClause | TSESTree.Identifier | undefined; -type IsNodeComingFromTestingLibraryFn = ( - node: TSESTree.MemberExpression | TSESTree.Identifier -) => boolean; - -export interface DetectionHelpers { - getTestingLibraryImportNode: GetTestingLibraryImportNodeFn; - getCustomModuleImportNode: GetCustomModuleImportNodeFn; - getTestingLibraryImportName: GetTestingLibraryImportNameFn; - getCustomModuleImportName: GetCustomModuleImportNameFn; - isTestingLibraryImported: IsTestingLibraryImportedFn; - isGetQueryVariant: IsGetQueryVariantFn; - isQueryQueryVariant: IsQueryQueryVariantFn; - isFindQueryVariant: IsFindQueryVariantFn; - isSyncQuery: IsSyncQueryFn; - isAsyncQuery: IsAsyncQueryFn; - isQuery: IsQueryFn; - isCustomQuery: IsCustomQueryFn; - isBuiltInQuery: IsBuiltInQueryFn; - isAsyncUtil: IsAsyncUtilFn; - isFireEventUtil: (node: TSESTree.Identifier) => boolean; - isUserEventUtil: (node: TSESTree.Identifier) => boolean; - isFireEventMethod: IsFireEventMethodFn; - isUserEventMethod: IsUserEventMethodFn; - isRenderUtil: IsRenderUtilFn; - isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; - isDebugUtil: IsDebugUtilFn; - isPresenceAssert: IsPresenceAssertFn; - isAbsenceAssert: IsAbsenceAssertFn; - canReportErrors: CanReportErrorsFn; - findImportedUtilSpecifier: FindImportedUtilSpecifierFn; - isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; -} - -const USER_EVENT_PACKAGE = '@testing-library/user-event'; -const FIRE_EVENT_NAME = 'fireEvent'; -const USER_EVENT_NAME = 'userEvent'; -const RENDER_NAME = 'render'; - -/** - * Enhances a given rule `create` with helpers to detect Testing Library utils. - */ -export function detectTestingLibraryUtils< - TOptions extends readonly unknown[], - TMessageIds extends string, - TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener ->(ruleCreate: EnhancedRuleCreate) { - return ( - context: TestingLibraryContext, - optionsWithDefault: Readonly - ): TSESLint.RuleListener => { - let importedTestingLibraryNode: ImportModuleNode | null = null; - let importedCustomModuleNode: ImportModuleNode | null = null; - let importedUserEventLibraryNode: ImportModuleNode | null = null; - - // Init options based on shared ESLint settings - const customModuleSetting = - context.settings['testing-library/utils-module']; - const customRendersSetting = - context.settings['testing-library/custom-renders']; - const customQueriesSetting = - context.settings['testing-library/custom-queries']; - - /** - * Small method to extract common checks to determine whether a node is - * related to Testing Library or not. - * - * To determine whether a node is a valid Testing Library util, there are - * two conditions to match: - * - it's named in a particular way (decided by given callback) - * - it's imported from valid Testing Library module (depends on aggressive - * reporting) - */ - function isTestingLibraryUtil( - node: TSESTree.Identifier, - isUtilCallback: ( - identifierNodeName: string, - originalNodeName?: string - ) => boolean - ): boolean { - if (!node) { - return false; - } - - const referenceNode = getReferenceNode(node); - const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); - - if (!referenceNodeIdentifier) { - return false; - } - - const importedUtilSpecifier = getImportedUtilSpecifier( - referenceNodeIdentifier - ); - - const originalNodeName = - isImportSpecifier(importedUtilSpecifier) && - importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name - ? importedUtilSpecifier.imported.name - : undefined; - - if (!isUtilCallback(node.name, originalNodeName)) { - return false; - } - - if (isAggressiveModuleReportingEnabled()) { - return true; - } - - return isNodeComingFromTestingLibrary(referenceNodeIdentifier); - } - - /** - * Determines whether aggressive module reporting is enabled or not. - * - * This aggressive reporting mechanism is considered as enabled when custom - * module is not set, so we need to assume everything matching Testing - * Library utils is related to Testing Library no matter from where module - * they are coming from. Otherwise, this aggressive reporting mechanism is - * opted-out in favour to report only those utils coming from Testing - * Library package or custom module set up on settings. - */ - const isAggressiveModuleReportingEnabled = () => !customModuleSetting; - - /** - * Determines whether aggressive render reporting is enabled or not. - * - * This aggressive reporting mechanism is considered as enabled when custom - * renders are not set, so we need to assume every method containing - * "render" is a valid Testing Library `render`. Otherwise, this aggressive - * reporting mechanism is opted-out in favour to report only `render` or - * names set up on custom renders setting. - */ - const isAggressiveRenderReportingEnabled = (): boolean => { - const isSwitchedOff = customRendersSetting === SETTING_OPTION_OFF; - const hasCustomOptions = - Array.isArray(customRendersSetting) && customRendersSetting.length > 0; - - return !isSwitchedOff && !hasCustomOptions; - }; - - /** - * Determines whether Aggressive Reporting for queries is enabled or not. - * - * This Aggressive Reporting mechanism is considered as enabled when custom-queries setting is not set, - * so the plugin needs to report both built-in and custom queries. - * Otherwise, this Aggressive Reporting mechanism is opted-out in favour of reporting only built-in queries + those - * indicated in custom-queries setting. - */ - const isAggressiveQueryReportingEnabled = (): boolean => { - const isSwitchedOff = customQueriesSetting === SETTING_OPTION_OFF; - const hasCustomOptions = - Array.isArray(customQueriesSetting) && customQueriesSetting.length > 0; - - return !isSwitchedOff && !hasCustomOptions; - }; - - const getCustomModule = (): string | undefined => { - if ( - !isAggressiveModuleReportingEnabled() && - customModuleSetting !== SETTING_OPTION_OFF - ) { - return customModuleSetting; - } - return undefined; - }; - - const getCustomRenders = (): string[] => { - if ( - !isAggressiveRenderReportingEnabled() && - customRendersSetting !== SETTING_OPTION_OFF - ) { - return customRendersSetting as string[]; - } - - return []; - }; - - const getCustomQueries = (): string[] => { - if ( - !isAggressiveQueryReportingEnabled() && - customQueriesSetting !== SETTING_OPTION_OFF - ) { - return customQueriesSetting as string[]; - } - - return []; - }; - - // Helpers for Testing Library detection. - const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => { - return importedTestingLibraryNode; - }; - - const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => { - return importedCustomModuleNode; - }; - - const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => { - return getImportModuleName(importedTestingLibraryNode); - }; - - const getCustomModuleImportName: GetCustomModuleImportNameFn = () => { - return getImportModuleName(importedCustomModuleNode); - }; - - /** - * Determines whether Testing Library utils are imported or not for - * current file being analyzed. - * - * By default, it is ALWAYS considered as imported. This is what we call - * "aggressive reporting" so we don't miss TL utils reexported from - * custom modules. - * - * However, there is a setting to customize the module where TL utils can - * be imported from: "testing-library/utils-module". If this setting is enabled, - * then this method will return `true` ONLY IF a testing-library package - * or custom module are imported. - */ - const isTestingLibraryImported: IsTestingLibraryImportedFn = ( - isStrict = false - ) => { - const isSomeModuleImported = - !!importedTestingLibraryNode || !!importedCustomModuleNode; - - return ( - (!isStrict && isAggressiveModuleReportingEnabled()) || - isSomeModuleImported - ); - }; - - /** - * Determines whether a given node is a reportable query, - * either a built-in or a custom one. - * - * Depending on Aggressive Query Reporting setting, custom queries will be - * reportable or not. - */ - const isQuery: IsQueryFn = (node) => { - const hasQueryPattern = /^(get|query|find)(All)?By.+$/.test(node.name); - if (!hasQueryPattern) { - return false; - } - - if (isAggressiveQueryReportingEnabled()) { - return true; - } - - const customQueries = getCustomQueries(); - const isBuiltInQuery = ALL_QUERIES_COMBINATIONS.includes(node.name); - const isReportableCustomQuery = customQueries.some((pattern) => - new RegExp(pattern).test(node.name) - ); - return isBuiltInQuery || isReportableCustomQuery; - }; - - /** - * Determines whether a given node is `get*` query variant or not. - */ - const isGetQueryVariant: IsGetQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('get'); - }; - - /** - * Determines whether a given node is `query*` query variant or not. - */ - const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('query'); - }; - - /** - * Determines whether a given node is `find*` query variant or not. - */ - const isFindQueryVariant: IsFindQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('find'); - }; - - /** - * Determines whether a given node is sync query or not. - */ - const isSyncQuery: IsSyncQueryFn = (node) => { - return isGetQueryVariant(node) || isQueryQueryVariant(node); - }; - - /** - * Determines whether a given node is async query or not. - */ - const isAsyncQuery: IsAsyncQueryFn = (node) => { - return isFindQueryVariant(node); - }; - - const isCustomQuery: IsCustomQueryFn = (node) => { - return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name); - }; - - const isBuiltInQuery = (node: TSESTree.Identifier): boolean => { - return isQuery(node) && ALL_QUERIES_COMBINATIONS.includes(node.name); - }; - - /** - * Determines whether a given node is a valid async util or not. - * - * A node will be interpreted as a valid async util based on two conditions: - * the name matches with some Testing Library async util, and the node is - * coming from Testing Library module. - * - * The latter depends on Aggressive module reporting: - * if enabled, then it doesn't matter from where the given node was imported - * from as it will be considered part of Testing Library. - * Otherwise, it means `custom-module` has been set up, so only those nodes - * coming from Testing Library will be considered as valid. - */ - const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { - return isTestingLibraryUtil( - node, - (identifierNodeName, originalNodeName) => { - return ( - (validNames as string[]).includes(identifierNodeName) || - (!!originalNodeName && - (validNames as string[]).includes(originalNodeName)) - ); - } - ); - }; - - /** - * Determines whether a given node is fireEvent util itself or not. - * - * Not to be confused with {@link isFireEventMethod} - */ - const isFireEventUtil = (node: TSESTree.Identifier): boolean => { - return isTestingLibraryUtil( - node, - (identifierNodeName, originalNodeName) => { - return [identifierNodeName, originalNodeName].includes('fireEvent'); - } - ); - }; - - /** - * Determines whether a given node is userEvent util itself or not. - * - * Not to be confused with {@link isUserEventMethod} - */ - const isUserEventUtil = (node: TSESTree.Identifier): boolean => { - const userEvent = findImportedUserEventSpecifier(); - let userEventName: string | undefined; - - if (userEvent) { - userEventName = userEvent.name; - } else if (isAggressiveModuleReportingEnabled()) { - userEventName = USER_EVENT_NAME; - } - - if (!userEventName) { - return false; - } - - return node.name === userEventName; - }; - - /** - * Determines whether a given node is fireEvent method or not - */ - const isFireEventMethod: IsFireEventMethodFn = (node) => { - const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); - let fireEventUtilName: string | undefined; - - if (fireEventUtil) { - fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) - ? fireEventUtil.name - : fireEventUtil.local.name; - } else if (isAggressiveModuleReportingEnabled()) { - fireEventUtilName = FIRE_EVENT_NAME; - } - - if (!fireEventUtilName) { - return false; - } - - const parentMemberExpression: TSESTree.MemberExpression | undefined = - node.parent && 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; - }; - - const isUserEventMethod: IsUserEventMethodFn = (node) => { - const userEvent = findImportedUserEventSpecifier(); - let userEventName: string | undefined; - - if (userEvent) { - userEventName = userEvent.name; - } else if (isAggressiveModuleReportingEnabled()) { - userEventName = USER_EVENT_NAME; - } - - if (!userEventName) { - return false; - } - - const parentMemberExpression: TSESTree.MemberExpression | undefined = - node.parent && isMemberExpression(node.parent) - ? node.parent - : undefined; - - if (!parentMemberExpression) { - return false; - } - - // make sure that given node it's not userEvent object itself - if ( - [userEventName, USER_EVENT_NAME].includes(node.name) || - (ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === node.name) - ) { - return false; - } - - // check userEvent.click() usage - return ( - ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === userEventName - ); - }; - - /** - * Determines whether a given node is a valid render util or not. - * - * A node will be interpreted as a valid render based on two conditions: - * the name matches with a valid "render" option, and the node is coming - * from Testing Library module. This depends on: - * - * - Aggressive render reporting: if enabled, then every node name - * containing "render" will be assumed as Testing Library render util. - * Otherwise, it means `custom-modules` has been set up, so only those nodes - * named as "render" or some of the `custom-modules` options will be - * considered as Testing Library render util. - * - Aggressive module reporting: if enabled, then it doesn't matter from - * where the given node was imported from as it will be considered part of - * Testing Library. Otherwise, it means `custom-module` has been set up, so - * only those nodes coming from Testing Library will be considered as valid. - */ - const isRenderUtil: IsRenderUtilFn = (node) => - isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => { - if (isAggressiveRenderReportingEnabled()) { - return identifierNodeName.toLowerCase().includes(RENDER_NAME); - } - - return [RENDER_NAME, ...getCustomRenders()].some( - (validRenderName) => - validRenderName === identifierNodeName || - (Boolean(originalNodeName) && validRenderName === originalNodeName) - ); - }); - - const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { - if (!node.init) { - return false; - } - const initIdentifierNode = getDeepestIdentifierNode(node.init); - - if (!initIdentifierNode) { - return false; - } - - return isRenderUtil(initIdentifierNode); - }; - - const isDebugUtil: IsDebugUtilFn = (identifierNode) => { - const isBuiltInConsole = - isMemberExpression(identifierNode.parent) && - ASTUtils.isIdentifier(identifierNode.parent.object) && - identifierNode.parent.object.name === 'console'; - - return ( - !isBuiltInConsole && - isTestingLibraryUtil( - identifierNode, - (identifierNodeName, originalNodeName) => { - return [identifierNodeName, originalNodeName] - .filter(Boolean) - .includes('debug'); - } - ) - ); - }; - - /** - * Determines whether a given MemberExpression node is a presence assert - * - * Presence asserts could have shape of: - * - expect(element).toBeInTheDocument() - * - expect(element).not.toBeNull() - */ - const isPresenceAssert: IsPresenceAssertFn = (node) => { - const { matcher, isNegated } = getAssertNodeInfo(node); - - if (!matcher) { - return false; - } - - return isNegated - ? ABSENCE_MATCHERS.includes(matcher) - : PRESENCE_MATCHERS.includes(matcher); - }; - - /** - * Determines whether a given MemberExpression node is an absence assert - * - * Absence asserts could have shape of: - * - expect(element).toBeNull() - * - expect(element).not.toBeInTheDocument() - */ - const isAbsenceAssert: IsAbsenceAssertFn = (node) => { - const { matcher, isNegated } = getAssertNodeInfo(node); - - if (!matcher) { - return false; - } - - return isNegated - ? PRESENCE_MATCHERS.includes(matcher) - : ABSENCE_MATCHERS.includes(matcher); - }; - - /** - * Gets a string and verifies if it was imported/required by Testing Library - * related module. - */ - const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( - specifierName - ) => { - const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); - - if (!node) { - return undefined; - } - - if (isImportDeclaration(node)) { - const namedExport = node.specifiers.find((n) => { - return ( - isImportSpecifier(n) && - [n.imported.name, n.local.name].includes(specifierName) - ); - }); - - // it is "import { foo [as alias] } from 'baz'"" - if (namedExport) { - return namedExport; - } - - // it could be "import * as rtl from 'baz'" - return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); - } else { - if (!ASTUtils.isVariableDeclarator(node.parent)) { - return undefined; - } - const requireNode = node.parent; - - if (ASTUtils.isIdentifier(requireNode.id)) { - // this is const rtl = require('foo') - return requireNode.id; - } - - // this should be const { something } = require('foo') - if (!isObjectPattern(requireNode.id)) { - return undefined; - } - - const property = requireNode.id.properties.find( - (n) => - isProperty(n) && - ASTUtils.isIdentifier(n.key) && - n.key.name === specifierName - ); - if (!property) { - return undefined; - } - return (property as TSESTree.Property).key as TSESTree.Identifier; - } - }; - - const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { - if (!importedUserEventLibraryNode) { - return null; - } - - if (isImportDeclaration(importedUserEventLibraryNode)) { - const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( - (specifier) => isImportDefaultSpecifier(specifier) - ); - - if (userEventIdentifier) { - return userEventIdentifier.local; - } - } else { - if ( - !ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent) - ) { - return null; - } - - const requireNode = importedUserEventLibraryNode.parent; - if (!ASTUtils.isIdentifier(requireNode.id)) { - return null; - } - - return requireNode.id; - } - - return null; - }; - - const getImportedUtilSpecifier = ( - node: TSESTree.MemberExpression | TSESTree.Identifier - ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { - const identifierName: string | undefined = getPropertyIdentifierNode(node) - ?.name; - - if (!identifierName) { - return undefined; - } - - return findImportedUtilSpecifier(identifierName); - }; - - /** - * Determines if file inspected meets all conditions to be reported by rules or not. - */ - const canReportErrors: CanReportErrorsFn = () => { - return isTestingLibraryImported(); - }; - - /** - * Determines whether a node is imported from a valid Testing Library module - * - * This method will try to find any import matching the given node name, - * and also make sure the name is a valid match in case it's been renamed. - */ - const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( - node - ) => { - const importNode = getImportedUtilSpecifier(node); - - if (!importNode) { - return false; - } - - const identifierName: string | undefined = getPropertyIdentifierNode(node) - ?.name; - - if (!identifierName) { - return false; - } - - return hasImportMatch(importNode, identifierName); - }; - - const helpers: DetectionHelpers = { - getTestingLibraryImportNode, - getCustomModuleImportNode, - getTestingLibraryImportName, - getCustomModuleImportName, - isTestingLibraryImported, - isGetQueryVariant, - isQueryQueryVariant, - isFindQueryVariant, - isSyncQuery, - isAsyncQuery, - isQuery, - isCustomQuery, - isBuiltInQuery, - isAsyncUtil, - isFireEventUtil, - isUserEventUtil, - isFireEventMethod, - isUserEventMethod, - isRenderUtil, - isRenderVariableDeclarator, - isDebugUtil, - isPresenceAssert, - isAbsenceAssert, - canReportErrors, - findImportedUtilSpecifier, - isNodeComingFromTestingLibrary, - }; - - // Instructions for Testing Library detection. - const detectionInstructions: TSESLint.RuleListener = { - /** - * This ImportDeclaration rule listener will check if Testing Library related - * modules are imported. Since imports happen first thing in a file, it's - * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule` - * since they will have corresponding value already updated when reporting other - * parts of the file. - */ - ImportDeclaration(node: TSESTree.ImportDeclaration) { - // check only if testing library import not found yet so we avoid - // to override importedTestingLibraryNode after it's found - if ( - !importedTestingLibraryNode && - /testing-library/g.test(node.source.value as string) - ) { - importedTestingLibraryNode = node; - } - - // check only if custom module import not found yet so we avoid - // to override importedCustomModuleNode after it's found - const customModule = getCustomModule(); - if ( - customModule && - !importedCustomModuleNode && - String(node.source.value).endsWith(customModule) - ) { - importedCustomModuleNode = node; - } - - // check only if user-event import not found yet so we avoid - // to override importedUserEventLibraryNode after it's found - if ( - !importedUserEventLibraryNode && - String(node.source.value) === USER_EVENT_PACKAGE - ) { - importedUserEventLibraryNode = node; - } - }, - - // Check if Testing Library related modules are loaded with required. - [`CallExpression > Identifier[name="require"]`]( - node: TSESTree.Identifier - ) { - const callExpression = node.parent as TSESTree.CallExpression; - const { arguments: args } = callExpression; - - if ( - !importedTestingLibraryNode && - args.some( - (arg) => - isLiteral(arg) && - typeof arg.value === 'string' && - /testing-library/g.test(arg.value) - ) - ) { - importedTestingLibraryNode = callExpression; - } - - const customModule = getCustomModule(); - if ( - !importedCustomModuleNode && - args.some( - (arg) => - customModule && - isLiteral(arg) && - typeof arg.value === 'string' && - arg.value.endsWith(customModule) - ) - ) { - importedCustomModuleNode = callExpression; - } - - if ( - !importedCustomModuleNode && - args.some( - (arg) => - isLiteral(arg) && - typeof arg.value === 'string' && - arg.value === USER_EVENT_PACKAGE - ) - ) { - importedUserEventLibraryNode = callExpression; - } - }, - }; - - // update given rule to inject Testing Library detection - const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); - const enhancedRuleInstructions: TSESLint.RuleListener = {}; - - const allKeys = new Set( - Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)) - ); - - // Iterate over ALL instructions keys so we can override original rule instructions - // to prevent their execution if conditions to report errors are not met. - allKeys.forEach((instruction) => { - enhancedRuleInstructions[instruction] = (node) => { - if (instruction in detectionInstructions) { - detectionInstructions[instruction]?.(node); - } - - if (canReportErrors() && ruleInstructions[instruction]) { - return ruleInstructions[instruction]?.(node); - } - }; - }); - - return enhancedRuleInstructions; - }; -} From a5d84554d7bcfdc07a85df3d8e8bf3421d4a5ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Tue, 27 Apr 2021 14:18:07 +0200 Subject: [PATCH 2/4] refactor(consistent-data-testid): use createTestingLibraryRule --- lib/rules/consistent-data-testid.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/rules/consistent-data-testid.ts b/lib/rules/consistent-data-testid.ts index f31a7b53..bb1a83aa 100644 --- a/lib/rules/consistent-data-testid.ts +++ b/lib/rules/consistent-data-testid.ts @@ -1,5 +1,4 @@ -import { getDocsUrl } from '../utils'; -import { ESLintUtils } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; import { isJSXAttribute, isLiteral } from '../node-utils'; export const RULE_NAME = 'consistent-data-testid'; @@ -13,12 +12,7 @@ type Options = [ const FILENAME_PLACEHOLDER = '{fileName}'; -/** - * This rule is not created with `createTestingLibraryRule` since: - * - it doesn't need any detection helper - * - it doesn't apply to testing files but component files - */ -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', @@ -64,8 +58,11 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ testIdAttribute: 'data-testid', }, ], + detectionOptions: { + skipRuleReportingCheck: true, + }, - create(context, [options]) { + create: (context, [options]) => { const { getFilename } = context; const { testIdPattern, testIdAttribute: attr } = options; From 1a4f88befe220e777dc1963e3aab8164e63d6581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Tue, 27 Apr 2021 16:37:26 +0200 Subject: [PATCH 3/4] revert(createTestingLibraryRule): keep function style --- .../detect-testing-library-utils.ts | 1337 +++++++++-------- lib/create-testing-library-rule/index.ts | 7 +- 2 files changed, 678 insertions(+), 666 deletions(-) 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 d632e585..3d6662be 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -138,780 +138,791 @@ export type DetectionOptions = { /** * Enhances a given rule `create` with helpers to detect Testing Library utils. */ -export const detectTestingLibraryUtils = < +export function detectTestingLibraryUtils< TOptions extends readonly unknown[], TMessageIds extends string, TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener >( ruleCreate: EnhancedRuleCreate, { skipRuleReportingCheck = false }: Partial = {} -) => ( - context: TestingLibraryContext, - optionsWithDefault: Readonly -): TSESLint.RuleListener => { - let importedTestingLibraryNode: ImportModuleNode | null = null; - let importedCustomModuleNode: ImportModuleNode | null = null; - let importedUserEventLibraryNode: ImportModuleNode | null = null; - - // Init options based on shared ESLint settings - const customModuleSetting = context.settings['testing-library/utils-module']; - const customRendersSetting = - context.settings['testing-library/custom-renders']; - const customQueriesSetting = - context.settings['testing-library/custom-queries']; +) { + return ( + context: TestingLibraryContext, + optionsWithDefault: Readonly + ): TSESLint.RuleListener => { + let importedTestingLibraryNode: ImportModuleNode | null = null; + let importedCustomModuleNode: ImportModuleNode | null = null; + let importedUserEventLibraryNode: ImportModuleNode | null = null; + + // Init options based on shared ESLint settings + const customModuleSetting = + context.settings['testing-library/utils-module']; + const customRendersSetting = + context.settings['testing-library/custom-renders']; + const customQueriesSetting = + context.settings['testing-library/custom-queries']; - /** - * Small method to extract common checks to determine whether a node is - * related to Testing Library or not. - * - * To determine whether a node is a valid Testing Library util, there are - * two conditions to match: - * - it's named in a particular way (decided by given callback) - * - it's imported from valid Testing Library module (depends on aggressive - * reporting) - */ - function isTestingLibraryUtil( - node: TSESTree.Identifier, - isUtilCallback: ( - identifierNodeName: string, - originalNodeName?: string - ) => boolean - ): boolean { - if (!node) { - return false; - } + /** + * Small method to extract common checks to determine whether a node is + * related to Testing Library or not. + * + * To determine whether a node is a valid Testing Library util, there are + * two conditions to match: + * - it's named in a particular way (decided by given callback) + * - it's imported from valid Testing Library module (depends on aggressive + * reporting) + */ + function isTestingLibraryUtil( + node: TSESTree.Identifier, + isUtilCallback: ( + identifierNodeName: string, + originalNodeName?: string + ) => boolean + ): boolean { + if (!node) { + return false; + } - const referenceNode = getReferenceNode(node); - const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); + const referenceNode = getReferenceNode(node); + const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); - if (!referenceNodeIdentifier) { - return false; - } + if (!referenceNodeIdentifier) { + return false; + } - const importedUtilSpecifier = getImportedUtilSpecifier( - referenceNodeIdentifier - ); + const importedUtilSpecifier = getImportedUtilSpecifier( + referenceNodeIdentifier + ); - const originalNodeName = - isImportSpecifier(importedUtilSpecifier) && - importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name - ? importedUtilSpecifier.imported.name - : undefined; + const originalNodeName = + isImportSpecifier(importedUtilSpecifier) && + importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name + ? importedUtilSpecifier.imported.name + : undefined; - if (!isUtilCallback(node.name, originalNodeName)) { - return false; - } + if (!isUtilCallback(node.name, originalNodeName)) { + return false; + } + + if (isAggressiveModuleReportingEnabled()) { + return true; + } - if (isAggressiveModuleReportingEnabled()) { - return true; + return isNodeComingFromTestingLibrary(referenceNodeIdentifier); } - return isNodeComingFromTestingLibrary(referenceNodeIdentifier); - } + /** + * Determines whether aggressive module reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * module is not set, so we need to assume everything matching Testing + * Library utils is related to Testing Library no matter from where module + * they are coming from. Otherwise, this aggressive reporting mechanism is + * opted-out in favour to report only those utils coming from Testing + * Library package or custom module set up on settings. + */ + const isAggressiveModuleReportingEnabled = () => !customModuleSetting; - /** - * Determines whether aggressive module reporting is enabled or not. - * - * This aggressive reporting mechanism is considered as enabled when custom - * module is not set, so we need to assume everything matching Testing - * Library utils is related to Testing Library no matter from where module - * they are coming from. Otherwise, this aggressive reporting mechanism is - * opted-out in favour to report only those utils coming from Testing - * Library package or custom module set up on settings. - */ - const isAggressiveModuleReportingEnabled = () => !customModuleSetting; + /** + * Determines whether aggressive render reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * renders are not set, so we need to assume every method containing + * "render" is a valid Testing Library `render`. Otherwise, this aggressive + * reporting mechanism is opted-out in favour to report only `render` or + * names set up on custom renders setting. + */ + const isAggressiveRenderReportingEnabled = (): boolean => { + const isSwitchedOff = customRendersSetting === SETTING_OPTION_OFF; + const hasCustomOptions = + Array.isArray(customRendersSetting) && customRendersSetting.length > 0; - /** - * Determines whether aggressive render reporting is enabled or not. - * - * This aggressive reporting mechanism is considered as enabled when custom - * renders are not set, so we need to assume every method containing - * "render" is a valid Testing Library `render`. Otherwise, this aggressive - * reporting mechanism is opted-out in favour to report only `render` or - * names set up on custom renders setting. - */ - const isAggressiveRenderReportingEnabled = (): boolean => { - const isSwitchedOff = customRendersSetting === SETTING_OPTION_OFF; - const hasCustomOptions = - Array.isArray(customRendersSetting) && customRendersSetting.length > 0; + return !isSwitchedOff && !hasCustomOptions; + }; - return !isSwitchedOff && !hasCustomOptions; - }; + /** + * Determines whether Aggressive Reporting for queries is enabled or not. + * + * This Aggressive Reporting mechanism is considered as enabled when custom-queries setting is not set, + * so the plugin needs to report both built-in and custom queries. + * Otherwise, this Aggressive Reporting mechanism is opted-out in favour of reporting only built-in queries + those + * indicated in custom-queries setting. + */ + const isAggressiveQueryReportingEnabled = (): boolean => { + const isSwitchedOff = customQueriesSetting === SETTING_OPTION_OFF; + const hasCustomOptions = + Array.isArray(customQueriesSetting) && customQueriesSetting.length > 0; - /** - * Determines whether Aggressive Reporting for queries is enabled or not. - * - * This Aggressive Reporting mechanism is considered as enabled when custom-queries setting is not set, - * so the plugin needs to report both built-in and custom queries. - * Otherwise, this Aggressive Reporting mechanism is opted-out in favour of reporting only built-in queries + those - * indicated in custom-queries setting. - */ - const isAggressiveQueryReportingEnabled = (): boolean => { - const isSwitchedOff = customQueriesSetting === SETTING_OPTION_OFF; - const hasCustomOptions = - Array.isArray(customQueriesSetting) && customQueriesSetting.length > 0; + return !isSwitchedOff && !hasCustomOptions; + }; - return !isSwitchedOff && !hasCustomOptions; - }; + const getCustomModule = (): string | undefined => { + if ( + !isAggressiveModuleReportingEnabled() && + customModuleSetting !== SETTING_OPTION_OFF + ) { + return customModuleSetting; + } + return undefined; + }; - const getCustomModule = (): string | undefined => { - if ( - !isAggressiveModuleReportingEnabled() && - customModuleSetting !== SETTING_OPTION_OFF - ) { - return customModuleSetting; - } - return undefined; - }; + const getCustomRenders = (): string[] => { + if ( + !isAggressiveRenderReportingEnabled() && + customRendersSetting !== SETTING_OPTION_OFF + ) { + return customRendersSetting as string[]; + } - const getCustomRenders = (): string[] => { - if ( - !isAggressiveRenderReportingEnabled() && - customRendersSetting !== SETTING_OPTION_OFF - ) { - return customRendersSetting as string[]; - } + return []; + }; - return []; - }; + const getCustomQueries = (): string[] => { + if ( + !isAggressiveQueryReportingEnabled() && + customQueriesSetting !== SETTING_OPTION_OFF + ) { + return customQueriesSetting as string[]; + } - const getCustomQueries = (): string[] => { - if ( - !isAggressiveQueryReportingEnabled() && - customQueriesSetting !== SETTING_OPTION_OFF - ) { - return customQueriesSetting as string[]; - } + return []; + }; - return []; - }; + // Helpers for Testing Library detection. + const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => { + return importedTestingLibraryNode; + }; - // Helpers for Testing Library detection. - const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => { - return importedTestingLibraryNode; - }; + const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => { + return importedCustomModuleNode; + }; - const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => { - return importedCustomModuleNode; - }; + const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => { + return getImportModuleName(importedTestingLibraryNode); + }; - const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => { - return getImportModuleName(importedTestingLibraryNode); - }; + const getCustomModuleImportName: GetCustomModuleImportNameFn = () => { + return getImportModuleName(importedCustomModuleNode); + }; - const getCustomModuleImportName: GetCustomModuleImportNameFn = () => { - return getImportModuleName(importedCustomModuleNode); - }; + /** + * Determines whether Testing Library utils are imported or not for + * current file being analyzed. + * + * By default, it is ALWAYS considered as imported. This is what we call + * "aggressive reporting" so we don't miss TL utils reexported from + * custom modules. + * + * However, there is a setting to customize the module where TL utils can + * be imported from: "testing-library/utils-module". If this setting is enabled, + * then this method will return `true` ONLY IF a testing-library package + * or custom module are imported. + */ + const isTestingLibraryImported: IsTestingLibraryImportedFn = ( + isStrict = false + ) => { + const isSomeModuleImported = + !!importedTestingLibraryNode || !!importedCustomModuleNode; + + return ( + (!isStrict && isAggressiveModuleReportingEnabled()) || + isSomeModuleImported + ); + }; - /** - * Determines whether Testing Library utils are imported or not for - * current file being analyzed. - * - * By default, it is ALWAYS considered as imported. This is what we call - * "aggressive reporting" so we don't miss TL utils reexported from - * custom modules. - * - * However, there is a setting to customize the module where TL utils can - * be imported from: "testing-library/utils-module". If this setting is enabled, - * then this method will return `true` ONLY IF a testing-library package - * or custom module are imported. - */ - const isTestingLibraryImported: IsTestingLibraryImportedFn = ( - isStrict = false - ) => { - const isSomeModuleImported = - !!importedTestingLibraryNode || !!importedCustomModuleNode; - - return ( - (!isStrict && isAggressiveModuleReportingEnabled()) || - isSomeModuleImported - ); - }; + /** + * Determines whether a given node is a reportable query, + * either a built-in or a custom one. + * + * Depending on Aggressive Query Reporting setting, custom queries will be + * reportable or not. + */ + const isQuery: IsQueryFn = (node) => { + const hasQueryPattern = /^(get|query|find)(All)?By.+$/.test(node.name); + if (!hasQueryPattern) { + return false; + } - /** - * Determines whether a given node is a reportable query, - * either a built-in or a custom one. - * - * Depending on Aggressive Query Reporting setting, custom queries will be - * reportable or not. - */ - const isQuery: IsQueryFn = (node) => { - const hasQueryPattern = /^(get|query|find)(All)?By.+$/.test(node.name); - if (!hasQueryPattern) { - return false; - } + if (isAggressiveQueryReportingEnabled()) { + return true; + } - if (isAggressiveQueryReportingEnabled()) { - return true; - } + const customQueries = getCustomQueries(); + const isBuiltInQuery = ALL_QUERIES_COMBINATIONS.includes(node.name); + const isReportableCustomQuery = customQueries.some((pattern) => + new RegExp(pattern).test(node.name) + ); + return isBuiltInQuery || isReportableCustomQuery; + }; - const customQueries = getCustomQueries(); - const isBuiltInQuery = ALL_QUERIES_COMBINATIONS.includes(node.name); - const isReportableCustomQuery = customQueries.some((pattern) => - new RegExp(pattern).test(node.name) - ); - return isBuiltInQuery || isReportableCustomQuery; - }; + /** + * Determines whether a given node is `get*` query variant or not. + */ + const isGetQueryVariant: IsGetQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('get'); + }; - /** - * Determines whether a given node is `get*` query variant or not. - */ - const isGetQueryVariant: IsGetQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('get'); - }; + /** + * Determines whether a given node is `query*` query variant or not. + */ + const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('query'); + }; - /** - * Determines whether a given node is `query*` query variant or not. - */ - const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('query'); - }; + /** + * Determines whether a given node is `find*` query variant or not. + */ + const isFindQueryVariant: IsFindQueryVariantFn = (node) => { + return isQuery(node) && node.name.startsWith('find'); + }; - /** - * Determines whether a given node is `find*` query variant or not. - */ - const isFindQueryVariant: IsFindQueryVariantFn = (node) => { - return isQuery(node) && node.name.startsWith('find'); - }; + /** + * Determines whether a given node is sync query or not. + */ + const isSyncQuery: IsSyncQueryFn = (node) => { + return isGetQueryVariant(node) || isQueryQueryVariant(node); + }; - /** - * Determines whether a given node is sync query or not. - */ - const isSyncQuery: IsSyncQueryFn = (node) => { - return isGetQueryVariant(node) || isQueryQueryVariant(node); - }; + /** + * Determines whether a given node is async query or not. + */ + const isAsyncQuery: IsAsyncQueryFn = (node) => { + return isFindQueryVariant(node); + }; - /** - * Determines whether a given node is async query or not. - */ - const isAsyncQuery: IsAsyncQueryFn = (node) => { - return isFindQueryVariant(node); - }; + const isCustomQuery: IsCustomQueryFn = (node) => { + return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name); + }; - const isCustomQuery: IsCustomQueryFn = (node) => { - return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name); - }; + const isBuiltInQuery = (node: TSESTree.Identifier): boolean => { + return isQuery(node) && ALL_QUERIES_COMBINATIONS.includes(node.name); + }; - const isBuiltInQuery = (node: TSESTree.Identifier): boolean => { - return isQuery(node) && ALL_QUERIES_COMBINATIONS.includes(node.name); - }; + /** + * Determines whether a given node is a valid async util or not. + * + * A node will be interpreted as a valid async util based on two conditions: + * the name matches with some Testing Library async util, and the node is + * coming from Testing Library module. + * + * The latter depends on Aggressive module reporting: + * if enabled, then it doesn't matter from where the given node was imported + * from as it will be considered part of Testing Library. + * Otherwise, it means `custom-module` has been set up, so only those nodes + * coming from Testing Library will be considered as valid. + */ + const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return ( + (validNames as string[]).includes(identifierNodeName) || + (!!originalNodeName && + (validNames as string[]).includes(originalNodeName)) + ); + } + ); + }; - /** - * Determines whether a given node is a valid async util or not. - * - * A node will be interpreted as a valid async util based on two conditions: - * the name matches with some Testing Library async util, and the node is - * coming from Testing Library module. - * - * The latter depends on Aggressive module reporting: - * if enabled, then it doesn't matter from where the given node was imported - * from as it will be considered part of Testing Library. - * Otherwise, it means `custom-module` has been set up, so only those nodes - * coming from Testing Library will be considered as valid. - */ - const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { - return isTestingLibraryUtil( - node, - (identifierNodeName, originalNodeName) => { - return ( - (validNames as string[]).includes(identifierNodeName) || - (!!originalNodeName && - (validNames as string[]).includes(originalNodeName)) - ); - } - ); - }; + /** + * Determines whether a given node is fireEvent util itself or not. + * + * Not to be confused with {@link isFireEventMethod} + */ + const isFireEventUtil = (node: TSESTree.Identifier): boolean => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName].includes('fireEvent'); + } + ); + }; - /** - * Determines whether a given node is fireEvent util itself or not. - * - * Not to be confused with {@link isFireEventMethod} - */ - const isFireEventUtil = (node: TSESTree.Identifier): boolean => { - return isTestingLibraryUtil( - node, - (identifierNodeName, originalNodeName) => { - return [identifierNodeName, originalNodeName].includes('fireEvent'); + /** + * Determines whether a given node is userEvent util itself or not. + * + * Not to be confused with {@link isUserEventMethod} + */ + const isUserEventUtil = (node: TSESTree.Identifier): boolean => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; } - ); - }; - /** - * Determines whether a given node is userEvent util itself or not. - * - * Not to be confused with {@link isUserEventMethod} - */ - const isUserEventUtil = (node: TSESTree.Identifier): boolean => { - const userEvent = findImportedUserEventSpecifier(); - let userEventName: string | undefined; - - if (userEvent) { - userEventName = userEvent.name; - } else if (isAggressiveModuleReportingEnabled()) { - userEventName = USER_EVENT_NAME; - } - - if (!userEventName) { - return false; - } - - return node.name === userEventName; - }; + if (!userEventName) { + return false; + } - /** - * Determines whether a given node is fireEvent method or not - */ - const isFireEventMethod: IsFireEventMethodFn = (node) => { - const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); - let fireEventUtilName: string | undefined; - - if (fireEventUtil) { - fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) - ? fireEventUtil.name - : fireEventUtil.local.name; - } else if (isAggressiveModuleReportingEnabled()) { - fireEventUtilName = FIRE_EVENT_NAME; - } + return node.name === userEventName; + }; - if (!fireEventUtilName) { - return false; - } + /** + * Determines whether a given node is fireEvent method or not + */ + const isFireEventMethod: IsFireEventMethodFn = (node) => { + const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + let fireEventUtilName: string | undefined; + + if (fireEventUtil) { + fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) + ? fireEventUtil.name + : fireEventUtil.local.name; + } else if (isAggressiveModuleReportingEnabled()) { + fireEventUtilName = FIRE_EVENT_NAME; + } - const parentMemberExpression: TSESTree.MemberExpression | undefined = - node.parent && isMemberExpression(node.parent) ? node.parent : undefined; + if (!fireEventUtilName) { + return false; + } - if (!parentMemberExpression) { - return false; - } + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && isMemberExpression(node.parent) + ? node.parent + : undefined; - // 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; + if (!parentMemberExpression) { + return false; + } - // 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; + // 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; + } - return regularCall || wildcardCall; - }; + // check fireEvent.click() usage + const regularCall = + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === fireEventUtilName; - const isUserEventMethod: IsUserEventMethodFn = (node) => { - const userEvent = findImportedUserEventSpecifier(); - let userEventName: string | undefined; + // 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; - if (userEvent) { - userEventName = userEvent.name; - } else if (isAggressiveModuleReportingEnabled()) { - userEventName = USER_EVENT_NAME; - } + return regularCall || wildcardCall; + }; - if (!userEventName) { - return false; - } + const isUserEventMethod: IsUserEventMethodFn = (node) => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; - const parentMemberExpression: TSESTree.MemberExpression | undefined = - node.parent && isMemberExpression(node.parent) ? node.parent : undefined; + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } - if (!parentMemberExpression) { - return false; - } + if (!userEventName) { + return false; + } - // make sure that given node it's not userEvent object itself - if ( - [userEventName, USER_EVENT_NAME].includes(node.name) || - (ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === node.name) - ) { - return false; - } + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && isMemberExpression(node.parent) + ? node.parent + : undefined; - // check userEvent.click() usage - return ( - ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === userEventName - ); - }; + if (!parentMemberExpression) { + return false; + } - /** - * Determines whether a given node is a valid render util or not. - * - * A node will be interpreted as a valid render based on two conditions: - * the name matches with a valid "render" option, and the node is coming - * from Testing Library module. This depends on: - * - * - Aggressive render reporting: if enabled, then every node name - * containing "render" will be assumed as Testing Library render util. - * Otherwise, it means `custom-modules` has been set up, so only those nodes - * named as "render" or some of the `custom-modules` options will be - * considered as Testing Library render util. - * - Aggressive module reporting: if enabled, then it doesn't matter from - * where the given node was imported from as it will be considered part of - * Testing Library. Otherwise, it means `custom-module` has been set up, so - * only those nodes coming from Testing Library will be considered as valid. - */ - const isRenderUtil: IsRenderUtilFn = (node) => - isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => { - if (isAggressiveRenderReportingEnabled()) { - return identifierNodeName.toLowerCase().includes(RENDER_NAME); + // make sure that given node it's not userEvent object itself + if ( + [userEventName, USER_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; } - return [RENDER_NAME, ...getCustomRenders()].some( - (validRenderName) => - validRenderName === identifierNodeName || - (Boolean(originalNodeName) && validRenderName === originalNodeName) + // check userEvent.click() usage + return ( + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventName ); - }); - - const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { - if (!node.init) { - return false; - } - const initIdentifierNode = getDeepestIdentifierNode(node.init); - - if (!initIdentifierNode) { - return false; - } - - return isRenderUtil(initIdentifierNode); - }; - - const isDebugUtil: IsDebugUtilFn = (identifierNode) => { - const isBuiltInConsole = - isMemberExpression(identifierNode.parent) && - ASTUtils.isIdentifier(identifierNode.parent.object) && - identifierNode.parent.object.name === 'console'; + }; - return ( - !isBuiltInConsole && - isTestingLibraryUtil( - identifierNode, - (identifierNodeName, originalNodeName) => { - return [identifierNodeName, originalNodeName] - .filter(Boolean) - .includes('debug'); + /** + * Determines whether a given node is a valid render util or not. + * + * A node will be interpreted as a valid render based on two conditions: + * the name matches with a valid "render" option, and the node is coming + * from Testing Library module. This depends on: + * + * - Aggressive render reporting: if enabled, then every node name + * containing "render" will be assumed as Testing Library render util. + * Otherwise, it means `custom-modules` has been set up, so only those nodes + * named as "render" or some of the `custom-modules` options will be + * considered as Testing Library render util. + * - Aggressive module reporting: if enabled, then it doesn't matter from + * where the given node was imported from as it will be considered part of + * Testing Library. Otherwise, it means `custom-module` has been set up, so + * only those nodes coming from Testing Library will be considered as valid. + */ + const isRenderUtil: IsRenderUtilFn = (node) => + isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => { + if (isAggressiveRenderReportingEnabled()) { + return identifierNodeName.toLowerCase().includes(RENDER_NAME); } - ) - ); - }; - /** - * Determines whether a given MemberExpression node is a presence assert - * - * Presence asserts could have shape of: - * - expect(element).toBeInTheDocument() - * - expect(element).not.toBeNull() - */ - const isPresenceAssert: IsPresenceAssertFn = (node) => { - const { matcher, isNegated } = getAssertNodeInfo(node); + return [RENDER_NAME, ...getCustomRenders()].some( + (validRenderName) => + validRenderName === identifierNodeName || + (Boolean(originalNodeName) && validRenderName === originalNodeName) + ); + }); - if (!matcher) { - return false; - } + const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { + if (!node.init) { + return false; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); - return isNegated - ? ABSENCE_MATCHERS.includes(matcher) - : PRESENCE_MATCHERS.includes(matcher); - }; + if (!initIdentifierNode) { + return false; + } - /** - * Determines whether a given MemberExpression node is an absence assert - * - * Absence asserts could have shape of: - * - expect(element).toBeNull() - * - expect(element).not.toBeInTheDocument() - */ - const isAbsenceAssert: IsAbsenceAssertFn = (node) => { - const { matcher, isNegated } = getAssertNodeInfo(node); + return isRenderUtil(initIdentifierNode); + }; - if (!matcher) { - return false; - } + const isDebugUtil: IsDebugUtilFn = (identifierNode) => { + const isBuiltInConsole = + isMemberExpression(identifierNode.parent) && + ASTUtils.isIdentifier(identifierNode.parent.object) && + identifierNode.parent.object.name === 'console'; + + return ( + !isBuiltInConsole && + isTestingLibraryUtil( + identifierNode, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName] + .filter(Boolean) + .includes('debug'); + } + ) + ); + }; - return isNegated - ? PRESENCE_MATCHERS.includes(matcher) - : ABSENCE_MATCHERS.includes(matcher); - }; + /** + * Determines whether a given MemberExpression node is a presence assert + * + * Presence asserts could have shape of: + * - expect(element).toBeInTheDocument() + * - expect(element).not.toBeNull() + */ + const isPresenceAssert: IsPresenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); - /** - * Gets a string and verifies if it was imported/required by Testing Library - * related module. - */ - const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( - specifierName - ) => { - const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); + if (!matcher) { + return false; + } - if (!node) { - return undefined; - } + return isNegated + ? ABSENCE_MATCHERS.includes(matcher) + : PRESENCE_MATCHERS.includes(matcher); + }; - if (isImportDeclaration(node)) { - const namedExport = node.specifiers.find((n) => { - return ( - isImportSpecifier(n) && - [n.imported.name, n.local.name].includes(specifierName) - ); - }); + /** + * Determines whether a given MemberExpression node is an absence assert + * + * Absence asserts could have shape of: + * - expect(element).toBeNull() + * - expect(element).not.toBeInTheDocument() + */ + const isAbsenceAssert: IsAbsenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); - // it is "import { foo [as alias] } from 'baz'"" - if (namedExport) { - return namedExport; + if (!matcher) { + return false; } - // it could be "import * as rtl from 'baz'" - return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); - } else { - if (!ASTUtils.isVariableDeclarator(node.parent)) { - return undefined; - } - const requireNode = node.parent; + return isNegated + ? PRESENCE_MATCHERS.includes(matcher) + : ABSENCE_MATCHERS.includes(matcher); + }; - if (ASTUtils.isIdentifier(requireNode.id)) { - // this is const rtl = require('foo') - return requireNode.id; - } + /** + * Gets a string and verifies if it was imported/required by Testing Library + * related module. + */ + const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( + specifierName + ) => { + const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); - // this should be const { something } = require('foo') - if (!isObjectPattern(requireNode.id)) { + if (!node) { return undefined; } - const property = requireNode.id.properties.find( - (n) => - isProperty(n) && - ASTUtils.isIdentifier(n.key) && - n.key.name === specifierName - ); - if (!property) { - return undefined; - } - return (property as TSESTree.Property).key as TSESTree.Identifier; - } - }; + if (isImportDeclaration(node)) { + const namedExport = node.specifiers.find((n) => { + return ( + isImportSpecifier(n) && + [n.imported.name, n.local.name].includes(specifierName) + ); + }); + + // it is "import { foo [as alias] } from 'baz'"" + if (namedExport) { + return namedExport; + } - const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { - if (!importedUserEventLibraryNode) { - return null; - } + // it could be "import * as rtl from 'baz'" + return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); + } else { + if (!ASTUtils.isVariableDeclarator(node.parent)) { + return undefined; + } + const requireNode = node.parent; - if (isImportDeclaration(importedUserEventLibraryNode)) { - const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( - (specifier) => isImportDefaultSpecifier(specifier) - ); + if (ASTUtils.isIdentifier(requireNode.id)) { + // this is const rtl = require('foo') + return requireNode.id; + } - if (userEventIdentifier) { - return userEventIdentifier.local; - } - } else { - if (!ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent)) { - return null; + // this should be const { something } = require('foo') + if (!isObjectPattern(requireNode.id)) { + return undefined; + } + + const property = requireNode.id.properties.find( + (n) => + isProperty(n) && + ASTUtils.isIdentifier(n.key) && + n.key.name === specifierName + ); + if (!property) { + return undefined; + } + return (property as TSESTree.Property).key as TSESTree.Identifier; } + }; - const requireNode = importedUserEventLibraryNode.parent; - if (!ASTUtils.isIdentifier(requireNode.id)) { + const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { + if (!importedUserEventLibraryNode) { return null; } - return requireNode.id; - } - - return null; - }; - - const getImportedUtilSpecifier = ( - node: TSESTree.MemberExpression | TSESTree.Identifier - ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { - const identifierName: string | undefined = getPropertyIdentifierNode(node) - ?.name; - - if (!identifierName) { - return undefined; - } + if (isImportDeclaration(importedUserEventLibraryNode)) { + const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( + (specifier) => isImportDefaultSpecifier(specifier) + ); - return findImportedUtilSpecifier(identifierName); - }; + if (userEventIdentifier) { + return userEventIdentifier.local; + } + } else { + if ( + !ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent) + ) { + return null; + } - /** - * Determines if file inspected meets all conditions to be reported by rules or not. - */ - const canReportErrors: CanReportErrorsFn = () => { - return skipRuleReportingCheck || isTestingLibraryImported(); - }; + const requireNode = importedUserEventLibraryNode.parent; + if (!ASTUtils.isIdentifier(requireNode.id)) { + return null; + } - /** - * Determines whether a node is imported from a valid Testing Library module - * - * This method will try to find any import matching the given node name, - * and also make sure the name is a valid match in case it's been renamed. - */ - const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( - node - ) => { - const importNode = getImportedUtilSpecifier(node); + return requireNode.id; + } - if (!importNode) { - return false; - } + return null; + }; - const identifierName: string | undefined = getPropertyIdentifierNode(node) - ?.name; + const getImportedUtilSpecifier = ( + node: TSESTree.MemberExpression | TSESTree.Identifier + ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; - if (!identifierName) { - return false; - } + if (!identifierName) { + return undefined; + } - return hasImportMatch(importNode, identifierName); - }; + return findImportedUtilSpecifier(identifierName); + }; - const helpers: DetectionHelpers = { - getTestingLibraryImportNode, - getCustomModuleImportNode, - getTestingLibraryImportName, - getCustomModuleImportName, - isTestingLibraryImported, - isGetQueryVariant, - isQueryQueryVariant, - isFindQueryVariant, - isSyncQuery, - isAsyncQuery, - isQuery, - isCustomQuery, - isBuiltInQuery, - isAsyncUtil, - isFireEventUtil, - isUserEventUtil, - isFireEventMethod, - isUserEventMethod, - isRenderUtil, - isRenderVariableDeclarator, - isDebugUtil, - isPresenceAssert, - isAbsenceAssert, - canReportErrors, - findImportedUtilSpecifier, - isNodeComingFromTestingLibrary, - }; + /** + * Determines if file inspected meets all conditions to be reported by rules or not. + */ + const canReportErrors: CanReportErrorsFn = () => { + return skipRuleReportingCheck || isTestingLibraryImported(); + }; - // Instructions for Testing Library detection. - const detectionInstructions: TSESLint.RuleListener = { /** - * This ImportDeclaration rule listener will check if Testing Library related - * modules are imported. Since imports happen first thing in a file, it's - * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule` - * since they will have corresponding value already updated when reporting other - * parts of the file. + * Determines whether a node is imported from a valid Testing Library module + * + * This method will try to find any import matching the given node name, + * and also make sure the name is a valid match in case it's been renamed. */ - ImportDeclaration(node: TSESTree.ImportDeclaration) { - // check only if testing library import not found yet so we avoid - // to override importedTestingLibraryNode after it's found - if ( - !importedTestingLibraryNode && - /testing-library/g.test(node.source.value as string) - ) { - importedTestingLibraryNode = node; - } + const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( + node + ) => { + const importNode = getImportedUtilSpecifier(node); - // check only if custom module import not found yet so we avoid - // to override importedCustomModuleNode after it's found - const customModule = getCustomModule(); - if ( - customModule && - !importedCustomModuleNode && - String(node.source.value).endsWith(customModule) - ) { - importedCustomModuleNode = node; + if (!importNode) { + return false; } - // check only if user-event import not found yet so we avoid - // to override importedUserEventLibraryNode after it's found - if ( - !importedUserEventLibraryNode && - String(node.source.value) === USER_EVENT_PACKAGE - ) { - importedUserEventLibraryNode = node; + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; + + if (!identifierName) { + return false; } - }, - // Check if Testing Library related modules are loaded with required. - [`CallExpression > Identifier[name="require"]`](node: TSESTree.Identifier) { - const callExpression = node.parent as TSESTree.CallExpression; - const { arguments: args } = callExpression; + return hasImportMatch(importNode, identifierName); + }; - if ( - !importedTestingLibraryNode && - args.some( - (arg) => - isLiteral(arg) && - typeof arg.value === 'string' && - /testing-library/g.test(arg.value) - ) - ) { - importedTestingLibraryNode = callExpression; - } + const helpers: DetectionHelpers = { + getTestingLibraryImportNode, + getCustomModuleImportNode, + getTestingLibraryImportName, + getCustomModuleImportName, + isTestingLibraryImported, + isGetQueryVariant, + isQueryQueryVariant, + isFindQueryVariant, + isSyncQuery, + isAsyncQuery, + isQuery, + isCustomQuery, + isBuiltInQuery, + isAsyncUtil, + isFireEventUtil, + isUserEventUtil, + isFireEventMethod, + isUserEventMethod, + isRenderUtil, + isRenderVariableDeclarator, + isDebugUtil, + isPresenceAssert, + isAbsenceAssert, + canReportErrors, + findImportedUtilSpecifier, + isNodeComingFromTestingLibrary, + }; - const customModule = getCustomModule(); - if ( - !importedCustomModuleNode && - args.some( - (arg) => - customModule && - isLiteral(arg) && - typeof arg.value === 'string' && - arg.value.endsWith(customModule) - ) - ) { - importedCustomModuleNode = callExpression; - } + // Instructions for Testing Library detection. + const detectionInstructions: TSESLint.RuleListener = { + /** + * This ImportDeclaration rule listener will check if Testing Library related + * modules are imported. Since imports happen first thing in a file, it's + * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule` + * since they will have corresponding value already updated when reporting other + * parts of the file. + */ + ImportDeclaration(node: TSESTree.ImportDeclaration) { + // check only if testing library import not found yet so we avoid + // to override importedTestingLibraryNode after it's found + if ( + !importedTestingLibraryNode && + /testing-library/g.test(node.source.value as string) + ) { + importedTestingLibraryNode = node; + } - if ( - !importedCustomModuleNode && - args.some( - (arg) => - isLiteral(arg) && - typeof arg.value === 'string' && - arg.value === USER_EVENT_PACKAGE - ) - ) { - importedUserEventLibraryNode = callExpression; - } - }, - }; + // check only if custom module import not found yet so we avoid + // to override importedCustomModuleNode after it's found + const customModule = getCustomModule(); + if ( + customModule && + !importedCustomModuleNode && + String(node.source.value).endsWith(customModule) + ) { + importedCustomModuleNode = node; + } - // update given rule to inject Testing Library detection - const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); - const enhancedRuleInstructions: TSESLint.RuleListener = {}; + // check only if user-event import not found yet so we avoid + // to override importedUserEventLibraryNode after it's found + if ( + !importedUserEventLibraryNode && + String(node.source.value) === USER_EVENT_PACKAGE + ) { + importedUserEventLibraryNode = node; + } + }, - const allKeys = new Set( - Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)) - ); + // Check if Testing Library related modules are loaded with required. + [`CallExpression > Identifier[name="require"]`]( + node: TSESTree.Identifier + ) { + const callExpression = node.parent as TSESTree.CallExpression; + const { arguments: args } = callExpression; + + if ( + !importedTestingLibraryNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + /testing-library/g.test(arg.value) + ) + ) { + importedTestingLibraryNode = callExpression; + } - // Iterate over ALL instructions keys so we can override original rule instructions - // to prevent their execution if conditions to report errors are not met. - allKeys.forEach((instruction) => { - enhancedRuleInstructions[instruction] = (node) => { - if (instruction in detectionInstructions) { - detectionInstructions[instruction]?.(node); - } + const customModule = getCustomModule(); + if ( + !importedCustomModuleNode && + args.some( + (arg) => + customModule && + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value.endsWith(customModule) + ) + ) { + importedCustomModuleNode = callExpression; + } - if (canReportErrors() && ruleInstructions[instruction]) { - return ruleInstructions[instruction]?.(node); - } + if ( + !importedCustomModuleNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value === USER_EVENT_PACKAGE + ) + ) { + importedUserEventLibraryNode = callExpression; + } + }, }; - }); - return enhancedRuleInstructions; -}; + // update given rule to inject Testing Library detection + const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); + const enhancedRuleInstructions: TSESLint.RuleListener = {}; + + const allKeys = new Set( + Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)) + ); + + // Iterate over ALL instructions keys so we can override original rule instructions + // to prevent their execution if conditions to report errors are not met. + allKeys.forEach((instruction) => { + enhancedRuleInstructions[instruction] = (node) => { + if (instruction in detectionInstructions) { + detectionInstructions[instruction]?.(node); + } + + if (canReportErrors() && ruleInstructions[instruction]) { + return ruleInstructions[instruction]?.(node); + } + }; + }); + + return enhancedRuleInstructions; + }; +} diff --git a/lib/create-testing-library-rule/index.ts b/lib/create-testing-library-rule/index.ts index 88b2654c..fda6ad26 100644 --- a/lib/create-testing-library-rule/index.ts +++ b/lib/create-testing-library-rule/index.ts @@ -14,7 +14,7 @@ type CreateRuleMeta = { docs: CreateRuleMetaDocs; } & Omit, 'docs'>; -export const createTestingLibraryRule = < +export function createTestingLibraryRule< TOptions extends readonly unknown[], TMessageIds extends string, TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener @@ -28,11 +28,12 @@ export const createTestingLibraryRule = < defaultOptions: Readonly; detectionOptions?: Partial; create: EnhancedRuleCreate; -}>): TSESLint.RuleModule => - ESLintUtils.RuleCreator(getDocsUrl)({ +}>): TSESLint.RuleModule { + return ESLintUtils.RuleCreator(getDocsUrl)({ ...remainingConfig, create: detectTestingLibraryUtils( create, detectionOptions ), }); +} From 36852156ad3c673ca81b307c3cd0134260e4793f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Tue, 27 Apr 2021 17:38:45 +0200 Subject: [PATCH 4/4] test(consistent-data-testid): add tests with aggressive reporting disabled --- lib/rules/consistent-data-testid.ts | 2 +- .../lib/rules/consistent-data-testid.test.ts | 376 ++++++++++-------- 2 files changed, 202 insertions(+), 176 deletions(-) diff --git a/lib/rules/consistent-data-testid.ts b/lib/rules/consistent-data-testid.ts index bb1a83aa..63a9b888 100644 --- a/lib/rules/consistent-data-testid.ts +++ b/lib/rules/consistent-data-testid.ts @@ -3,7 +3,7 @@ import { isJSXAttribute, isLiteral } from '../node-utils'; export const RULE_NAME = 'consistent-data-testid'; export type MessageIds = 'consistentDataTestId'; -type Options = [ +export type Options = [ { testIdAttribute?: string | string[]; testIdPattern: string; diff --git a/tests/lib/rules/consistent-data-testid.test.ts b/tests/lib/rules/consistent-data-testid.test.ts index dd0892f2..4ea34f6c 100644 --- a/tests/lib/rules/consistent-data-testid.test.ts +++ b/tests/lib/rules/consistent-data-testid.test.ts @@ -1,12 +1,31 @@ +import type { TSESLint } from '@typescript-eslint/experimental-utils'; + +import rule, { + MessageIds, + Options, + RULE_NAME, +} from '../../../lib/rules/consistent-data-testid'; + import { createRuleTester } from '../test-utils'; -import rule, { RULE_NAME } from '../../../lib/rules/consistent-data-testid'; const ruleTester = createRuleTester(); -ruleTester.run(RULE_NAME, rule, { - valid: [ - { - code: ` +type ValidTestCase = TSESLint.ValidTestCase; +type InvalidTestCase = TSESLint.InvalidTestCase; +type TestCase = ValidTestCase | InvalidTestCase; +const disableAggressiveReporting = (array: T[]): T[] => + array.map((testCase) => ({ + ...testCase, + settings: { + 'testing-library/utils-module': 'off', + 'testing-library/custom-renders': 'off', + 'testing-library/custom-queries': 'off', + }, + })); + +const validTestCases: ValidTestCase[] = [ + { + code: ` import React from 'react'; const TestComponent = props => { @@ -17,10 +36,10 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [{ testIdPattern: 'cool' }], - }, - { - code: ` + options: [{ testIdPattern: 'cool' }], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -31,10 +50,10 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [{ testIdPattern: 'cool' }], - }, - { - code: ` + options: [{ testIdPattern: 'cool' }], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -45,15 +64,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', - }, - ], - filename: '/my/cool/file/path/Awesome.js', - }, - { - code: ` + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/path/Awesome.js', + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -64,15 +83,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', - }, - ], - filename: '/my/cool/file/path/Awesome.js', - }, - { - code: ` + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/path/Awesome.js', + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -83,15 +102,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', - }, - ], - filename: '/my/cool/file/Parent/index.js', - }, - { - code: ` + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/Parent/index.js', + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -102,15 +121,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '{fileName}', - }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - }, - { - code: ` + options: [ + { + testIdPattern: '{fileName}', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -121,15 +140,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^right(.*)$', - testIdAttribute: 'custom-attr', - }, - ], - }, - { - code: ` + options: [ + { + testIdPattern: '^right(.*)$', + testIdAttribute: 'custom-attr', + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -140,15 +159,15 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^right(.*)$', - testIdAttribute: ['custom-attr', 'another-custom-attr'], - }, - ], - }, - { - code: ` + options: [ + { + testIdPattern: '^right(.*)$', + testIdAttribute: ['custom-attr', 'another-custom-attr'], + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -159,16 +178,16 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '{fileName}', - testIdAttribute: 'data-test-id', - }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - }, - { - code: ` + options: [ + { + testIdPattern: '{fileName}', + testIdAttribute: 'data-test-id', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -180,12 +199,12 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [{ testIdPattern: 'somethingElse' }], - }, - ], - invalid: [ - { - code: ` + options: [{ testIdPattern: 'somethingElse' }], + }, +]; +const invalidTestCases: InvalidTestCase[] = [ + { + code: ` import React from 'react'; const TestComponent = props => { @@ -196,20 +215,20 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [{ testIdPattern: 'error' }], - errors: [ - { - messageId: 'consistentDataTestId', - data: { - attr: 'data-testid', - value: 'Awesome__CoolStuff', - regex: '/error/', - }, + options: [{ testIdPattern: 'error' }], + errors: [ + { + messageId: 'consistentDataTestId', + data: { + attr: 'data-testid', + value: 'Awesome__CoolStuff', + regex: '/error/', }, - ], - }, - { - code: ` + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -220,25 +239,25 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: 'matchMe', + options: [ + { + testIdPattern: 'matchMe', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + messageId: 'consistentDataTestId', + data: { + attr: 'data-testid', + value: 'Nope', + regex: '/matchMe/', }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - errors: [ - { - messageId: 'consistentDataTestId', - data: { - attr: 'data-testid', - value: 'Nope', - regex: '/matchMe/', - }, - }, - ], - }, - { - code: ` + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -249,26 +268,26 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', - testIdAttribute: 'my-custom-attr', + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + testIdAttribute: 'my-custom-attr', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + messageId: 'consistentDataTestId', + data: { + attr: 'my-custom-attr', + value: 'WrongComponent__cool', + regex: '/^Parent(__([A-Z]+[a-z]*?)+)*$/', }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - errors: [ - { - messageId: 'consistentDataTestId', - data: { - attr: 'my-custom-attr', - value: 'WrongComponent__cool', - regex: '/^Parent(__([A-Z]+[a-z]*?)+)*$/', - }, - }, - ], - }, - { - code: ` + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -279,34 +298,34 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^right$', - testIdAttribute: ['custom-attr', 'another-custom-attr'], - }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - errors: [ - { - messageId: 'consistentDataTestId', - data: { - attr: 'custom-attr', - value: 'wrong', - regex: '/^right$/', - }, + options: [ + { + testIdPattern: '^right$', + testIdAttribute: ['custom-attr', 'another-custom-attr'], + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + messageId: 'consistentDataTestId', + data: { + attr: 'custom-attr', + value: 'wrong', + regex: '/^right$/', }, - { - messageId: 'consistentDataTestId', - data: { - attr: 'another-custom-attr', - value: 'wrong', - regex: '/^right$/', - }, + }, + { + messageId: 'consistentDataTestId', + data: { + attr: 'another-custom-attr', + value: 'wrong', + regex: '/^right$/', }, - ], - }, - { - code: ` + }, + ], + }, + { + code: ` import React from 'react'; const TestComponent = props => { @@ -317,22 +336,29 @@ ruleTester.run(RULE_NAME, rule, { ) }; `, - options: [ - { - testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', - }, - ], - filename: '/my/cool/__tests__/Parent/index.js', - errors: [ - { - messageId: 'consistentDataTestId', - data: { - attr: 'data-testid', - value: 'WrongComponent__cool', - regex: '/^Parent(__([A-Z]+[a-z]*?)+)*$/', - }, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + messageId: 'consistentDataTestId', + data: { + attr: 'data-testid', + value: 'WrongComponent__cool', + regex: '/^Parent(__([A-Z]+[a-z]*?)+)*$/', }, - ], - }, + }, + ], + }, +]; + +ruleTester.run(RULE_NAME, rule, { + valid: [...validTestCases, ...disableAggressiveReporting(validTestCases)], + invalid: [ + ...invalidTestCases, + ...disableAggressiveReporting(invalidTestCases), ], });