Bug 1250102 - Rewrite element location to be promise-compatible; r?automatedtester draft
authorAndreas Tolfsen <ato@mozilla.com>
Tue, 23 Feb 2016 15:18:55 +0000
changeset 333400 03a3e366bb24ddd0eb485a3f519c62717cbfe8d9
parent 333399 5e00cc18b097e32842ee36b7c4b065760cec9352
child 333401 aa692d7e5e5031e8efca63600911c58e531a4f8d
push id11352
push userbmo:ato@mozilla.com
push dateTue, 23 Feb 2016 16:54:31 +0000
reviewersautomatedtester
bugs1250102
milestone47.0a1
Bug 1250102 - Rewrite element location to be promise-compatible; r?automatedtester Element location is rewritten with this patch in order to make it compatible for use with promises. This makes consuming the API nicer in the wider context of Marionette, since it no longer takes callbacks and no longer has to be wrapped in external promises to be compatible with the new dispatching technique. MozReview-Commit-ID: DjZOXPqkZ5j
testing/marionette/element.js
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -307,99 +307,141 @@ ElementManager.prototype = {
           namedArgs[prop] = arg['__marionetteArgs'][prop];
         }
       }
     });
     return namedArgs;
   },
 
   /**
-   * Find an element or elements starting at the document root or
-   * given node, using the given search strategy. Search
-   * will continue until the search timelimit has been reached.
+   * Find a single element or a collection of elements starting at the
+   * document root or a given node.
+   *
+   * If |timeout| is above 0, an implicit search technique is used.
+   * This will wait for the duration of |timeout| for the element
+   * to appear in the DOM.
+   *
+   * See the |element.Strategies| enum for a full list of supported
+   * search strategies that can be passed to |strategy|.
+   *
+   * Available flags for |opts|:
+   *
+   *     |all|
+   *       If true, a multi-element search selector is used and a sequence
+   *       of elements will be returned.  Otherwise a single element.
+   *
+   *     |timeout|
+   *       Duration to wait before timing out the search.  If |all| is
+   *       false, a NoSuchElementError is thrown if unable to find
+   *       the element within the timeout duration.
+   *
+   *     |startNode|
+   *       Element to use as the root of the search.
    *
-   * @param nsIDOMWindow, ShadowRoot container
-   *        The window and an optional shadow root that contains the element
-   * @param object values
-   *        The 'using' member of values will tell us which search
-   *        method to use. The 'value' member tells us the value we
-   *        are looking for.
-   *        If this object has an 'element' member, this will be used
-   *        as the start node instead of the document root
-   *        If this object has a 'time' member, this number will be
-   *        used to see if we have hit the search timelimit.
-   * @param boolean all
-   *        If true, all found elements will be returned.
-   *        If false, only the first element will be returned.
-   * @param function on_success
-   *        Callback used when operating is successful.
-   * @param function on_error
-   *        Callback to invoke when an error occurs.
+   * @param {Object.<string, Window>} container
+   *     Window object and an optional shadow root that contains the
+   *     root shadow DOM element.
+   * @param {string} strategy
+   *     Search strategy whereby to locate the element(s).
+   * @param {string} selector
+   *     Selector search pattern.  The selector must be compatible with
+   *     the chosen search |strategy|.
+   * @param {Object.<string, ?>} opts
+   *     Options.
+   *
+   * @return {Promise: (WebElement|Array<WebElement>)}
+   *     Single element or a sequence of elements.
    *
-   * @return nsIDOMElement or list of nsIDOMElements
-   *        Returns the element(s) by calling the on_success function.
+   * @throws InvalidSelectorError
+   *     If |strategy| is unknown.
+   * @throws InvalidSelectorError
+   *     If |selector| is malformed.
+   * @throws NoSuchElementError
+   *     If a single element is requested, this error will throw if the
+   *     element is not found.
    */
