import { parse } from "@babel/parser"; import traverse, { TraverseOptions, Binding, Scope } from "@babel/traverse"; import * as t from "@babel/types"; export interface Mock { identifier: string; path: string; type: "namespace" | "name" | "default"; } const createNewIdentifierOrMemberExp = (node: t.Identifier | t.MemberExpression | t.JSXIdentifier | t.JSXMemberExpression): t.Identifier | t.MemberExpression => { if (t.isIdentifier(node) || t.isJSXIdentifier(node)) { return t.identifier(node.name); } return t.memberExpression( createNewIdentifierOrMemberExp(node.object as t.Identifier | t.MemberExpression), t.identifier((node.property as t.Identifier).name), ); } const getMostLeftJSXIdentifier = (exp: t.JSXMemberExpression): t.JSXIdentifier => { if (t.isJSXIdentifier(exp.object)) { return exp.object; } return getMostLeftJSXIdentifier(exp.object); } const getMostLeftIdentifier = (exp: t.Node): t.Identifier | undefined => { if (!t.isMemberExpression(exp)) { return; } if (t.isIdentifier(exp.object)) { return exp.object; } return getMostLeftIdentifier(exp.object); } const getFullNameOfIdentifier = (exp: t.JSXMemberExpression | t.JSXIdentifier | t.Identifier | t.MemberExpression): string => { if (t.isIdentifier(exp) || t.isJSXIdentifier(exp)) { return exp.name; } return getFullNameOfIdentifier(exp.object as t.MemberExpression | t.Identifier) + `.${(exp.property as t.Identifier).name}`; } const processHocLikeCallExpression = (exp: t.CallExpression, scope: Scope): [t.Identifier | t.MemberExpression | undefined, Binding | undefined] => { if (!exp.arguments.length) { return [undefined, undefined]; } // get first identifier or member exp, for HOC this is most common case, don't want to bother with anothers const firstArg: t.MemberExpression | t.Identifier | undefined = exp.arguments.find(a => t.isIdentifier(a) || t.isMemberExpression(a)) as any; if (!firstArg) { return [undefined, undefined]; } const identifier = t.isIdentifier(firstArg) ? firstArg : getMostLeftIdentifier(firstArg); if (!identifier) { return [undefined, undefined]; } const binding = scope.getBinding(identifier.name); if (!binding) { return [undefined, undefined]; } if (binding.kind === "module") { // direct binding to module, return used binding return [firstArg, binding]; } else { // import B; // const A = HOC(B); // const C = HOC(A); // // need to process again if (!binding.path.isVariableDeclarator() || !(t.isTaggedTemplateExpression(binding.path.node.init!) || t.isCallExpression(binding.path.node.init!))) { return [undefined, undefined]; } const init = binding.path.node.init; if (!init) { return [undefined, undefined]; } if (t.isTaggedTemplateExpression(init) && !t.isCallExpression(init.tag)) { return [undefined, undefined]; } let callExp = t.isCallExpression(init) ? init : (init as t.TaggedTemplateExpression).tag as t.CallExpression; // const A = (0, _someHOC)(_ImportedComponent)(" styles ") - often as result of styled components preprocessors if (t.isCallExpression(callExp.callee)) { callExp = callExp.callee; } return processHocLikeCallExpression(callExp, binding.path.scope); } } interface State { mocks: Mock[]; alwaysMockIdentifiers: string[]; mockedIdentifiers: string[]; } const visitor: TraverseOptions = { CallExpression(path, state: State) { const callIdentifier = t.isIdentifier(path.node.callee) ? path.node.callee : t.isMemberExpression(path.node.callee) && t.isIdentifier(path.node.callee.property) ? path.node.callee.property : undefined; if (!callIdentifier || !state.alwaysMockIdentifiers.includes(callIdentifier.name)) { return; } let identifierNode: t.Identifier | t.MemberExpression | undefined; let identifierBinding: Binding | undefined; if (t.isCallExpression(path.node.callee)) { [identifierNode, identifierBinding] = processHocLikeCallExpression(path.node.callee, path.scope); } if (!identifierNode) { [identifierNode, identifierBinding] = processHocLikeCallExpression(path.node, path.scope); } if (!identifierNode || !identifierBinding || identifierBinding.kind !== "module") { return; } const identifierFullName = getFullNameOfIdentifier(identifierNode); if (state.mockedIdentifiers.includes(identifierFullName)) { return; } // if (!state.mockExpression) { // state.mockExpression = createEmptyMockExpression(identifierBinding.scope); // } // pushMockObjExpression(this.mockExpression, identifierFullName, identifierBinding); state.mocks.push({ identifier: identifierFullName, path: (identifierBinding.path.parent as t.ImportDeclaration).source.value, type: identifierBinding.path.isImportDefaultSpecifier() ? "default" : identifierBinding.path.isImportNamespaceSpecifier() ? "namespace" : "name", }); state.mockedIdentifiers.push(identifierFullName); }, JSXOpeningElement(path, state: State) { let identifierNode: t.MemberExpression | t.Identifier | t.JSXMemberExpression | t.JSXIdentifier | undefined; let identifierBinding: Binding | undefined; // this can be mapped only to imported module if (t.isJSXMemberExpression(path.node.name)) { const mostLeftIdentifier = getMostLeftJSXIdentifier(path.node.name); const binding = path.scope.getBinding(mostLeftIdentifier.name); if (!binding || binding.kind !== "module") { return; } identifierNode = path.node.name; identifierBinding = binding; } else if (t.isJSXIdentifier(path.node.name)) { const identifier = path.node.name.name; const binding = path.scope.getBinding(identifier); if (!binding) { return; } if (binding.kind === "module") { // direct use if imported component identifierNode = path.node.name; identifierBinding = binding; } else { // may be hoc // 1) const Styled = styled(A)`` // 2) const A = HOC(B); // 3) const C = A.B.C; // member access shorthand if (!binding.path.isVariableDeclarator()) { return; } const init = binding.path.node.init; if (init && (t.isIdentifier(init) || t.isMemberExpression(init))) { identifierNode = init; const leftIdentifier = getMostLeftIdentifier(identifierNode); if (leftIdentifier) { const newBinding = binding.path.scope.getBinding(leftIdentifier.name); if (newBinding && newBinding.kind === "module") { identifierBinding = newBinding; } } } else if (init && (t.isTaggedTemplateExpression(init) || t.isCallExpression(init))) { if (t.isTaggedTemplateExpression(init) && !t.isCallExpression(init.tag)) { return; } let callExp = t.isTaggedTemplateExpression(init) ? init.tag as t.CallExpression : init; if (t.isCallExpression(callExp.callee)) { // const A = (0, _someHOC)(_ImportedComponent)(" styles ") - often as result of styled components preprocessors // note: need to first process inner call expression, otherwise it may not work - see "Does not import inside in taggle template" test [identifierNode, identifierBinding] = processHocLikeCallExpression(callExp.callee, binding.path.scope); } // otherwise process normally if (!identifierNode && !t.isCallExpression(callExp.callee)) { [identifierNode, identifierBinding] = processHocLikeCallExpression(callExp, binding.path.scope); } } } } if (!identifierNode || !identifierBinding || identifierBinding.kind !== "module") { return; } const identifierFullName = getFullNameOfIdentifier(identifierNode); if (state.mockedIdentifiers.includes(identifierFullName)) { return; } // if (!this.mockExpression) { // this.mockExpression = createEmptyMockExpression(identifierBinding.scope); // } // pushMockObjExpression(this.mockExpression, identifierFullName, identifierBinding); // this.mockedIdentifiers.push(identifierFullName); state.mocks.push({ identifier: identifierFullName, path: (identifierBinding.path.parent as t.ImportDeclaration).source.value, type: identifierBinding.path.isImportDefaultSpecifier() ? "default" : identifierBinding.path.isImportNamespaceSpecifier() ? "namespace" : "name", }); state.mockedIdentifiers.push(identifierFullName); }, } export function getMocks(code: string, type: "flow" | "typescript", alwaysMock: string[] = ["styled", "withComponent"]): Mock[] { try { const state: State = { mocks: [], mockedIdentifiers: [], alwaysMockIdentifiers: alwaysMock, }; const ast = parse(code, { sourceType: "module", plugins: [ "jsx", type, "asyncGenerators", "classProperties", "classPrivateProperties", "classPrivateMethods", ["decorators", { decoratorsBeforeExport: true }] as any, "doExpressions", "dynamicImport", "functionBind", "functionSent", "objectRestSpread", "bigInt", "exportDefaultFrom", "exportNamespaceFrom", "importMeta", "optionalCatchBinding", "optionalChaining", "nullishCoalescingOperator" ], }); if (!ast) { return []; } traverse(ast, visitor, undefined, state); return state.mocks; } catch { return []; } }