Skip to content

Commit e690b9b

Browse files
committed
fix: flag unhandled async methods from custom setup() calls
1 parent 23caac1 commit e690b9b

File tree

2 files changed

+228
-3
lines changed

2 files changed

+228
-3
lines changed

lib/rules/await-async-events.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,12 @@ export default createTestingLibraryRule<Options, MessageIds>({
8686
create(context, [options], helpers) {
8787
const functionWrappersNames: string[] = [];
8888

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

92+
// Temporary: Map function names to property names that are assigned from userEvent.setup()
93+
const tempSetupFunctionProps = new Map<string, Set<string>>();
94+
9295
function reportUnhandledNode({
9396
node,
9497
closestCallExpression,
@@ -126,8 +129,9 @@ export default createTestingLibraryRule<Options, MessageIds>({
126129
const isUserEventEnabled = eventModules.includes(USER_EVENT_NAME);
127130

128131
return {
129-
// Track variables assigned from userEvent.setup()
132+
// Track variables assigned from userEvent.setup() and destructuring from setup functions
130133
VariableDeclarator(node: TSESTree.VariableDeclarator) {
134+
// Direct assignment: const user = userEvent.setup();
131135
if (
132136
isUserEventEnabled &&
133137
node.init &&
@@ -141,6 +145,83 @@ export default createTestingLibraryRule<Options, MessageIds>({
141145
) {
142146
userEventSetupVars.add(node.id.name);
143147
}
148+
149+
// Destructuring: const { user, myUser: alias } = setup(...)
150+
if (
151+
isUserEventEnabled &&
152+
node.id.type === AST_NODE_TYPES.ObjectPattern &&
153+
node.init &&
154+
node.init.type === AST_NODE_TYPES.CallExpression &&
155+
node.init.callee.type === AST_NODE_TYPES.Identifier &&
156+
tempSetupFunctionProps.has(node.init.callee.name)
157+
) {
158+
const setupProps = tempSetupFunctionProps.get(node.init.callee.name)!;
159+
for (const prop of node.id.properties) {
160+
if (
161+
prop.type === AST_NODE_TYPES.Property &&
162+
prop.key.type === AST_NODE_TYPES.Identifier &&
163+
setupProps.has(prop.key.name) &&
164+
prop.value.type === AST_NODE_TYPES.Identifier
165+
) {
166+
userEventSetupVars.add(prop.value.name);
167+
}
168+
}
169+
}
170+
},
171+
172+
// Track functions that return { ...: userEvent.setup(), ... }
173+
ReturnStatement(node: TSESTree.ReturnStatement) {
174+
if (
175+
isUserEventEnabled &&
176+
node.argument &&
177+
node.argument.type === AST_NODE_TYPES.ObjectExpression
178+
) {
179+
const setupProps = new Set<string>();
180+
for (const prop of node.argument.properties) {
181+
if (
182+
prop.type === AST_NODE_TYPES.Property &&
183+
prop.key.type === AST_NODE_TYPES.Identifier
184+
) {
185+
// Direct: foo: userEvent.setup()
186+
if (
187+
prop.value.type === AST_NODE_TYPES.CallExpression &&
188+
prop.value.callee.type === AST_NODE_TYPES.MemberExpression &&
189+
prop.value.callee.object.type === AST_NODE_TYPES.Identifier &&
190+
prop.value.callee.object.name === USER_EVENT_NAME &&
191+
prop.value.callee.property.type === AST_NODE_TYPES.Identifier &&
192+
prop.value.callee.property.name ===
193+
USER_EVENT_SETUP_FUNCTION_NAME
194+
) {
195+
setupProps.add(prop.key.name);
196+
}
197+
// Indirect: foo: u, where u is a userEvent.setup() var
198+
else if (
199+
prop.value.type === AST_NODE_TYPES.Identifier &&
200+
userEventSetupVars.has(prop.value.name)
201+
) {
202+
setupProps.add(prop.key.name);
203+
}
204+
}
205+
}
206+
if (setupProps.size > 0) {
207+
// Find the function this return is in
208+
let parent: TSESTree.Node | undefined = node.parent;
209+
while (parent) {
210+
if (
211+
parent.type === AST_NODE_TYPES.FunctionDeclaration ||
212+
parent.type === AST_NODE_TYPES.FunctionExpression ||
213+
parent.type === AST_NODE_TYPES.ArrowFunctionExpression
214+
) {
215+
const name = getFunctionName(parent);
216+
if (name) {
217+
tempSetupFunctionProps.set(name, setupProps);
218+
}
219+
break;
220+
}
221+
parent = parent.parent;
222+
}
223+
}
224+
}
144225
},
145226

146227
'CallExpression Identifier'(node: TSESTree.Identifier) {

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

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,7 @@ ruleTester.run(RULE_NAME, rule, {
10881088
({
10891089
code: `
10901090
import userEvent from '${testingFramework}'
1091-
test('unhandled promise from event method called from userEvent.setup() return value is invalid', async () => {
1091+
test('unhandled promise from event method called from userEvent.setup() return value is invalid', () => {
10921092
const user = userEvent.setup();
10931093
user.${eventMethod}(getByLabelText('username'))
10941094
})
@@ -1108,6 +1108,150 @@ ruleTester.run(RULE_NAME, rule, {
11081108
const user = userEvent.setup();
11091109
await user.${eventMethod}(getByLabelText('username'))
11101110
})
1111+
`,
1112+
}) as const
1113+
),
1114+
// This covers the example in the docs:
1115+
// https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent
1116+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1117+
(eventMethod) =>
1118+
({
1119+
code: `
1120+
import userEvent from '${testingFramework}'
1121+
test('unhandled promise from event method called from destructured custom setup function is invalid', () => {
1122+
function customSetup(jsx) {
1123+
return {
1124+
user: userEvent.setup(),
1125+
...render(jsx)
1126+
}
1127+
}
1128+
const { user } = customSetup(<MyComponent />);
1129+
user.${eventMethod}(getByLabelText('username'))
1130+
})
1131+
`,
1132+
errors: [
1133+
{
1134+
line: 11,
1135+
column: 11,
1136+
messageId: 'awaitAsyncEvent',
1137+
data: { name: eventMethod },
1138+
},
1139+
],
1140+
options: [{ eventModule: 'userEvent' }],
1141+
output: `
1142+
import userEvent from '${testingFramework}'
1143+
test('unhandled promise from event method called from destructured custom setup function is invalid', async () => {
1144+
function customSetup(jsx) {
1145+
return {
1146+
user: userEvent.setup(),
1147+
...render(jsx)
1148+
}
1149+
}
1150+
const { user } = customSetup(<MyComponent />);
1151+
await user.${eventMethod}(getByLabelText('username'))
1152+
})
1153+
`,
1154+
}) as const
1155+
),
1156+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1157+
(eventMethod) =>
1158+
({
1159+
code: `
1160+
import userEvent from '${testingFramework}'
1161+
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', () => {
1162+
function customSetup(jsx) {
1163+
return {
1164+
foo: userEvent.setup(),
1165+
bar: userEvent.setup(),
1166+
...render(jsx)
1167+
}
1168+
}
1169+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1170+
myUser.${eventMethod}(getByLabelText('username'))
1171+
foo.${eventMethod}(getByLabelText('username'))
1172+
})
1173+
`,
1174+
errors: [
1175+
{
1176+
line: 12,
1177+
column: 11,
1178+
messageId: 'awaitAsyncEvent',
1179+
data: { name: eventMethod },
1180+
},
1181+
{
1182+
line: 13,
1183+
column: 11,
1184+
messageId: 'awaitAsyncEvent',
1185+
data: { name: eventMethod },
1186+
},
1187+
],
1188+
options: [{ eventModule: 'userEvent' }],
1189+
output: `
1190+
import userEvent from '${testingFramework}'
1191+
test('unhandled promise from aliased event method called from destructured custom setup function is invalid', async () => {
1192+
function customSetup(jsx) {
1193+
return {
1194+
foo: userEvent.setup(),
1195+
bar: userEvent.setup(),
1196+
...render(jsx)
1197+
}
1198+
}
1199+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1200+
await myUser.${eventMethod}(getByLabelText('username'))
1201+
await foo.${eventMethod}(getByLabelText('username'))
1202+
})
1203+
`,
1204+
}) as const
1205+
),
1206+
...USER_EVENT_ASYNC_FUNCTIONS.map(
1207+
(eventMethod) =>
1208+
({
1209+
code: `
1210+
import userEvent from '${testingFramework}'
1211+
test('unhandled promise from setup reference in custom setup function is invalid', () => {
1212+
function customSetup(jsx) {
1213+
const u = userEvent.setup()
1214+
return {
1215+
foo: u,
1216+
bar: u,
1217+
...render(jsx)
1218+
}
1219+
}
1220+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1221+
myUser.${eventMethod}(getByLabelText('username'))
1222+
foo.${eventMethod}(getByLabelText('username'))
1223+
})
1224+
`,
1225+
errors: [
1226+
{
1227+
line: 13,
1228+
column: 11,
1229+
messageId: 'awaitAsyncEvent',
1230+
data: { name: eventMethod },
1231+
},
1232+
{
1233+
line: 14,
1234+
column: 11,
1235+
messageId: 'awaitAsyncEvent',
1236+
data: { name: eventMethod },
1237+
},
1238+
],
1239+
options: [{ eventModule: 'userEvent' }],
1240+
output: `
1241+
import userEvent from '${testingFramework}'
1242+
test('unhandled promise from setup reference in custom setup function is invalid', async () => {
1243+
function customSetup(jsx) {
1244+
const u = userEvent.setup()
1245+
return {
1246+
foo: u,
1247+
bar: u,
1248+
...render(jsx)
1249+
}
1250+
}
1251+
const { foo, bar: myUser } = customSetup(<MyComponent />);
1252+
await myUser.${eventMethod}(getByLabelText('username'))
1253+
await foo.${eventMethod}(getByLabelText('username'))
1254+
})
11111255
`,
11121256
}) as const
11131257
),

0 commit comments

Comments
 (0)