-  find: function EM_find(container, values, searchTimeout, all, on_success, on_error, command_id) {
-    let startTime = values.time ? values.time : new Date().getTime();
-    let rootNode = container.shadowRoot || container.frame.document;
-    let startNode = (values.element != undefined) ?
-                    this.getKnownElement(values.element, container) : rootNode;
-    if (this.elementStrategies.indexOf(values.using) < 0) {
-      throw new InvalidSelectorError(`No such strategy: ${values.using}`);
+  find: function(container, strategy, selector, opts = {}) {
+    opts.all = !!opts.all;
+    opts.timeout = opts.timeout || 0;
+
+    let searchFn;
+    if (opts.all) {
+      searchFn = this.findElements.bind(this);
+    } else {
+      searchFn = this.findElement.bind(this);
     }
 
-    let found;
-    try {
-      found = all ? this.findElements(values.using, values.value, rootNode, startNode) :
-                      this.findElement(values.using, values.value, rootNode, startNode);
-    } catch (e) {
-      throw new InvalidSelectorError(`Given ${values.using} expression "${values.value}" is invalid`);
-    }
-    let type = Object.prototype.toString.call(found);
-    let isArrayLike = ((type == '[object Array]') || (type == '[object HTMLCollection]') || (type == '[object NodeList]'));
-    if (found == null || (isArrayLike && found.length <= 0)) {
-      if (!searchTimeout || new Date().getTime() - startTime > searchTimeout) {
-        if (all) {
-          on_success([], command_id); // findElements should return empty list
-        } else {
-          // Format message depending on strategy if necessary
-          let message = `Unable to locate element: ${values.value}`
-          if (values.using == ANON) {
-            message = "Unable to locate anonymous children";
-          } else if (values.using == ANON_ATTRIBUTE) {
-            message = `Unable to locate anonymous element: ${JSON.stringify(values.value)}`;
+    return new Promise((resolve, reject) => {
+      let findElements = implicitlyWaitFor(
+          () => this.find_(container, strategy, selector, searchFn, opts),
+          opts.timeout);
+
+      findElements.then(foundEls => {
+        // when looking for a single element and none is found,
+        // an error must be thrown
+        if (foundEls.length == 0 && !opts.all) {
+          let msg;
+          switch (strategy) {
+            case ANON:
+              msg = "Unable to locate anonymous children";
+              break;
+
+            case ANON_ATTRIBUTE:
+              msg = "Unable to locate anonymous element: " + JSON.stringify(selector);
+              break;
+
+            default:
+              msg = "Unable to locate element: " + selector;
           }
-          on_error(new NoSuchElementError(message), command_id);
+
+          reject(new NoSuchElementError(msg));
+        }
+
+        // serialise elements for return
+        let rv = [];
+        for (let el of foundEls) {
+          let ref = this.addToKnownElements(el);
+          let we = element.makeWebElement(ref);
+          rv.push(we);
         }
-      } else {
-        values.time = startTime;
-        this.timer.initWithCallback(this.find.bind(this, container, values,
-                                                   searchTimeout, all,
-                                                   on_success, on_error,
-                                                   command_id),
-                                    100,
-                                    Ci.nsITimer.TYPE_ONE_SHOT);
-      }
+
+        if (opts.all) {
+          resolve(rv);
+        }
+        resolve(rv[0]);
+      }, reject);
+    });
+  },
+
+  find_: function(container, strategy, selector, searchFn, opts) {
+    let rootNode = container.shadowRoot || container.frame.document;
+    let startNode;
+    if (opts.startNode) {
+      startNode = this.getKnownElement(opts.startNode, container);
     } else {
-      if (isArrayLike) {
-        let ids = []
-        for (let i = 0 ; i < found.length ; i++) {
-          let foundElement = this.addToKnownElements(found[i]);
-          let returnElement = {
-            [this.elementKey] : foundElement,
-            [this.w3cElementKey] : foundElement,
-          };
-          ids.push(returnElement);
-        }
-        on_success(ids, command_id);
-      } else {
-        let id = this.addToKnownElements(found);
-        on_success({[this.elementKey]: id, [this.w3cElementKey]:id}, command_id);
-      }
+      startNode = rootNode;
+    }
+
+    if (strategy in element.Strategies) {
+      throw new InvalidSelectorError("No such strategy: " + strategy);
     }
+
+    let res;
+    try {
+      res = searchFn(strategy, selector, rootNode, startNode);
+    } catch (e) {
+      throw new InvalidSelectorError(`Given ${strategy} expression "${selector}" is invalid`);
+    }
+
+    if (element.isElementCollection(res)) {
+      return res;
+    } else if (res !== null) {
+      return [res];
+    }
+    return [];
   },
 
   /**
    * Find a value by XPATH
    *
    * @param nsIDOMElement root
    *        Document root
    * @param string value
@@ -590,20 +632,117 @@ ElementManager.prototype = {
         break;
       default:
         throw new InvalidSelectorError(`No such strategy: ${using}`);
     }
     return elements;
   },
 };
 
+/**
+ * Runs function off the main thread until its return value is truthy
+ * or the provided timeout is reached.  The function is guaranteed to be
+ * run at least once, irregardless of the timeout.
+ *
+ * A truthy return value constitutes a truthful boolean, positive number,
+ * object, or non-empty array.
+ *
+ * The |func| is evaluated every |interval| for as long as its runtime
+ * duration does not exceed |interval|.  If the runtime evaluation duration
+ * of |func| is greater than |interval|, evaluations of |func| are queued.
+ *
+ * @param {function(): ?} func
+ *     Function to run off the main thread.
+ * @param {number} timeout
+ *     Desired timeout.  If 0 or less than the runtime evaluation time
+ *     of |func|, |func| is guaranteed to run at least once.
+ * @param {number=} interval
+ *     Duration between each poll of |func| in milliseconds.  Defaults to
+ *     100 milliseconds.
+ *
+ * @return {Promise}
+ *     Yields the return value from |func|.  The promise is rejected if
+ *     |func| throws.
+ */
+function implicitlyWaitFor(func, timeout, interval = 100) {
+  let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+  return new Promise((resolve, reject) => {
+    let startTime = new Date().getTime();
+    let endTime = startTime + timeout;
+
+    let observer = function() {
+      let res;
+      try {
+        res = func();
+      } catch (e) {
+        reject(e);
+      }
+
+      // empty arrays evaluate to true in JS,
+      // so we must first ascertan if the result is a collection
+      //
+      // we also return immediately if timeout is 0,
+      // allowing |func| to be evaluated at least once
+      let col = element.isElementCollection(res);
+      if (((col && res.length > 0 ) || (!col && !!res)) ||
+          (startTime == endTime || new Date().getTime() >= endTime)) {
+        resolve(res);
+      }
+    };
+
+    timer.init(observer, interval, Ci.nsITimer.TYPE_REPEATING_SLACK);
+
+  // cancel timer and return result for yielding
+  }).then(res => {
+    timer.cancel();
+    return res;
+  });
+};
 
 this.element = {};
+
 element.LegacyKey = "ELEMENT";
 element.Key = "element-6066-11e4-a52e-4f735466cecf";
+
+element.Strategies = {
+  CLASS_NAME: 0,
+  SELECTOR: 1,
+  ID: 2,
+  NAME: 3,
+  LINK_TEXT: 4,
+  PARTIAL_LINK_TEXT: 5,
+  TAG: 6,
+  XPATH: 7,
+  ANON: 8,
+  ANON_ATTRIBUTE: 9,
+};
+
+element.isElementCollection = function(seq) {
+  if (seq === null) {
+    return false;
+  }
+
+  const arrayLike = {
+    "[object Array]": 0,
+    "[object HTMLCollection]": 1,
+    "[object NodeList]": 2,
+  };
+
+  let typ = Object.prototype.toString.call(seq);
+  return typ in arrayLike;
+};
+
+element.makeWebElement = function(uuid) {
+  return {
+    [element.Key]: uuid,
+    [element.LegacyKey]: uuid,
+  };
+};
+
 element.generateUUID = function() {
   let uuid = uuidGen.generateUUID().toString();
   return uuid.substring(1, uuid.length - 1);
 };
 
 /**
  * This function generates a pair of coordinates relative to the viewport
  * given a target element and coordinates relative to that element's