From d8778d2198ccb7208fb9bc7ec90bc8af36cb7c43 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 17:02:44 -0700 Subject: [PATCH 01/18] Mobile CSS selector --- lib/commands/find.js | 8 + lib/css-converter.js | 167 ++++++++++++++++++ lib/driver.js | 1 + package.json | 1 + .../commands/find/by-css-e2e-specs.js | 43 +++++ .../commands/find/by-uiautomator-e2e-specs.js | 2 + test/unit/css-converter-specs.js | 44 +++++ 7 files changed, 266 insertions(+) create mode 100644 lib/css-converter.js create mode 100644 test/functional/commands/find/by-css-e2e-specs.js create mode 100644 test/unit/css-converter-specs.js diff --git a/lib/commands/find.js b/lib/commands/find.js index 24bd01387..00463a2bf 100644 --- a/lib/commands/find.js +++ b/lib/commands/find.js @@ -1,4 +1,7 @@ +import CssSelector from '../css-converter'; +import CssConverter from '../css-converter'; + let helpers = {}, extensions = {}; // we override the xpath search for this first-visible-child selector, which @@ -21,6 +24,11 @@ helpers.doFindElementOrEls = async function (params) { params.strategy = '-android uiautomator'; params.selector = MAGIC_SCROLLABLE_BY; } + console.log('****', params.strategy); + 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..7d1721159 --- /dev/null +++ b/lib/css-converter.js @@ -0,0 +1,167 @@ +import { CssSelectorParser } from 'css-selector-parser'; + +const CssConverter = {}; + +const parser = new CssSelectorParser(); +parser.registerSelectorPseudos('has'); +parser.registerNestingOperators('>', '+', '~'); +parser.registerAttrEqualityMods('^', '$', '*', '~'); +parser.enableSubstitutes(); + +CssConverter.toUiAutomatorSelector = function (cssSelector) { + let uiAutomatorSelector = 'new UiSelector()'; + const css = parser.parse(cssSelector); + + const booleanAttrs = [ + 'checkable', 'checked', 'clickable', 'enabled', 'focusable', + 'focused', 'long-clickable', 'scrollable', 'selected', + ]; + + const numericAttrs = [ + 'index', 'instance', + ]; + + const strAttrs = [ + 'description', 'resource-id', 'text', 'class-name', 'package-name' + ]; + + const allAttrs = [ + ...booleanAttrs, + ...numericAttrs, + ...strAttrs, + ]; + + const attributeAliases = [ + ['resource-id', ['id']], + ['description', [ + 'content-description', 'content-desc', + 'desc', 'accessibility-id', + ]], + ['index', ['nth-child']], + ]; + + function toSnakeCase (str) { + const tokens = str.split('-').map((str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()); + let out = tokens.join(''); + return out.charAt(0).toLowerCase() + out.slice(1); + } + + function getAttrName (attrName) { + attrName = attrName.toLowerCase(); + + // Check if it's supported and if it is, return it + if (allAttrs.includes(attrName)) { + return attrName.toLowerCase(); + } + + // If attrName is an alias for something else, return that + for (let [officialAttr, aliasAttrs] of attributeAliases) { + if (aliasAttrs.includes(attrName)) { + return officialAttr; + } + } + throw new Error(`Unsupported attribute: '${attrName}'`); + } + + function escapeRegexLiterals (str) { + return str.replace(/[\[\^\$\.\|\?\*\+\(\)\\]/g, (tok) => `\\${tok}`); + } + + function prependAndroidId (str) { + if (!str.startsWith("android:id/")) { + return `android:id/${str}`; + } + return str; + } + + function parseAttr (cssAttr) { + const attrName = getAttrName(cssAttr.name); + const methodName = toSnakeCase(attrName); + if (booleanAttrs.includes(attrName)) { + const value = cssAttr.value?.toLowerCase() || ''; + if (['', 'true', 'false'].includes(value)) { + return `.${methodName}(${value})`; + } + throw new Error(); + } + + if (strAttrs.includes(attrName)) { + let value = cssAttr.value || ''; + if (attrName === 'resource-id') { + value = prependAndroidId(value); + } + if (value === '') { + return `.${methodName}Matches("")`; + } else { + switch (cssAttr.operator) { + case '=': + return `.${methodName}("${value}")`; + case '*=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}Contains("${value}")`; + } + return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; + case '^=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}StartsWith("${value}")`; + } + return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; + case '$=': + return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; + case '~=': + return `.${methodName}Matches()`; + default: + throw new Error(`Unsupported CSS attribute operator '${cssAttr.operator}'`); + } + } + } + } + + function parsePseudo (cssPseudo) { + const pseudoName = getAttrName(cssPseudo.name); + if (booleanAttrs.includes(pseudoName)) { + return `.${toSnakeCase(pseudoName)}()`; + } + + if (numericAttrs.includes(pseudoName)) { + return `.${pseudoName}(${cssPseudo.value})`; + } + } + + function parseRule (cssRule) { + const {rule} = cssRule; + if (rule.tagName && rule.tagName != '*') { + let androidClass = [rule.tagName]; + for (const cssClassNames of (rule.classNames || [])) { + androidClass.push(cssClassNames); + } + uiAutomatorSelector += `.className("${androidClass.join('.')}")`; + } else if (rule.classNames) { + uiAutomatorSelector += `.classNameMatches("\\.${rule.classNames.join('\\.')}")`; + } + if (rule.id) { + uiAutomatorSelector += `.resourceId("${prependAndroidId(rule.id)}")`; + } + if (rule.attrs) { + for (const attr of rule.attrs) { + uiAutomatorSelector += parseAttr(attr); + } + } + if (rule.pseudos) { + for (const pseudo of rule.pseudos) { + uiAutomatorSelector += parsePseudo(pseudo); + } + } + } + + if (css.type === 'ruleSet') { + parseRule(css); + } else if (css.type === 'selectors') { + throw new Error('TODO: Add multiple selectors'); + } + return uiAutomatorSelector; +}; + + + +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..0dd48eaf8 --- /dev/null +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -0,0 +1,43 @@ +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'); + }); +}); diff --git a/test/functional/commands/find/by-uiautomator-e2e-specs.js b/test/functional/commands/find/by-uiautomator-e2e-specs.js index f6a02d3d8..a6853e13e 100644 --- a/test/functional/commands/find/by-uiautomator-e2e-specs.js +++ b/test/functional/commands/find/by-uiautomator-e2e-specs.js @@ -7,6 +7,8 @@ import { initSession, deleteSession } from '../../helpers/session'; chai.should(); chai.use(chaiAsPromised); +// TODO: Add the companion CSS selectors here + describe('Find - uiautomator', function () { let driver; before(async function () { diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js new file mode 100644 index 000000000..a6ef8afef --- /dev/null +++ b/test/unit/css-converter-specs.js @@ -0,0 +1,44 @@ +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")'], + ['.widget.TextView', 'new UiSelector().classNameMatches("\\.widget\\.TextView")'], + ['*[checkable=true]', 'new UiSelector().checkable(true)'], + ['*[checkable]', 'new UiSelector().checkable()'], + ['*:checked', 'new UiSelector().checked()'], + ['*[checked]', 'new UiSelector().checked()'], + ['TextView[description="Some description"]', 'new UiSelector().className("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("identifier").description("android:id/foo")'], + ['*[description$="hello [ ^ $ . | ? * + ( ) world"]', 'new UiSelector().descriptionMatches("hello \\[ \\^ \\$ \\. \\| \\? \\* \\+ \\( \\) world$")'], + ['TextView:iNdEx(4)', 'new UiSelector().className("TextView").index(4)'], + ['*:long-clickable', 'new UiSelector().longClickable()'], + ['*[lOnG-cLiCkAbLe]', 'new UiSelector().longClickable()'], + ['*:nth-child(3)', 'new UiSelector().index(3)'], + ['*:instance(3)', 'new UiSelector().instance(3)'], + ]; + for (const [cssSelector, uiAutomatorSelector] of simpleCases) { + it(`should convert '${cssSelector}' to '${uiAutomatorSelector}'`, function () { + CssConverter.toUiAutomatorSelector(cssSelector).should.equal(uiAutomatorSelector); + }); + } + }); + describe('attributes', function () { + + }); + describe('pseudo-classes', function () { + + }); +}); From 50918f1c735856967d16cdc1f34b668226b5b09d Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 18:43:45 -0700 Subject: [PATCH 02/18] Allow hierarchical CSS classes --- lib/css-converter.js | 14 ++++++++------ test/functional/commands/find/by-css-e2e-specs.js | 4 ++++ test/unit/css-converter-specs.js | 7 ++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 7d1721159..46e95bb62 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -9,9 +9,6 @@ parser.registerAttrEqualityMods('^', '$', '*', '~'); parser.enableSubstitutes(); CssConverter.toUiAutomatorSelector = function (cssSelector) { - let uiAutomatorSelector = 'new UiSelector()'; - const css = parser.parse(cssSelector); - const booleanAttrs = [ 'checkable', 'checked', 'clickable', 'enabled', 'focusable', 'focused', 'long-clickable', 'scrollable', 'selected', @@ -128,8 +125,8 @@ CssConverter.toUiAutomatorSelector = function (cssSelector) { } } - function parseRule (cssRule) { - const {rule} = cssRule; + function parseRule (rule) { + let uiAutomatorSelector = "new UiSelector()"; if (rule.tagName && rule.tagName != '*') { let androidClass = [rule.tagName]; for (const cssClassNames of (rule.classNames || [])) { @@ -152,10 +149,15 @@ CssConverter.toUiAutomatorSelector = function (cssSelector) { uiAutomatorSelector += parsePseudo(pseudo); } } + if (rule.rule) { + uiAutomatorSelector += `.childSelector(${parseRule(rule.rule)})`; + } + return uiAutomatorSelector; } + const css = parser.parse(cssSelector); if (css.type === 'ruleSet') { - parseRule(css); + return parseRule(css.rule); } else if (css.type === 'selectors') { throw new Error('TODO: Add multiple selectors'); } diff --git a/test/functional/commands/find/by-css-e2e-specs.js b/test/functional/commands/find/by-css-e2e-specs.js index 0dd48eaf8..71be47d05 100644 --- a/test/functional/commands/find/by-css-e2e-specs.js +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -40,4 +40,8 @@ describe('Find - CSS', 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); + }); }); diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index a6ef8afef..9139e1034 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -21,13 +21,18 @@ describe('css-converter.js', function () { ['*[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("identifier").description("android:id/foo")'], + ['*[id=foo]', 'new UiSelector().resourceId("android:id/foo")'], ['*[description$="hello [ ^ $ . | ? * + ( ) world"]', 'new UiSelector().descriptionMatches("hello \\[ \\^ \\$ \\. \\| \\? \\* \\+ \\( \\) world$")'], ['TextView:iNdEx(4)', 'new UiSelector().className("TextView").index(4)'], ['*:long-clickable', 'new UiSelector().longClickable()'], ['*[lOnG-cLiCkAbLe]', 'new UiSelector().longClickable()'], ['*: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().childSelector(new UiSelector().className("android.widget.WidgetView").focusable().index(1))' + ], + ['* *[clickable=true][focused]', 'new UiSelector().childSelector(new UiSelector().clickable(true).focused())'] ]; for (const [cssSelector, uiAutomatorSelector] of simpleCases) { it(`should convert '${cssSelector}' to '${uiAutomatorSelector}'`, function () { From cabeb89c46de333e1e548541c9bce5788a14c147 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 18:48:19 -0700 Subject: [PATCH 03/18] Allow hierarchical CSS classes --- lib/css-converter.js | 11 ++++++++++- test/unit/css-converter-specs.js | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 46e95bb62..6113bf539 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -156,8 +156,17 @@ CssConverter.toUiAutomatorSelector = function (cssSelector) { } const css = parser.parse(cssSelector); + + switch (css.type) { + case 'ruleSet': + return parseRule(css.rule); + case 'selectors': + console.log(css); process.exit(); + default: + throw new Error(`UiAutomator does not support '${css.type}' css`); + } + if (css.type === 'ruleSet') { - return parseRule(css.rule); } else if (css.type === 'selectors') { throw new Error('TODO: Add multiple selectors'); } diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index 9139e1034..282e08763 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -32,7 +32,11 @@ describe('css-converter.js', function () { 'android.widget.TextView[checkable] android.widget.WidgetView[focusable]:nth-child(1)', 'new UiSelector().className("android.widget.TextView").checkable().childSelector(new UiSelector().className("android.widget.WidgetView").focusable().index(1))' ], - ['* *[clickable=true][focused]', 'new UiSelector().childSelector(new UiSelector().clickable(true).focused())'] + ['* *[clickable=true][focused]', 'new UiSelector().childSelector(new UiSelector().clickable(true).focused())'], + [ + '*[clickable=true], *[clickable=false]', + 'new UiSelector().clickable(true); new UiSelector().clickable(false);', + ] ]; for (const [cssSelector, uiAutomatorSelector] of simpleCases) { it(`should convert '${cssSelector}' to '${uiAutomatorSelector}'`, function () { From 69231050b8507b1eb3c7d6fe8d1e6175a92aec4e Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 20:33:50 -0700 Subject: [PATCH 04/18] Again --- lib/commands/find.js | 3 - lib/css-converter.js | 333 +++++++++++------- .../commands/find/by-css-e2e-specs.js | 25 ++ test/unit/css-converter-specs.js | 38 +- 4 files changed, 245 insertions(+), 154 deletions(-) diff --git a/lib/commands/find.js b/lib/commands/find.js index 00463a2bf..9fa9bae86 100644 --- a/lib/commands/find.js +++ b/lib/commands/find.js @@ -1,5 +1,3 @@ - -import CssSelector from '../css-converter'; import CssConverter from '../css-converter'; let helpers = {}, extensions = {}; @@ -24,7 +22,6 @@ helpers.doFindElementOrEls = async function (params) { params.strategy = '-android uiautomator'; params.selector = MAGIC_SCROLLABLE_BY; } - console.log('****', params.strategy); if (params.strategy === 'css selector') { params.strategy = '-android uiautomator'; params.selector = CssConverter.toUiAutomatorSelector(params.selector); diff --git a/lib/css-converter.js b/lib/css-converter.js index 6113bf539..607bd84c6 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -1,4 +1,5 @@ import { CssSelectorParser } from 'css-selector-parser'; +import log from './logger'; const CssConverter = {}; @@ -8,169 +9,229 @@ parser.registerNestingOperators('>', '+', '~'); parser.registerAttrEqualityMods('^', '$', '*', '~'); parser.enableSubstitutes(); -CssConverter.toUiAutomatorSelector = function (cssSelector) { - const booleanAttrs = [ - 'checkable', 'checked', 'clickable', 'enabled', 'focusable', - 'focused', 'long-clickable', 'scrollable', 'selected', - ]; - - const numericAttrs = [ - 'index', 'instance', - ]; - - const strAttrs = [ - 'description', 'resource-id', 'text', 'class-name', 'package-name' - ]; - - const allAttrs = [ - ...booleanAttrs, - ...numericAttrs, - ...strAttrs, - ]; - - const attributeAliases = [ - ['resource-id', ['id']], - ['description', [ - 'content-description', 'content-desc', - 'desc', 'accessibility-id', - ]], - ['index', ['nth-child']], - ]; - - function toSnakeCase (str) { - const tokens = str.split('-').map((str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()); - let out = tokens.join(''); - return out.charAt(0).toLowerCase() + out.slice(1); - } - - function getAttrName (attrName) { - attrName = attrName.toLowerCase(); - - // Check if it's supported and if it is, return it - if (allAttrs.includes(attrName)) { - return attrName.toLowerCase(); - } - // If attrName is an alias for something else, return that - for (let [officialAttr, aliasAttrs] of attributeAliases) { - if (aliasAttrs.includes(attrName)) { - return officialAttr; - } - } - throw new Error(`Unsupported attribute: '${attrName}'`); +const booleanAttrs = [ + 'checkable', 'checked', 'clickable', 'enabled', 'focusable', + 'focused', 'long-clickable', 'scrollable', 'selected', +]; + +const numericAttrs = [ + 'index', 'instance', +]; + +const strAttrs = [ + 'description', 'resource-id', 'text', 'class-name', 'package-name' +]; + +const allAttrs = [ + ...booleanAttrs, + ...numericAttrs, + ...strAttrs, +]; + +const attributeAliases = [ + ['resource-id', ['id']], + ['description', [ + 'content-description', 'content-desc', + 'desc', 'accessibility-id', + ]], + ['index', ['nth-child']], +]; + +/** + * Convert hyphen separated word to snake case + * @param {String} str + */ +function toSnakeCase (str) { + const tokens = str.split('-').map((str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()); + let out = tokens.join(''); + return out.charAt(0).toLowerCase() + out.slice(1); +} + +function assertGetBool (css) { + const val = css.value?.toLowerCase() || 'true'; + if (['true', 'false'].includes(val)) { + return val; } - - function escapeRegexLiterals (str) { - return str.replace(/[\[\^\$\.\|\?\*\+\(\)\\]/g, (tok) => `\\${tok}`); + log.errorAndThrow(`Could not parse '${css.name}=${css.value}'. '${css.name}' must be true, false or empty`); +} + +/** + * 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 {*} css CSS object + */ +function assertGetAttrName (css) { + const attrName = css.name.toLowerCase(); + + // Check if it's supported and if it is, return it + if (allAttrs.includes(attrName)) { + return attrName.toLowerCase(); } - function prependAndroidId (str) { - if (!str.startsWith("android:id/")) { - return `android:id/${str}`; + // If attrName is an alias for something else, return that + for (let [officialAttr, aliasAttrs] of attributeAliases) { + if (aliasAttrs.includes(attrName)) { + return officialAttr; } - return str; + } + log.errorAndThrow(`'${attrName}' is not a valid attribute. ` + + `Supported attributes are '${allAttrs.join(', ')}'`); +} + +function getWordMatcherRegex (word) { + return `\\b(\\w*${escapeRegexLiterals(word)}\\w*)\\b`; +} + +function escapeRegexLiterals (str) { + // The no-useless-escape regex rule is wrong when it's in a Regex. These escapes are intentional. + // eslint-disable-next-line no-useless-escape + return str.replace(/[\[\^\$\.\|\?\*\+\(\)\\]/g, (tok) => `\\${tok}`); +} + +function prependAndroidId (str) { + if (!str.startsWith('android:id/')) { + return `android:id/${str}`; + } + return str; +} + +/** + * Convert a CSS attribute into a UiSelector method call + * @param {*} cssAttr CSS attribute object + */ +function parseAttr (cssAttr) { + if (cssAttr.valueType && cssAttr.valueType !== 'string') { + log.errorAndThrow(`Could not parse '${cssAttr.name}=${cssAttr.value}'.` + + `Unsupported attribute type '${cssAttr.valueType}'`); + } + const attrName = assertGetAttrName(cssAttr); + const methodName = toSnakeCase(attrName); + if (booleanAttrs.includes(attrName)) { + return `.${methodName}(${assertGetBool(cssAttr)})`; } - function parseAttr (cssAttr) { - const attrName = getAttrName(cssAttr.name); - const methodName = toSnakeCase(attrName); - if (booleanAttrs.includes(attrName)) { - const value = cssAttr.value?.toLowerCase() || ''; - if (['', 'true', 'false'].includes(value)) { - return `.${methodName}(${value})`; - } - throw new Error(); + if (strAttrs.includes(attrName)) { + let value = cssAttr.value || ''; + if (attrName === 'resource-id') { + value = prependAndroidId(value); } - - if (strAttrs.includes(attrName)) { - let value = cssAttr.value || ''; - if (attrName === 'resource-id') { - value = prependAndroidId(value); - } - if (value === '') { - return `.${methodName}Matches("")`; - } else { - switch (cssAttr.operator) { - case '=': - return `.${methodName}("${value}")`; - case '*=': - if (['description', 'text'].includes(attrName)) { - return `.${methodName}Contains("${value}")`; - } - return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; - case '^=': - if (['description', 'text'].includes(attrName)) { - return `.${methodName}StartsWith("${value}")`; - } - return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; - case '$=': - return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; - case '~=': - return `.${methodName}Matches()`; - default: - throw new Error(`Unsupported CSS attribute operator '${cssAttr.operator}'`); - } + if (value === '') { + return `.${methodName}Matches("")`; + } else { + switch (cssAttr.operator) { + case '=': + return `.${methodName}("${value}")`; + case '*=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}Contains("${value}")`; + } + return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; + case '^=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}StartsWith("${value}")`; + } + return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; + case '$=': + return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; + case '~=': + return `.${methodName}Matches("${getWordMatcherRegex(value)}")`; + default: + log.errorAndThrow(`Unsupported CSS attribute operator '${cssAttr.operator}'`); } } } +} + +/** + * Convert a CSS pseudo class to a UiSelector + * @param {*} cssPseudo CSS Pseudo class + */ +function parsePseudo (cssPseudo) { + if (cssPseudo.valueType && cssPseudo.valueType !== 'string') { + log.errorAndThrow(`Could not parse '${cssPseudo.name}=${cssPseudo.value}'. ` + + `Unsupported css pseudo class value type: '${cssPseudo.valueType}'`); + } - function parsePseudo (cssPseudo) { - const pseudoName = getAttrName(cssPseudo.name); - if (booleanAttrs.includes(pseudoName)) { - return `.${toSnakeCase(pseudoName)}()`; - } + const pseudoName = assertGetAttrName(cssPseudo); - if (numericAttrs.includes(pseudoName)) { - return `.${pseudoName}(${cssPseudo.value})`; - } + if (booleanAttrs.includes(pseudoName)) { + return `.${toSnakeCase(pseudoName)}(${assertGetBool(cssPseudo)})`; } - function parseRule (rule) { - let uiAutomatorSelector = "new UiSelector()"; - if (rule.tagName && rule.tagName != '*') { - let androidClass = [rule.tagName]; - for (const cssClassNames of (rule.classNames || [])) { - androidClass.push(cssClassNames); - } - uiAutomatorSelector += `.className("${androidClass.join('.')}")`; - } else if (rule.classNames) { - uiAutomatorSelector += `.classNameMatches("\\.${rule.classNames.join('\\.')}")`; - } - if (rule.id) { - uiAutomatorSelector += `.resourceId("${prependAndroidId(rule.id)}")`; - } - if (rule.attrs) { - for (const attr of rule.attrs) { - uiAutomatorSelector += parseAttr(attr); - } + if (numericAttrs.includes(pseudoName)) { + return `.${pseudoName}(${cssPseudo.value})`; + } +} + +/** + * Convert a CSS rule to a UiSelector + * @param {*} cssRule CSS rule definition + */ +function parseCssRule (cssRule) { + let uiAutomatorSelector = 'new UiSelector()'; + if (cssRule.tagName && cssRule.tagName !== '*') { + let androidClass = [cssRule.tagName]; + for (const cssClassNames of (cssRule.classNames || [])) { + androidClass.push(cssClassNames); } - if (rule.pseudos) { - for (const pseudo of rule.pseudos) { - uiAutomatorSelector += parsePseudo(pseudo); - } + uiAutomatorSelector += `.className("${androidClass.join('.')}")`; + } 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 (rule.rule) { - uiAutomatorSelector += `.childSelector(${parseRule(rule.rule)})`; + } + if (cssRule.pseudos) { + for (const pseudo of cssRule.pseudos) { + uiAutomatorSelector += parsePseudo(pseudo); } - return uiAutomatorSelector; } + if (cssRule.rule) { + uiAutomatorSelector += `.childSelector(${parseCssRule(cssRule.rule)})`; + } + return uiAutomatorSelector; +} - const css = parser.parse(cssSelector); - +/** + * Convert CSS object to UiAutomator2 selector + * @param {*} css CSS object + */ +function parseCssObject (css) { switch (css.type) { + case 'rule': + return parseCssRule(css); case 'ruleSet': - return parseRule(css.rule); + return parseCssObject(css.rule); case 'selectors': - console.log(css); process.exit(); + 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`); } - - if (css.type === 'ruleSet') { - } else if (css.type === 'selectors') { - throw new Error('TODO: Add multiple selectors'); +} + +/** + * Convert a CSS selector to a UiAutomator2 selector + * @param {string} cssSelector CSS Selector + */ +CssConverter.toUiAutomatorSelector = function toUiAutomatorSelector (cssSelector) { + let cssObj; + try { + cssObj = parser.parse(cssSelector); + } catch (e) { + log.errorAndThrow(`Could not parse CSS. Reason: '${e}'`); } - return uiAutomatorSelector; + return parseCssObject(cssObj); }; diff --git a/test/functional/commands/find/by-css-e2e-specs.js b/test/functional/commands/find/by-css-e2e-specs.js index 71be47d05..a20874b59 100644 --- a/test/functional/commands/find/by-css-e2e-specs.js +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -44,4 +44,29 @@ describe('Find - CSS', 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 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; + }); }); diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index 282e08763..908452315 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -12,9 +12,9 @@ describe('css-converter.js', function () { ['.TextView', 'new UiSelector().classNameMatches("\\.TextView")'], ['.widget.TextView', 'new UiSelector().classNameMatches("\\.widget\\.TextView")'], ['*[checkable=true]', 'new UiSelector().checkable(true)'], - ['*[checkable]', 'new UiSelector().checkable()'], - ['*:checked', 'new UiSelector().checked()'], - ['*[checked]', 'new UiSelector().checked()'], + ['*[checkable]', 'new UiSelector().checkable(true)'], + ['*:checked', 'new UiSelector().checked(true)'], + ['*[checked]', 'new UiSelector().checked(true)'], ['TextView[description="Some description"]', 'new UiSelector().className("TextView").description("Some description")'], ['*[description]', 'new UiSelector().descriptionMatches("")'], ['*[description^=blah]', 'new UiSelector().descriptionStartsWith("blah")'], @@ -24,19 +24,20 @@ describe('css-converter.js', function () { ['*[id=foo]', 'new UiSelector().resourceId("android:id/foo")'], ['*[description$="hello [ ^ $ . | ? * + ( ) world"]', 'new UiSelector().descriptionMatches("hello \\[ \\^ \\$ \\. \\| \\? \\* \\+ \\( \\) world$")'], ['TextView:iNdEx(4)', 'new UiSelector().className("TextView").index(4)'], - ['*:long-clickable', 'new UiSelector().longClickable()'], - ['*[lOnG-cLiCkAbLe]', 'new UiSelector().longClickable()'], + ['*: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().childSelector(new UiSelector().className("android.widget.WidgetView").focusable().index(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())'], + ['* *[clickable=true][focused]', 'new UiSelector().childSelector(new UiSelector().clickable(true).focused(true))'], [ - '*[clickable=true], *[clickable=false]', - 'new UiSelector().clickable(true); new UiSelector().clickable(false);', - ] + '*[clickable=true], *[clickable=false]', + 'new UiSelector().clickable(true); new UiSelector().clickable(false)', + ], + ['*[description~="word"]', 'new UiSelector().descriptionMatches("\\b(\\w*word\\w*)\\b")'], ]; for (const [cssSelector, uiAutomatorSelector] of simpleCases) { it(`should convert '${cssSelector}' to '${uiAutomatorSelector}'`, function () { @@ -44,10 +45,17 @@ describe('css-converter.js', function () { }); } }); - describe('attributes', function () { - - }); - describe('pseudo-classes', function () { - + describe('unsupported css', function () { + const testCases = [ + ['*[checked="ItS ChEcKeD"]', /^Could not parse 'checked=ItS ChEcKeD'. 'checked' must be true, false or empty/], + ['*[foo="bar"]', /^'foo' is not a valid attribute. Supported attributes are */], + ['*:checked("ischecked")', /^Could not parse 'checked=ischecked'. 'checked' must be true, false or empty/], + [`This isn't valid[ css`, /^Could not parse CSS. Reason: */], + ]; + for (const [cssSelector, error] of testCases) { + it(`should reject '${cssSelector}' with '${error}'`, function () { + (() => CssConverter.toUiAutomatorSelector(cssSelector)).should.throw(error); + }); + } }); }); From 12f894c800eeb93bc6909a07040bb1ca3591dc91 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 20:38:42 -0700 Subject: [PATCH 05/18] Fixes --- lib/css-converter.js | 20 ++++++++++++++++++- .../commands/find/by-uiautomator-e2e-specs.js | 2 -- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 607bd84c6..88fd681ca 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -9,7 +9,6 @@ parser.registerNestingOperators('>', '+', '~'); parser.registerAttrEqualityMods('^', '$', '*', '~'); parser.enableSubstitutes(); - const booleanAttrs = [ 'checkable', 'checked', 'clickable', 'enabled', 'focusable', 'focused', 'long-clickable', 'scrollable', 'selected', @@ -48,6 +47,11 @@ function toSnakeCase (str) { 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 {*} css CSS object + */ function assertGetBool (css) { const val = css.value?.toLowerCase() || 'true'; if (['true', 'false'].includes(val)) { @@ -82,16 +86,30 @@ function assertGetAttrName (css) { `Supported attributes are '${allAttrs.join(', ')}'`); } +/** + * Get a regex that matches a whole word. For the ~= CSS attribute selector. + * + * @param {string} word + */ function getWordMatcherRegex (word) { return `\\b(\\w*${escapeRegexLiterals(word)}\\w*)\\b`; } +/** + * Add escapes to regex literals + * + * @param {*} str + */ function escapeRegexLiterals (str) { // The no-useless-escape regex rule is wrong when it's in a Regex. These escapes are intentional. // eslint-disable-next-line no-useless-escape return str.replace(/[\[\^\$\.\|\?\*\+\(\)\\]/g, (tok) => `\\${tok}`); } +/** + * Add android:id/ to beginning of string if it's not there already + * @param {*} str + */ function prependAndroidId (str) { if (!str.startsWith('android:id/')) { return `android:id/${str}`; diff --git a/test/functional/commands/find/by-uiautomator-e2e-specs.js b/test/functional/commands/find/by-uiautomator-e2e-specs.js index a6853e13e..f6a02d3d8 100644 --- a/test/functional/commands/find/by-uiautomator-e2e-specs.js +++ b/test/functional/commands/find/by-uiautomator-e2e-specs.js @@ -7,8 +7,6 @@ import { initSession, deleteSession } from '../../helpers/session'; chai.should(); chai.use(chaiAsPromised); -// TODO: Add the companion CSS selectors here - describe('Find - uiautomator', function () { let driver; before(async function () { From 90779561a1c31e517a9be9ffde9ec58e4fb864fd Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 20:58:46 -0700 Subject: [PATCH 06/18] Add text test with unicode --- test/functional/commands/find/by-css-e2e-specs.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/functional/commands/find/by-css-e2e-specs.js b/test/functional/commands/find/by-css-e2e-specs.js index a20874b59..1828ed84d 100644 --- a/test/functional/commands/find/by-css-e2e-specs.js +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -69,4 +69,10 @@ describe('Find - CSS', function () { 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('عربي'); + }); }); From a37663177e75478e5249290afa879dfa0c02f41a Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 21:37:11 -0700 Subject: [PATCH 07/18] Enforce descendant operators --- lib/css-converter.js | 6 ++++++ test/unit/css-converter-specs.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/lib/css-converter.js b/lib/css-converter.js index 88fd681ca..3f709fc02 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -190,6 +190,12 @@ function parsePseudo (cssPseudo) { * @param {*} cssRule CSS rule definition */ function parseCssRule (cssRule) { + const { nestingOperator } = cssRule; + if (nestingOperator && nestingOperator !== ' ') { + log.errorAndThrow(`'${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]; diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index 908452315..6137cf039 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -38,6 +38,10 @@ describe('css-converter.js', function () { '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 () { @@ -51,6 +55,8 @@ describe('css-converter.js', function () { ['*[foo="bar"]', /^'foo' is not a valid attribute. Supported attributes are */], ['*:checked("ischecked")', /^Could not parse 'checked=ischecked'. 'checked' must be true, false or empty/], [`This isn't valid[ css`, /^Could not parse CSS. Reason: */], + ['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 () { From 7cf25fb6a1774ae1db601396affb52701282fd9a Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 23:33:00 -0700 Subject: [PATCH 08/18] PR fixes --- lib/css-converter.js | 150 ++++++++++++++++++++----------- test/unit/css-converter-specs.js | 2 +- 2 files changed, 100 insertions(+), 52 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 3f709fc02..507bd0c67 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -9,23 +9,23 @@ parser.registerNestingOperators('>', '+', '~'); parser.registerAttrEqualityMods('^', '$', '*', '~'); parser.enableSubstitutes(); -const booleanAttrs = [ +const BOOLEAN_ATTRS = [ 'checkable', 'checked', 'clickable', 'enabled', 'focusable', 'focused', 'long-clickable', 'scrollable', 'selected', ]; -const numericAttrs = [ +const NUMERIC_ATTRS = [ 'index', 'instance', ]; -const strAttrs = [ +const STR_ATTRS = [ 'description', 'resource-id', 'text', 'class-name', 'package-name' ]; -const allAttrs = [ - ...booleanAttrs, - ...numericAttrs, - ...strAttrs, +const ALL_ATTRS = [ + ...BOOLEAN_ATTRS, + ...NUMERIC_ATTRS, + ...STR_ATTRS, ]; const attributeAliases = [ @@ -37,12 +37,16 @@ const attributeAliases = [ ['index', ['nth-child']], ]; +const RESOURCE_ID = 'resource-id'; + /** * Convert hyphen separated word to snake case - * @param {String} str + * + * @param {string} str + * @returns {string} The hyphen separated word translated to snake case */ function toSnakeCase (str) { - const tokens = str.split('-').map((str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()); + const tokens = str.split('-').map((str) => str.length > 0 ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : ''); let out = tokens.join(''); return out.charAt(0).toLowerCase() + out.slice(1); } @@ -50,7 +54,8 @@ function toSnakeCase (str) { /** * Get the boolean from a CSS object. If empty, return true. If not true/false/empty, throw exception * - * @param {*} css CSS object + * @param {Object} css CSS object + * @returns {string} Either 'true' or 'false'. If value is empty, return 'true' */ function assertGetBool (css) { const val = css.value?.toLowerCase() || 'true'; @@ -66,13 +71,14 @@ function assertGetBool (css) { * Converts to lowercase and if an attribute name is an alias for something else, return * what it is an alias for * - * @param {*} css CSS object + * @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 (allAttrs.includes(attrName)) { + if (ALL_ATTRS.includes(attrName)) { return attrName.toLowerCase(); } @@ -83,13 +89,14 @@ function assertGetAttrName (css) { } } log.errorAndThrow(`'${attrName}' is not a valid attribute. ` + - `Supported attributes are '${allAttrs.join(', ')}'`); + `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*${escapeRegexLiterals(word)}\\w*)\\b`; @@ -98,17 +105,20 @@ function getWordMatcherRegex (word) { /** * Add escapes to regex literals * - * @param {*} str + * @param {string} str + * @returns {string} 'str' with regex escapes */ function escapeRegexLiterals (str) { - // The no-useless-escape regex rule is wrong when it's in a Regex. These escapes are intentional. + // The "no-useless-escape" regex rule is wrong when it's in a Regex. These escapes are intentional. // eslint-disable-next-line no-useless-escape - return str.replace(/[\[\^\$\.\|\?\*\+\(\)\\]/g, (tok) => `\\${tok}`); + return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } /** * Add android:id/ to beginning of string if it's not there already - * @param {*} str + * + * @param {string} str + * @returns {string} String with `android:id/` prepended (if it wasn't already) */ function prependAndroidId (str) { if (!str.startsWith('android:id/')) { @@ -117,77 +127,108 @@ function prependAndroidId (str) { return str; } +/** + * @typedef {Object} CssAttr + * @param {?string} valueType Type of attribute (must be string or empty) + * @param {?string} value Value of the attribute + * @param {?string} operator The operator between value and value type (=, *=, , ^=, $=) + */ + /** * Convert a CSS attribute into a UiSelector method call - * @param {*} cssAttr CSS attribute object + * + * @param {CssAttr} cssAttr CSS attribute object + * @returns {string} CSS attribute parsed as UiSelector */ function parseAttr (cssAttr) { if (cssAttr.valueType && cssAttr.valueType !== 'string') { log.errorAndThrow(`Could not parse '${cssAttr.name}=${cssAttr.value}'.` + - `Unsupported attribute type '${cssAttr.valueType}'`); + `Unsupported attribute type '${cssAttr.valueType}'. Only 'string' and empty attribute types are supported.`); } const attrName = assertGetAttrName(cssAttr); const methodName = toSnakeCase(attrName); - if (booleanAttrs.includes(attrName)) { + if (BOOLEAN_ATTRS.includes(attrName)) { return `.${methodName}(${assertGetBool(cssAttr)})`; } - if (strAttrs.includes(attrName)) { + if (STR_ATTRS.includes(attrName)) { let value = cssAttr.value || ''; - if (attrName === 'resource-id') { + if (attrName === RESOURCE_ID) { value = prependAndroidId(value); } if (value === '') { return `.${methodName}Matches("")`; - } else { - switch (cssAttr.operator) { - case '=': - return `.${methodName}("${value}")`; - case '*=': - if (['description', 'text'].includes(attrName)) { - return `.${methodName}Contains("${value}")`; - } - return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; - case '^=': - if (['description', 'text'].includes(attrName)) { - return `.${methodName}StartsWith("${value}")`; - } - return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; - case '$=': - return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; - case '~=': - return `.${methodName}Matches("${getWordMatcherRegex(value)}")`; - default: - log.errorAndThrow(`Unsupported CSS attribute operator '${cssAttr.operator}'`); - } + } + + switch (cssAttr.operator) { + case '=': + return `.${methodName}("${value}")`; + case '*=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}Contains("${value}")`; + } + return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; + case '^=': + if (['description', 'text'].includes(attrName)) { + return `.${methodName}StartsWith("${value}")`; + } + return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; + case '$=': + return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; + case '~=': + return `.${methodName}Matches("${getWordMatcherRegex(value)}")`; + default: + // Unreachable, but adding error in case a new CSS attribute is added. + log.errorAndThrow(`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 CSS Pseudo class + * + * @param {CssPseudo} cssPseudo CSS Pseudo class + * @returns {string} Pseudo selector parsed as UiSelector */ function parsePseudo (cssPseudo) { if (cssPseudo.valueType && cssPseudo.valueType !== 'string') { log.errorAndThrow(`Could not parse '${cssPseudo.name}=${cssPseudo.value}'. ` + - `Unsupported css pseudo class value type: '${cssPseudo.valueType}'`); + `Unsupported css pseudo class value type: '${cssPseudo.valueType}'. Only 'string' type or empty is supported.`); } const pseudoName = assertGetAttrName(cssPseudo); - if (booleanAttrs.includes(pseudoName)) { + if (BOOLEAN_ATTRS.includes(pseudoName)) { return `.${toSnakeCase(pseudoName)}(${assertGetBool(cssPseudo)})`; } - if (numericAttrs.includes(pseudoName)) { + 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 CSS rule definition + * @param {CssRule} cssRule CSS rule definition */ function parseCssRule (cssRule) { const { nestingOperator } = cssRule; @@ -225,9 +266,15 @@ function parseCssRule (cssRule) { return uiAutomatorSelector; } +/** + * @typedef {Object} CssObject + * @param {Object} type Type of CSS object. 'rule', 'ruleset' or 'selectors' + */ + /** * Convert CSS object to UiAutomator2 selector - * @param {*} css CSS object + * @param {CssObject} css CSS object + * @returns {string} The CSS object parsed as a UiSelector */ function parseCssObject (css) { switch (css.type) { @@ -240,20 +287,21 @@ function parseCssObject (css) { default: // This is never reachable, but if it ever is do this. - throw new Error(`UiAutomator does not support '${css.type}' css`); + 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) { - log.errorAndThrow(`Could not parse CSS. Reason: '${e}'`); + log.errorAndThrow(`Could not parse CSS '${cssSelector}'. Reason: '${e}'`); } return parseCssObject(cssObj); }; diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index 6137cf039..9ed00ea6a 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -54,7 +54,7 @@ describe('css-converter.js', function () { ['*[checked="ItS ChEcKeD"]', /^Could not parse 'checked=ItS ChEcKeD'. 'checked' must be true, false or empty/], ['*[foo="bar"]', /^'foo' is not a valid attribute. Supported attributes are */], ['*:checked("ischecked")', /^Could not parse 'checked=ischecked'. 'checked' must be true, false or empty/], - [`This isn't valid[ css`, /^Could not parse CSS. Reason: */], + [`This isn't valid[ css`, /^Could not parse CSS/], ['p ~ a', /^'~' is not a supported combinator. /], ['p > a', /^'>' is not a supported combinator. /], ]; From 415c7b4e423ac899dfac65c6873c0cebe8eeb3dc Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 23:34:53 -0700 Subject: [PATCH 09/18] PR fixes --- lib/css-converter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 507bd0c67..c8530e1fb 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -58,7 +58,7 @@ function toSnakeCase (str) { * @returns {string} Either 'true' or 'false'. If value is empty, return 'true' */ function assertGetBool (css) { - const val = css.value?.toLowerCase() || 'true'; + 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; } From fa604f27db797818b5205ca5a146f96958247493 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 3 Sep 2020 23:53:07 -0700 Subject: [PATCH 10/18] PR fixes --- lib/css-converter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index c8530e1fb..1e28951a3 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -28,7 +28,7 @@ const ALL_ATTRS = [ ...STR_ATTRS, ]; -const attributeAliases = [ +const ATTRIBUTE_ALIASES = [ ['resource-id', ['id']], ['description', [ 'content-description', 'content-desc', @@ -83,7 +83,7 @@ function assertGetAttrName (css) { } // If attrName is an alias for something else, return that - for (let [officialAttr, aliasAttrs] of attributeAliases) { + for (let [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) { if (aliasAttrs.includes(attrName)) { return officialAttr; } From ec69ffd39fbbc089fda6bb8e7c808c7b8fde3b1c Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 00:07:03 -0700 Subject: [PATCH 11/18] PR fixes --- lib/css-converter.js | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 1e28951a3..4d9e72eca 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -9,6 +9,8 @@ parser.registerNestingOperators('>', '+', '~'); parser.registerAttrEqualityMods('^', '$', '*', '~'); parser.enableSubstitutes(); +const RESOURCE_ID = 'resource-id'; + const BOOLEAN_ATTRS = [ 'checkable', 'checked', 'clickable', 'enabled', 'focusable', 'focused', 'long-clickable', 'scrollable', 'selected', @@ -19,7 +21,7 @@ const NUMERIC_ATTRS = [ ]; const STR_ATTRS = [ - 'description', 'resource-id', 'text', 'class-name', 'package-name' + 'description', RESOURCE_ID, 'text', 'class-name', 'package-name' ]; const ALL_ATTRS = [ @@ -29,7 +31,7 @@ const ALL_ATTRS = [ ]; const ATTRIBUTE_ALIASES = [ - ['resource-id', ['id']], + [RESOURCE_ID, ['id']], ['description', [ 'content-description', 'content-desc', 'desc', 'accessibility-id', @@ -37,8 +39,6 @@ const ATTRIBUTE_ALIASES = [ ['index', ['nth-child']], ]; -const RESOURCE_ID = 'resource-id'; - /** * Convert hyphen separated word to snake case * @@ -51,10 +51,16 @@ function toSnakeCase (str) { 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 {Object} css CSS object + * @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) { @@ -83,7 +89,7 @@ function assertGetAttrName (css) { } // If attrName is an alias for something else, return that - for (let [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) { + for (const [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) { if (aliasAttrs.includes(attrName)) { return officialAttr; } @@ -129,9 +135,9 @@ function prependAndroidId (str) { /** * @typedef {Object} CssAttr - * @param {?string} valueType Type of attribute (must be string or empty) - * @param {?string} value Value of the attribute - * @param {?string} operator The operator between value and value type (=, *=, , ^=, $=) + * @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 (=, *=, , ^=, $=) */ /** @@ -268,7 +274,7 @@ function parseCssRule (cssRule) { /** * @typedef {Object} CssObject - * @param {Object} type Type of CSS object. 'rule', 'ruleset' or 'selectors' + * @property {?string} type Type of CSS object. 'rule', 'ruleset' or 'selectors' */ /** @@ -306,6 +312,4 @@ CssConverter.toUiAutomatorSelector = function toUiAutomatorSelector (cssSelector return parseCssObject(cssObj); }; - - export default CssConverter; \ No newline at end of file From 9cb11e74f8994d5957cac9f4c78c9f479850ed60 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 00:10:04 -0700 Subject: [PATCH 12/18] Use regex escaper --- lib/css-converter.js | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 4d9e72eca..e8c6ff86f 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -1,5 +1,6 @@ import { CssSelectorParser } from 'css-selector-parser'; import log from './logger'; +import _ from 'lodash'; const CssConverter = {}; @@ -105,19 +106,7 @@ function assertGetAttrName (css) { * @returns {string} A regex "word" matcher */ function getWordMatcherRegex (word) { - return `\\b(\\w*${escapeRegexLiterals(word)}\\w*)\\b`; -} - -/** - * Add escapes to regex literals - * - * @param {string} str - * @returns {string} 'str' with regex escapes - */ -function escapeRegexLiterals (str) { - // The "no-useless-escape" regex rule is wrong when it's in a Regex. These escapes are intentional. - // eslint-disable-next-line no-useless-escape - return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + return `\\b(\\w*${_.escapeRegExp(word)}\\w*)\\b`; } /** @@ -173,14 +162,14 @@ function parseAttr (cssAttr) { if (['description', 'text'].includes(attrName)) { return `.${methodName}Contains("${value}")`; } - return `.${methodName}Matches("${escapeRegexLiterals(value)}")`; + return `.${methodName}Matches("${_.escapeRegExp(value)}")`; case '^=': if (['description', 'text'].includes(attrName)) { return `.${methodName}StartsWith("${value}")`; } - return `.${methodName}Matches("^${escapeRegexLiterals(value)}")`; + return `.${methodName}Matches("^${_.escapeRegExp(value)}")`; case '$=': - return `.${methodName}Matches("${escapeRegexLiterals(value)}$")`; + return `.${methodName}Matches("${_.escapeRegExp(value)}$")`; case '~=': return `.${methodName}Matches("${getWordMatcherRegex(value)}")`; default: From ff8661d6f5beb733da39b0583ee8403c0751849b Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 00:28:27 -0700 Subject: [PATCH 13/18] Use regex escaper --- lib/css-converter.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/css-converter.js b/lib/css-converter.js index e8c6ff86f..0792586c5 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -47,6 +47,9 @@ const ATTRIBUTE_ALIASES = [ * @returns {string} The hyphen separated word translated to snake case */ function toSnakeCase (str) { + if (!str) { + return ''; + } const tokens = str.split('-').map((str) => str.length > 0 ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : ''); let out = tokens.join(''); return out.charAt(0).toLowerCase() + out.slice(1); From 234e2bea8e13640be2378a3c0712990fc0e05313 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 01:43:47 -0700 Subject: [PATCH 14/18] Again From f8fecced7bdcb423cf2a39edfd563fea75aa88e2 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 12:16:23 -0700 Subject: [PATCH 15/18] PR fixes --- lib/css-converter.js | 92 ++++++++++++++++---------------- test/unit/css-converter-specs.js | 12 ++--- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 0792586c5..1b44e07f1 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -1,6 +1,6 @@ import { CssSelectorParser } from 'css-selector-parser'; -import log from './logger'; import _ from 'lodash'; +import { errors } from 'appium-base-driver'; const CssConverter = {}; @@ -50,8 +50,8 @@ function toSnakeCase (str) { if (!str) { return ''; } - const tokens = str.split('-').map((str) => str.length > 0 ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : ''); - let out = tokens.join(''); + 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); } @@ -72,7 +72,7 @@ function assertGetBool (css) { if (['true', 'false'].includes(val)) { return val; } - log.errorAndThrow(`Could not parse '${css.name}=${css.value}'. '${css.name}' must be true, false or empty`); + throw new Error(`'${css.name}' must be true, false or empty. Found '${css.value}'`); } /** @@ -98,7 +98,7 @@ function assertGetAttrName (css) { return officialAttr; } } - log.errorAndThrow(`'${attrName}' is not a valid attribute. ` + + throw new Error(`'${attrName}' is not a valid attribute. ` + `Supported attributes are '${ALL_ATTRS.join(', ')}'`); } @@ -119,10 +119,7 @@ function getWordMatcherRegex (word) { * @returns {string} String with `android:id/` prepended (if it wasn't already) */ function prependAndroidId (str) { - if (!str.startsWith('android:id/')) { - return `android:id/${str}`; - } - return str; + return str.startsWith('android:id/') ? str : `android:id/${str}`; } /** @@ -140,8 +137,8 @@ function prependAndroidId (str) { */ function parseAttr (cssAttr) { if (cssAttr.valueType && cssAttr.valueType !== 'string') { - log.errorAndThrow(`Could not parse '${cssAttr.name}=${cssAttr.value}'.` + - `Unsupported attribute type '${cssAttr.valueType}'. Only 'string' and empty attribute types are supported.`); + 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); @@ -149,37 +146,38 @@ function parseAttr (cssAttr) { return `.${methodName}(${assertGetBool(cssAttr)})`; } - if (STR_ATTRS.includes(attrName)) { - let value = cssAttr.value || ''; - if (attrName === RESOURCE_ID) { - value = prependAndroidId(value); - } - if (value === '') { - return `.${methodName}Matches("")`; - } + if (!STR_ATTRS.includes(attrName)) { + return; + } + 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. - log.errorAndThrow(`Unsupported CSS attribute operator '${cssAttr.operator}'. ` + - ` '=', '*=', '^=', '$=' and '~=' are supported.`); - } + 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.`); } } @@ -198,7 +196,7 @@ function parseAttr (cssAttr) { */ function parsePseudo (cssPseudo) { if (cssPseudo.valueType && cssPseudo.valueType !== 'string') { - log.errorAndThrow(`Could not parse '${cssPseudo.name}=${cssPseudo.value}'. ` + + throw new Error(`'${cssPseudo.name}=${cssPseudo.value}'. ` + `Unsupported css pseudo class value type: '${cssPseudo.valueType}'. Only 'string' type or empty is supported.`); } @@ -231,7 +229,7 @@ function parsePseudo (cssPseudo) { function parseCssRule (cssRule) { const { nestingOperator } = cssRule; if (nestingOperator && nestingOperator !== ' ') { - log.errorAndThrow(`'${nestingOperator}' is not a supported combinator. ` + + throw new Error(`'${nestingOperator}' is not a supported combinator. ` + `Only child combinator (>) and descendant combinator are supported.`); } @@ -299,9 +297,13 @@ CssConverter.toUiAutomatorSelector = function toUiAutomatorSelector (cssSelector try { cssObj = parser.parse(cssSelector); } catch (e) { - log.errorAndThrow(`Could not parse CSS '${cssSelector}'. Reason: '${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}'`); } - return parseCssObject(cssObj); }; export default CssConverter; \ No newline at end of file diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index 9ed00ea6a..d6ddcc2d6 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -51,12 +51,12 @@ describe('css-converter.js', function () { }); describe('unsupported css', function () { const testCases = [ - ['*[checked="ItS ChEcKeD"]', /^Could not parse 'checked=ItS ChEcKeD'. 'checked' must be true, false or empty/], - ['*[foo="bar"]', /^'foo' is not a valid attribute. Supported attributes are */], - ['*:checked("ischecked")', /^Could not parse 'checked=ischecked'. 'checked' must be true, false or empty/], - [`This isn't valid[ css`, /^Could not parse CSS/], - ['p ~ a', /^'~' is not a supported combinator. /], - ['p > a', /^'>' is not a supported combinator. /], + ['*[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 () { From 53cf04b532769c59ea9561c0032b85bcd76bd3b0 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 13:18:39 -0700 Subject: [PATCH 16/18] PR fixes --- lib/css-converter.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 1b44e07f1..56fd2bbe4 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -142,13 +142,19 @@ function parseAttr (cssAttr) { } 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 a support attribute. 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)})`; } - if (!STR_ATTRS.includes(attrName)) { - return; - } + // Otherwise parse as string let value = cssAttr.value || ''; if (attrName === RESOURCE_ID) { value = prependAndroidId(value); From 84dff5335ea0c2112a15e344eb7213210a1af090 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 4 Sep 2020 13:47:41 -0700 Subject: [PATCH 17/18] Again --- lib/css-converter.js | 12 ++++++++---- test/functional/commands/find/by-css-e2e-specs.js | 4 ++++ test/unit/css-converter-specs.js | 9 +++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 56fd2bbe4..122f702e6 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -242,12 +242,16 @@ function parseCssRule (cssRule) { let uiAutomatorSelector = 'new UiSelector()'; if (cssRule.tagName && cssRule.tagName !== '*') { let androidClass = [cssRule.tagName]; - for (const cssClassNames of (cssRule.classNames || [])) { - androidClass.push(cssClassNames); + if (cssRule.classNames) { + for (const cssClassNames of cssRule.classNames) { + androidClass.push(cssClassNames); + } + uiAutomatorSelector += `.className("${androidClass.join('.')}")`; + } else { + uiAutomatorSelector += `.classNameMatches("${cssRule.tagName}")`; } - uiAutomatorSelector += `.className("${androidClass.join('.')}")`; } else if (cssRule.classNames) { - uiAutomatorSelector += `.classNameMatches("\\.${cssRule.classNames.join('\\.')}")`; + uiAutomatorSelector += `.classNameMatches("${cssRule.classNames.join('\\.')}")`; } if (cssRule.id) { uiAutomatorSelector += `.resourceId("${prependAndroidId(cssRule.id)}")`; diff --git a/test/functional/commands/find/by-css-e2e-specs.js b/test/functional/commands/find/by-css-e2e-specs.js index 1828ed84d..a6601f44a 100644 --- a/test/functional/commands/find/by-css-e2e-specs.js +++ b/test/functional/commands/find/by-css-e2e-specs.js @@ -52,6 +52,10 @@ describe('Find - CSS', function () { 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); diff --git a/test/unit/css-converter-specs.js b/test/unit/css-converter-specs.js index d6ddcc2d6..01c9abfa7 100644 --- a/test/unit/css-converter-specs.js +++ b/test/unit/css-converter-specs.js @@ -9,13 +9,14 @@ 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")'], - ['.widget.TextView', 'new UiSelector().classNameMatches("\\.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().className("TextView").description("Some description")'], + ['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$")'], @@ -23,7 +24,7 @@ describe('css-converter.js', function () { ['#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().className("TextView").index(4)'], + ['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)'], From b0a0f0219be4293f3f3ad4ab64a628a882e59452 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 5 Sep 2020 09:50:53 -0700 Subject: [PATCH 18/18] Final PR --- lib/css-converter.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/css-converter.js b/lib/css-converter.js index 122f702e6..9dd72f8f2 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -1,5 +1,5 @@ import { CssSelectorParser } from 'css-selector-parser'; -import _ from 'lodash'; +import { escapeRegExp } from 'lodash'; import { errors } from 'appium-base-driver'; const CssConverter = {}; @@ -109,7 +109,7 @@ function assertGetAttrName (css) { * @returns {string} A regex "word" matcher */ function getWordMatcherRegex (word) { - return `\\b(\\w*${_.escapeRegExp(word)}\\w*)\\b`; + return `\\b(\\w*${escapeRegExp(word)}\\w*)\\b`; } /** @@ -145,7 +145,7 @@ function parseAttr (cssAttr) { // Validate that it's a supported attribute if (!STR_ATTRS.includes(attrName) && !BOOLEAN_ATTRS.includes(attrName)) { - throw new Error(`'${attrName}' is not a support attribute. Supported attributes are ` + + throw new Error(`'${attrName}' is not supported. Supported attributes are ` + `'${[...STR_ATTRS, ...BOOLEAN_ATTRS].join(', ')}'`); } @@ -170,14 +170,14 @@ function parseAttr (cssAttr) { if (['description', 'text'].includes(attrName)) { return `.${methodName}Contains("${value}")`; } - return `.${methodName}Matches("${_.escapeRegExp(value)}")`; + return `.${methodName}Matches("${escapeRegExp(value)}")`; case '^=': if (['description', 'text'].includes(attrName)) { return `.${methodName}StartsWith("${value}")`; } - return `.${methodName}Matches("^${_.escapeRegExp(value)}")`; + return `.${methodName}Matches("^${escapeRegExp(value)}")`; case '$=': - return `.${methodName}Matches("${_.escapeRegExp(value)}$")`; + return `.${methodName}Matches("${escapeRegExp(value)}$")`; case '~=': return `.${methodName}Matches("${getWordMatcherRegex(value)}")`; default: