Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: native CSS selector for Android UiAutomator #410

Merged
merged 18 commits into from
Sep 5, 2020

Conversation

dpgraham
Copy link
Contributor

@dpgraham dpgraham commented Sep 4, 2020

The CSS selector is going unused in all Appium Drivers 😢 . Why not put it to use?

This PR adds support for a native CSS selector strategy. Instead of using CSS to select HTML elements, it parses CSS selectors and translate the selector to a native -android uiautomator selector.

Example

android.widget.TextView[description="Some description"]

translates to

new UiSelector().className("android.widget.TextView").description("Some description")

ie:

elementByCss('android.widget.TextView[description="Some description"]')

does the same thing as

elementBy('-android uiautomator', 'new UiSelector().className("android.widget.TextView").description("Some description")')

many more examples in css-converter-specs.js and by-css-e2e-specs.js

Versatile

This selector can be used to replace every other selector:

  • find by id:
    elementsById("someResourceID") -->
    elementsByCss("#someResourceID")

  • find by class name:
    elementsByClassName("android.widget.TextView") -->
    elementsByCss("android.widget.TextView")

  • find by accessibility id:
    elementsByAccessibilityId("Some Content Description") -->
    elementsByCss('*[description="Some Content Description"]')

  • find by xpath:
    elementsByXpath("//android.widget.TextView[@description='Accessibility']") --> elementsByCss("android.widget.TextView[description='Accessibility']")

Performance

Unlike Xpath, this selector doesn't require a document to be generated that the selector is applied to. It just parses the CSS selector and translates it to an analagous UiAutomator selector.

How is it done?

  • css is parsed by css-converter.js which uses the https://www.npmjs.com/package/css-selector-parser library
  • the CSS object is parsed and converted to a UiSelector expression that is passed to UiAutomator2 Server as -android uiautomator selector

How is it different from CSS selectors being applied to HTML?

  • Throws exceptions if unsupported attributes are given. This is so that it's more helpful for users so they can see when they're using a bad attribute
  • The descendant operators (>, ~, ...) are unsupported.
  • Class names:
    • For HTML CSS selectors, a selector like p.classOne.classTwo means that it's matching an HTML <p> element that has classOne and classTwo as class attributes (order doesn't matter).
    • For this UiAutomator2 CSS selector, a selector like android.widget.TextView means that it's matching a widget with the fully qualified Java class name android.widget.TextView

Next Step

Support this in iOS with class chain and in Espresso with ViewMatchers and DataMatchers

@dpgraham dpgraham changed the title WIP: Mobile CSS selector for Android UiAutomator feat: Mobile CSS selector for Android UiAutomator Sep 4, 2020
@dpgraham dpgraham changed the title feat: Mobile CSS selector for Android UiAutomator WIP: feat: Mobile CSS selector for Android UiAutomator Sep 4, 2020
@dpgraham dpgraham changed the title WIP: feat: Mobile CSS selector for Android UiAutomator feat: Mobile CSS selector for Android UiAutomator Sep 4, 2020
*
* @param {*} str
*/
function escapeRegexLiterals (str) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lodash already has a helper to escape regex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found this: https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript ... copied and pasted. I couldn't find lodash version of this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* Convert a CSS rule to a UiSelector
* @param {*} cssRule CSS rule definition
*/
function parseCssRule (cssRule) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to have a class for such transformation rather than a set of functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mykola-mokhnach I prefer the procedural approach myself, but that's a much larger debate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having a class could help to avoid passing the css object (and possibly many other shared args) to all the functions

Copy link
Contributor

@mykola-mokhnach mykola-mokhnach Sep 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, this could be refactored to have a main class that performs css expression validation and tokenization and then passing a tranformer class/function to it. This class/function could be responsible for the expression tranformation itself to, let say, UiSelector or class chain expression. This way we could extract the common part of the code and possibly reuse it by moving to appium-support module. So, only the driver-specific transformation method would stay in the driver source.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose this approach because of my personal preference ... I find the testing simpler and I personally lean towards avoiding the cognitive overhead of thinking about "state". That is it to say, when I'm writing and troubleshooting functions, I prefer the functions to be "pure" so that I only have to think about the inputs (the args) and the output and that I don't have to think about the state of the object under test.

