Skip to content

Commit 7b4e38c

Browse files
committed
feat: implement resolveToTestingLibraryFn utility for handling user event imports
1 parent 05b2934 commit 7b4e38c

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed

lib/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './compat';
22
export * from './file-import';
33
export * from './types';
4+
export * from './resolve-to-testing-library-fn';
45

56
const combineQueries = (
67
variants: readonly string[],
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { DefinitionType } from '@typescript-eslint/scope-manager';
2+
import {
3+
AST_NODE_TYPES,
4+
ASTUtils,
5+
TSESLint,
6+
TSESTree,
7+
} from '@typescript-eslint/utils';
8+
9+
import {
10+
isImportDefaultSpecifier,
11+
isImportExpression,
12+
isProperty,
13+
isImportSpecifier,
14+
isTSImportEqualsDeclaration,
15+
isCallExpression,
16+
} from '../node-utils';
17+
import {
18+
AccessorNode,
19+
getAccessorValue,
20+
getStringValue,
21+
isIdentifier,
22+
isStringNode,
23+
isSupportedAccessor,
24+
} from '../node-utils/accessors';
25+
26+
import { LIBRARY_MODULES } from '.';
27+
28+
interface ImportDetails {
29+
source: string;
30+
local: string;
31+
imported: string | null;
32+
}
33+
34+
const describeImportDefAsImport = (
35+
def: TSESLint.Scope.Definitions.ImportBindingDefinition
36+
): ImportDetails | null => {
37+
if (isTSImportEqualsDeclaration(def.parent)) {
38+
return null;
39+
}
40+
41+
if (isImportDefaultSpecifier(def.node)) {
42+
return {
43+
source: def.parent.source.value,
44+
imported: null,
45+
local: def.node.local.name,
46+
};
47+
}
48+
49+
if (!isImportSpecifier(def.node)) {
50+
return null;
51+
}
52+
53+
// we only care about value imports
54+
if (def.parent.importKind === 'type') {
55+
return null;
56+
}
57+
58+
return {
59+
source: def.parent.source.value,
60+
imported:
61+
'name' in def.node.imported
62+
? def.node.imported.name
63+
: def.node.imported.value,
64+
local: def.node.local.name,
65+
};
66+
};
67+
68+
const describeVariableDefAsImport = (
69+
def: TSESLint.Scope.Definitions.VariableDefinition
70+
): ImportDetails | null => {
71+
if (!def.node.init) return null;
72+
73+
const sourceNode =
74+
isCallExpression(def.node.init) &&
75+
isIdentifier(def.node.init.callee, 'require')
76+
? def.node.init.arguments[0]
77+
: ASTUtils.isAwaitExpression(def.node.init) &&
78+
isImportExpression(def.node.init.argument)
79+
? def.node.init.argument.source
80+
: null;
81+
82+
if (!sourceNode || !isStringNode(sourceNode)) return null;
83+
if (!isProperty(def.name.parent)) return null;
84+
if (!isSupportedAccessor(def.name.parent.key)) return null;
85+
86+
return {
87+
source: getStringValue(sourceNode),
88+
imported: getAccessorValue(def.name.parent.key),
89+
local: def.name.name,
90+
};
91+
};
92+
93+
const describePossibleImportDef = (
94+
def: TSESLint.Scope.Definition
95+
): ImportDetails | null => {
96+
if (def.type === DefinitionType.Variable) {
97+
return describeVariableDefAsImport(def);
98+
}
99+
if (def.type === DefinitionType.ImportBinding) {
100+
return describeImportDefAsImport(def);
101+
}
102+
return null;
103+
};
104+
105+
const resolveScope = (
106+
scope: TSESLint.Scope.Scope,
107+
identifier: string
108+
): ImportDetails | 'local' | null => {
109+
let currentScope: TSESLint.Scope.Scope | null = scope;
110+
while (currentScope !== null) {
111+
const ref = currentScope.set.get(identifier);
112+
if (ref && ref.defs.length > 0) {
113+
const def = ref.defs[ref.defs.length - 1];
114+
const importDetails = describePossibleImportDef(def);
115+
116+
if (importDetails?.local === identifier) {
117+
return importDetails;
118+
}
119+
120+
return 'local';
121+
}
122+
123+
currentScope = currentScope.upper;
124+
}
125+
126+
return null;
127+
};
128+
129+
const joinChains = (
130+
a: AccessorNode[] | null,
131+
b: AccessorNode[] | null
132+
): AccessorNode[] | null => (a && b ? [...a, ...b] : null);
133+
134+
export const getNodeChain = (node: TSESTree.Node): AccessorNode[] | null => {
135+
if (isSupportedAccessor(node)) {
136+
return [node];
137+
}
138+
139+
switch (node.type) {
140+
case AST_NODE_TYPES.TaggedTemplateExpression:
141+
return getNodeChain(node.tag);
142+
case AST_NODE_TYPES.MemberExpression:
143+
return joinChains(getNodeChain(node.object), getNodeChain(node.property));
144+
case AST_NODE_TYPES.CallExpression:
145+
return getNodeChain(node.callee);
146+
}
147+
148+
return null;
149+
};
150+
151+
interface ResolvedTestingLibraryUserEventFn {
152+
original: string | null;
153+
local: string;
154+
}
155+
156+
const USER_EVENT_PACKAGE = '@testing-library/user-event';
157+
158+
export const resolveToTestingLibraryFn = (
159+
node: TSESTree.CallExpression,
160+
context: TSESLint.RuleContext<string, unknown[]>
161+
): ResolvedTestingLibraryUserEventFn | null => {
162+
const chain = getNodeChain(node);
163+
if (!chain?.length) return null;
164+
165+
const identifier = chain[0];
166+
const scope = context.sourceCode.getScope(identifier);
167+
const maybeImport = resolveScope(scope, getAccessorValue(identifier));
168+
169+
if (maybeImport === 'local' || maybeImport === null) {
170+
return null;
171+
}
172+
173+
if (
174+
[...LIBRARY_MODULES, USER_EVENT_PACKAGE].some(
175+
(module) => module === maybeImport.source
176+
)
177+
) {
178+
return {
179+
original: maybeImport.imported,
180+
local: maybeImport.local,
181+
};
182+
}
183+
184+
return null;
185+
};

0 commit comments

Comments
 (0)