Skip to content
37 changes: 27 additions & 10 deletions lib/create-testing-library-rule/detect-testing-library-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ type IsAsyncUtilFn = (
validNames?: readonly (typeof ASYNC_UTILS)[number][]
) => boolean;
type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean;
type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean;
type IsUserEventMethodFn = (
node: TSESTree.Identifier,
userEventSetupVars?: Set<string>
) => boolean;
type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean;
type IsCreateEventUtil = (
node: TSESTree.CallExpression | TSESTree.Identifier
Expand Down Expand Up @@ -563,7 +566,10 @@ export function detectTestingLibraryUtils<
return regularCall || wildcardCall || wildcardCallWithCallExpression;
};

const isUserEventMethod: IsUserEventMethodFn = (node) => {
const isUserEventMethod: IsUserEventMethodFn = (
node,
userEventSetupVars
) => {
const userEvent = findImportedUserEventSpecifier();
let userEventName: string | undefined;

Expand All @@ -573,10 +579,6 @@ export function detectTestingLibraryUtils<
userEventName = USER_EVENT_NAME;
}

if (!userEventName) {
return false;
}

const parentMemberExpression: TSESTree.MemberExpression | undefined =
node.parent && isMemberExpression(node.parent)
? node.parent
Expand All @@ -588,18 +590,33 @@ export function detectTestingLibraryUtils<

// make sure that given node it's not userEvent object itself
if (
[userEventName, USER_EVENT_NAME].includes(node.name) ||
(userEventName &&
[userEventName, USER_EVENT_NAME].includes(node.name)) ||
(ASTUtils.isIdentifier(parentMemberExpression.object) &&
parentMemberExpression.object.name === node.name)
) {
return false;
}

// check userEvent.click() usage
return (
// check userEvent.click() usage (imported identifier)
if (
userEventName &&
ASTUtils.isIdentifier(parentMemberExpression.object) &&
parentMemberExpression.object.name === userEventName
);
) {
return true;
}

// check user.click() usage where user is a variable from userEvent.setup()
if (
userEventSetupVars &&
ASTUtils.isIdentifier(parentMemberExpression.object) &&
userEventSetupVars.has(parentMemberExpression.object.name)
) {
return true;
}

return false;
};

