Skip to content

fix(no-node-access): Improve detection logic in no-node-access to resolve imported aliases and setup instances #1033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6260880
feat: add StringLiteral and TemplateLiteral type checks
y-hsgw Jun 22, 2025
05b2934
feat: add type checks for TSImportEqualsDeclaration and ImportExpression
y-hsgw Jun 22, 2025
7b4e38c
feat: implement resolveToTestingLibraryFn utility for handling user e…
y-hsgw Jun 22, 2025
d445ec2
feat: enhance rule to handle user event instances and improve reporting
y-hsgw Jun 22, 2025
bdc3506
test: update no-node-access tests to include userEvent setup scenarios
y-hsgw Jun 22, 2025
7510adf
feat: enhance no-node-access rule to support userEvent setup function…
y-hsgw Jun 22, 2025
bb7df93
refactor: remove unnecessary case for TaggedTemplateExpression in get…
y-hsgw Jun 22, 2025
bf45566
test: add tests for resolveToTestingLibraryFn
y-hsgw Jun 22, 2025
9e49403
test: update userEvent import syntax in resolveToTestingLibraryFn tests
y-hsgw Jun 22, 2025
6781117
feat: add support for settings['testing-library/utils-module']
y-hsgw Jun 29, 2025
f7ce66f
test: add test case with settings['testing-library/utils-module'] con…
y-hsgw Jun 29, 2025
ad7daab
test: add additional test cases for userEvent import variations
y-hsgw Jul 3, 2025
0663480
test: update userEvent import cases and enhance error reporting in tests
y-hsgw Jul 5, 2025
42278fe
refactor: remove redundant JSDoc comments from string and identifier …
y-hsgw Jul 9, 2025
e91aa36
refactor: replace direct type check with isLiteral utility in isStrin…
y-hsgw Jul 9, 2025
d426a29
refactor: rename isTemplateLiteral to isSimpleTemplateLiteral for cla…
y-hsgw Jul 9, 2025
df09b1a
refactor: add isTemplateLiteral utility function and update isSimpleT…
y-hsgw Jul 9, 2025
f51421f
refactor: import USER_EVENT_MODULE in resolve-to-testing-library-fn a…
y-hsgw Jul 9, 2025
cc0ab47
test: add test
y-hsgw Jul 9, 2025
c4a884b
Revert "test: add test"
y-hsgw Jul 9, 2025
db38d9f
test: add test
y-hsgw Jul 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions lib/node-utils/accessors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
AST_NODE_TYPES,
ASTUtils,
type TSESTree,
} from '@typescript-eslint/utils';

import { isLiteral, isTemplateLiteral } from './is-node-of-type';

/**
* A `Literal` with a `value` of type `string`.
*/
interface StringLiteral<Value extends string = string>
extends TSESTree.StringLiteral {
value: Value;
}

/**
* Checks if the given `node` is a `StringLiteral`.
*
* If a `value` is provided & the `node` is a `StringLiteral`,
* the `value` will be compared to that of the `StringLiteral`.
*/
const isStringLiteral = <V extends string>(
node: TSESTree.Node,
value?: V
): node is StringLiteral<V> =>
isLiteral(node) &&
typeof node.value === 'string' &&
(value === undefined || node.value === value);

interface TemplateLiteral<Value extends string = string>
extends TSESTree.TemplateLiteral {
quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }];
}

/**
* Checks if the given `node` is a `TemplateLiteral`.
*
* Complex `TemplateLiteral`s are not considered specific, and so will return `false`.
*
* If a `value` is provided & the `node` is a `TemplateLiteral`,
* the `value` will be compared to that of the `TemplateLiteral`.
*/
const isSimpleTemplateLiteral = <V extends string>(
node: TSESTree.Node,
value?: V
): node is TemplateLiteral<V> =>
isTemplateLiteral(node) &&
node.quasis.length === 1 && // bail out if not simple
(value === undefined || node.quasis[0].value.raw === value);

