diff --git a/README.md b/README.md index 1438ce6f..2bbe9062 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ To enable this configuration use the `extends` property in your | [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | | [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | | | [no-multiple-assertions-wait-for](docs/rules/no-multiple-assertions-wait-for.md) | Disallow the use of multiple expect inside `waitFor` | | | +| [no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | | | | [no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | | | [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | | diff --git a/docs/rules/no-multiple-assertions-wait-for.md b/docs/rules/no-multiple-assertions-wait-for.md index 4259187f..b1f946bf 100644 --- a/docs/rules/no-multiple-assertions-wait-for.md +++ b/docs/rules/no-multiple-assertions-wait-for.md @@ -18,9 +18,9 @@ const foo = async () => { // or await waitFor(function() { - expect(a).toEqual('a') + expect(a).toEqual('a'); expect(b).toEqual('b'); - }) + }); }; ``` @@ -30,11 +30,11 @@ Examples of **correct** code for this rule: const foo = async () => { await waitFor(() => expect(a).toEqual('a')); expect(b).toEqual('b'); - + // or await waitFor(function() { - expect(a).toEqual('a') - }) + expect(a).toEqual('a'); + }); expect(b).toEqual('b'); // it only detects expect diff --git a/docs/rules/no-node-access.md b/docs/rules/no-node-access.md new file mode 100644 index 00000000..78d775dd --- /dev/null +++ b/docs/rules/no-node-access.md @@ -0,0 +1,60 @@ +# Disallow direct Node access (no-node-access) + +The Testing Library already provides methods for querying DOM elements. + +## Rule Details + +This rule aims to disallow DOM traversal using native HTML methods and properties, such as `closest`, `lastChild` and all that returns another Node element from an HTML tree. + +Examples of **incorrect** code for this rule: + +```js +import { screen } from '@testing-library/react'; + +screen.getByText('Submit').closest('button'); // chaining with Testing Library methods +``` + +```js +import { screen } from '@testing-library/react'; + +const buttons = screen.getAllByRole('button'); +expect(buttons[1].lastChild).toBeInTheDocument(); +``` + +```js +import { screen } from '@testing-library/react'; + +const buttonText = screen.getByText('Submit'); +const button = buttonText.closest('button'); +``` + +Examples of **correct** code for this rule: + +```js +import { screen } from '@testing-library/react'; + +const button = screen.getByRole('button'); +expect(button).toHaveTextContent('submit'); +``` + +```js +import { render, within } from '@testing-library/react'; + +const { getByLabelText } = render(); +const signinModal = getByLabelText('Sign In'); +within(signinModal).getByPlaceholderText('Username'); +``` + +```js +// If is not importing a testing-library package + +document.getElementById('submit-btn').closest('button'); +``` + +## Further Reading + +### Properties / methods that return another Node + +- [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document) +- [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element) +- [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node) diff --git a/lib/index.ts b/lib/index.ts index 4b5149f2..12301b65 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -7,6 +7,7 @@ import noContainer from './rules/no-container'; import noDebug from './rules/no-debug'; import noDomImport from './rules/no-dom-import'; import noManualCleanup from './rules/no-manual-cleanup'; +import noNodeAccess from './rules/no-node-access'; import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback'; import noPromiseInFireEvent from './rules/no-promise-in-fire-event'; import preferExplicitAssert from './rules/prefer-explicit-assert'; @@ -27,6 +28,7 @@ const rules = { 'no-dom-import': noDomImport, 'no-manual-cleanup': noManualCleanup, 'no-multiple-assertions-wait-for': noMultipleAssertionsWaitFor, + 'no-node-access': noNodeAccess, 'no-promise-in-fire-event': noPromiseInFireEvent, 'no-wait-for-empty-callback': noWaitForEmptyCallback, 'prefer-explicit-assert': preferExplicitAssert, @@ -51,6 +53,7 @@ const angularRules = { 'testing-library/no-container': 'error', 'testing-library/no-debug': 'warn', 'testing-library/no-dom-import': ['error', 'angular'], + 'testing-library/no-node-access': 'error', }; const reactRules = { @@ -58,6 +61,7 @@ const reactRules = { 'testing-library/no-container': 'error', 'testing-library/no-debug': 'warn', 'testing-library/no-dom-import': ['error', 'react'], + 'testing-library/no-node-access': 'error', }; const vueRules = { @@ -66,6 +70,7 @@ const vueRules = { 'testing-library/no-container': 'error', 'testing-library/no-debug': 'warn', 'testing-library/no-dom-import': ['error', 'vue'], + 'testing-library/no-node-access': 'error', }; export = { diff --git a/lib/rules/no-node-access.ts b/lib/rules/no-node-access.ts new file mode 100644 index 00000000..e0a12933 --- /dev/null +++ b/lib/rules/no-node-access.ts @@ -0,0 +1,49 @@ +import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { getDocsUrl, ALL_RETURNING_NODES } from '../utils'; +import { isIdentifier } from '../node-utils'; + +export const RULE_NAME = 'no-node-access'; + +export default ESLintUtils.RuleCreator(getDocsUrl)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Disallow direct Node access', + category: 'Best Practices', + recommended: 'error', + }, + messages: { + noNodeAccess: + 'Avoid direct Node access. Prefer using the methods from Testing Library.', + }, + fixable: null, + schema: [], + }, + defaultOptions: [], + + create(context) { + let isImportingTestingLibrary = false; + + function checkTestingEnvironment(node: TSESTree.ImportDeclaration) { + isImportingTestingLibrary = /testing-library/g.test(node.source.value as string); + } + + function showErrorForNodeAccess(node: TSESTree.MemberExpression) { + isIdentifier(node.property) && + ALL_RETURNING_NODES.includes(node.property.name) && + isImportingTestingLibrary && + context.report({ + node: node, + loc: node.property.loc.start, + messageId: 'noNodeAccess', + }); + } + + return { + ['ImportDeclaration']: checkTestingEnvironment, + ['ExpressionStatement MemberExpression']: showErrorForNodeAccess, + ['VariableDeclarator MemberExpression']: showErrorForNodeAccess, + }; + }, +}); diff --git a/lib/utils.ts b/lib/utils.ts index 2652aa08..7af670c8 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -63,6 +63,41 @@ const ASYNC_UTILS = [ 'waitForDomChange', ]; +const PROPERTIES_RETURNING_NODES = [ + 'activeElement', + 'children', + 'firstChild', + 'firstElementChild', + 'fullscreenElement', + 'lastChild', + 'lastElementChild', + 'nextElementSibling', + 'nextSibling', + 'parentElement', + 'parentNode', + 'pointerLockElement', + 'previousElementSibling', + 'previousSibling', + 'rootNode', + 'scripts', +]; + +const METHODS_RETURNING_NODES = [ + 'closest', + 'getElementById', + 'getElementsByClassName', + 'getElementsByName', + 'getElementsByTagName', + 'getElementsByTagNameNS', + 'querySelector', + 'querySelectorAll', +]; + +const ALL_RETURNING_NODES = [ + ...PROPERTIES_RETURNING_NODES, + ...METHODS_RETURNING_NODES, +]; + export { getDocsUrl, SYNC_QUERIES_VARIANTS, @@ -74,4 +109,7 @@ export { ALL_QUERIES_COMBINATIONS, ASYNC_UTILS, LIBRARY_MODULES, + PROPERTIES_RETURNING_NODES, + METHODS_RETURNING_NODES, + ALL_RETURNING_NODES, }; diff --git a/tests/__snapshots__/index.test.ts.snap b/tests/__snapshots__/index.test.ts.snap index b86f6a5c..250540c3 100644 --- a/tests/__snapshots__/index.test.ts.snap +++ b/tests/__snapshots__/index.test.ts.snap @@ -15,6 +15,7 @@ Object { "error", "angular", ], + "testing-library/no-node-access": "error", "testing-library/no-promise-in-fire-event": "error", "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", @@ -55,6 +56,7 @@ Object { "error", "react", ], + "testing-library/no-node-access": "error", "testing-library/no-promise-in-fire-event": "error", "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", @@ -79,6 +81,7 @@ Object { "error", "vue", ], + "testing-library/no-node-access": "error", "testing-library/no-promise-in-fire-event": "error", "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", diff --git a/tests/lib/rules/no-node-access.test.ts b/tests/lib/rules/no-node-access.test.ts new file mode 100644 index 00000000..ee432767 --- /dev/null +++ b/tests/lib/rules/no-node-access.test.ts @@ -0,0 +1,262 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-node-access'; + +const ruleTester = createRuleTester({ + ecmaFeatures: { + jsx: true, + }, +}); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import { screen } from '@testing-library/react'; + + const buttonText = screen.getByText('submit'); + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const { getByText } = screen + const firstChild = getByText('submit'); + expect(firstChild).toBeInTheDocument() + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const firstChild = screen.getByText('submit'); + expect(firstChild).toBeInTheDocument() + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const { getByText } = screen; + const button = getByRole('button'); + expect(button).toHaveTextContent('submit'); + `, + }, + { + code: ` + import { render, within } from '@testing-library/react'; + + const { getByLabelText } = render(); + const signinModal = getByLabelText('Sign In'); + within(signinModal).getByPlaceholderText('Username'); + `, + }, + { + code: ` + const Component = props => { + return
{props.children}
+ } + `, + }, + { + // Not importing a testing-library package + code: ` + const closestButton = document.getElementById('submit-btn').closest('button'); + expect(closestButton).toBeInTheDocument(); + `, + }, + ], + invalid: [ + { + code: ` + import { screen } from '@testing-library/react'; + + const button = document.getElementById('submit-btn').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + document.getElementById('submit-btn'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + screen.getByText('submit').closest('button'); + `, + errors: [ + { + // error points to `closest` + line: 4, + column: 36, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + expect(screen.getByText('submit').closest('button').textContent).toBe('Submit'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + getByText('submit').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const buttons = screen.getAllByRole('button'); + const childA = buttons[1].firstChild; + const button = buttons[2]; + button.lastChild + `, + errors: [ + { + // error points to `firstChild` + line: 5, + column: 35, + messageId: 'noNodeAccess', + }, + { + // error points to `lastChild` + line: 7, + column: 16, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const buttonText = screen.getByText('submit'); + const button = buttonText.closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + const buttonText = getByText('submit'); + const button = buttonText.closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + const button = getByText('submit').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + function getExampleDOM() { + const container = document.createElement('div'); + container.innerHTML = \` + + + + + + + + \`; + return container; + }; + const exampleDOM = getExampleDOM(); + const buttons = screen.getAllByRole(exampleDOM, 'button'); + const buttonText = buttons[1].firstChild; + `, + errors: [ + { + // error points to `firstChild` + line: 19, + column: 39, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + function getExampleDOM() { + const container = document.createElement('div'); + container.innerHTML = \` + + + + + + + + \`; + return container; + }; + const exampleDOM = getExampleDOM(); + const submitButton = screen.getByText(exampleDOM, 'Submit'); + const previousButton = submitButton.previousSibling; + `, + errors: [ + { + // error points to `previousSibling` + line: 19, + column: 45, + messageId: 'noNodeAccess', + }, + ], + }, + ], +});