babel-plugin-react-native-testid
Version:
babel plugin for react native testid attributes
232 lines • 8.58 kB
JavaScript
;
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const t = __importStar(require("@babel/types"));
function nameForReactComponent(path) {
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, attributeName) {
return t.jsxAttribute(t.jsxIdentifier(attributeName), t.stringLiteral(name));
}
function hasDataAttribute(node, attributeName) {
// 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, attributeName) {
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) {
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, jsxElement, currentElementName, parentChain, delimiter, meaningfulAttributes, testIdAttributes) {
// 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;
}
function addTestIdToElement(openingElement, testId, attributes) {
for (const attributeName of attributes) {
if (!hasDataAttribute(openingElement, attributeName)) {
const dataAttribute = createDataAttribute(testId, attributeName);
openingElement.attributes.push(dataAttribute);
}
}
}
function processElementTestId(p, currentElementName, currentChain, s) {
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 = {
JSXFragment(p, s) {
p.traverse(chainingVisitor, s);
p.skip();
},
JSXElement(p, s) {
const { parentChain, delimiter, ignoreElements } = s;
const openingElement = p.get('openingElement');
let currentElementName = 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, Object.assign(Object.assign({}, s), { parentChain: currentChain }));
p.skip();
},
};
function plugin() {
return {
name: 'react-native-testid-intelligent',
visitor: {
'FunctionExpression|ArrowFunctionExpression|FunctionDeclaration': (path, state) => {
var _a;
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, } = (_a = state.opts) !== null && _a !== void 0 ? _a : {};
const visitorState = {
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);
}
},
},
};
}
exports.default = plugin;
//# sourceMappingURL=index.js.map