appium-uiautomator2-driver
Version:
UiAutomator2 integration for Appium
280 lines (244 loc) • 8.84 kB
text/typescript
import {createParser} from 'css-selector-parser';
import type {
AstAttribute,
AstClassName,
AstId,
AstPseudoClass,
AstRule,
AstSelector,
AstTagName,
} from 'css-selector-parser';
import {errors} from 'appium/driver';
import {log} from './logger';
import {escapeRegExp, isEmpty} from './utils';
const parseCssSelector = createParser({
syntax: {
pseudoClasses: {
unknown: 'accept',
definitions: {
Selector: ['has'],
},
},
combinators: ['>', '+', '~'],
attributes: {
operators: ['^=', '$=', '*=', '~=', '='],
},
ids: true,
classNames: true,
tag: {
wildcard: true,
},
},
substitutes: true,
});
const RESOURCE_ID = 'resource-id';
const ID_LOCATOR_PATTERN = /^[a-zA-Z_][a-zA-Z0-9._]*:id\/[\S]+$/;
const BOOLEAN_ATTRS = [
'checkable',
'checked',
'clickable',
'enabled',
'focusable',
'focused',
'long-clickable',
'scrollable',
'selected',
] as const;
const NUMERIC_ATTRS = ['index', 'instance'] as const;
const STR_ATTRS = ['description', RESOURCE_ID, 'text', 'class-name', 'package-name'] as const;
const ALL_ATTRS = [...BOOLEAN_ATTRS, ...NUMERIC_ATTRS, ...STR_ATTRS] as readonly string[];
const ATTRIBUTE_ALIASES: Array<[string, string[]]> = [
[RESOURCE_ID, ['id']],
['description', ['content-description', 'content-desc', 'desc', 'accessibility-id']],
['index', ['nth-child']],
];
const isAstAttribute = (item: {type?: string}): item is AstAttribute => item.type === 'Attribute';
const isAstPseudoClass = (item: {type?: string}): item is AstPseudoClass =>
item.type === 'PseudoClass';
const isAstClassName = (item: {type?: string}): item is AstClassName => item.type === 'ClassName';
const isAstTagName = (item: {type?: string}): item is AstTagName => item.type === 'TagName';
const isAstId = (item: {type?: string}): item is AstId => item.type === 'Id';
export class CssConverter {
constructor(
private readonly selector: string,
private readonly pkg?: string | null,
) {}
toUiAutomatorSelector(): string {
let cssObj: AstSelector;
try {
cssObj = parseCssSelector(this.selector) as AstSelector;
} catch (e: any) {
log.debug(e.stack);
throw new errors.InvalidSelectorError(
`Invalid CSS selector '${this.selector}'. Reason: '${e.message}'`,
);
}
try {
return this.parseCssObject(cssObj);
} catch (e: any) {
log.debug(e.stack);
throw new errors.InvalidSelectorError(
`Unsupported CSS selector '${this.selector}'. Reason: '${e.message}'`,
);
}
}
private formatIdLocator(locator: string): string {
return ID_LOCATOR_PATTERN.test(locator) ? locator : `${this.pkg || 'android'}:id/${locator}`;
}
private parseAttr(cssAttr: AstAttribute): string {
const attrValueNode = cssAttr.value as {value?: string} | undefined;
const attrValue = attrValueNode?.value;
if (typeof attrValue !== 'string' && !isEmpty(attrValue)) {
throw new Error(
`'${cssAttr.name}=${attrValue}' is an invalid attribute. Only 'string' and empty attribute types are supported. Found '${attrValue}'`,
);
}
const attrName = requireEntityName(cssAttr);
const methodName = toSnakeCase(attrName);
if (
!STR_ATTRS.includes(attrName as (typeof STR_ATTRS)[number]) &&
!BOOLEAN_ATTRS.includes(attrName as (typeof BOOLEAN_ATTRS)[number])
) {
throw new Error(
`'${attrName}' is not supported. Supported attributes are '${[...STR_ATTRS, ...BOOLEAN_ATTRS].join(', ')}'`,
);
}
if (BOOLEAN_ATTRS.includes(attrName as (typeof BOOLEAN_ATTRS)[number])) {
return `.${methodName}(${requireBoolean(cssAttr)})`;
}
let value = attrValue || '';
if (attrName === RESOURCE_ID) {
value = this.formatIdLocator(value);
}
if (value === '') {
return `.${methodName}Matches("")`;
}
switch (cssAttr.operator) {
case '=':
return `.${methodName}("${value}")`;
case '*=':
if (['description', 'text'].includes(attrName)) {
return `.${methodName}Contains("${value}")`;
}
return `.${methodName}Matches("${escapeRegExp(value)}")`;
case '^=':
if (['description', 'text'].includes(attrName)) {
return `.${methodName}StartsWith("${value}")`;
}
return `.${methodName}Matches("^${escapeRegExp(value)}")`;
case '$=':
return `.${methodName}Matches("${escapeRegExp(value)}$")`;
case '~=':
return `.${methodName}Matches("${getWordMatcherRegex(value)}")`;
default:
throw new Error(
`Unsupported CSS attribute operator '${cssAttr.operator}'. '=', '*=', '^=', '$=' and '~=' are supported.`,
);
}
}
private parsePseudo(cssPseudo: AstPseudoClass): string | undefined {
const argValue = (cssPseudo.argument as {value?: string} | undefined)?.value;
if (typeof argValue !== 'string' && !isEmpty(argValue)) {
throw new Error(
`'${cssPseudo.name}=${argValue}'. Unsupported css pseudo class value: '${argValue}'. Only 'string' type or empty is supported.`,
);
}
const pseudoName = requireEntityName(cssPseudo);
if (BOOLEAN_ATTRS.includes(pseudoName as (typeof BOOLEAN_ATTRS)[number])) {
return `.${toSnakeCase(pseudoName)}(${requireBoolean(cssPseudo)})`;
}
if (NUMERIC_ATTRS.includes(pseudoName as (typeof NUMERIC_ATTRS)[number])) {
return `.${pseudoName}(${argValue})`;
}
}
private parseCssRule(cssRule: AstRule): string {
if (cssRule.combinator && ![' ', '>'].includes(cssRule.combinator)) {
throw new Error(
`'${cssRule.combinator}' is not a supported combinator. Only child combinator (>) and descendant combinator are supported.`,
);
}
const uiAutomatorSelector: string[] = ['new UiSelector()'];
const items = cssRule.items ?? [];
const astClassNames = items.filter(isAstClassName);
const classNames = astClassNames.map(({name}) => name);
const astTag = items.find(isAstTagName);
const tagName = astTag?.name;
if (tagName && tagName !== '*') {
const androidClass = [tagName];
if (classNames.length) {
for (const cssClassNames of classNames) {
androidClass.push(cssClassNames);
}
uiAutomatorSelector.push(`.className("${androidClass.join('.')}")`);
} else {
uiAutomatorSelector.push(`.classNameMatches("${tagName}")`);
}
} else if (classNames.length) {
uiAutomatorSelector.push(`.classNameMatches("${classNames.join('\\.')}")`);
}
const astIds = items.filter(isAstId);
const ids = astIds.map(({name}) => name);
if (ids.length) {
uiAutomatorSelector.push(`.resourceId("${this.formatIdLocator(ids[0])}")`);
}
const attributes = items.filter(isAstAttribute);
for (const attr of attributes) {
uiAutomatorSelector.push(this.parseAttr(attr));
}
const pseudoClasses = items.filter(isAstPseudoClass);
for (const pseudo of pseudoClasses) {
const sel = this.parsePseudo(pseudo);
if (sel) {
uiAutomatorSelector.push(sel);
}
}
if (cssRule.nestedRule) {
uiAutomatorSelector.push(`.childSelector(${this.parseCssRule(cssRule.nestedRule)})`);
}
return uiAutomatorSelector.join('');
}
private parseCssObject(css: AstSelector): string {
if (!isEmpty(css.rules)) {
return this.parseCssRule(css.rules[0] as AstRule);
}
throw new Error('No rules could be parsed out of the current selector');
}
}
function toSnakeCase(str?: string | null): string {
if (!str) {
return '';
}
const tokens = str
.split('-')
.map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase());
const out = tokens.join('');
return out.charAt(0).toLowerCase() + out.slice(1);
}
function requireBoolean(css: AstAttribute | AstPseudoClass): 'true' | 'false' {
const rawValue = (css as any).value?.value ?? (css as any).argument?.value;
const value = String(rawValue ?? 'true').toLowerCase();
if (value === 'true') {
return 'true';
}
if (value === 'false') {
return 'false';
}
throw new Error(`'${css.name}' must be true, false or empty. Found '${(css as any).value}'`);
}
function requireEntityName(cssEntity: AstAttribute | AstPseudoClass): string {
const attrName = cssEntity.name.toLowerCase();
if (ALL_ATTRS.includes(attrName)) {
return attrName;
}
for (const [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) {
if (aliasAttrs.includes(attrName)) {
return officialAttr;
}
}
throw new Error(
`'${attrName}' is not a valid attribute. Supported attributes are '${ALL_ATTRS.join(', ')}'`,
);
}
function getWordMatcherRegex(word: string): string {
return `\\b(\\w*${escapeRegExp(word)}\\w*)\\b`;
}