diff --git a/lib/node-utils.ts b/lib/node-utils.ts index d25c97a0..12eae555 100644 --- a/lib/node-utils.ts +++ b/lib/node-utils.ts @@ -1,4 +1,5 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'; +import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; export function isCallExpression( node: TSESTree.Node @@ -6,12 +7,6 @@ export function isCallExpression( return node && node.type === AST_NODE_TYPES.CallExpression; } -export function isAwaitExpression( - node: TSESTree.Node -): node is TSESTree.AwaitExpression { - return node && node.type === AST_NODE_TYPES.AwaitExpression; -} - export function isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier { return node && node.type === AST_NODE_TYPES.Identifier; } @@ -95,6 +90,10 @@ export function findClosestCallNode( } } +export function isObjectExpression(node: TSESTree.Expression): node is TSESTree.ObjectExpression { + return node?.type === AST_NODE_TYPES.ObjectExpression +} + export function hasThenProperty(node: TSESTree.Node) { return ( isMemberExpression(node) && @@ -103,10 +102,36 @@ export function hasThenProperty(node: TSESTree.Node) { ); } +export function isAwaitExpression( + node: TSESTree.Node +): node is TSESTree.AwaitExpression { + return node && node.type === AST_NODE_TYPES.AwaitExpression; +} + export function isArrowFunctionExpression(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression { return node && node.type === AST_NODE_TYPES.ArrowFunctionExpression } -export function isObjectExpression(node: TSESTree.Expression): node is TSESTree.ObjectExpression { - return node?.type === AST_NODE_TYPES.ObjectExpression +export function isReturnStatement(node: TSESTree.Node): node is TSESTree.ReturnStatement { + return node && node.type === AST_NODE_TYPES.ReturnStatement +} + +export function isAwaited(node: TSESTree.Node) { + return isAwaitExpression(node) || isArrowFunctionExpression(node) || isReturnStatement(node) +} + +export function isPromiseResolved(node: TSESTree.Node) { + const parent = node.parent; + + // wait(...).then(...) + if (isCallExpression(parent)) { + return hasThenProperty(parent.parent); + } + + // promise.then(...) + return hasThenProperty(parent); +} + +export function getVariableReferences(context: RuleContext, node: TSESTree.Node) { + return (isVariableDeclarator(node) && context.getDeclaredVariables(node)[0].references.slice(1)) || []; } \ No newline at end of file diff --git a/lib/rules/await-async-query.ts b/lib/rules/await-async-query.ts index 0bba19da..10d8d658 100644 --- a/lib/rules/await-async-query.ts +++ b/lib/rules/await-async-query.ts @@ -1,41 +1,21 @@ import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; +import { getDocsUrl, LIBRARY_MODULES } from '../utils'; import { - isVariableDeclarator, - hasThenProperty, isCallExpression, isIdentifier, isMemberExpression, + isAwaited, + isPromiseResolved, + getVariableReferences, } from '../node-utils'; +import { ReportDescriptor } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; export const RULE_NAME = 'await-async-query'; export type MessageIds = 'awaitAsyncQuery'; type Options = []; -const VALID_PARENTS = [ - 'AwaitExpression', - 'ArrowFunctionExpression', - 'ReturnStatement', -]; - const ASYNC_QUERIES_REGEXP = /^find(All)?By(LabelText|PlaceholderText|Text|AltText|Title|DisplayValue|Role|TestId)$/; -function isAwaited(node: TSESTree.Node) { - return VALID_PARENTS.includes(node.type); -} - -function isPromiseResolved(node: TSESTree.Node) { - const parent = node.parent; - - // findByText("foo").then(...) - if (isCallExpression(parent)) { - return hasThenProperty(parent.parent); - } - - // promise.then(...) - return hasThenProperty(parent); -} - function hasClosestExpectResolvesRejects(node: TSESTree.Node): boolean { if (!node.parent) { return false; @@ -79,6 +59,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ node: TSESTree.Identifier | TSESTree.MemberExpression; queryName: string; }[] = []; + const isQueryUsage = ( node: TSESTree.Identifier | TSESTree.MemberExpression ) => @@ -86,7 +67,25 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ !isPromiseResolved(node) && !hasClosestExpectResolvesRejects(node); + let hasImportedFromTestingLibraryModule = false; + + function report(params: ReportDescriptor<'awaitAsyncQuery'>) { + if (hasImportedFromTestingLibraryModule) { + context.report(params); + } + } + return { + 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'( + node: TSESTree.Node + ) { + const importDeclaration = node.parent as TSESTree.ImportDeclaration; + const module = importDeclaration.source.value.toString(); + + if (LIBRARY_MODULES.includes(module)) { + hasImportedFromTestingLibraryModule = true; + } + }, [`CallExpression > Identifier[name=${ASYNC_QUERIES_REGEXP}]`]( node: TSESTree.Identifier ) { @@ -105,17 +104,10 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }, 'Program:exit'() { testingLibraryQueryUsage.forEach(({ node, queryName }) => { - const variableDeclaratorParent = node.parent.parent; - - const references = - (isVariableDeclarator(variableDeclaratorParent) && - context - .getDeclaredVariables(variableDeclaratorParent)[0] - .references.slice(1)) || - []; + const references = getVariableReferences(context, node.parent.parent); if (references && references.length === 0) { - context.report({ + report({ node, messageId: 'awaitAsyncQuery', data: { @@ -129,7 +121,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ !isAwaited(referenceNode.parent) && !isPromiseResolved(referenceNode) ) { - context.report({ + report({ node, messageId: 'awaitAsyncQuery', data: { diff --git a/lib/rules/await-async-utils.ts b/lib/rules/await-async-utils.ts index be9c9e69..407513fd 100644 --- a/lib/rules/await-async-utils.ts +++ b/lib/rules/await-async-utils.ts @@ -1,36 +1,18 @@ import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { getDocsUrl, ASYNC_UTILS, LIBRARY_MODULES } from '../utils'; -import { isCallExpression, hasThenProperty } from '../node-utils'; +import { + isAwaited, + isPromiseResolved, + getVariableReferences, +} from '../node-utils'; export const RULE_NAME = 'await-async-utils'; export type MessageIds = 'awaitAsyncUtil'; type Options = []; -const VALID_PARENTS = [ - 'AwaitExpression', - 'ArrowFunctionExpression', - 'ReturnStatement', -]; - const ASYNC_UTILS_REGEXP = new RegExp(`^(${ASYNC_UTILS.join('|')})$`); -function isAwaited(node: TSESTree.Node) { - return VALID_PARENTS.includes(node.type); -} - -function isPromiseResolved(node: TSESTree.Node) { - const parent = node.parent; - - // wait(...).then(...) - if (isCallExpression(parent)) { - return hasThenProperty(parent.parent); - } - - // promise.then(...) - return hasThenProperty(parent); -} - export default ESLintUtils.RuleCreator(getDocsUrl)({ name: RULE_NAME, meta: { @@ -49,12 +31,17 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ defaultOptions: [], create(context) { - const asyncUtilsUsage: Array<{ node: TSESTree.Identifier | TSESTree.MemberExpression, name: string }> = []; + const asyncUtilsUsage: Array<{ + node: TSESTree.Identifier | TSESTree.MemberExpression; + name: string; + }> = []; const importedAsyncUtils: string[] = []; return { - 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'(node: TSESTree.Node) { - const parent = (node.parent as TSESTree.ImportDeclaration); + 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'( + node: TSESTree.Node + ) { + const parent = node.parent as TSESTree.ImportDeclaration; if (!LIBRARY_MODULES.includes(parent.source.value.toString())) return; @@ -78,28 +65,24 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ const identifier = memberExpression.object as TSESTree.Identifier; const memberExpressionName = identifier.name; - asyncUtilsUsage.push({ node: memberExpression, name: memberExpressionName }); + asyncUtilsUsage.push({ + node: memberExpression, + name: memberExpressionName, + }); }, 'Program:exit'() { const testingLibraryUtilUsage = asyncUtilsUsage.filter(usage => { if (usage.node.type === 'MemberExpression') { const object = usage.node.object as TSESTree.Identifier; - return importedAsyncUtils.includes(object.name) + return importedAsyncUtils.includes(object.name); } - return importedAsyncUtils.includes(usage.name) + return importedAsyncUtils.includes(usage.name); }); testingLibraryUtilUsage.forEach(({ node, name }) => { - const variableDeclaratorParent = node.parent.parent; - - const references = - (variableDeclaratorParent.type === 'VariableDeclarator' && - context - .getDeclaredVariables(variableDeclaratorParent)[0] - .references.slice(1)) || - []; + const references = getVariableReferences(context, node.parent.parent); if ( references && diff --git a/lib/rules/await-fire-event.ts b/lib/rules/await-fire-event.ts index 8e5ee6a4..71ea463e 100644 --- a/lib/rules/await-fire-event.ts +++ b/lib/rules/await-fire-event.ts @@ -1,28 +1,10 @@ import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { getDocsUrl } from '../utils'; -import { isIdentifier, isCallExpression, hasThenProperty } from '../node-utils'; +import { isIdentifier, isAwaited, isPromiseResolved } from '../node-utils'; export const RULE_NAME = 'await-fire-event'; export type MessageIds = 'awaitFireEvent'; type Options = []; - -const VALID_PARENTS = [ - 'AwaitExpression', - 'ArrowFunctionExpression', - 'ReturnStatement', -]; - -function isAwaited(node: TSESTree.Node) { - return VALID_PARENTS.includes(node.type); -} - -function isPromiseResolved(node: TSESTree.Node) { - const parent = node.parent.parent; - - // fireEvent.click().then(...) - return isCallExpression(parent) && hasThenProperty(parent.parent); -} - export default ESLintUtils.RuleCreator(getDocsUrl)({ name: RULE_NAME, meta: { @@ -51,7 +33,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ if ( isIdentifier(fireEventMethodNode) && !isAwaited(node.parent.parent.parent) && - !isPromiseResolved(fireEventMethodNode) + !isPromiseResolved(fireEventMethodNode.parent) ) { context.report({ node: fireEventMethodNode, diff --git a/tests/lib/rules/await-async-query.test.ts b/tests/lib/rules/await-async-query.test.ts index fba5a375..8e666cf7 100644 --- a/tests/lib/rules/await-async-query.test.ts +++ b/tests/lib/rules/await-async-query.test.ts @@ -1,3 +1,4 @@ +import { TestCaseError } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/await-async-query'; import { @@ -7,199 +8,194 @@ import { const ruleTester = createRuleTester(); +interface TestCode { + code: string; + isAsync?: boolean; +} + +function createTestCode({ code, isAsync = true }: TestCode) { + return ` + import { render } from '@testing-library/react' + test("An example test",${isAsync ? ' async ' : ' '}() => { + ${code} + }) + `; +} + +interface TestCaseParams { + isAsync?: boolean; + combinations?: string[]; + errors?: TestCaseError<'awaitAsyncQuery'>[]; +} + +function createTestCase( + getTest: ( + query: string + ) => string | { code: string; errors?: TestCaseError<'awaitAsyncQuery'>[] }, + { combinations = ASYNC_QUERIES_COMBINATIONS, isAsync }: TestCaseParams = {} +) { + return combinations.map(query => { + const test = getTest(query); + + return typeof test === 'string' + ? { code: createTestCode({ code: test, isAsync }), errors: [] } + : { + code: createTestCode({ code: test.code, isAsync }), + errors: test.errors, + }; + }); +} + ruleTester.run(RULE_NAME, rule, { valid: [ - // async queries declaration are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: ` - const { ${query} } = setUp() - `, - })), + // async queries declaration from render functions are valid + ...createTestCase(query => `const { ${query} } = render()`, { + isAsync: false, + }), - // async queries declaration are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - await screen.${query}('foo') - } - `, - })), + // async screen queries declaration are valid + ...createTestCase(query => `await screen.${query}('foo')`), - // async queries with await operator are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { + // async queries are valid with await operator + ...createTestCase( + query => ` doSomething() await ${query}('foo') - } - `, - })), + ` + ), - // async queries saving element in var with await operator are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { + // async queries are valid when saved in a variable with await operator + ...createTestCase( + query => ` doSomething() const foo = await ${query}('foo') expect(foo).toBeInTheDocument(); - } - `, - })), + ` + ), - // async queries saving element in var with promise immediately resolved are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - doSomething() - const foo = ${query}('foo').then(node => node) - expect(foo).toBeInTheDocument(); - } - `, - })), + // async queries are valid when saved in a promise variable immediately resolved + ...createTestCase( + query => ` + const promise = ${query}('foo') + await promise + ` + ), - // async queries with promise in variable and await operator are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { + // async queries are valid when saved in a promise variable resolved by an await operator + ...createTestCase( + query => ` const promise = ${query}('foo') await promise - } - `, - })), + ` + ), - // async queries with then method are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `() => { + // async queries are valid when used with then method + ...createTestCase( + query => ` ${query}('foo').then(() => { done() }) - } - `, - })), + ` + ), - // async queries with promise in variable and then method are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `() => { + // async queries are valid with promise in variable resolved by then method + ...createTestCase( + query => ` const promise = ${query}('foo') - promise.then(() => done()) - } - `, - })), + promise.then((done) => done()) + ` + ), - // async queries with promise returned in arrow function definition are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `anArrowFunction = () => ${query}('foo')`, - })), + // async queries are valid with promise returned in arrow function + ...createTestCase(query => `const anArrowFunction = () => ${query}('foo')`), - // async queries with promise returned in regular function definition are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `function foo() { return ${query}('foo') }`, - })), + // async queries are valid with promise returned in regular function + ...createTestCase(query => `function foo() { return ${query}('foo') }`), - // async queries with promise in variable and returned in regular function definition are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `function foo() { + // async queries are valid with promise in variable and returned in regular functio + ...createTestCase( + query => ` const promise = ${query}('foo') return promise - } - `, - })), + ` + ), // sync queries are valid - ...SYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `() => { + ...createTestCase( + query => ` doSomething() ${query}('foo') - } `, - })), + { combinations: SYNC_QUERIES_COMBINATIONS } + ), + + // async queries with resolves matchers are valid + ...createTestCase( + query => ` + expect(${query}("foo")).resolves.toBe("bar") + expect(wrappedQuery(${query}("foo"))).resolves.toBe("bar") + ` + ), + + // async queries with rejects matchers are valid + ...createTestCase( + query => ` + expect(${query}("foo")).rejects.toBe("bar") + expect(wrappedQuery(${query}("foo"))).rejects.toBe("bar") + ` + ), - // non-existing queries are valid - { - code: `async () => { + // non existing queries are valid + createTestCode({ + code: ` doSomething() const foo = findByNonExistingTestingLibraryQuery('foo') - } `, - }, + }), - // resolves/rejects matchers are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `test(() => { - expect(${query}("foo")).resolves.toBe("bar") - expect(wrappedQuery(${query}("foo"))).resolves.toBe("bar") - }) - `, - })), + // unresolved async queries are valid if there are no imports from a testing library module ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `test(() => { - expect(${query}("foo")).rejects.toBe("bar") - expect(wrappedQuery(${query}("foo"))).rejects.toBe("bar") - }) + code: ` + import { render } from "another-library" + + test('An example test', async () => { + const example = ${query}("my example") + }) `, })), ], - invalid: + invalid: [ // async queries without await operator or then method are not valid - [ - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { + ...createTestCase(query => ({ + code: ` doSomething() const foo = ${query}('foo') - } `, - errors: [ - { - messageId: 'awaitAsyncQuery', - }, - ], - })), - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - screen.${query}('foo') - } - `, - errors: [ - { - messageId: 'awaitAsyncQuery', - }, - ], - })), - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `const foo = screen.${query}('foo')`, - errors: [ - { - messageId: 'awaitAsyncQuery', - }, - ], - })), - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { + errors: [{ messageId: 'awaitAsyncQuery' }], + })), + + // async screen queries without await operator or then method are not valid + ...createTestCase(query => ({ + code: `screen.${query}('foo')`, + errors: [{ messageId: 'awaitAsyncQuery' }], + })), + + ...createTestCase(query => ({ + code: ` const foo = ${query}('foo') expect(foo).toBeInTheDocument() expect(foo).toHaveAttribute('src', 'bar'); - } `, - errors: [ - { - line: 2, - messageId: 'awaitAsyncQuery', - data: { - name: query, - }, + errors: [ + { + line: 5, + messageId: 'awaitAsyncQuery', + data: { + name: query, }, - ], - })), - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - expect(${query}('foo')).toBeInTheDocument() - } - `, - errors: [ - { - line: 2, - messageId: 'awaitAsyncQuery', - data: { - name: query, - }, - }, - ], - })), - ], + }, + ], + })), + ], });