UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

247 lines 9.24 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CssConverter = void 0; const css_selector_parser_1 = require("css-selector-parser"); const lodash_1 = __importDefault(require("lodash")); const driver_1 = require("appium/driver"); const logger_1 = require("./logger"); exports.CssConverter = { toIosClassChainSelector(cssSelector) { let cssObj; try { cssObj = parseCssSelector(cssSelector); } catch (e) { logger_1.log.debug(e.stack); throw new driver_1.errors.InvalidSelectorError(`Invalid CSS selector '${cssSelector}'. Reason: '${e.message}'`); } try { return parseCssObject(cssObj); } catch (e) { logger_1.log.debug(e.stack); throw new driver_1.errors.InvalidSelectorError(`Unsupported CSS selector '${cssSelector}'. Reason: '${e.message}'`); } }, }; const parseCssSelector = (0, css_selector_parser_1.createParser)({ syntax: { pseudoClasses: { unknown: 'accept', definitions: { Selector: ['has'], }, }, combinators: ['>', '+', '~'], attributes: { operators: ['^=', '$=', '*=', '~=', '='], }, ids: true, classNames: true, tag: { wildcard: true, }, }, substitutes: true, }); const BOOLEAN_ATTRS = ['visible', 'accessible', 'accessibility-container', 'enabled']; const NUMERIC_ATTRS = ['index']; const STR_ATTRS = ['label', 'name', 'value', 'type']; const ALL_ATTRS = [...BOOLEAN_ATTRS, ...NUMERIC_ATTRS, ...STR_ATTRS]; const ATTRIBUTE_ALIASES = [ ['name', ['id']], ['index', ['nth-child']], ]; /** * Convert hyphen separated word to camel case * * @param str * @returns The hyphen separated word translated to camel case */ function toCamelCase(str) { if (!str) { return ''; } const tokens = str .split('-') .map((str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()); const out = tokens.join(''); return out.charAt(0).toLowerCase() + out.slice(1); } /** * Get the boolean from a CSS object. If empty, return true. If not true/false/empty, throw exception * * @param cssAttr * @returns Either 'true' or 'false'. If value is empty, return 'true' */ function requireBoolean(cssAttr) { const attrValue = cssAttr.value?.value; const val = lodash_1.default.toLower(attrValue) || 'true'; // an omitted boolean attribute means 'true' (e.g.: input[checked] means checked is true) switch (val) { case '0': case 'false': return '0'; case '1': case 'true': return '1'; default: throw new TypeError(`'${cssAttr.name}' must be true/1 or false/0 or empty. Found '${attrValue}'`); } } /** * Get the canonical form of a CSS attribute name * * Converts to lowercase and if an attribute name is an alias for something else, return * what it is an alias for * * @param cssEntity * @returns The canonical attribute name */ function requireEntityName(cssEntity) { const entityName = cssEntity.name.toLowerCase(); // Check if it's supported and if it is, return it if (ALL_ATTRS.includes(entityName)) { return entityName.toLowerCase(); } // If attrName is an alias for something else, return that for (const [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) { if (aliasAttrs.includes(entityName)) { return officialAttr; } } throw new Error(`'${entityName}' is not a valid attribute. ` + `Supported attributes are: '${ALL_ATTRS.join(', ')}'`); } /** * Convert a CSS attribute into a UiSelector method call * * @param cssAttr CSS attribute object * @returns CSS attribute parsed as UiSelector */ function parseAttr(cssAttr) { const attrValue = cssAttr.value?.value; if (!lodash_1.default.isString(attrValue) && !lodash_1.default.isEmpty(attrValue)) { throw new TypeError(`'${cssAttr.name}=${attrValue}' is an invalid attribute. ` + `Only 'string' and empty attribute types are supported. Found '${attrValue}'`); } const attrName = toCamelCase(requireEntityName(cssAttr)); // Validate that it's a supported attribute if (!STR_ATTRS.includes(attrName) && !BOOLEAN_ATTRS.includes(attrName)) { throw new Error(`'${attrName}' is not supported. Supported attributes are ` + `'${[...STR_ATTRS, ...BOOLEAN_ATTRS].join(', ')}'`); } // Parse index if it's an index attribute if (attrName === 'index') { return { index: attrValue }; } if (BOOLEAN_ATTRS.includes(attrName)) { return `${attrName} == ${requireBoolean(cssAttr)}`; } const value = attrValue || ''; if (value === '') { return `[${attrName} LIKE ${value}]`; } switch (cssAttr.operator) { case '=': return `${attrName} == "${value}"`; case '*=': return `${attrName} MATCHES "${lodash_1.default.escapeRegExp(value)}"`; case '^=': return `${attrName} BEGINSWITH "${value}"`; case '$=': return `${attrName} ENDSWITH "${value}"`; case '~=': return `${attrName} CONTAINS "${value}"`; default: // Unreachable, but adding error in case a new CSS attribute is added. throw new Error(`Unsupported CSS attribute operator '${cssAttr.operator}'. ` + ` '=', '*=', '^=', '$=' and '~=' are supported.`); } } /** * Convert a CSS pseudo class to a UiSelector * * @param cssPseudo * @returns Pseudo selector parsed as UiSelector */ function parsePseudo(cssPseudo) { const argValue = cssPseudo.argument?.value; if (!lodash_1.default.isString(argValue) && !lodash_1.default.isEmpty(argValue)) { throw new TypeError(`'${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)) { return `${toCamelCase(pseudoName)} == ${requireBoolean(cssPseudo)}`; } if (pseudoName === 'index') { return { index: argValue }; } } /** * Convert a CSS rule to a UiSelector * @param cssRule CSS rule definition */ function parseCssRule(cssRule) { if (cssRule.combinator && ![' ', '>'].includes(cssRule.combinator)) { throw new Error(`'${cssRule.combinator}' is not a supported combinator. ` + `Only child combinator (>) and descendant combinator are supported.`); } let iosClassChainSelector = ''; const astClassNames = cssRule.items.filter(({ type }) => type === 'ClassName'); const classNames = astClassNames.map(({ name }) => name); if (classNames.length) { throw new driver_1.errors.InvalidSelectorError(`'${[cssRule || '', ...classNames].join('.')}' is not a valid ios class. Must be a single string (e.g.: XCUIElementTypeWindow) without dots separating them`); } const astTag = cssRule.items.find(({ type }) => type === 'TagName'); let tagName = astTag?.name ?? ''; if (tagName && tagName !== '*' && !lodash_1.default.startsWith(lodash_1.default.toLower(tagName), 'xcuielementtype')) { const capitalizedTagName = tagName.charAt(0).toUpperCase() + tagName.slice(1); tagName = `XCUIElementType${capitalizedTagName}`; } iosClassChainSelector += tagName || '*'; const attrs = []; const astIds = cssRule.items.filter(({ type }) => type === 'Id'); const ids = astIds.map(({ name }) => name); if (ids.length) { attrs.push(`name == "${ids[0]}"`); } const attributes = cssRule.items.filter(({ type }) => type === 'Attribute'); for (const attr of attributes) { attrs.push(parseAttr(attr)); } const pseudoClasses = cssRule.items.filter(({ type }) => type === 'PseudoClass'); for (const pseudo of pseudoClasses) { attrs.push(parsePseudo(pseudo)); } const nonIndexAttrs = attrs.filter((attr) => lodash_1.default.isString(attr)); if (!lodash_1.default.isEmpty(nonIndexAttrs)) { iosClassChainSelector += `[\`${nonIndexAttrs.join(' AND ')}\`]`; } const indexAttr = attrs.find((attr) => lodash_1.default.isObject(attr) && attr.index); if (indexAttr) { iosClassChainSelector += `[${indexAttr.index}]`; } if (cssRule.nestedRule) { iosClassChainSelector += `/${parseCssRule(cssRule.nestedRule)}`; } return cssRule.combinator === '>' ? iosClassChainSelector : `**/${iosClassChainSelector}`; } /** * Convert CSS object to iOS Class Chain selector * * @param css CSS object * @returns The CSS object parsed as a UiSelector */ function parseCssObject(css) { if (!lodash_1.default.isEmpty(css.rules)) { return parseCssRule(css.rules[0]); } throw new Error('No rules could be parsed out of the current selector'); } //# sourceMappingURL=css-converter.js.map