I still believe there are many cases where using classes/objects is the right approach. I won't be advocating that we port Appium to Haskell any time soon. I like that Javascript affords the choice between functional programming and object-oriented :)

Copy link
Contributor

@mykola-mokhnach mykola-mokhnach Sep 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is fine to use Haskell-based approach. For me it's more important to have it properly designed from architectural perspective. E.g. that we could easily separate different entities (tokenization, validation and transformation) and then implement the feature in different drivers without copying the same parts of code here and there

Copy link
Contributor Author

@dpgraham dpgraham Sep 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this feature, my plan is to not share any code between the drivers and just copy-paste + refactor.

Normally this is terrible coding practice, but I'm making an exception because this is based on the CSS selector standard, which isn't going to be introducing breaking changes any time soon and it's very unlikely that we'll ever need to make alterations to the code that would have to be ported to all drivers. I would rather each driver do it's own independent implementation.

@@ -0,0 +1,78 @@
import chai from 'chai';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the tests. It would also make sense to add a documentation to this feature

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mykola-mokhnach definitely. I'll add something to the appium docs and then probably an AppiumPro article.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to do the iOS and Android-Espresso versions of this next. The Espresso version is going to be awesome... a hybrid of datamatchers and viewmatchers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for sure, if you want to guest write an appium pro article, i'd love to feature it

@dpgraham
Copy link
Contributor Author

dpgraham commented Sep 4, 2020

Suggestions implemented.

@dpgraham dpgraham changed the title feat: Mobile CSS selector for Android UiAutomator feat: native CSS selector for Android UiAutomator Sep 4, 2020

describe('css-converter.js', function () {
describe('simple cases', function () {
const simpleCases = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome

} catch (e) {
log.errorAndThrow(`Could not parse CSS '${cssSelector}'. Reason: '${e}'`);
}
return parseCssObject(cssObj);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we mix these helper function into cssObj to avoid passing cssObj argument around:

function parseRule () {
...}
....

// was function parseCssObject (css)
function parse () {
     switch (this.type) {
     case 'rule':
       return this.parseRule();
...
}

...

cssObj.parse = parse.bind(cssObj);
cssObj.parseRule = parseRule.bind(cssObj);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No object gets passed around multiple times, they only ever get passed into one function and then the result is returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we're going to agree on this one 😄 ... I'd be happy to have a third Appium contributor be a tie-breaker.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to block the PR. You've done a great work and it would be a waste not to have it merged.

@jlipps Do you have any more comments or suggestions on the PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mykola-mokhnach !

}

if (!STR_ATTRS.includes(attrName)) {
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according to the docstring this method cannot return undefined

Copy link
Member

@jlipps jlipps left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wasn't able to give the code a very close look but i think overall approach is great! i guess we could end up doing the same for iOS too.

@@ -0,0 +1,78 @@
import chai from 'chai';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for sure, if you want to guest write an appium pro article, i'd love to feature it

@dpgraham
Copy link
Contributor Author

dpgraham commented Sep 5, 2020

@jlipps yeah my plan is to do this for Espresso (viewmatchers + datamatchers) and then iOS (class chain).

@@ -0,0 +1,319 @@
import { CssSelectorParser } from 'css-selector-parser';
import _ from 'lodash';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just do this

Suggested change
import _ from 'lodash';
import { escapeRegExp } from 'lodash';

and change the _ escapeRegExp into escapeRegExp like we normally do with imports?

}

let uiAutomatorSelector = 'new UiSelector()';
if (cssRule.tagName && cssRule.tagName !== '*') {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how to do this differently, but I always have some issues reading multiple if|else-statements like this


// 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 ` +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'${attrName}' attribute is not supported

@dpgraham dpgraham merged commit ac60ec3 into master Sep 5, 2020
@dpgraham dpgraham deleted the dpgraham-mobile-css branch September 5, 2020 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants