Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
fix: flag unhandled async methods from custom setup() calls
  • Loading branch information
puglyfe committed Jul 15, 2025
commit e690b9b860b76ea30e9de2793d71dd91353512a2
85 changes: 83 additions & 2 deletions lib/rules/await-async-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ export default createTestingLibraryRule<Options, MessageIds>({
create(context, [options], helpers) {
const functionWrappersNames: string[] = [];

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

// Temporary: Map function names to property names that are assigned from userEvent.setup()
const tempSetupFunctionProps = new Map<string, Set<string>>();

function reportUnhandledNode({
node,
closestCallExpression,
Expand Down Expand Up @@ -126,8 +129,9 @@ export default createTestingLibraryRule<Options, MessageIds>({
const isUserEventEnabled = eventModules.includes(USER_EVENT_NAME);

return {
// Track variables assigned from userEvent.setup()
// Track variables assigned from userEvent.setup() and destructuring from setup functions
VariableDeclarator(node: TSESTree.VariableDeclarator) {
// Direct assignment: const user = userEvent.setup();
if (
isUserEventEnabled &&
node.init &&
Expand All @@ -141,6 +145,83 @@ export default createTestingLibraryRule<Options, MessageIds>({
) {
userEventSetupVars.add(node.id.name);
}

// Destructuring: const { user, myUser: alias } = setup(...)
if (
isUserEventEnabled &&
node.id.type === AST_NODE_TYPES.ObjectPattern &&
node.init &&
node.init.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.Identifier &&
tempSetupFunctionProps.has(node.init.callee.name)
) {
const setupProps = tempSetupFunctionProps.get(node.init.callee.name)!;
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
) {
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 (
prop.value.type === AST_NODE_TYPES.CallExpression &&
prop.value.callee.type === AST_NODE_TYPES.MemberExpression &&
prop.value.callee.object.type === AST_NODE_TYPES.Identifier &&
prop.value.callee.object.name === USER_EVENT_NAME &&
prop.value.callee.property.type === AST_NODE_TYPES.Identifier &&
prop.value.callee.property.name ===
USER_EVENT_SETUP_FUNCTION_NAME
) {
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) {
// Find the function this return is in
let parent: TSESTree.Node | undefined = node.parent;
while (parent) {
if (
parent.type === AST_NODE_TYPES.FunctionDeclaration ||
parent.type === AST_NODE_TYPES.FunctionExpression ||
parent.type === AST_NODE_TYPES.ArrowFunctionExpression
) {
const name = getFunctionName(parent);
if (name) {
tempSetupFunctionProps.set(name, setupProps);
}
break;
}
parent = parent.parent;
}
}
}
},

'CallExpression Identifier'(node: TSESTree.Identifier) {
Expand Down
146 changes: 145 additions & 1 deletion tests/lib/rules/await-async-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ ruleTester.run(RULE_NAME, rule, {
({
code: `
import userEvent from '${testingFramework}'
test('unhandled promise from event method called from userEvent.setup() return value is invalid', async () => {
test('unhandled promise from event method called from userEvent.setup() return value is invalid', () => {
const user = userEvent.setup();
user.${eventMethod}(getByLabelText('username'))
})
Expand All @@ -1108,6 +1108,150 @@ ruleTester.run(RULE_NAME, rule, {
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
),
Expand Down