Skip to content

Commit 9d4c1e4

Browse files
feat: add no-node-access rule (#190)
* refactor(utils): add properties and methods that returns another Node * test(no-node-access): add first scenarios * feat(no-node-access): add rule with few test cases * test(no-node-access): add scenarios * refactor(no-node-access): simplify conditions * refactor(no-node-access): add scenario when no variable is declared * refactor(no-node-access): remove conditional * refactor(utils): add DOM properties * refactor(no-node-access): add scenario for accessing document directly * docs(no-node-access): add readme * refactor(utils): export const containing all properties and methods that return a Node * docs(no-node-access): fix file location * docs(readme): add no-node-access * refactor(no-node-access): change highlight location * docs(no-node-access): fix typo * refactor(utils): add missing property that returns a Node * refactor(no-node-access): simplify checks triggering error for all methods with names matching the forbidden ones * test(no-node-access): add more scenarios with destructuring * docs(no-node-access): update examples * refactor(no-node-access): narrow error cases * refactor(no-node-access): check imports to validate whether is importing a testing-library package | update examples and testing scenarios * refactor(no-node-access): rename variable
1 parent b2ef721 commit 9d4c1e4

File tree

8 files changed

+423
-5
lines changed

8 files changed

+423
-5
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ To enable this configuration use the `extends` property in your
137137
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
138138
| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
139139
| [no-multiple-assertions-wait-for](docs/rules/no-multiple-assertions-wait-for.md) | Disallow the use of multiple expect inside `waitFor` | | |
140+
| [no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | | |
140141
| [no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | |
141142
| [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][] | |
142143
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |

docs/rules/no-multiple-assertions-wait-for.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ const foo = async () => {
1818

1919
// or
2020
await waitFor(function() {
21-
expect(a).toEqual('a')
21+
expect(a).toEqual('a');
2222
expect(b).toEqual('b');
23-
})
23+
});
2424
};
2525
```
2626

@@ -30,11 +30,11 @@ Examples of **correct** code for this rule:
3030
const foo = async () => {
3131
await waitFor(() => expect(a).toEqual('a'));
3232
expect(b).toEqual('b');
33-
33+
3434
// or
3535
await waitFor(function() {
36-
expect(a).toEqual('a')
37-
})
36+
expect(a).toEqual('a');
37+
});
3838
expect(b).toEqual('b');
3939

4040
// it only detects expect

docs/rules/no-node-access.md

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Disallow direct Node access (no-node-access)
2+
3+
The Testing Library already provides methods for querying DOM elements.
4+
5+
## Rule Details
6+
7+
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.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```js
12+
import { screen } from '@testing-library/react';
13+
14+
screen.getByText('Submit').closest('button'); // chaining with Testing Library methods
15+
```
16+
17+
```js
18+
import { screen } from '@testing-library/react';
19+
20+
const buttons = screen.getAllByRole('button');
21+
expect(buttons[1].lastChild).toBeInTheDocument();
22+
```
23+
24+
```js
25+
import { screen } from '@testing-library/react';
26+
27+
const buttonText = screen.getByText('Submit');
28+
const button = buttonText.closest('button');
29+
```
30+
31+
Examples of **correct** code for this rule:
32+
33+
```js
34+
import { screen } from '@testing-library/react';
35+
36+
const button = screen.getByRole('button');
37+
expect(button).toHaveTextContent('submit');
38+
```
39+
40+
```js
41+
import { render, within } from '@testing-library/react';
42+
43+
const { getByLabelText } = render(<MyComponent />);
44+
const signinModal = getByLabelText('Sign In');
45+
within(signinModal).getByPlaceholderText('Username');
46+
```
47+
48+
```js
49+
// If is not importing a testing-library package
50+
51+
document.getElementById('submit-btn').closest('button');
52+
```
53+
54+
## Further Reading
55+
56+
### Properties / methods that return another Node
57+
58+
- [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document)
59+
- [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element)
60+
- [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node)

lib/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import noContainer from './rules/no-container';
77
import noDebug from './rules/no-debug';
88
import noDomImport from './rules/no-dom-import';
99
import noManualCleanup from './rules/no-manual-cleanup';
10+
import noNodeAccess from './rules/no-node-access';
1011
import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback';
1112
import noPromiseInFireEvent from './rules/no-promise-in-fire-event';
1213
import preferExplicitAssert from './rules/prefer-explicit-assert';
@@ -27,6 +28,7 @@ const rules = {
2728
'no-dom-import': noDomImport,
2829
'no-manual-cleanup': noManualCleanup,
2930
'no-multiple-assertions-wait-for': noMultipleAssertionsWaitFor,
31+
'no-node-access': noNodeAccess,
3032
'no-promise-in-fire-event': noPromiseInFireEvent,
3133
'no-wait-for-empty-callback': noWaitForEmptyCallback,
3234
'prefer-explicit-assert': preferExplicitAssert,
@@ -51,13 +53,15 @@ const angularRules = {
5153
'testing-library/no-container': 'error',
5254
'testing-library/no-debug': 'warn',
5355
'testing-library/no-dom-import': ['error', 'angular'],
56+
'testing-library/no-node-access': 'error',
5457
};
5558

5659
const reactRules = {
5760
...domRules,
5861
'testing-library/no-container': 'error',
5962
'testing-library/no-debug': 'warn',
6063
'testing-library/no-dom-import': ['error', 'react'],
64+
'testing-library/no-node-access': 'error',
6165
};
6266

6367
const vueRules = {
@@ -66,6 +70,7 @@ const vueRules = {
6670
'testing-library/no-container': 'error',
6771
'testing-library/no-debug': 'warn',
6872
'testing-library/no-dom-import': ['error', 'vue'],
73+
'testing-library/no-node-access': 'error',
6974
};
7075

7176
export = {

lib/rules/no-node-access.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { getDocsUrl, ALL_RETURNING_NODES } from '../utils';
3+
import { isIdentifier } from '../node-utils';
4+
5+
export const RULE_NAME = 'no-node-access';
6+
7+
export default ESLintUtils.RuleCreator(getDocsUrl)({
8+
name: RULE_NAME,
9+
meta: {
10+
type: 'problem',
11+
docs: {
12+
description: 'Disallow direct Node access',
13+
category: 'Best Practices',
14+
recommended: 'error',
15+
},
16+
messages: {
17+
noNodeAccess:
18+
'Avoid direct Node access. Prefer using the methods from Testing Library.',
19+
},
20+
fixable: null,
21+
schema: [],
22+
},
23+
defaultOptions: [],
24+
25+
create(context) {
26+
let isImportingTestingLibrary = false;
27+
28+
function checkTestingEnvironment(node: TSESTree.ImportDeclaration) {
29+
isImportingTestingLibrary = /testing-library/g.test(node.source.value as string);
30+
}
31+
32+
function showErrorForNodeAccess(node: TSESTree.MemberExpression) {
33+
isIdentifier(node.property) &&
34+
ALL_RETURNING_NODES.includes(node.property.name) &&
35+
isImportingTestingLibrary &&
36+
context.report({
37+
node: node,
38+
loc: node.property.loc.start,
39+
messageId: 'noNodeAccess',
40+
});
41+
}
42+
43+
return {
44+
['ImportDeclaration']: checkTestingEnvironment,
45+
['ExpressionStatement MemberExpression']: showErrorForNodeAccess,
46+
['VariableDeclarator MemberExpression']: showErrorForNodeAccess,
47+
};
48+
},
49+
});

lib/utils.ts

+38
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ const ASYNC_UTILS = [
6363
'waitForDomChange',
6464
];
6565

66+
const PROPERTIES_RETURNING_NODES = [
67+
'activeElement',
68+
'children',
69+
'firstChild',
70+
'firstElementChild',
71+
'fullscreenElement',
72+
'lastChild',
73+
'lastElementChild',
74+
'nextElementSibling',
75+
'nextSibling',
76+
'parentElement',
77+
'parentNode',
78+
'pointerLockElement',
79+
'previousElementSibling',
80+
'previousSibling',
81+
'rootNode',
82+
'scripts',
83+
];
84+
85+
const METHODS_RETURNING_NODES = [
86+
'closest',
87+
'getElementById',
88+
'getElementsByClassName',
89+
'getElementsByName',
90+
'getElementsByTagName',
91+
'getElementsByTagNameNS',
92+
'querySelector',
93+
'querySelectorAll',
94+
];
95+
96+
const ALL_RETURNING_NODES = [
97+
...PROPERTIES_RETURNING_NODES,
98+
...METHODS_RETURNING_NODES,
99+
];
100+
66101
export {
67102
getDocsUrl,
68103
SYNC_QUERIES_VARIANTS,
@@ -74,4 +109,7 @@ export {
74109
ALL_QUERIES_COMBINATIONS,
75110
ASYNC_UTILS,
76111
LIBRARY_MODULES,
112+
PROPERTIES_RETURNING_NODES,
113+
METHODS_RETURNING_NODES,
114+
ALL_RETURNING_NODES,
77115
};

tests/__snapshots__/index.test.ts.snap

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Object {
1515
"error",
1616
"angular",
1717
],
18+
"testing-library/no-node-access": "error",
1819
"testing-library/no-promise-in-fire-event": "error",
1920
"testing-library/no-wait-for-empty-callback": "error",
2021
"testing-library/prefer-find-by": "error",
@@ -55,6 +56,7 @@ Object {
5556
"error",
5657
"react",
5758
],
59+
"testing-library/no-node-access": "error",
5860
"testing-library/no-promise-in-fire-event": "error",
5961
"testing-library/no-wait-for-empty-callback": "error",
6062
"testing-library/prefer-find-by": "error",
@@ -79,6 +81,7 @@ Object {
7981
"error",
8082
"vue",
8183
],
84+
"testing-library/no-node-access": "error",
8285
"testing-library/no-promise-in-fire-event": "error",
8386
"testing-library/no-wait-for-empty-callback": "error",
8487
"testing-library/prefer-find-by": "error",

0 commit comments

Comments
 (0)