Skip to content

Commit 742cd11

Browse files
authored
fix(await-async-events): improveuserEvent.setup() detection (#1056)
Fixes #812
1 parent 086eab9 commit 742cd11

File tree

3 files changed

+296
-12
lines changed

3 files changed

+296
-12
lines changed

lib/create-testing-library-rule/detect-testing-library-utils.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ type IsAsyncUtilFn = (
8181
validNames?: readonly (typeof ASYNC_UTILS)[number][]
8282
) => boolean;
8383
type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean;
84-
type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean;
84+
type IsUserEventMethodFn = (
85+
node: TSESTree.Identifier,
86+
userEventSetupVars?: Set<string>
87+
) => boolean;
8588
type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean;
8689
type IsCreateEventUtil = (
8790
node: TSESTree.CallExpression | TSESTree.Identifier
@@ -563,7 +566,10 @@ export function detectTestingLibraryUtils<
563566
return regularCall || wildcardCall || wildcardCallWithCallExpression;
564567
};
565568

566-
const isUserEventMethod: IsUserEventMethodFn = (node) => {
569+
const isUserEventMethod: IsUserEventMethodFn = (
570+
node,
571+
userEventSetupVars
572+
) => {
567573
const userEvent = findImportedUserEventSpecifier();
568574
let userEventName: string | undefined;
569575

@@ -573,10 +579,6 @@ export function detectTestingLibraryUtils<
573579
userEventName = USER_EVENT_NAME;
574580
}
575581

576-
if (!userEventName) {
577-
return false;
578-
}
579-
580582
const parentMemberExpression: TSESTree.MemberExpression | undefined =
581583
node.parent && isMemberExpression(node.parent)
582584
? node.parent
@@ -588,18 +590,33 @@ export function detectTestingLibraryUtils<
588590

589591
// make sure that given node it's not userEvent object itself
590592
if (
591-
[userEventName, USER_EVENT_NAME].includes(node.name) ||
593+
(userEventName &&
594+
[userEventName, USER_EVENT_NAME].includes(node.name)) ||
592595
(ASTUtils.isIdentifier(parentMemberExpression.object) &&
593596
parentMemberExpression.object.name === node.name)
594597
) {
595598
return false;
596599
}
597600

598-
// check userEvent.click() usage
599-
return (
601+
// check userEvent.click() usage (imported identifier)
602+
if (
603+
userEventName &&
600604
ASTUtils.isIdentifier(parentMemberExpression.object) &&
601605
parentMemberExpression.object.name === userEventName
602-
);
606+
) {
607+
return true;
608+
}
609+
610+
// check user.click() usage where user is a variable from userEvent.setup()
611+
if (
612+
userEventSetupVars &&
613+
ASTUtils.isIdentifier(parentMemberExpression.object) &&
614+
userEventSetupVars.has(parentMemberExpression.object.name)
615+
) {
616+
return true;
617+
}
618+
619+
return false;
603620
};
604621

605622
/**

lib/rules/await-async-events.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ASTUtils } from '@typescript-eslint/utils';
1+
import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils';
22

33
import { createTestingLibraryRule } from '../create-testing-library-rule';
44
import {
@@ -83,6 +83,12 @@ export default createTestingLibraryRule<Options, MessageIds>({
8383
create(context, [options], helpers) {
8484
const functionWrappersNames: string[] = [];
8585

86+
// Track variables assigned from userEvent.setup() (directly or via destructuring)
87+
const userEventSetupVars = new Set<string>();
88+
89+
// Track functions that return userEvent.setup() instances and their property names
90+
const setupFunctions = new Map<string, Set<string>>();
91+
8692
function reportUnhandledNode({
8793
node,
8894
closestCallExpression,
@@ -112,6 +118,17 @@ export default createTestingLibraryRule<Options, MessageIds>({
112118
}
113119
}
114120

121+
function isUserEventSetupCall(node: TSESTree.Node): boolean {
122+
return (
123+
node.type === AST_NODE_TYPES.CallExpression &&
124+
node.callee.type === AST_NODE_TYPES.MemberExpression &&
125+
node.callee.object.type === AST_NODE_TYPES.Identifier &&
126+
node.callee.object.name === USER_EVENT_NAME &&
127+
node.callee.property.type === AST_NODE_TYPES.Identifier &&
128+
node.callee.property.name === USER_EVENT_SETUP_FUNCTION_NAME
129+
);
130+
}
131+
115132
const eventModules =
116133
typeof options.eventModule === 'string'
117134
? [options.eventModule]
@@ -120,10 +137,88 @@ export default createTestingLibraryRule<Options, MessageIds>({
120137
const isUserEventEnabled = eventModules.includes(USER_EVENT_NAME);
121138

122139
return {
140+
// Track variables assigned from userEvent.setup() and destructuring from setup functions
141+
VariableDeclarator(node: TSESTree.VariableDeclarator) {
142+
if (!isUserEventEnabled) return;
143+
144+
// Direct assignment: const user = userEvent.setup();
145+
if (
146+
node.init &&
147+
isUserEventSetupCall(node.init) &&
148+
node.id.type === AST_NODE_TYPES.Identifier
149+
) {
150+
userEventSetupVars.add(node.id.name);
151+
}
152+
153+
// Destructuring: const { user, myUser: alias } = setup(...)
154+
if (
155+
node.id.type === AST_NODE_TYPES.ObjectPattern &&
156+
node.init &&
157+
node.init.type === AST_NODE_TYPES.CallExpression &&
158+
node.init.callee.type === AST_NODE_TYPES.Identifier
159+
) {
160+
const functionName = node.init.callee.name;
161+
const setupProps = setupFunctions.get(functionName);
162+
163+
if (setupProps) {
164+
for (const prop of node.id.properties) {
165+
if (
166+
prop.type === AST_NODE_TYPES.Property &&
167+
prop.key.type === AST_NODE_TYPES.Identifier &&
168+
setupProps.has(prop.key.name) &&
169+
prop.value.type === AST_NODE_TYPES.Identifier
170+
) {
171+
userEventSetupVars.add(prop.value.name);
172+
}
173+
}
174+
}
175+
}
176+
},
177+
178+
// Track functions that return { ...: userEvent.setup(), ... }
179+
ReturnStatement(node: TSESTree.ReturnStatement) {
180+
if (
181+
!isUserEventEnabled ||
182+
!node.argument ||
183+
node.argument.type !== AST_NODE_TYPES.ObjectExpression
184+
) {
185+
return;
186+
}
187+
188+
const setupProps = new Set<string>();
189+
for (const prop of node.argument.properties) {
190+
if (
191+
prop.type === AST_NODE_TYPES.Property &&
192+
prop.key.type === AST_NODE_TYPES.Identifier
193+
) {
194+
// Direct: foo: userEvent.setup()
195+
if (isUserEventSetupCall(prop.value)) {
196+
setupProps.add(prop.key.name);
197+
}
198+
// Indirect: foo: u, where u is a userEvent.setup() var
199+
else if (
200+
prop.value.type === AST_NODE_TYPES.Identifier &&
201+
userEventSetupVars.has(prop.value.name)
202+
) {
203+
setupProps.add(prop.key.name);
204+
}
205+
}
206+
}
207+
208+
if (setupProps.size > 0) {
209+
const functionNode = findClosestFunctionExpressionNode(node);
210+
if (functionNode) {
211+
const functionName = getFunctionName(functionNode);
212+
setupFunctions.set(functionName, setupProps);
213+
}
214+
}
215+
},
216+
123217
'CallExpression Identifier'(node: TSESTree.Identifier) {
124218
if (
125219
(isFireEventEnabled && helpers.isFireEventMethod(node)) ||
126-
(isUserEventEnabled && helpers.isUserEventMethod(node))
220+
(isUserEventEnabled &&
221+
helpers.isUserEventMethod(node, userEventSetupVars))
127222
) {
128223
if (node.name === USER_EVENT_SETUP_FUNCTION_NAME) {
129224
return;

tests/lib/rules/await-async-events.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,178 @@ ruleTester.run(RULE_NAME, rule, {
12081208
`,
12091209
}) as const
12101210
),
1211+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1212+
(eventMethod) =>
1213+
({
1214+
code: `
1215+
import userEvent from '${testingFramework}'
1216+
test('unhandled promise from event method called from userEvent.setup() return value is invalid', () => {
1217+
const user = userEvent.setup();
1218+
user.${eventMethod}(getByLabelText('username'))
1219+
})
1220+
`,
1221+
errors: [
1222+
{
1223+
line: 5,
1224+
column: 11,
1225+
messageId: 'awaitAsyncEvent',
1226+
data: { name: eventMethod },
1227+
},
1228+
],
1229+
options: [{ eventModule: 'userEvent' }],
1230+
output: `
1231+
import userEvent from '${testingFramework}'
1232+
test('unhandled promise from event method called from userEvent.setup() return value is invalid', async () => {
1233+
const user = userEvent.setup();
1234+
await user.${eventMethod}(getByLabelText('username'))
1235+
})
1236+
`,
1237+
}) as const
1238+
),
1239+
// This covers the example in the docs:
1240+
// https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent
1241+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1242+
(eventMethod) =>
1243+
({
1244+
code: `
1245+
import userEvent from '${testingFramework}'
1246+
test('unhandled promise from event method called from destructured custom setup function is invalid', () => {
1247+
function customSetup(jsx) {
1248+
return {
1249+
user: userEvent.setup(),
1250+
...render(jsx)
1251+
}
1252+
}
1253+
const { user } = customSetup(<MyComponent />);
1254+
user.${eventMethod}(getByLabelText('username'))
1255+
})
1256+
`,
1257+
errors: [
1258+
{
1259+
line: 11,
1260+
column: 11,
1261+
messageId: 'awaitAsyncEvent',
1262+
data: { name: eventMethod },
1263+
},
1264+
],
1265+
options: [{ eventModule: 'userEvent' }],
1266+
output: `
1267+
import userEvent from '${testingFramework}'
1268+
test('unhandled promise from event method called from destructured custom setup function is invalid', async () => {
1269+
function customSetup(jsx) {
1270+
return {
1271+
user: userEvent.setup(),
1272+
...render(jsx)
1273+
}
1274+
}
1275+
const { user } = customSetup(<MyComponent />);
1276+
await user.${eventMethod}(getByLabelText('username'))
1277+
})
1278+
`,
1279+
}) as const
1280+
),
1281+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1282+
(eventMethod) =>
1283+
({
1284+
code: `
1285+
import userEvent from '${testingFramework}'
1286+
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', () => {
1287+
function customSetup(jsx) {
1288+
return {
1289+
foo: userEvent.setup(),
1290+
bar: userEvent.setup(),
1291+
...render(jsx)
1292+
}
1293+
}
1294+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1295+
myUser.${eventMethod}(getByLabelText('username'))
1296+
foo.${eventMethod}(getByLabelText('username'))
1297+
})
1298+
`,
1299+
errors: [
1300+
{
1301+
line: 12,
1302+
column: 11,
1303+
messageId: 'awaitAsyncEvent',
1304+
data: { name: eventMethod },
1305+
},
1306+
{
1307+
line: 13,
1308+
column: 11,
1309+
messageId: 'awaitAsyncEvent',
1310+
data: { name: eventMethod },
1311+
},
1312+
],
1313+
options: [{ eventModule: 'userEvent' }],
1314+
output: `
1315+
import userEvent from '${testingFramework}'
1316+
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', async () => {
1317+
function customSetup(jsx) {
1318+
return {
1319+
foo: userEvent.setup(),
1320+
bar: userEvent.setup(),
1321+
...render(jsx)
1322+
}
1323+
}
1324+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1325+
await myUser.${eventMethod}(getByLabelText('username'))
1326+
await foo.${eventMethod}(getByLabelText('username'))
1327+
})
1328+
`,
1329+
}) as const
1330+
),
1331+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1332+
(eventMethod) =>
1333+
({
1334+
code: `
1335+
import userEvent from '${testingFramework}'
1336+
test('unhandled promise from setup reference in custom setup function is invalid', () => {
1337+
function customSetup(jsx) {
1338+
const u = userEvent.setup()
1339+
return {
1340+
foo: u,
1341+
bar: u,
1342+
...render(jsx)
1343+
}
1344+
}
1345+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1346+
myUser.${eventMethod}(getByLabelText('username'))
1347+
foo.${eventMethod}(getByLabelText('username'))
1348+
})
1349+
`,
1350+
errors: [
1351+
{
1352+
line: 13,
1353+
column: 11,
1354+
messageId: 'awaitAsyncEvent',
1355+
data: { name: eventMethod },
1356+
},
1357+
{
1358+
line: 14,
1359+
column: 11,
1360+
messageId: 'awaitAsyncEvent',
1361+
data: { name: eventMethod },
1362+
},
1363+
],
1364+
options: [{ eventModule: 'userEvent' }],
1365+
output: `
1366+
import userEvent from '${testingFramework}'
1367+
test('unhandled promise from setup reference in custom setup function is invalid', async () => {
1368+
function customSetup(jsx) {
1369+
const u = userEvent.setup()
1370+
return {
1371+
foo: u,
1372+
bar: u,
1373+
...render(jsx)
1374+
}
1375+
}
1376+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1377+
await myUser.${eventMethod}(getByLabelText('username'))
1378+
await foo.${eventMethod}(getByLabelText('username'))
1379+
})
1380+
`,
1381+
}) as const
1382+
),
12111383
]),
12121384
{
12131385
code: `

0 commit comments

Comments
 (0)