UNPKG

babel-plugin-react-native-testid

Version:

babel plugin for react native testid attributes

332 lines (295 loc) 8.37 kB
// @ts-nocheck import { PluginObj, NodePath, Visitor } from '@babel/core' import * as t from '@babel/types' type FunctionType = | t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression function nameForReactComponent( path: NodePath<FunctionType> ): t.Identifier | null { const { parentPath } = path if (!t.isArrowFunctionExpression(path.node) && t.isIdentifier(path.node.id)) { return path.node.id } if (t.isVariableDeclarator(parentPath)) { // @ts-ignore return parentPath.node.id } return null } const DEFAULT_ATTRIBUTE = 'testID' const DEFAULT_ATTRIBUTE_ID = 'id' const DEFAULT_RN_IGNORE_ELEMENTS = [ 'View', 'Text', 'Image', 'ScrollView', 'FlatList', 'SectionList', 'TouchableOpacity', 'TouchableHighlight', 'TouchableWithoutFeedback', 'SafeAreaView', 'Modal', 'Pressable', 'ActivityIndicator', 'Fragment', ] // 默认的有意义属性 const DEFAULT_MEANINGFUL_ATTRIBUTES = [ 'title', 'placeholder', 'label', 'alt', 'name', 'id', 'key', 'accessibilityLabel', ] function createDataAttribute(name: string, attributeName: string) { return t.jsxAttribute(t.jsxIdentifier(attributeName), t.stringLiteral(name)) } function hasDataAttribute( node: t.JSXOpeningElement, attributeName: string ): boolean { // This is a robust check. It looks for the specific attribute. // Crucially, if it finds a spread attribute ({...props}), it assumes the // attribute *might* be present in the spread and returns true to be safe. // This prevents the plugin from adding a duplicate testID. let hasSpread = false for (const attribute of node.attributes) { if ( t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: attributeName }) ) { return true // Found exact attribute } if (t.isJSXSpreadAttribute(attribute)) { hasSpread = true } } return hasSpread } function getAttributeValue( node: t.JSXOpeningElement, attributeName: string ): string | null { const attribute = node.attributes.find( (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: attributeName }) ) if (!attribute || !t.isJSXAttribute(attribute)) { return null } if (t.isStringLiteral(attribute.value)) { return attribute.value.value } return null } function extractTextFromJSXElement(element: t.JSXElement): string | null { const children = element.children for (const child of children) { if (t.isJSXText(child)) { const text = child.value.trim() if (text) { return text } } else if (t.isJSXExpressionContainer(child)) { // 处理 {t('key')} 这样的i18n调用 const expression = child.expression if (t.isCallExpression(expression)) { // 检查是否是 t() 调用 if ( t.isIdentifier(expression.callee, { name: 't' }) && expression.arguments.length > 0 ) { const firstArg = expression.arguments[0] if (t.isStringLiteral(firstArg)) { return firstArg.value } } // 检查是否是 i18n.t() 调用 if ( t.isMemberExpression(expression.callee) && t.isIdentifier(expression.callee.property, { name: 't' }) && expression.arguments.length > 0 ) { const firstArg = expression.arguments[0] if (t.isStringLiteral(firstArg)) { return firstArg.value } } } } else if (t.isJSXElement(child)) { // 递归处理嵌套元素 const text = extractTextFromJSXElement(child) if (text) { return text } } } return null } function generateTestId( openingElement: t.JSXOpeningElement, jsxElement: t.JSXElement, currentElementName: string, parentChain: string, delimiter: string, meaningfulAttributes: string[], testIdAttributes: string[] ): string | null { // 1. 最高优先级:手动指定的testID for (const testIdAttr of testIdAttributes) { if (hasDataAttribute(openingElement, testIdAttr)) { return null // 已有testID,不需要生成 } } // 3. 核心:从有意义的属性中提取 for (const meaningfulAttr of meaningfulAttributes) { const meaningfulValue = getAttributeValue(openingElement, meaningfulAttr) if (meaningfulValue) { // 对于name属性,添加前缀 if (meaningfulAttr === 'name') { return `${currentElementName.toLowerCase()}-${meaningfulValue}` } return meaningfulValue } } // 4. 核心:从组件内容中提取 const textContent = extractTextFromJSXElement(jsxElement) if (textContent) { return textContent } // 5. 最低优先级:回退到基于组件名的层级结构 return parentChain ? `${parentChain}${delimiter}${currentElementName}` : currentElementName } type ChainingVisitorState = { parentChain: string attributes: string[] delimiter: string ignoreElements: string[] meaningfulAttributes: string[] } function addTestIdToElement( openingElement: t.JSXOpeningElement, testId: string, attributes: string[] ): void { for (const attributeName of attributes) { if (!hasDataAttribute(openingElement, attributeName)) { const dataAttribute = createDataAttribute(testId, attributeName) openingElement.attributes.push(dataAttribute) } } } function processElementTestId( p: NodePath<t.JSXElement>, currentElementName: string, currentChain: string, s: ChainingVisitorState ): void { const { attributes, delimiter, meaningfulAttributes, } = s const openingElement = p.get('openingElement') const testId = generateTestId( openingElement.node, p.node, currentElementName, currentChain, delimiter, meaningfulAttributes, attributes ) if (testId) { addTestIdToElement(openingElement.node, testId, attributes) } } const chainingVisitor: Visitor<ChainingVisitorState> = { JSXFragment(p, s) { p.traverse(chainingVisitor, s) p.skip() }, JSXElement(p, s) { const { parentChain, delimiter, ignoreElements } = s const openingElement = p.get('openingElement') let currentElementName: string | null = null if (t.isJSXIdentifier(openingElement.node.name)) { currentElementName = openingElement.node.name.name } if (!currentElementName) { p.traverse(chainingVisitor, s) p.skip() return } const isIgnored = ignoreElements.includes(currentElementName) const currentChain = isIgnored ? parentChain : parentChain ? `${parentChain}${delimiter}${currentElementName}` : currentElementName if (!isIgnored) { processElementTestId(p, currentElementName, currentChain, s) } p.traverse(chainingVisitor, { ...s, parentChain: currentChain, }) p.skip() }, } export type State = { opts: { attributes?: string[] delimiter?: string ignoreElements?: string[] meaningfulAttributes?: string[] } } export default function plugin(): PluginObj<State> { return { name: 'react-native-testid-intelligent', visitor: { 'FunctionExpression|ArrowFunctionExpression|FunctionDeclaration': ( path: NodePath<FunctionType>, state: State ) => { const componentIdentifier = nameForReactComponent(path) if (!componentIdentifier) { return } const componentName = componentIdentifier.name if (!/^[A-Z]/u.test(componentName)) { return } const { attributes = [DEFAULT_ATTRIBUTE, DEFAULT_ATTRIBUTE_ID], delimiter = '-', ignoreElements = DEFAULT_RN_IGNORE_ELEMENTS, meaningfulAttributes = DEFAULT_MEANINGFUL_ATTRIBUTES, } = state.opts ?? {} const visitorState: ChainingVisitorState = { parentChain: componentName, attributes, delimiter, ignoreElements, meaningfulAttributes, } const body = path.get('body') if (body.isBlockStatement()) { body.traverse(chainingVisitor, visitorState) } else if (body.isJSXElement() || body.isJSXFragment()) { body.traverse(chainingVisitor, visitorState) } }, }, } as PluginObj<State> }