diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/jest.config.js b/jest.config.js index d9dd44ea..963a9b50 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,11 +11,17 @@ module.exports = { statements: 100, }, // TODO drop this custom threshold in v4 - "./lib/node-utils.ts": { + './lib/detect-testing-library-utils.ts': { + branches: 50, + functions: 90, + lines: 90, + statements: 90, + }, + './lib/node-utils.ts': { branches: 90, functions: 90, lines: 90, statements: 90, - } + }, }, }; diff --git a/lib/create-testing-library-rule.ts b/lib/create-testing-library-rule.ts new file mode 100644 index 00000000..a34d9fef --- /dev/null +++ b/lib/create-testing-library-rule.ts @@ -0,0 +1,38 @@ +import { ESLintUtils, TSESLint } from '@typescript-eslint/experimental-utils'; +import { getDocsUrl } from './utils'; +import { + detectTestingLibraryUtils, + DetectionHelpers, +} from './detect-testing-library-utils'; + +// These 2 types are copied from @typescript-eslint/experimental-utils +type CreateRuleMetaDocs = Omit; +type CreateRuleMeta = { + docs: CreateRuleMetaDocs; +} & Omit, 'docs'>; + +export function createTestingLibraryRule< + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +>( + config: Readonly<{ + name: string; + meta: CreateRuleMeta; + defaultOptions: Readonly; + create: ( + context: Readonly>, + optionsWithDefault: Readonly, + detectionHelpers: Readonly + ) => TRuleListener; + }> +): TSESLint.RuleModule { + const { create, ...remainingConfig } = config; + + return ESLintUtils.RuleCreator(getDocsUrl)({ + ...remainingConfig, + create: detectTestingLibraryUtils( + create + ), + }); +} diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts new file mode 100644 index 00000000..25a7991f --- /dev/null +++ b/lib/detect-testing-library-utils.ts @@ -0,0 +1,65 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +export type DetectionHelpers = { + getIsImportingTestingLibrary: () => boolean; +}; + +/** + * 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: ( + context: Readonly>, + optionsWithDefault: Readonly, + detectionHelpers: Readonly + ) => TRuleListener +) { + return ( + context: Readonly>, + optionsWithDefault: Readonly + ): TRuleListener => { + let isImportingTestingLibrary = false; + + // TODO: init here options based on shared ESLint config + + // helpers for Testing Library detection + const helpers: DetectionHelpers = { + getIsImportingTestingLibrary() { + return isImportingTestingLibrary; + }, + }; + + // instructions for Testing Library detection + const detectionInstructions: TSESLint.RuleListener = { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + isImportingTestingLibrary = /testing-library/g.test( + node.source.value as string + ); + }, + }; + + // update given rule to inject Testing Library detection + const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); + const enhancedRuleInstructions = Object.assign({}, ruleInstructions); + + Object.keys(detectionInstructions).forEach((instruction) => { + (enhancedRuleInstructions as TSESLint.RuleListener)[instruction] = ( + node + ) => { + if (instruction in detectionInstructions) { + detectionInstructions[instruction](node); + } + + if (ruleInstructions[instruction]) { + return ruleInstructions[instruction](node); + } + }; + }); + + return enhancedRuleInstructions; + }; +} diff --git a/lib/node-utils.ts b/lib/node-utils.ts index 29a39517..d4220381 100644 --- a/lib/node-utils.ts +++ b/lib/node-utils.ts @@ -40,7 +40,7 @@ export function isImportSpecifier( export function isImportNamespaceSpecifier( node: TSESTree.Node ): node is TSESTree.ImportNamespaceSpecifier { - return node?.type === AST_NODE_TYPES.ImportNamespaceSpecifier + return node?.type === AST_NODE_TYPES.ImportNamespaceSpecifier; } export function isImportDefaultSpecifier( @@ -145,7 +145,7 @@ export function isReturnStatement( export function isArrayExpression( node: TSESTree.Node ): node is TSESTree.ArrayExpression { - return node?.type === AST_NODE_TYPES.ArrayExpression + return node?.type === AST_NODE_TYPES.ArrayExpression; } export function isAwaited(node: TSESTree.Node): boolean { diff --git a/lib/rules/no-node-access.ts b/lib/rules/no-node-access.ts index d693dc7a..098787ef 100644 --- a/lib/rules/no-node-access.ts +++ b/lib/rules/no-node-access.ts @@ -1,12 +1,13 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, ALL_RETURNING_NODES } from '../utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { ALL_RETURNING_NODES } from '../utils'; import { isIdentifier } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-node-access'; export type MessageIds = 'noNodeAccess'; type Options = []; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -24,19 +25,11 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }, defaultOptions: [], - create(context) { - let isImportingTestingLibrary = false; - - function checkTestingEnvironment(node: TSESTree.ImportDeclaration) { - isImportingTestingLibrary = /testing-library/g.test( - node.source.value as string - ); - } - + create: (context, _, helpers) => { function showErrorForNodeAccess(node: TSESTree.MemberExpression) { isIdentifier(node.property) && ALL_RETURNING_NODES.includes(node.property.name) && - isImportingTestingLibrary && + helpers.getIsImportingTestingLibrary() && context.report({ node: node, loc: node.property.loc.start, @@ -45,7 +38,6 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ } return { - ['ImportDeclaration']: checkTestingEnvironment, ['ExpressionStatement MemberExpression']: showErrorForNodeAccess, ['VariableDeclarator MemberExpression']: showErrorForNodeAccess, }; diff --git a/lib/utils.ts b/lib/utils.ts index 7ed1af77..284e8f7e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -128,5 +128,5 @@ export { METHODS_RETURNING_NODES, ALL_RETURNING_NODES, PRESENCE_MATCHERS, - ABSENCE_MATCHERS + ABSENCE_MATCHERS, }; diff --git a/package.json b/package.json index 8ed48264..1ec8caa7 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "postbuild": "cpy README.md ./dist && cpy package.json ./dist && cpy LICENSE ./dist", "lint": "eslint . --ext .js,.ts", "lint:fix": "npm run lint -- --fix", - "format": "prettier --write README.md {lib,docs,tests}/**/*.{js,ts,md}", - "format:check": "prettier --check README.md {lib,docs,tests}/**/*.{js,json,yml,ts,md}", + "format": "prettier --write README.md \"{lib,docs,tests}/**/*.{js,ts,md}\"", + "format:check": "prettier --check README.md \"{lib,docs,tests}/**/*.{js,json,yml,ts,md}\"", "test:local": "jest", "test:ci": "jest --coverage", "test:update": "npm run test:local -- --u", diff --git a/tests/lib/rules/await-async-utils.test.ts b/tests/lib/rules/await-async-utils.test.ts index 1ae25c46..e14ad2c0 100644 --- a/tests/lib/rules/await-async-utils.test.ts +++ b/tests/lib/rules/await-async-utils.test.ts @@ -120,7 +120,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util used in with Promise.all() does not trigger an error', async () => { @@ -131,7 +131,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util used in with Promise.all() with an await does not trigger an error', async () => { @@ -142,7 +142,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util used in with Promise.all() with ".then" does not trigger an error', async () => { @@ -162,7 +162,7 @@ ruleTester.run(RULE_NAME, rule, { waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')), ]) }); - ` + `, }, { code: ` @@ -191,8 +191,8 @@ ruleTester.run(RULE_NAME, rule, { await foo().then(() => baz()) ]) }) - ` - } + `, + }, ], invalid: [ ...ASYNC_UTILS.map((asyncUtil) => ({ diff --git a/tests/lib/rules/no-wait-for-snapshot.test.ts b/tests/lib/rules/no-wait-for-snapshot.test.ts index 5f489513..522bf12d 100644 --- a/tests/lib/rules/no-wait-for-snapshot.test.ts +++ b/tests/lib/rules/no-wait-for-snapshot.test.ts @@ -6,7 +6,7 @@ const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -16,7 +16,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -28,7 +28,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -38,7 +38,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -50,7 +50,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -58,7 +58,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -68,7 +68,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -76,7 +76,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -86,7 +86,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -94,7 +94,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -104,7 +104,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -112,7 +112,7 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from 'some-other-library'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -124,7 +124,7 @@ ruleTester.run(RULE_NAME, rule, { })), ], invalid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -133,7 +133,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -144,7 +144,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -153,7 +153,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -164,7 +164,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -173,7 +173,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -184,7 +184,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { @@ -193,7 +193,7 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { diff --git a/tests/lib/rules/prefer-find-by.test.ts b/tests/lib/rules/prefer-find-by.test.ts index 2e64829e..eacef738 100644 --- a/tests/lib/rules/prefer-find-by.test.ts +++ b/tests/lib/rules/prefer-find-by.test.ts @@ -30,7 +30,7 @@ function createScenario< return WAIT_METHODS.reduce( (acc: T[], waitMethod) => acc.concat( - SYNC_QUERIES_COMBINATIONS.map(queryMethod => + SYNC_QUERIES_COMBINATIONS.map((queryMethod) => callback(waitMethod, queryMethod) ) ), @@ -40,19 +40,19 @@ function createScenario< ruleTester.run(RULE_NAME, rule, { valid: [ - ...ASYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` const { ${queryMethod} } = setup() const submitButton = await ${queryMethod}('foo') `, })), - ...ASYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `const submitButton = await screen.${queryMethod}('foo')`, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `await waitForElementToBeRemoved(() => ${queryMethod}(baz))`, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `await waitFor(function() { return ${queryMethod}('baz', { name: 'foo' }) })`, @@ -66,7 +66,7 @@ ruleTester.run(RULE_NAME, rule, { { code: `await waitForElementToBeRemoved(document.querySelector('foo'))`, }, - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` await waitFor(() => { foo() @@ -74,12 +74,12 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` await waitFor(() => expect(screen.${queryMethod}('baz')).toBeDisabled()); `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` await waitFor(() => expect(${queryMethod}('baz')).toBeInTheDocument()); `,