appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
116 lines (96 loc) • 3.78 kB
text/typescript
import type {ParsedAttribute, ParsedRule, ParsedSelector, StrategyEmitter} from '@appium/css-locator-to-native';
import {errors} from 'appium/driver';
import {escapeRegExp, isEmpty} from '../utils';
const BOOLEAN_ATTRS = new Set(['visible', 'accessible', 'accessibility-container', 'enabled']);
const STRING_ATTRS = new Set(['label', 'name', 'value', 'type']);
/** Converts parsed CSS selectors into class chain strings. */
export class ClassChainEmitter implements StrategyEmitter {
readonly strategy: string;
constructor(strategy: string) {
this.strategy = strategy;
}
emit(parsed: ParsedSelector): string {
return this.emitRule(parsed.rule);
}
private emitRule(rule: ParsedRule): string {
if (rule.classes.length) {
throw new errors.InvalidSelectorError(
`'${rule.classes.join('.')}' is not a valid class. Must be a single string ` +
`(e.g.: XCUIElementTypeWindow) without dots separating them`,
);
}
let tagName = rule.tag ?? '';
if (tagName && tagName !== '*' && !tagName.toLowerCase().startsWith('xcuielementtype')) {
const capitalizedTagName = tagName.charAt(0).toUpperCase() + tagName.slice(1);
tagName = `XCUIElementType${capitalizedTagName}`;
}
let classChainSelector = tagName || '*';
const attrs: (string | {index: string} | undefined)[] = [];
if (rule.id) {
attrs.push(`name == "${rule.id}"`);
}
for (const attr of rule.attributes) {
attrs.push(this.formatEntity(attr));
}
for (const pseudo of rule.pseudos) {
attrs.push(this.formatEntity(pseudo));
}
const nonIndexAttrs = attrs.filter((attr) => typeof attr === 'string') as string[];
if (!isEmpty(nonIndexAttrs)) {
classChainSelector += `[\`${nonIndexAttrs.join(' AND ')}\`]`;
}
const indexAttr = attrs.find(
(attr) => typeof attr === 'object' && attr !== null && (attr as {index: string}).index !== undefined,
) as {index: string} | undefined;
if (indexAttr) {
classChainSelector += `[${indexAttr.index}]`;
}
if (rule.nested) {
classChainSelector += `/${this.emitRule(rule.nested)}`;
}
return rule.combinator === 'child' ? classChainSelector : `**/${classChainSelector}`;
}
private formatEntity(attr: ParsedAttribute): string | {index: string} | undefined {
if (attr.name === 'index') {
return this.formatIndex(attr.value);
}
if (BOOLEAN_ATTRS.has(attr.name)) {
return this.formatBooleanAttr(attr.name, attr.value);
}
if (STRING_ATTRS.has(attr.name)) {
return this.formatStringAttr(attr);
}
}
private formatIndex(value: string | undefined): {index: string} {
return {index: value ?? ''};
}
private formatBooleanAttr(name: string, value: string | undefined): string {
return `${this.toCamelCase(name)} == ${value ?? '1'}`;
}
private formatStringAttr(attr: ParsedAttribute): string {
const attrName = this.toCamelCase(attr.name);
const value = attr.value ?? '';
if (value === '') {
return `[${attrName} LIKE ${value}]`;
}
switch (attr.operator) {
case '=':
return `${attrName} == "${value}"`;
case '*=':
return `${attrName} MATCHES "${escapeRegExp(value)}"`;
case '^=':
return `${attrName} BEGINSWITH "${value}"`;
case '$=':
return `${attrName} ENDSWITH "${value}"`;
case '~=':
return `${attrName} CONTAINS "${value}"`;
default:
return `${attrName} == "${value}"`;
}
}
private toCamelCase(str: string): string {
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);
}
}