diff --git a/docs/en/writing-running-appium/appium-bindings.md b/docs/en/writing-running-appium/appium-bindings.md index 8d823b741..bbb35ee66 100644 --- a/docs/en/writing-running-appium/appium-bindings.md +++ b/docs/en/writing-running-appium/appium-bindings.md @@ -18,9 +18,8 @@ Language | Source [php]: https://github.com/appium/php-client [nuget]: http://www.nuget.org/packages/Appium.WebDriver/ -Note that some methods such as `endTestCoverage()` and `complexFind()` are +Note that some methods such as `endTestCoverage()` are not generally useful. Proper coverage support will be added once [this issue](https://github.com/appium/appium/issues/2448) -is resolved. `complexFind()` will be removed once [this issue](https://github.com/appium/appium/issues/2264) is resolved. If you want to use them anyway, consult the documentation for the bindings on GitHub. ### Lock diff --git a/lib/devices/android/android-controller.js b/lib/devices/android/android-controller.js index cc89ebf64..e0fa9ca9a 100644 --- a/lib/devices/android/android-controller.js +++ b/lib/devices/android/android-controller.js @@ -7,14 +7,11 @@ var errors = require('../../server/errors.js') , helpers = require('../../helpers.js') , status = require('../../server/status.js') , NotYetImplementedError = errors.NotYetImplementedError - , exec = require('child_process').exec , fs = require('fs') , temp = require('temp') , async = require('async') , mkdirp = require('mkdirp') , path = require('path') - , xpath = require("xpath") - , XMLDom = require("xmldom") , AdmZip = require("adm-zip") , helpers = require('../../helpers.js') , Args = require("vargs").Constructor; @@ -58,15 +55,9 @@ androidController.findUIElementOrElements = function (strategy, selector, many, }; var doFind = function (findCb) { - if (strategy === "xpath") { - this.findUIElementsByXPath(selector, many, function (err, res) { - this.handleFindCb(err, res, many, findCb); - }.bind(this)); - } else { - this.proxy(["find", params], function (err, res) { - this.handleFindCb(err, res, many, findCb); - }.bind(this)); - } + this.proxy(["find", params], function (err, res) { + this.handleFindCb(err, res, many, findCb); + }.bind(this)); }.bind(this); this.implicitWaitForCondition(doFind, cb); }; @@ -93,63 +84,6 @@ androidController.findElementsFromElement = function (element, strategy, selecto this.findUIElementOrElements(strategy, selector, true, element, cb); }; -var _instanceAndClassFromDomNode = function (node) { - var androidClass = node.getAttribute("class"); - var instance = node.getAttribute("instance"); - - // if we can't get the class and instance, return nothing, since we cannot select the node - // this should only happen for the wrapping tag. - if (!androidClass || !instance) { - return null; - } - - return {class: androidClass, instance: instance}; -}; - -androidController.findUIElementsByXPath = function (selector, many, cb) { - this.getPageSource(function (err, res) { - if (err || res.status !== status.codes.Success.code) return cb(err, res); - var dom, nodes; - var xmlSource = res.value; - try { - dom = new XMLDom.DOMParser().parseFromString(xmlSource); - nodes = xpath.select(selector, dom); - } catch (e) { - logger.error(e); - return cb(e); - } - var instanceClassPairs = _.map(nodes, _instanceAndClassFromDomNode); - instanceClassPairs = _.compact(instanceClassPairs); - - if (!many) instanceClassPairs = instanceClassPairs.slice(0, 1); - - if (!many && instanceClassPairs.length < 1) { - // if we don't have any matching nodes, and we wanted at least one, fail - return cb(null, { - status: status.codes.NoSuchElement.code, - value: null - }); - } else if (instanceClassPairs.length < 1) { - // and if we don't have any matching nodes, return the empty array - return cb(null, { - status: status.codes.Success.code, - value: [] - }); - } - - var selectorString = instanceClassPairs.map(function (pair) { - return pair.class + ":" + pair.instance; - }).join(","); - - var findParams = { - strategy: "index paths", - selector: selectorString, - multiple: many - }; - this.proxy(["find", findParams], cb); - }.bind(this)); -}; - androidController.setValueImmediate = function (elementId, value, cb) { cb(new NotYetImplementedError(), null); }; @@ -318,15 +252,6 @@ androidController.getCssProperty = function (elementId, propertyName, cb) { cb(new NotYetImplementedError(), null); }; -var _updateSourceXMLNodeNames = function (source) { - var newSource; - var origDom = new XMLDom.DOMParser().parseFromString(source); - var newDom = new XMLDom.DOMImplementation().createDocument(null); - _annotateXmlNodes(newDom, newDom, origDom); - newSource = new XMLDom.XMLSerializer().serializeToString(newDom); - return newSource; -}; - var _getNodeClass = function (node) { var nodeClass = null; _.each(node.attributes, function (attr) { @@ -371,53 +296,7 @@ var _annotateXmlNodes = function (newDom, newParent, oldNode, instances) { }; androidController.getPageSource = function (cb) { - var xmlFile = temp.path({suffix: '.xml'}); - var onDeviceXmlPath = this.dataDir + '/local/tmp/dump.xml'; - async.series( - [ - function (cb) { - this.proxy(["dumpWindowHierarchy"], cb); - }.bind(this), - function (cb) { - var cmd = this.adb.adbCmd + ' pull ' + onDeviceXmlPath + ' "' + xmlFile + '"'; - logger.debug('transferPageSourceXML command: ' + cmd); - exec(cmd, { maxBuffer: 524288 }, function (err, stdout, stderr) { - if (err) { - logger.error(stderr); - return cb(err); - } - cb(null); - }); - }.bind(this) - - ], - // Top level cb - function (err) { - if (err) return cb(err); - var xml = ''; - if (fs.existsSync(xmlFile)) { - xml = fs.readFileSync(xmlFile, 'utf8'); - fs.unlinkSync(xmlFile); - } - - // xml file may not exist or it could be empty. - if (xml === '') { - var error = "dumpWindowHierarchy failed"; - logger.error(error); - return cb(error); - } - - try { - xml = _updateSourceXMLNodeNames(xml); - } catch (e) { - logger.error(e); - return cb(e); - } - cb(null, { - status: status.codes.Success.code - , value: xml - }); - }); + this.proxy(["source", {}], cb); }; androidController.getAlertText = function (cb) { diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/AndroidCommandExecutor.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/AndroidCommandExecutor.java index dc25f4933..bed570d7f 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/AndroidCommandExecutor.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/AndroidCommandExecutor.java @@ -38,7 +38,6 @@ class AndroidCommandExecutor { map.put("getSize", new GetSize()); map.put("wake", new Wake()); map.put("pressBack", new PressBack()); - map.put("dumpWindowHierarchy", new DumpWindowHierarchy()); map.put("pressKeyCode", new PressKeyCode()); map.put("longPressKeyCode", new LongPressKeyCode()); map.put("takeScreenshot", new TakeScreenshot()); @@ -46,6 +45,7 @@ class AndroidCommandExecutor { map.put("getDataDir", new GetDataDir()); map.put("performMultiPointerGesture", new MultiPointerGesture()); map.put("openNotification", new OpenNotification()); + map.put("source", new Source()); } /** diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/SocketServer.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/SocketServer.java index 0cb7b9ab8..4089b9757 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/SocketServer.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/SocketServer.java @@ -165,7 +165,8 @@ class SocketServer { } else if (cmd.commandType() == AndroidCommandType.ACTION) { try { res = executor.execute(cmd); - } catch (final Exception e) { + } catch (final Exception e) { // Here we catch all possible exceptions and return a JSON Wire Protocol UnknownError + // This prevents exceptions from halting the bootstrap app res = new AndroidCommandResult(WDStatus.UNKNOWN_ERROR, e.getMessage()); } } else { diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/InvalidSelectorException.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/InvalidSelectorException.java new file mode 100644 index 000000000..629a4001b --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/InvalidSelectorException.java @@ -0,0 +1,8 @@ +package io.appium.android.bootstrap.exceptions; + +/** + * If an invalid element selector is encountered + */ +public class InvalidSelectorException extends Throwable { + public InvalidSelectorException(String message) { super(message); } +} diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/PairCreationException.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/PairCreationException.java new file mode 100644 index 000000000..a8f4c6901 --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/PairCreationException.java @@ -0,0 +1,8 @@ +package io.appium.android.bootstrap.exceptions; + +/** + * For trying to create a ClassInstancePair and something goes wrong. + */ +public class PairCreationException extends Throwable { + public PairCreationException(String msg) { super(msg); } +} diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/DumpWindowHierarchy.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/DumpWindowHierarchy.java deleted file mode 100644 index 9b2c069c4..000000000 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/DumpWindowHierarchy.java +++ /dev/null @@ -1,65 +0,0 @@ -package io.appium.android.bootstrap.handler; - -import android.os.Environment; -import com.android.uiautomator.core.UiDevice; -import io.appium.android.bootstrap.AndroidCommand; -import io.appium.android.bootstrap.AndroidCommandResult; -import io.appium.android.bootstrap.CommandHandler; -import io.appium.android.bootstrap.utils.NotImportantViews; - -import java.io.File; - -/** - * This handler is used to dumpWindowHierarchy. - * https://android.googlesource.com/ - * platform/frameworks/testing/+/master/uiautomator - * /library/core-src/com/android/uiautomator/core/UiDevice.java - */ -@SuppressWarnings("ResultOfMethodCallIgnored") -public class DumpWindowHierarchy extends CommandHandler { - // Note that - // "new File(new File(Environment.getDataDirectory(), "local/tmp"), fileName)" - // is directly from the UiDevice.java source code. - private static final File dumpFolder = new File(Environment.getDataDirectory(), "local/tmp"); - private static final String dumpFileName = "dump.xml"; - private static final File dumpFile = new File(dumpFolder, dumpFileName); - - private static void deleteDumpFile() { - if (dumpFile.exists()) { - dumpFile.delete(); - } - } - - public static boolean dump() { - dumpFolder.mkdirs(); - - deleteDumpFile(); - - try { - // dumpWindowHierarchy often has a NullPointerException - UiDevice.getInstance().dumpWindowHierarchy(dumpFileName); - } catch (Exception e) { - e.printStackTrace(); - // If there's an error then the dumpfile may exist and be empty. - deleteDumpFile(); - } - - return dumpFile.exists(); - } - - /* - * @param command The {@link AndroidCommand} used for this handler. - * - * @return {@link AndroidCommandResult} - * - * @throws JSONException - * - * @see io.appium.android.bootstrap.CommandHandler#execute(io.appium.android. - * bootstrap.AndroidCommand) - */ - @Override - public AndroidCommandResult execute(final AndroidCommand command) { - NotImportantViews.discard(true); - return getSuccessResult(dump()); - } -} \ No newline at end of file diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java index e17a3665d..f70903615 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java @@ -1,25 +1,26 @@ package io.appium.android.bootstrap.handler; -import com.android.uiautomator.core.UiObject; import com.android.uiautomator.core.UiObjectNotFoundException; -import com.android.uiautomator.core.UiScrollable; import com.android.uiautomator.core.UiSelector; import io.appium.android.bootstrap.*; import io.appium.android.bootstrap.exceptions.ElementNotFoundException; +import io.appium.android.bootstrap.exceptions.InvalidSelectorException; import io.appium.android.bootstrap.exceptions.InvalidStrategyException; import io.appium.android.bootstrap.exceptions.UiSelectorSyntaxException; import io.appium.android.bootstrap.selector.Strategy; +import io.appium.android.bootstrap.utils.ClassInstancePair; import io.appium.android.bootstrap.utils.ElementHelpers; -import io.appium.android.bootstrap.utils.NotImportantViews; import io.appium.android.bootstrap.utils.UiAutomatorParser; +import io.appium.android.bootstrap.utils.XMLHierarchy; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import javax.xml.parsers.ParserConfigurationException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Hashtable; import java.util.List; +import java.util.NoSuchElementException; import java.util.regex.Pattern; import static io.appium.android.bootstrap.utils.API.API_18; @@ -33,7 +34,6 @@ import static io.appium.android.bootstrap.utils.API.API_18; public class Find extends CommandHandler { // These variables are expected to persist across executions. AndroidElementsHash elements = AndroidElementsHash.getInstance(); - Dynamic dynamic = new Dynamic(); static JSONObject apkStrings = null; UiAutomatorParser uiAutomatorParser = new UiAutomatorParser(); /** @@ -74,134 +74,12 @@ public class Find extends CommandHandler { } final String contextId = (String) params.get("context"); - - if (strategy == Strategy.DYNAMIC) { - Logger.debug("Finding dynamic."); - final JSONArray selectors = (JSONArray) params.get("selector"); - final String option = selectors.get(0).toString().toLowerCase(); - final boolean all = option.contentEquals("all"); - Logger.debug("Returning all? " + all); - UiScrollable scrollable = null; - final boolean scroll = option.contentEquals("scroll"); - boolean canScroll = true; - if (scroll) { - UiSelector scrollableListView = new UiSelector().className( - android.widget.ListView.class).scrollable(true); - if (!new UiObject(scrollableListView).exists()) { - // Select anything that's scrollable if there's no list view. - scrollableListView = new UiSelector().scrollable(true); - } - - // Nothing scrollable exists. - if (!new UiObject(scrollableListView).exists()) { - // we're not going to scroll - canScroll = false; - } - - scrollable = new UiScrollable(scrollableListView).setAsVerticalList(); - } - Logger.debug("Scrolling? " + scroll); - // Return the first element of the first selector that matches. - Logger.debug(selectors.toString()); - try { - int finalizer = 0; - JSONArray pair; - List elementResults = new ArrayList(); - final JSONArray jsonResults = new JSONArray(); - // Start at 1 to skip over all. - for (int selIndex = all || scroll ? 1 : 0; selIndex < selectors - .length(); selIndex++) { - Logger.debug("Parsing selector " + selIndex); - pair = (JSONArray) selectors.get(selIndex); - Logger.debug("Pair is: " + pair); - UiSelector sel; - // 100+ int represents a method called on the element - // after the element has been found. - // [[4,"android.widget.EditText"],[100]] => 100 - final int int0 = pair.getJSONArray(pair.length() - 1).getInt(0); - Logger.debug("int0: " + int0); - sel = dynamic.get(pair); - Logger.debug("Selector: " + sel.toString()); - if (int0 >= 100) { - finalizer = int0; - Logger.debug("Finalizer " + Integer.toString(int0)); - } - try { - // fetch will throw on not found. - if (finalizer != 0) { - if (all) { - Logger.debug("Finding all with finalizer"); - List eles = elements.getElements( - sel, contextId); - Logger.debug("Elements found: " + eles); - for (final String found : Dynamic.finalize(eles, finalizer)) { - jsonResults.put(found); - } - continue; - } else { - final AndroidElement ele = elements.getElement(sel, contextId); - final String result = Dynamic.finalize(ele, finalizer); - return getSuccessResult(result); - } - } - - if (all) { - for (AndroidElement e : elements.getElements(sel, contextId)) { - elementResults.add(e); - } - continue; - } else if (scroll && canScroll) { - Logger.debug("Scrolling into view..."); - final boolean result = scrollable.scrollIntoView(sel); - if (!result) { - continue; // try scrolling next selector - } - // return the element we've scrolled to - return getSuccessResult(fetchElement(sel, contextId)); - } else { - return getSuccessResult(fetchElement(sel, contextId)); - } - } catch (final ElementNotFoundException enf) { - Logger.debug("Not found."); - } - } // end for loop - if (all) { - // matching on multiple selectors may return duplicate elements - elementResults = ElementHelpers.dedupe(elementResults); - - for (final AndroidElement el : elementResults) { - jsonResults.put(new JSONObject().put("ELEMENT", el.getId())); - } - - return getSuccessResult(jsonResults); - } - return new AndroidCommandResult(WDStatus.NO_SUCH_ELEMENT, - "No such element."); - } catch (final Exception e) { - final String errorMessage = e.getMessage(); - if (errorMessage != null - && errorMessage - .contains("UiAutomationService not connected. Did you call #register()?")) { - // Crash on not connected so Appium restarts the bootstrap jar. - throw new RuntimeException(e); - } - return getErrorResult(errorMessage); - } - } - final String text = (String) params.get("selector"); final boolean multiple = (Boolean) params.get("multiple"); Logger.debug("Finding " + text + " using " + strategy.toString() + " with the contextId: " + contextId + " multiple: " + multiple); - if (strategy == Strategy.INDEX_PATHS) { - NotImportantViews.discard(true); - return findElementsByIndexPaths(text, multiple); - } else { - NotImportantViews.discard(false); - } - try { Object result = null; List selectors = getSelectors(strategy, text, multiple); @@ -211,7 +89,7 @@ public class Find extends CommandHandler { try { Logger.debug("Using: " + sel.toString()); result = fetchElement(sel, contextId); - } catch (final ElementNotFoundException e) { + } catch (final ElementNotFoundException ignored) { } if (result != null) { break; @@ -226,7 +104,7 @@ public class Find extends CommandHandler { Logger.debug("Using: " + sel.toString()); List elementsFromSelector = fetchElements(sel, contextId); foundElements.addAll(elementsFromSelector); - } catch (final UiObjectNotFoundException e) { + } catch (final UiObjectNotFoundException ignored) { } } if (strategy == Strategy.ANDROID_UIAUTOMATOR) { @@ -248,6 +126,10 @@ public class Find extends CommandHandler { return new AndroidCommandResult(WDStatus.UNKNOWN_COMMAND, e.getMessage()); } catch (final ElementNotFoundException e) { return new AndroidCommandResult(WDStatus.NO_SUCH_ELEMENT, e.getMessage()); + } catch (ParserConfigurationException e) { + return getErrorResult("Error parsing xml hierarchy dump: " + e.getMessage()); + } catch (InvalidSelectorException e) { + return new AndroidCommandResult(WDStatus.INVALID_SELECTOR, e.getMessage()); } } @@ -270,28 +152,6 @@ public class Find extends CommandHandler { return res.put("ELEMENT", el.getId()); } - /** - * Get a single element by its index and its parent indexes. Used to resolve - * an xpath query - * - * @param indexPath - * @return - * @throws ElementNotFoundException - * @throws JSONException - */ - private JSONObject fetchElementByClassAndInstance(final String indexPath) - throws ElementNotFoundException, JSONException { - - // path looks like "className:instanceNumber" eg: "android.widget.Button:2" - String[] classInstancePair = indexPath.split(":"); - String androidClass = classInstancePair[0]; - String instance = classInstancePair[1]; - - UiSelector sel = new UiSelector().className(androidClass).instance(Integer.parseInt(instance)); - - return fetchElement(sel, ""); - } - /** * Get an array of AndroidElement objects from the {@link AndroidElementsHash} * @@ -323,37 +183,6 @@ public class Find extends CommandHandler { return resArray; } - /** - * Get a find element result by looking through the paths of indexes used to - * retrieve elements from an XPath search - * - * @param selector - * @return - */ - private AndroidCommandResult findElementsByIndexPaths(final String selector, - final Boolean multiple) { - final ArrayList indexPaths = new ArrayList( - Arrays.asList(selector.split(","))); - final JSONArray resArray = new JSONArray(); - JSONObject resEl = new JSONObject(); - for (final String indexPath : indexPaths) { - try { - resEl = fetchElementByClassAndInstance(indexPath); - resArray.put(resEl); - } catch (final JSONException e) { - return new AndroidCommandResult(WDStatus.UNKNOWN_ERROR, e.getMessage()); - } catch (final ElementNotFoundException e) { - return new AndroidCommandResult(WDStatus.NO_SUCH_ELEMENT, - e.getMessage()); - } - } - if (multiple) { - return getSuccessResult(resArray); - } else { - return getSuccessResult(resEl); - } - } - /** * Create and return a UiSelector based on the strategy, text, and how many * you want returned. @@ -370,11 +199,16 @@ public class Find extends CommandHandler { */ private List getSelectors(final Strategy strategy, final String text, final boolean many) throws InvalidStrategyException, - ElementNotFoundException, UiSelectorSyntaxException { + ElementNotFoundException, UiSelectorSyntaxException, ParserConfigurationException, InvalidSelectorException { final List selectors = new ArrayList(); UiSelector sel = new UiSelector(); switch (strategy) { + case XPATH: + for (UiSelector selector : getXPathSelectors(text, many)) { + selectors.add(selector); + } + break; case CLASS_NAME: sel = sel.className(text); if (!many) { @@ -434,7 +268,7 @@ public class Find extends CommandHandler { selectors.add(sel); break; case ANDROID_UIAUTOMATOR: - List parsedSelectors = new ArrayList(); + List parsedSelectors; try { parsedSelectors = uiAutomatorParser.parse(text); } catch (final UiSelectorSyntaxException e) { @@ -475,4 +309,24 @@ public class Find extends CommandHandler { return sel; } } + + /** returns List of UiSelectors for an xpath expression **/ + private List getXPathSelectors(final String expression, final boolean multiple) throws ElementNotFoundException, ParserConfigurationException, InvalidSelectorException { + List selectors = new ArrayList(); + + ArrayList pairs = XMLHierarchy.getClassInstancePairs(expression); + + if (!multiple) { + if (pairs.size() == 0) { + throw new NoSuchElementException(); + } + selectors.add(pairs.get(0).getSelector()); + } else { + for (ClassInstancePair pair : pairs) { + selectors.add(pair.getSelector()); + } + } + + return selectors; + } } diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Source.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Source.java new file mode 100644 index 000000000..1097ab336 --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Source.java @@ -0,0 +1,47 @@ +package io.appium.android.bootstrap.handler; + +import io.appium.android.bootstrap.AndroidCommand; +import io.appium.android.bootstrap.AndroidCommandResult; +import io.appium.android.bootstrap.CommandHandler; +import io.appium.android.bootstrap.utils.XMLHierarchy; +import org.json.JSONException; +import org.w3c.dom.Document; + +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.StringWriter; + +/** + * Get page source. Return as string of XML doc + */ +public class Source extends CommandHandler { + @Override + public AndroidCommandResult execute(AndroidCommand command) throws JSONException { + + Document doc = (Document) XMLHierarchy.getFormattedXMLDoc(); + + TransformerFactory tf = TransformerFactory.newInstance(); + StringWriter writer = new StringWriter(); + Transformer transformer; + String xmlString; + + + try { + transformer = tf.newTransformer(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + xmlString = writer.getBuffer().toString(); + + } catch (TransformerConfigurationException e) { + e.printStackTrace(); + throw new RuntimeException("Something went terribly wrong while converting xml document to string"); + } catch (TransformerException e) { + return getErrorResult("Could not parse xml hierarchy to string: " + e.getMessage()); + } + + return getSuccessResult(xmlString); + } +} diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java index 836a3fa69..9fe74668d 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java @@ -12,8 +12,7 @@ public enum Strategy { NAME("name"), LINK_TEXT("link text"), PARTIAL_LINK_TEXT("partial link text"), - INDEX_PATHS("index paths"), - DYNAMIC("dynamic"), + XPATH("xpath"), ACCESSIBILITY_ID("accessibility id"), ANDROID_UIAUTOMATOR("-android uiautomator"); diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/ClassInstancePair.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/ClassInstancePair.java new file mode 100644 index 000000000..e4167262b --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/ClassInstancePair.java @@ -0,0 +1,32 @@ +package io.appium.android.bootstrap.utils; + +import com.android.uiautomator.core.UiSelector; + +/** + * Simple class for holding a String 2-tuple. An android class, and instance number, used for finding elements by xpath. + */ +public class ClassInstancePair { + + private String androidClass; + private String instance; + + public ClassInstancePair(String clazz, String inst) { + androidClass = clazz; + instance = inst; + } + + public String getAndroidClass() { + return androidClass; + } + + public String getInstance() { + return instance; + } + + public UiSelector getSelector() { + String androidClass = getAndroidClass(); + String instance = getInstance(); + + return new UiSelector().className(androidClass).instance(Integer.parseInt(instance)); + } +} diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/XMLHierarchy.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/XMLHierarchy.java new file mode 100644 index 000000000..5165b4bec --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/XMLHierarchy.java @@ -0,0 +1,184 @@ +package io.appium.android.bootstrap.utils; + +import android.os.Environment; +import com.android.uiautomator.core.UiDevice; +import io.appium.android.bootstrap.exceptions.ElementNotFoundException; +import io.appium.android.bootstrap.exceptions.InvalidSelectorException; +import io.appium.android.bootstrap.exceptions.PairCreationException; +import org.w3c.dom.Document; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.*; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.HashMap; + + +/** + * Created by jonahss on 8/12/14. + */ +public abstract class XMLHierarchy { + + public static ArrayList getClassInstancePairs(String xpathExpression) throws ElementNotFoundException, InvalidSelectorException, ParserConfigurationException { + XPath xpath = XPathFactory.newInstance().newXPath(); + XPathExpression exp = null; + try { + exp = xpath.compile(xpathExpression); + } catch (XPathExpressionException e) { + throw new InvalidSelectorException(e.getMessage()); + } + + Node formattedXmlRoot; + + formattedXmlRoot = getFormattedXMLDoc(); + + return getClassInstancePairs(exp, formattedXmlRoot); + } + + public static ArrayList getClassInstancePairs(XPathExpression xpathExpression, Node root) throws ElementNotFoundException { + + NodeList nodes; + try { + nodes = (NodeList) xpathExpression.evaluate(root, XPathConstants.NODESET); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new ElementNotFoundException("XMLWindowHierarchy could not be parsed: " + e.getMessage()); + } + + ArrayList pairs = new ArrayList(); + for (int i = 0; i < nodes.getLength(); i++) { + if (nodes.item(i).getNodeType() == Node.ELEMENT_NODE) { + try { + pairs.add(getPairFromNode(nodes.item(i))); + } catch (PairCreationException e) { } + } + } + + return pairs; + } + + public static InputSource getRawXMLHierarchy() { + // Note that + // "new File(new File(Environment.getDataDirectory(), "local/tmp"), fileName)" + // is directly from the UiDevice.java source code. + final File dumpFolder = new File(Environment.getDataDirectory(), "local/tmp"); + final String dumpFileName = "dump.xml"; + final File dumpFile = new File(dumpFolder, dumpFileName); + + dumpFolder.mkdirs(); + + dumpFile.delete(); + + //compression off by default TODO add this as a config option + NotImportantViews.discard(false); + + try { + // dumpWindowHierarchy often has a NullPointerException + UiDevice.getInstance().dumpWindowHierarchy(dumpFileName); + } catch (Exception e) { + e.printStackTrace(); + // If there's an error then the dumpfile may exist and be empty. + dumpFile.delete(); + } + + try { + return new InputSource(new FileReader(dumpFile)); + } catch (FileNotFoundException e) { + e.printStackTrace(); + throw new RuntimeException("Failed to Dump Window Hierarchy"); + } + } + + public static Node getFormattedXMLDoc() { + return formatXMLInput(getRawXMLHierarchy()); + } + + public static Node formatXMLInput(InputSource input) { + XPath xpath = XPathFactory.newInstance().newXPath(); + + Node root = null; + try { + root = (Node) xpath.evaluate("/", input, XPathConstants.NODE); + } catch (XPathExpressionException e) { + throw new RuntimeException("Could not read xml hierarchy: " + e.getMessage()); + } + + HashMap instances = new HashMap(); + + // rename all the nodes with their "class" attribute + // add an instance attribute + annotateNodes(root, instances); + + return root; + } + + + private static ClassInstancePair getPairFromNode(Node node) throws PairCreationException { + + NamedNodeMap attrElements = node.getAttributes(); + String androidClass; + String instance; + + try { + androidClass = attrElements.getNamedItem("class").getNodeValue(); + instance = attrElements.getNamedItem("instance").getNodeValue(); + } catch (Exception e) { + throw new PairCreationException("Could not create ClassInstancePair object: " + e.getMessage()); + } + + return new ClassInstancePair(androidClass, instance); + } + + private static void annotateNodes(Node node, HashMapinstances) { + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + visitNode(children.item(i), instances); + annotateNodes(children.item(i), instances); + } + } + } + + // set the node's tag name to the same as it's android class. + // also number all instances of each class with an "instance" number. It increments for each class separately. + // this allows use to use class and instance to identify a node. + // we also take this chance to clean class names that might have dollar signs in them (and other odd characters) + private static void visitNode(Node node, HashMap instances) { + + Document doc = node.getOwnerDocument(); + NamedNodeMap attributes = node.getAttributes(); + + String androidClass; + try { + androidClass = attributes.getNamedItem("class").getNodeValue(); + } catch (Exception e) { + return; + } + + androidClass = cleanTagName(androidClass); + + if (!instances.containsKey(androidClass)) { + instances.put(androidClass, 0); + } + Integer instance = instances.get(androidClass); + + Node attrNode = doc.createAttribute("instance"); + attrNode.setNodeValue(instance.toString()); + attributes.setNamedItem(attrNode); + + doc.renameNode(node, node.getNamespaceURI(), androidClass); + + instances.put(androidClass, instance+1); + } + + private static String cleanTagName(String name) { + name = name.replaceAll("[$@#&]", "."); + return name.replaceAll("\\s", ""); + } +} diff --git a/lib/devices/android/bootstrap/test/io/appium/android/bootstrap/utils/XMLHierarchyTest.java b/lib/devices/android/bootstrap/test/io/appium/android/bootstrap/utils/XMLHierarchyTest.java new file mode 100644 index 000000000..547ff4b53 --- /dev/null +++ b/lib/devices/android/bootstrap/test/io/appium/android/bootstrap/utils/XMLHierarchyTest.java @@ -0,0 +1,90 @@ +package io.appium.android.bootstrap.utils; + +import junit.framework.TestCase; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; + +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathFactory; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; + +public class XMLHierarchyTest extends TestCase { + + private static XPath xpath = XPathFactory.newInstance().newXPath(); + + public void testGetClassInstancePairs() throws Exception { + + String xmlString = "\n"; + InputSource testInput = new InputSource(new StringReader(xmlString)); + + Node root = (Node) xpath.evaluate("/", testInput, XPathConstants.NODE); + + + XPathExpression expression = xpath.compile("//android.widget.GridView/android.widget.RelativeLayout"); + ArrayList pairs = XMLHierarchy.getClassInstancePairs(expression, root); + assertEquals(4, pairs.size()); + assertEquals("android.widget.RelativeLayout", pairs.get(0).getAndroidClass()); + assertEquals("0", pairs.get(0).getInstance()); + assertEquals("2", pairs.get(2).getInstance()); + + } + + public void testFormatXMLInput() throws Exception { + String xmlString = ""; + InputSource testInput = new InputSource(new StringReader(xmlString)); + + Node formatted = XMLHierarchy.formatXMLInput(testInput); + + Node childNode = formatted.getFirstChild().getFirstChild(); + + assertEquals("class0", childNode.getNodeName()); + assertEquals("0", childNode.getAttributes().getNamedItem("instance").getNodeValue()); + + childNode = formatted.getFirstChild().getLastChild(); + + assertEquals("class1", childNode.getNodeName()); + assertEquals("2", childNode.getAttributes().getNamedItem("instance").getNodeValue()); + + } + + public void testCleaningTags() throws Exception { + String[] samples = {"foo $ bar", "foo$bar", "foo.bar", "foo. bar"}; + String expected = "foo.bar"; + for (String sample : samples) { + String test = "teeeeeext"; + InputSource testInput = new InputSource(new StringReader(test)); + Node formatted = XMLHierarchy.formatXMLInput(testInput); + assertEquals(expected, formatted.getFirstChild().getNodeName()); + } + } + + public void testOutput() throws Exception { + String xmlString = "\n"; + InputSource testInput = new InputSource(new StringReader(xmlString)); + + Node node = XMLHierarchy.formatXMLInput(testInput); + Document doc = (Document) node; + + TransformerFactory tf = TransformerFactory.newInstance(); + StringWriter writer = new StringWriter(); + Transformer transformer; + String out; + + transformer = tf.newTransformer(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + out = writer.getBuffer().toString(); + + assertEquals(xmlString, out); //close enough + + + } +} \ No newline at end of file diff --git a/lib/devices/common.js b/lib/devices/common.js index 66c016da0..b3a14b704 100644 --- a/lib/devices/common.js +++ b/lib/devices/common.js @@ -216,7 +216,6 @@ exports.checkValidLocStrat = function (strat, includeWeb, cb) { 'xpath', 'id', 'name', - 'dynamic', 'class name' ]; var nativeStrats = [ @@ -232,7 +231,7 @@ exports.checkValidLocStrat = function (strat, includeWeb, cb) { ]; var nativeDeprecations = {}; var webDeprecations = {}; - var deprecations = {dynamic: '-android uiautomator or -ios uiautomation', name: 'accessibility id'}; + var deprecations = {name: 'accessibility id'}; if (includeWeb) { validStrats = validStrats.concat(webStrats); diff --git a/lib/server/controller.js b/lib/server/controller.js index 88f5be060..86e2d22d2 100644 --- a/lib/server/controller.js +++ b/lib/server/controller.js @@ -435,26 +435,6 @@ exports.mobileDrag = function (req, res) { req.device.drag(startX, startY, endX, endY, duration, touchCount, element, destEl, getResponseHandler(req, res)); }; -exports.find = function (req, res) { - var strategy = "dynamic" - , selector = req.body; - - // some clients send the selector as the `selector` property of an object, - // rather than the whole body. - // selector ought to be an array - if (selector && !Array.isArray(selector)) { - selector = req.body.selector; - } - - var all = selector && selector[0] && typeof selector[0] === "string" && selector[0].toLowerCase() === "all"; - - if (all) { - req.device.findElements(strategy, selector, getResponseHandler(req, res)); - } else { - req.device.findElement(strategy, selector, getResponseHandler(req, res)); - } -}; - exports.mobileSwipe = function (req, res) { req.body = _.defaults(req.body, { touchCount: 1 diff --git a/lib/server/routing.js b/lib/server/routing.js index 6a476176c..323a97f02 100644 --- a/lib/server/routing.js +++ b/lib/server/routing.js @@ -127,7 +127,6 @@ module.exports = function (appium) { rest.post('/wd/hub/session/:sessionId?/appium/app/reset', controller.reset); rest.post('/wd/hub/session/:sessionId?/appium/app/background', controller.background); rest.post('/wd/hub/session/:sessionId?/appium/app/end_test_coverage', controller.endCoverage); - rest.post('/wd/hub/session/:sessionId?/appium/app/complex_find', controller.find); rest.post('/wd/hub/session/:sessionId?/appium/app/strings', controller.getStrings); rest.post('/wd/hub/session/:sessionId?/appium/element/:elementId?/value', controller.setValueImmediate); diff --git a/package.json b/package.json index 79dca3397..f6e199b05 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "ws": "~0.4.31", "xml2js": "~0.4.4", "xmldom": "~0.1.19", - "xpath": "~0.0.6" + "xpath": "0.0.7" }, "scripts": { "test": "grunt travis" diff --git a/test/functional/android/apidemos/attributes-specs.js b/test/functional/android/apidemos/attributes-specs.js index 62fa4bb45..49a69c8dc 100644 --- a/test/functional/android/apidemos/attributes-specs.js +++ b/test/functional/android/apidemos/attributes-specs.js @@ -58,16 +58,4 @@ describe("apidemos - attributes", function () { }); // TODO: tests for checkable, checked, clickable, focusable, focused, // longClickable, scrollable, selected - - // TODO: fix that, the second scroll doesn't scroll far enough. - it('should be able to get selected value of a tab @skip-android-all', function (done) { - driver - .complexFind(["scroll", [[3, "views"]], [[7, "views"]]]).click() - .complexFind(["scroll", [[3, "tabs"]], [[7, "tabs"]]]).click() - .complexFind(["scroll", [[3, "content by id"]], [[7, "content by id"]]]).click() - .elementsByClassName("android.widget.TextView").then(function (els) { - els[0].getAttribute('selected').should.become('false'); // the 1st text is not selected - els[1].getAttribute('selected').should.become('true'); // tab 1 is selected - }).nodeify(done); - }); }); diff --git a/test/functional/android/apidemos/find/by-xpath-specs.js b/test/functional/android/apidemos/find/by-xpath-specs.js index 15f3b4949..beae6c39f 100644 --- a/test/functional/android/apidemos/find/by-xpath-specs.js +++ b/test/functional/android/apidemos/find/by-xpath-specs.js @@ -38,21 +38,21 @@ describe("apidemo - find - by xpath", function () { }); it('should find the last element', function (done) { driver - .elementByXPath("//" + t + "[last()]").text() + .elementByXPath("(//" + t + ")[last()]").text() .then(function (text) { ["OS", "Text", "Views"].should.include(text); }).nodeify(done); }); it('should find element by xpath index and child', function (done) { driver - .elementByXPath("//" + f + "[1]/" + l + "[1]/" + t + "[3]").text() + .elementByXPath("//" + f + "[2]/" + l + "[1]/" + t + "[3]").text() .should.become("App") .nodeify(done); }); it('should find element by index and embedded desc', function (done) { driver .elementByXPath("//" + f + "//" + t + "[4]").text() - .should.become("App") + .should.become("Content") .nodeify(done); }); it('should find all elements', function (done) { diff --git a/test/functional/android/apidemos/find/complex-find-specs.js b/test/functional/android/apidemos/find/complex-find-specs.js deleted file mode 100644 index 1342637a3..000000000 --- a/test/functional/android/apidemos/find/complex-find-specs.js +++ /dev/null @@ -1,27 +0,0 @@ -"use strict"; - -var setup = require("../../../common/setup-base") - , desired = require("../desired"); - -describe("apidemo - find - complex", function () { - - var driver; - setup(this, desired).then(function (d) { driver = d; }); - - it('should scroll to an element by text or content desc', function (done) { - driver - .complexFind(["scroll", [[3, "views"]], [[7, "views"]]]).text() - .should.become("Views") - .nodeify(done); - }); - it('should find a single element by content-description', function (done) { - driver.complexFind([[[7, "Animation"]]]).text() - .should.become("Animation") - .nodeify(done); - }); - it('should find a single element by text', function (done) { - driver.complexFind([[[3, "Animation"]]]).text() - .should.become("Animation") - .nodeify(done); - }); -}); diff --git a/test/functional/android/apidemos/source-specs.js b/test/functional/android/apidemos/source-specs.js index 510392b56..04c8148c3 100644 --- a/test/functional/android/apidemos/source-specs.js +++ b/test/functional/android/apidemos/source-specs.js @@ -18,7 +18,7 @@ describe("apidemos - source", function () { it('should return the page source', function (done) { driver - .elementByNameOrNull('Accessibility') // waiting for page to load + .elementByAccessibilityId('Animation') // waiting for page to load .source() .then(function (source) { assertSource(source); @@ -26,11 +26,11 @@ describe("apidemos - source", function () { }); it('should return the page source without crashing other commands', function (done) { driver - .complexFind([[[3, "Animation"]]]) + .elementByAccessibilityId('Animation') .source().then(function (source) { assertSource(source); }) - .complexFind([[[3, "Animation"]]]) + .elementByAccessibilityId('Animation') .nodeify(done); }); }); diff --git a/test/unit/common-device-specs.js b/test/unit/common-device-specs.js index 21a8330b2..8ce2d54d2 100644 --- a/test/unit/common-device-specs.js +++ b/test/unit/common-device-specs.js @@ -4,8 +4,6 @@ var common = require('../../lib/devices/common.js') , checkValidLocStrat = common.checkValidLocStrat , _ = require('underscore'); - - describe('devices/common.js', function () { var nullCb = function () {}; @@ -31,7 +29,6 @@ describe('devices/common.js', function () { 'xpath', 'id', 'name', - 'dynamic', 'class name' ];