export type StringNode<S extends string = string> =
| StringLiteral<S>
| TemplateLiteral<S>;

/**
* Checks if the given `node` is a {@link StringNode}.
*/
export const isStringNode = <V extends string>(
node: TSESTree.Node,
specifics?: V
): node is StringNode<V> =>
isStringLiteral(node, specifics) || isSimpleTemplateLiteral(node, specifics);

/**
* Gets the value of the given `StringNode`.
*
* If the `node` is a `TemplateLiteral`, the `raw` value is used;
* otherwise, `value` is returned instead.
*/
export const getStringValue = <S extends string>(node: StringNode<S>): S =>
isSimpleTemplateLiteral(node) ? node.quasis[0].value.raw : node.value;

/**
* An `Identifier` with a known `name` value
*/
interface KnownIdentifier<Name extends string> extends TSESTree.Identifier {
name: Name;
}

/**
* Checks if the given `node` is an `Identifier`.
*
* If a `name` is provided, & the `node` is an `Identifier`,
* the `name` will be compared to that of the `identifier`.
*/
export const isIdentifier = <V extends string>(
node: TSESTree.Node,
name?: V
): node is KnownIdentifier<V> =>
ASTUtils.isIdentifier(node) && (name === undefined || node.name === name);

/**
* Checks if the given `node` is a "supported accessor".
*
* This means that it's a node can be used to access properties,
* and who's "value" can be statically determined.
*
* `MemberExpression` nodes most commonly contain accessors,
* but it's possible for other nodes to contain them.
*
* If a `value` is provided & the `node` is an `AccessorNode`,
* the `value` will be compared to that of the `AccessorNode`.
*
* Note that `value` here refers to the normalised value.
* The property that holds the value is not always called `name`.
*/
export const isSupportedAccessor = <V extends string>(
node: TSESTree.Node,
value?: V
): node is AccessorNode<V> =>
isIdentifier(node, value) || isStringNode(node, value);

/**
* Gets the value of the given `AccessorNode`,
* account for the different node types.
*/
export const getAccessorValue = <S extends string = string>(
accessor: AccessorNode<S>
): S =>
accessor.type === AST_NODE_TYPES.Identifier
? accessor.name
: getStringValue(accessor);