/**
Expand Down
118 changes: 116 additions & 2 deletions lib/rules/await-async-events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ASTUtils, TSESLint, TSESTree } from '@typescript-eslint/utils';
import {
AST_NODE_TYPES,
ASTUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';

import { createTestingLibraryRule } from '../create-testing-library-rule';
import {
Expand Down Expand Up @@ -81,6 +86,12 @@ export default createTestingLibraryRule<Options, MessageIds>({
create(context, [options], helpers) {
const functionWrappersNames: string[] = [];

// Track variables assigned from userEvent.setup() (directly or via destructuring)
const userEventSetupVars = new Set<string>();

// Track functions that return userEvent.setup() instances and their property names
const setupFunctions = new Map<string, Set<string>>();

function reportUnhandledNode({
node,
closestCallExpression,
Expand Down Expand Up @@ -110,6 +121,32 @@ export default createTestingLibraryRule<Options, MessageIds>({
}
}

function isUserEventSetupCall(node: TSESTree.Node): boolean {
return (
node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
node.callee.object.name === USER_EVENT_NAME &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.property.name === USER_EVENT_SETUP_FUNCTION_NAME
);
}

function findFunctionName(node: TSESTree.Node): string | null {
let current: TSESTree.Node | undefined = node;
while (current) {
if (
current.type === AST_NODE_TYPES.FunctionDeclaration ||
current.type === AST_NODE_TYPES.FunctionExpression ||
current.type === AST_NODE_TYPES.ArrowFunctionExpression
) {
return getFunctionName(current);
}
current = current.parent;
}
return null;
}

const eventModules =
typeof options.eventModule === 'string'
? [options.eventModule]
Expand All @@ -118,10 +155,87 @@ export default createTestingLibraryRule<Options, MessageIds>({
const isUserEventEnabled = eventModules.includes(USER_EVENT_NAME);

return {
// Track variables assigned from userEvent.setup() and destructuring from setup functions
VariableDeclarator(node: TSESTree.VariableDeclarator) {
if (!isUserEventEnabled) return;

// Direct assignment: const user = userEvent.setup();
if (
node.init &&
isUserEventSetupCall(node.init) &&
node.id.type === AST_NODE_TYPES.Identifier
) {
userEventSetupVars.add(node.id.name);
}

// Destructuring: const { user, myUser: alias } = setup(...)
if (
node.id.type === AST_NODE_TYPES.ObjectPattern &&
node.init &&
node.init.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.Identifier
) {
const functionName = node.init.callee.name;
const setupProps = setupFunctions.get(functionName);

if (setupProps) {
for (const prop of node.id.properties) {
if (
prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier &&
setupProps.has(prop.key.name) &&
prop.value.type === AST_NODE_TYPES.Identifier
) {
userEventSetupVars.add(prop.value.name);
}
}
}
}
},

// Track functions that return { ...: userEvent.setup(), ... }
ReturnStatement(node: TSESTree.ReturnStatement) {
if (
!isUserEventEnabled ||
!node.argument ||
node.argument.type !== AST_NODE_TYPES.ObjectExpression
) {
return;
}

const setupProps = new Set<string>();
for (const prop of node.argument.properties) {
if (
prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier
) {
// Direct: foo: userEvent.setup()
if (isUserEventSetupCall(prop.value)) {
setupProps.add(prop.key.name);
}
// Indirect: foo: u, where u is a userEvent.setup() var
else if (
prop.value.type === AST_NODE_TYPES.Identifier &&
userEventSetupVars.has(prop.value.name)
) {
setupProps.add(prop.key.name);
}
}
}

if (setupProps.size > 0) {
const functionName = findFunctionName(node);
if (functionName) {
setupFunctions.set(functionName, setupProps);
}
}
},

'CallExpression Identifier'(node: TSESTree.Identifier) {
if (
(isFireEventEnabled && helpers.isFireEventMethod(node)) ||
(isUserEventEnabled && helpers.isUserEventMethod(node))
(isUserEventEnabled &&
helpers.isUserEventMethod(node, userEventSetupVars))
) {
if (node.name === USER_EVENT_SETUP_FUNCTION_NAME) {
return;
Expand Down
172 changes: 172 additions & 0 deletions tests/lib/rules/await-async-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,178 @@ ruleTester.run(RULE_NAME, rule, {
`,
}) as const
),
...USER_EVENT_ASYNC_FUNCTIONS.map(
(eventMethod) =>
({
code: `
import userEvent from '${testingFramework}'
test('unhandled promise from event method called from userEvent.setup() return value is invalid', () => {
const user = userEvent.setup();
user.${eventMethod}(getByLabelText('username'))
})
`,
errors: [
{
line: 5,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
],
options: [{ eventModule: 'userEvent' }],
output: `
import userEvent from '${testingFramework}'
test('unhandled promise from event method called from userEvent.setup() return value is invalid', async () => {
const user = userEvent.setup();
await user.${eventMethod}(getByLabelText('username'))
})
`,
}) as const
),
// This covers the example in the docs:
// https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent
...USER_EVENT_ASYNC_FUNCTIONS.map(
(eventMethod) =>
({
code: `
import userEvent from '${testingFramework}'
test('unhandled promise from event method called from destructured custom setup function is invalid', () => {
function customSetup(jsx) {
return {
user: userEvent.setup(),
...render(jsx)
}
}
const { user } = customSetup(<MyComponent />);
user.${eventMethod}(getByLabelText('username'))
})
`,
errors: [
{
line: 11,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
],
options: [{ eventModule: 'userEvent' }],
output: `
import userEvent from '${testingFramework}'
test('unhandled promise from event method called from destructured custom setup function is invalid', async () => {
function customSetup(jsx) {
return {
user: userEvent.setup(),
...render(jsx)
}
}
const { user } = customSetup(<MyComponent />);
await user.${eventMethod}(getByLabelText('username'))
})
`,
}) as const
),
...USER_EVENT_ASYNC_FUNCTIONS.map(
(eventMethod) =>
({
code: `
import userEvent from '${testingFramework}'
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', () => {
function customSetup(jsx) {
return {
foo: userEvent.setup(),
bar: userEvent.setup(),
...render(jsx)
}
}
const { foo, bar: myUser } = customSetup(<MyComponent />);
myUser.${eventMethod}(getByLabelText('username'))
foo.${eventMethod}(getByLabelText('username'))
})
`,
errors: [
{
line: 12,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
{
line: 13,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
],
options: [{ eventModule: 'userEvent' }],
output: `
import userEvent from '${testingFramework}'
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', async () => {
function customSetup(jsx) {
return {
foo: userEvent.setup(),
bar: userEvent.setup(),
...render(jsx)
}
}
const { foo, bar: myUser } = customSetup(<MyComponent />);
await myUser.${eventMethod}(getByLabelText('username'))
await foo.${eventMethod}(getByLabelText('username'))
})
`,
}) as const
),
...USER_EVENT_ASYNC_FUNCTIONS.map(
(eventMethod) =>
({
code: `
import userEvent from '${testingFramework}'
test('unhandled promise from setup reference in custom setup function is invalid', () => {
function customSetup(jsx) {
const u = userEvent.setup()
return {
foo: u,
bar: u,
...render(jsx)
}
}
const { foo, bar: myUser } = customSetup(<MyComponent />);
myUser.${eventMethod}(getByLabelText('username'))
foo.${eventMethod}(getByLabelText('username'))
})
`,
errors: [
{
line: 13,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
{
line: 14,
column: 11,
messageId: 'awaitAsyncEvent',
data: { name: eventMethod },
},
],
options: [{ eventModule: 'userEvent' }],
output: `
import userEvent from '${testingFramework}'
test('unhandled promise from setup reference in custom setup function is invalid', async () => {
function customSetup(jsx) {
const u = userEvent.setup()
return {
foo: u,
bar: u,
...render(jsx)
}
}
const { foo, bar: myUser } = customSetup(<MyComponent />);
await myUser.${eventMethod}(getByLabelText('username'))
await foo.${eventMethod}(getByLabelText('username'))
})
`,
}) as const
),
]),
{
code: `
Expand Down