diff --git a/lib/commands/find.js b/lib/commands/find.js index 24bd01387..9fa9bae86 100644 --- a/lib/commands/find.js +++ b/lib/commands/find.js @@ -1,3 +1,4 @@ +import CssConverter from '../css-converter'; let helpers = {}, extensions = {}; @@ -21,6 +22,10 @@ helpers.doFindElementOrEls = async function (params) { params.strategy = '-android uiautomator'; params.selector = MAGIC_SCROLLABLE_BY; } + if (params.strategy === 'css selector') { + params.strategy = '-android uiautomator'; + params.selector = CssConverter.toUiAutomatorSelector(params.selector); + } if (params.multiple) { return await this.uiautomator2.jwproxy.command(`/elements`, 'POST', params); } else { diff --git a/lib/css-converter.js b/lib/css-converter.js new file mode 100644 index 000000000..9dd72f8f2 --- /dev/null +++ b/lib/css-converter.js @@ -0,0 +1,319 @@ +import { CssSelectorParser } from 'css-selector-parser'; +import { escapeRegExp } from 'lodash'; +import { errors } from 'appium-base-driver'; + +const CssConverter = {}; + +const parser = new CssSelectorParser(); +parser.registerSelectorPseudos('has'); +parser.registerNestingOperators('>', '+', '~'); +parser.registerAttrEqualityMods('^', '$', '*', '~'); +parser.enableSubstitutes(); + +const RESOURCE_ID = 'resource-id'; + +const BOOLEAN_ATTRS = [ + 'checkable', 'checked', 'clickable', 'enabled', 'focusable', + 'focused', 'long-clickable', 'scrollable', 'selected', +]; + +const NUMERIC_ATTRS = [ + 'index', 'instance', +]; + +const STR_ATTRS = [ + 'description', RESOURCE_ID, 'text', 'class-name', 'package-name' +]; + +const ALL_ATTRS = [ + ...BOOLEAN_ATTRS, + ...NUMERIC_ATTRS, + ...STR_ATTRS, +]; + +const ATTRIBUTE_ALIASES = [ + [RESOURCE_ID, ['id']], + ['description', [ + 'content-description', 'content-desc', + 'desc', 'accessibility-id', + ]], + ['index', ['nth-child']], +]; + +/** + * Convert hyphen separated word to snake case + * + * @param {string} str + * @returns {string} The hyphen separated word translated to snake case + */ +function toSnakeCase (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); +} + +/** + * @typedef {Object} CssNameValueObject + * @property {?name} name The name of the CSS object + * @property {?string} value The value of the CSS object + */ + +/** + * Get the boolean from a CSS object. If empty, return true. If not true/false/empty, throw exception + * + * @param {CssNameValueObject} css A CSS object that has 'name' and 'value' + * @returns {string} Either 'true' or 'false'. If value is empty, return 'true' + */ +function assertGetBool (css) { + const val = css.value?.toLowerCase() || 'true'; // an omitted boolean attribute means 'true' (e.g.: input[checked] means checked is true) + if (['true', 'false'].includes(val)) { + return val; + } + throw new Error(`'${css.name}' must be true, false or empty. Found '${css.value}'`); +} + +/** + * 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 {Object} css CSS object + * @returns {string} The canonical attribute name + */ +function assertGetAttrName (css) { + const attrName = css.name.toLowerCase(); + + // Check if it's supported and if it is, return it + if (ALL_ATTRS.includes(attrName)) { + return attrName.toLowerCase(); + } + + // If attrName is an alias for something else, return that + 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(', ')}'`); +} + +/** + * Get a regex that matches a whole word. For the ~= CSS attribute selector. + * + * @param {string} word + * @returns {string} A regex "word" matcher + */ +function getWordMatcherRegex (word) { + return `\\b(\\w*${escapeRegExp(word)}\\w*)\\b`; +} + +/** + * Add android:id/ to beginning of string if it's not there already + * + * @param {string} str + * @returns {string} String with `android:id/` prepended (if it wasn't already) + */ +function prependAndroidId (str) { + return str.startsWith('android:id/') ? str : `android:id/${str}`; +} + +/** + * @typedef {Object} CssAttr + * @property {?string} valueType Type of attribute (must be string or empty) + * @property {?string} value Value of the attribute + * @property {?string} operator The operator between value and value type (=, *=, , ^=, $=) + */ + +/** + * Convert a CSS attribute into a UiSelector method call + * + * @param {CssAttr} cssAttr CSS attribute object + * @returns {string} CSS attribute parsed as UiSelector + */ +function parseAttr (cssAttr) { + if (cssAttr.valueType && cssAttr.valueType !== 'string') { + throw new Error(`'${cssAttr.name}=${cssAttr.value}' is an invalid attribute. ` + + `Only 'string' and empty attribute types are supported. Found '${cssAttr.valueType}'`); + } + const attrName = assertGetAttrName(cssAttr); + const methodName = toSnakeCase(attrName); + + // 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 boolean, if it's a boolean attribute + if (BOOLEAN_ATTRS.includes(attrName)) { + return `.${methodName}(${assertGetBool(cssAttr)})`; + } + + // Otherwise parse as string + let value = cssAttr.value || ''; + if (attrName === RESOURCE_ID) { + value = prependAndroidId(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: + // Unreachable, but adding error in case a new CSS attribute is added. + throw new Error(`Unsupported CSS attribute operator '${cssAttr.operator}'. ` + + ` '=', '*=', '^=', '$=' and '~=' are supported.`); + } +} + +/** + * @typedef {Object} CssPseudo + * @property {?string} valueType The type of CSS pseudo selector (https://www.npmjs.com/package/css-selector-parser for reference) + * @property {?string} name The name of the pseudo selector + * @property {?string} value The value of the pseudo selector + */ + +/** + * Convert a CSS pseudo class to a UiSelector + * + * @param {CssPseudo} cssPseudo CSS Pseudo class + * @returns {string} Pseudo selector parsed as UiSelector + */ +function parsePseudo (cssPseudo) { + if (cssPseudo.valueType && cssPseudo.valueType !== 'string') { + throw new Error(`'${cssPseudo.name}=${cssPseudo.value}'. ` + + `Unsupported css pseudo class value type: '${cssPseudo.valueType}'. Only 'string' type or empty is supported.`); + } + + const pseudoName = assertGetAttrName(cssPseudo); + + if (BOOLEAN_ATTRS.includes(pseudoName)) { + return `.${toSnakeCase(pseudoName)}(${assertGetBool(cssPseudo)})`; + } + + if (NUMERIC_ATTRS.includes(pseudoName)) { + return `.${pseudoName}(${cssPseudo.value})`; + } +} + +/** + * @typedef {Object} CssRule + * @property {?string} nestingOperator The nesting operator (aka: combinator https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) + * @property {?string} tagName The tag name (aka: type selector https://developer.mozilla.org/en-US/docs/Web/CSS/Type_selectors) + * @property {?string[]} classNames An array of CSS class names + * @property {?CssAttr[]} attrs An array of CSS attributes + * @property {?CssPseudo[]} attrs An array of CSS pseudos + * @property {?string} id CSS identifier + * @property {?CssRule} rule A descendant of this CSS rule + */ + +/** + * Convert a CSS rule to a UiSelector + * @param {CssRule} cssRule CSS rule definition + */ +function parseCssRule (cssRule) { + const { nestingOperator } = cssRule; + if (nestingOperator && nestingOperator !== ' ') { + throw new Error(`'${nestingOperator}' is not a supported combinator. ` + + `Only child combinator (>) and descendant combinator are supported.`); + } + + let uiAutomatorSelector = 'new UiSelector()'; + if (cssRule.tagName && cssRule.tagName !== '*') { + let androidClass = [cssRule.tagName]; + if (cssRule.classNames) { + for (const cssClassNames of cssRule.classNames) { + androidClass.push(cssClassNames); + } + uiAutomatorSelector += `.className("${androidClass.join('.')}")`; + } else { + uiAutomatorSelector += `.classNameMatches("${cssRule.tagName}")`; + } + } else if (cssRule.classNames) { + uiAutomatorSelector += `.classNameMatches("${cssRule.classNames.join('\\.')}")`; + } + if (cssRule.id) { + uiAutomatorSelector += `.resourceId("${prependAndroidId(cssRule.id)}")`; + } + if (cssRule.attrs) { + for (const attr of cssRule.attrs) { + uiAutomatorSelector += parseAttr(attr); + } + } + if (cssRule.pseudos) { + for (const pseudo of cssRule.pseudos) { + uiAutomatorSelector += parsePseudo(pseudo); + } + } + if (cssRule.rule) { + uiAutomatorSelector += `.childSelector(${parseCssRule(cssRule.rule)})`; + } + return uiAutomatorSelector; +} + +/** + * @typedef {Object} CssObject + * @property {?string} type Type of CSS object. 'rule', 'ruleset' or 'selectors' + */ + +/** + * Convert CSS object to UiAutomator2 selector + * @param {CssObject} css CSS object + * @returns {string} The CSS object parsed as a UiSelector + */ +function parseCssObject (css) { + switch (css.type) { + case 'rule': + return parseCssRule(css); + case 'ruleSet': + return parseCssObject(css.rule); + case 'selectors': + return css.selectors.map((selector) => parseCssObject(selector)).join('; '); + + default: + // This is never reachable, but if it ever is do this. + throw new Error(`UiAutomator does not support '${css.type}' css. Only supports 'rule', 'ruleSet', 'selectors' `); + } +} + +/** + * Convert a CSS selector to a UiAutomator2 selector + * @param {string} cssSelector CSS Selector + * @returns {string} The CSS selector converted to a UiSelector + */ +CssConverter.toUiAutomatorSelector = function toUiAutomatorSelector (cssSelector) { + let cssObj; + try { + cssObj = parser.parse(cssSelector); + } catch (e) { + throw new errors.InvalidSelectorError(`Invalid CSS selector '${cssSelector}'. Reason: '${e}'`); + } + try { + return parseCssObject(cssObj); + } catch (e) { + throw new errors.InvalidSelectorError(`Unsupported CSS selector '${cssSelector}'. Reason: '${e}'`); + } +}; + +export default CssConverter; \ No newline at end of file diff --git a/lib/driver.js b/lib/driver.js index cf814ef4d..45ac4d326 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -138,6 +138,7 @@ class AndroidUiautomator2Driver extends BaseDriver { 'id', 'class name', 'accessibility id', + 'css selector', '-android uiautomator' ]; this.desiredCapConstraints = desiredCapConstraints; diff --git a/package.json b/package.json index 0ba460a42..a340da118 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "asyncbox": "^2.3.1", "axios": "^0.19.2", "bluebird": "^3.5.1", + "css-selector-parser": "^1.4.1", "lodash": "^4.17.4", "portscanner": "2.2.0", "source-map-support": "^0.5.5", diff --git a/test/functional/commands/find/by-css-e2e-specs.js b/test/functional/commands/find/by-css-e2e-specs.js new file mode 100644 index 000000000..a6601f44a --- /dev/null +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -0,0 +1,82 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { APIDEMOS_CAPS } from '../../desired'; +import { initSession, deleteSession } from '../../helpers/session'; + + +chai.should(); +chai.use(chaiAsPromised); + +describe('Find - CSS', function () { + let driver; + before(async function () { + driver = await initSession(APIDEMOS_CAPS); + }); + after(async function () { + await deleteSession(); + }); + it('should find an element by id (android resource-id)', async function () { + await driver.elementByCss('#text1').should.eventually.exist; + await driver.elementByCss('*[id="android:id/text1"]').should.eventually.exist; + await driver.elementByCss('*[resource-id="text1"]').should.eventually.exist; + }); + it('should find an element by content description', async function () { + await driver.elementByCss('*[description="Animation"]').should.eventually.exist; + }); + it('should return an array of one element if the `multi` param is true', async function () { + let els = await driver.elementsByCss('*[content-desc="Animation"]'); + els.should.be.an.instanceof(Array); + els.should.have.length(1); + }); + it('should find an element with a content-desc property containing an apostrophe', async function () { + await driver.elementByCss('*[content-description="Access\'ibility"]').should.eventually.exist; + }); + it('should find an element by class name', async function () { + let el = await driver.elementByCss('android.widget.TextView'); + const text = await el.text(); + text.toLowerCase().should.equal('api demos'); + }); + it('should find an element with a chain of attributes and pseudo-classes', async function () { + let el = await driver.elementByCss('android.widget.TextView[clickable=true]:nth-child(1)'); + await el.text().should.eventually.equal('Accessibility'); + }); + it('should find an element with recursive UiSelectors', async function () { + await driver.elementsByCss('*[focused=true] *[clickable=true]') + .should.eventually.have.length(1); + }); + it('should allow multiple selector statements and return the Union of the two sets', async function () { + let clickableEls = await driver.elementsByCss('*[clickable]'); + clickableEls.length.should.be.above(0); + let notClickableEls = await driver.elementsByCss('*[clickable=false]'); + notClickableEls.length.should.be.above(0); + let both = await driver.elementsByCss('*[clickable=true], *[clickable=false]'); + both.should.have.length(clickableEls.length + notClickableEls.length); + }); + it('should find an element by a non-fully qualified class name using CSS tag name', async function () { + const els = await driver.elementsByCss('android.widget.TextView'); + els.length.should.be.above(0); + }); + it('should find an element in the second selector if the first finds no elements (when finding multiple elements)', async function () { + let selector = 'not.a.class, android.widget.TextView'; + const els = await driver.elementsByCss(selector); + els.length.should.be.above(0); + }); + it('should find elements using starts with attribute', async function () { + await driver.elementByCss('*[description^="Animation"]').should.eventually.exist; + }); + it('should find elements using ends with attribute', async function () { + await driver.elementByCss('*[description$="Animation"]').should.eventually.exist; + }); + it('should find elements using word match attribute', async function () { + await driver.elementByCss('*[description~="Animation"]').should.eventually.exist; + }); + it('should find elements using wildcard attribute', async function () { + await driver.elementByCss('*[description*="Animation"]').should.eventually.exist; + }); + it('should allow UiScrollable with unicode string', async function () { + await driver.startActivity({appPackage: 'io.appium.android.apis', appActivity: '.text.Unicode'}); + let selector = '*[text="عربي"]:instance(0)'; + let el = await driver.elementByCss(selector); + await el.text().should.eventually.equal('عربي'); + }); +}); diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js new file mode 100644 index 000000000..01c9abfa7 --- /dev/null +++ b/test/unit/css-converter-specs.js @@ -0,0 +1,68 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import CssConverter from '../../lib/css-converter'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('css-converter.js', function () { + describe('simple cases', function () { + const simpleCases = [ + ['android.widget.TextView', 'new UiSelector().className("android.widget.TextView")'], + ['TextView', 'new UiSelector().classNameMatches("TextView")'], + ['.TextView', 'new UiSelector().classNameMatches("TextView")'], + ['.widget.TextView', 'new UiSelector().classNameMatches("widget\\.TextView")'], + ['*[checkable=true]', 'new UiSelector().checkable(true)'], + ['*[checkable]', 'new UiSelector().checkable(true)'], + ['*:checked', 'new UiSelector().checked(true)'], + ['*[checked]', 'new UiSelector().checked(true)'], + ['TextView[description="Some description"]', 'new UiSelector().classNameMatches("TextView").description("Some description")'], + ['*[description]', 'new UiSelector().descriptionMatches("")'], + ['*[description^=blah]', 'new UiSelector().descriptionStartsWith("blah")'], + ['*[description$=bar]', 'new UiSelector().descriptionMatches("bar$")'], + ['*[description*=bar]', 'new UiSelector().descriptionContains("bar")'], + ['#identifier[description=foo]', 'new UiSelector().resourceId("android:id/identifier").description("foo")'], + ['*[id=foo]', 'new UiSelector().resourceId("android:id/foo")'], + ['*[description$="hello [ ^ $ . | ? * + ( ) world"]', 'new UiSelector().descriptionMatches("hello \\[ \\^ \\$ \\. \\| \\? \\* \\+ \\( \\) world$")'], + ['TextView:iNdEx(4)', 'new UiSelector().classNameMatches("TextView").index(4)'], + ['*:long-clickable', 'new UiSelector().longClickable(true)'], + ['*[lOnG-cLiCkAbLe]', 'new UiSelector().longClickable(true)'], + ['*:nth-child(3)', 'new UiSelector().index(3)'], + ['*:instance(3)', 'new UiSelector().instance(3)'], + [ + 'android.widget.TextView[checkable] android.widget.WidgetView[focusable]:nth-child(1)', + 'new UiSelector().className("android.widget.TextView").checkable(true).childSelector(new UiSelector().className("android.widget.WidgetView").focusable(true).index(1))' + ], + ['* *[clickable=true][focused]', 'new UiSelector().childSelector(new UiSelector().clickable(true).focused(true))'], + [ + '*[clickable=true], *[clickable=false]', + 'new UiSelector().clickable(true); new UiSelector().clickable(false)', + ], + ['*[description~="word"]', 'new UiSelector().descriptionMatches("\\b(\\w*word\\w*)\\b")'], + [ + 'android.widget.ListView android.widget.TextView', + 'new UiSelector().className("android.widget.ListView").childSelector(new UiSelector().className("android.widget.TextView"))' + ], + ]; + for (const [cssSelector, uiAutomatorSelector] of simpleCases) { + it(`should convert '${cssSelector}' to '${uiAutomatorSelector}'`, function () { + CssConverter.toUiAutomatorSelector(cssSelector).should.equal(uiAutomatorSelector); + }); + } + }); + describe('unsupported css', function () { + const testCases = [ + ['*[checked="ItS ChEcKeD"]', /'checked' must be true, false or empty. Found 'ItS ChEcKeD'/], + ['*[foo="bar"]', /'foo' is not a valid attribute. Supported attributes are */], + ['*:checked("ischecked")', /'checked' must be true, false or empty. Found 'ischecked'/], + [`This isn't valid[ css`, /Invalid CSS selector/], + ['p ~ a', /'~' is not a supported combinator. /], + ['p > a', /'>' is not a supported combinator. /], + ]; + for (const [cssSelector, error] of testCases) { + it(`should reject '${cssSelector}' with '${error}'`, function () { + (() => CssConverter.toUiAutomatorSelector(cssSelector)).should.throw(error); + }); + } + }); +});