export type AccessorNode<Specifics extends string = string> =
| StringNode<Specifics>
| KnownIdentifier<Specifics>;
9 changes: 9 additions & 0 deletions lib/node-utils/is-node-of-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export const isImportDeclaration = ASTUtils.isNodeOfType(
export const isImportDefaultSpecifier = ASTUtils.isNodeOfType(
AST_NODE_TYPES.ImportDefaultSpecifier
);
export const isTSImportEqualsDeclaration = ASTUtils.isNodeOfType(
AST_NODE_TYPES.TSImportEqualsDeclaration
);
export const isImportExpression = ASTUtils.isNodeOfType(
AST_NODE_TYPES.ImportExpression
);
export const isImportNamespaceSpecifier = ASTUtils.isNodeOfType(
AST_NODE_TYPES.ImportNamespaceSpecifier
);
Expand All @@ -40,6 +46,9 @@ export const isJSXAttribute = ASTUtils.isNodeOfType(
AST_NODE_TYPES.JSXAttribute
);
export const isLiteral = ASTUtils.isNodeOfType(AST_NODE_TYPES.Literal);
export const isTemplateLiteral = ASTUtils.isNodeOfType(
AST_NODE_TYPES.TemplateLiteral
);
export const isMemberExpression = ASTUtils.isNodeOfType(
AST_NODE_TYPES.MemberExpression
);
Expand Down
75 changes: 59 additions & 16 deletions lib/rules/no-node-access.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { TSESTree, ASTUtils } from '@typescript-eslint/utils';

import { createTestingLibraryRule } from '../create-testing-library-rule';
import { isCallExpression, isMemberExpression } from '../node-utils';
import {
ALL_RETURNING_NODES,
EVENT_HANDLER_METHODS,
EVENTS_SIMULATORS,
resolveToTestingLibraryFn,
} from '../utils';

export const RULE_NAME = 'no-node-access';
export type MessageIds = 'noNodeAccess';
export type Options = [{ allowContainerFirstChild: boolean }];

const ALL_PROHIBITED_MEMBERS = [
...ALL_RETURNING_NODES,
...EVENT_HANDLER_METHODS,
] as const;
const userEventInstanceNames = new Set<string>();

export default createTestingLibraryRule<Options, MessageIds>({
name: RULE_NAME,
Expand Down Expand Up @@ -65,20 +63,11 @@ export default createTestingLibraryRule<Options, MessageIds>({
? node.property.name
: null;

const objectName = ASTUtils.isIdentifier(node.object)
? node.object.name
: null;
if (
propertyName &&
ALL_PROHIBITED_MEMBERS.some(
ALL_RETURNING_NODES.some(
(allReturningNode) => allReturningNode === propertyName
) &&
![
...EVENTS_SIMULATORS,
// TODO: As discussed in https://github.com/testing-library/eslint-plugin-testing-library/issues/1024, this is just a temporary workaround.
// We should address the root cause and implement a proper solution instead of explicitly excluding 'user' here.
'user',
].some((simulator) => simulator === objectName)
)
) {
if (allowContainerFirstChild && propertyName === 'firstChild') {
return;
Expand All @@ -100,6 +89,60 @@ export default createTestingLibraryRule<Options, MessageIds>({
}

return {
CallExpression(node: TSESTree.CallExpression) {
const { callee } = node;
const property = isMemberExpression(callee) ? callee.property : null;
const object = isMemberExpression(callee) ? callee.object : null;

const propertyName = ASTUtils.isIdentifier(property)
? property.name
: null;
const objectName = ASTUtils.isIdentifier(object) ? object.name : null;

const isEventHandlerMethod = EVENT_HANDLER_METHODS.some(
(method) => method === propertyName
);
const hasUserEventInstanceName = userEventInstanceNames.has(
objectName ?? ''
);
const testingLibraryFn = resolveToTestingLibraryFn(node, context);

if (
!testingLibraryFn &&
isEventHandlerMethod &&
!hasUserEventInstanceName
) {
context.report({
node,
loc: property?.loc.start,
messageId: 'noNodeAccess',
});
}
},
VariableDeclarator(node: TSESTree.VariableDeclarator) {
const { init, id } = node;

if (!isCallExpression(init)) {
return;
}

if (
!isMemberExpression(init.callee) ||
!ASTUtils.isIdentifier(init.callee.object)
) {
return;
}

const testingLibraryFn = resolveToTestingLibraryFn(init, context);
if (
init.callee.object.name === testingLibraryFn?.local &&
ASTUtils.isIdentifier(init.callee.property) &&
init.callee.property.name === 'setup' &&
ASTUtils.isIdentifier(id)
) {
userEventInstanceNames.add(id.name);
}
},
'ExpressionStatement MemberExpression': showErrorForNodeAccess,
'VariableDeclarator MemberExpression': showErrorForNodeAccess,
};
Expand Down
4 changes: 4 additions & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './compat';
export * from './file-import';
export * from './types';
export * from './resolve-to-testing-library-fn';

const combineQueries = (
variants: readonly string[],
Expand Down Expand Up @@ -30,6 +31,8 @@ const LIBRARY_MODULES = [
'@marko/testing-library',
] as const;

const USER_EVENT_MODULE = '@testing-library/user-event';

const SYNC_QUERIES_VARIANTS = [
'getBy',
'getAllBy',
Expand Down Expand Up @@ -150,4 +153,5 @@ export {
PRESENCE_MATCHERS,
ABSENCE_MATCHERS,
EVENT_HANDLER_METHODS,
USER_EVENT_MODULE,
};
Loading