babel-plugin-react-native-testid
Version:
babel plugin for react native testid attributes
332 lines (295 loc) • 8.37 kB
text/typescript
// @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>
}