Bug 1319237 - Generalise wait condition utility; r?automatedtester,maja_zf draft
authorAndreas Tolfsen <ato@mozilla.com>
Mon, 21 Nov 2016 23:41:20 +0100
changeset 479402 64427a6f82c592729a9853298f2b86d6ab293bbb
parent 479401 0469dce76124d18c153ceecec5d9e8dbf2da1c63
child 479403 d2632d17e9b1ba8ddae7ad77bc7c5ccfc33c0681
push id44243
push userbmo:ato@mozilla.com
push dateMon, 06 Feb 2017 16:11:43 +0000
reviewersautomatedtester, maja_zf
bugs1319237
milestone54.0a1
Bug 1319237 - Generalise wait condition utility; r?automatedtester,maja_zf This makes the `implicitWaitFor` utility from testing/marionette/element.js generally available in Marionette. It improves on the design of the old wait utility by providing promise-like resolve and reject options to the evaluated function. These can be used to indicate success or failure of waiting. If resolved, the provided value is returned immediately. When rejected, the function is evaluated over again until the timeout is reached or an error is thrown. It is useful to indicate success and failure state because it saves the calling code from guessing based on the return value. Guessing from the return value can be problematic since there are certain types and values in JavaScript that are ambigeous or misleading, such as the fact that empty arrays are evaluated as a truthy value. MozReview-Commit-ID: G8F99tdbiNb
testing/marionette/element.js
testing/marionette/jar.mn
testing/marionette/test_wait.js
testing/marionette/unit.ini
testing/marionette/wait.js
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -5,16 +5,17 @@
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/Log.jsm");
 
 Cu.import("chrome://marionette/content/atom.js");
 Cu.import("chrome://marionette/content/error.js");
+Cu.import("chrome://marionette/content/wait.js");
 
 const logger = Log.repository.getLogger("Marionette");
 
 /**
  * This module provides shared functionality for dealing with DOM-
  * and web elements in Marionette.
  *
  * A web element is an abstraction used to identify an element when it
@@ -241,19 +242,24 @@ element.find = function (container, stra
   let searchFn;
   if (opts.all) {
     searchFn = findElements.bind(this);
   } else {
     searchFn = findElement.bind(this);
   }
 
   return new Promise((resolve, reject) => {
-    let findElements = implicitlyWaitFor(
-        () => find_(container, strategy, selector, searchFn, opts),
-        opts.timeout);
+    let findElements = wait.until((resolve, reject) => {
+      let res = find_(container, strategy, selector, searchFn, opts);
+      if (res.length > 0) {
+        resolve(Array.from(res));
+      } else {
+        reject([]);
+      }
+    }, opts.timeout);
 
     findElements.then(foundEls => {
       // the following code ought to be moved into findElement
       // and findElements when bug 1254486 is addressed
       if (!opts.all && (!foundEls || foundEls.length == 0)) {
         let msg;
         switch (strategy) {
           case element.Strategy.AnonAttribute:
@@ -551,92 +557,16 @@ function findElements(using, value, root
       }
       return [];
 
     default:
       throw new InvalidSelectorError(`No such strategy: ${using}`);
   }
 }
 
-/**
- * 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 elementSearch = function() {
-      let res;
-      try {
-        res = func();
-      } catch (e) {
-        reject(e);
-      }
-
-      if (
-        // collections that might contain web elements
-        // should be checked until they are not empty
-        (element.isCollection(res) && res.length > 0)
-
-        // !![] (ensuring boolean type on empty array) always returns true
-        // and we can only use it on non-collections
-        || (!element.isCollection(res) && !!res)
-
-        // return immediately if timeout is 0,
-        // allowing |func| to be evaluted at least once
-        || startTime == endTime
-
-        // return if timeout has elapsed
-        || new Date().getTime() >= endTime
-      ) {
-        resolve(res);
-      }
-    };
-
-    // the repeating slack timer waits |interval|
-    // before invoking |elementSearch|
-    elementSearch();
-
-    timer.init(elementSearch, interval, Ci.nsITimer.TYPE_REPEATING_SLACK);
-
-  // cancel timer and propagate result
-  }).then(res => {
-    timer.cancel();
-    return res;
-  }, err => {
-    timer.cancel();
-    throw err;
-  });
-}
-
 /** Determines if |obj| is an HTML or JS collection. */
 element.isCollection = function (seq) {
   switch (Object.prototype.toString.call(seq)) {
     case "[object Arguments]":
     case "[object Array]":
     case "[object FileList]":
     case "[object HTMLAllCollection]":
     case "[object HTMLCollection]":
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -13,16 +13,17 @@ marionette.jar:
   content/accessibility.js (accessibility.js)
   content/listener.js (listener.js)
   content/element.js (element.js)
   content/simpletest.js (simpletest.js)
   content/frame.js (frame.js)
   content/cert.js (cert.js)
   content/event.js  (event.js)
   content/error.js (error.js)
+  content/wait.js (wait.js)
   content/message.js (message.js)
   content/dispatcher.js (dispatcher.js)
   content/modal.js (modal.js)
   content/proxy.js (proxy.js)
   content/capture.js (capture.js)
   content/cookies.js (cookies.js)
   content/atom.js (atom.js)
   content/evaluate.js (evaluate.js)
new file mode 100644
--- /dev/null
+++ b/testing/marionette/test_wait.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const {utils: Cu} = Components;
+
+Cu.import("chrome://marionette/content/wait.js");
+
+add_task(function* test_until_types() {
+  for (let typ of [true, false, "foo", 42, [], {}]) {
+    strictEqual(typ, yield wait.until(resolve => resolve(typ)));
+  }
+});
+
+add_task(function* test_until_timeoutElapse() {
+  let nevals = 0;
+  let start = new Date().getTime();
+  yield wait.until((resolve, reject) => {
+    ++nevals;
+    reject();
+  });
+  let end = new Date().getTime();
+  greaterOrEqual((end - start), 2000);
+  greaterOrEqual(nevals, 150);
+});
+
+add_task(function* test_until_rethrowError() {
+  let nevals = 0;
+  let err;
+  try {
+    yield wait.until(() => {
+      ++nevals;
+      throw new Error();
+    });
+  } catch (e) {
+    err = e;
+  }
+  equal(1, nevals);
+  ok(err instanceof Error);
+});
+
+add_task(function* test_until_noTimeout() {
+  // run at least once when timeout is 0
+  let nevals = 0;
+  let start = new Date().getTime();
+  yield wait.until((resolve, reject) => {
+    ++nevals;
+    reject();
+  }, 0);
+  let end = new Date().getTime();
+  equal(1, nevals);
+  less((end - start), 2000);
+});
+
+add_task(function* test_until_timeout() {
+  let nevals = 0;
+  let start = new Date().getTime();
+  yield wait.until((resolve, reject) => {
+    ++nevals;
+    reject();
+  }, 100);
+  let end = new Date().getTime();
+  greater(nevals, 1);
+  greaterOrEqual((end - start), 100);
+});
+
+add_task(function* test_until_interval() {
+  let nevals = 0;
+  yield wait.until((resolve, reject) => {
+    ++nevals;
+    reject();
+  }, 100, 100);
+  equal(2, nevals);
+});
--- a/testing/marionette/unit.ini
+++ b/testing/marionette/unit.ini
@@ -9,8 +9,9 @@ skip-if = appname == "thunderbird"
 
 [test_action.js]
 [test_assert.js]
 [test_element.js]
 [test_error.js]
 [test_message.js]
 [test_navigate.js]
 [test_session.js]
+[test_wait.js]
new file mode 100644
--- /dev/null
+++ b/testing/marionette/wait.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("chrome://marionette/content/error.js");
+
+this.EXPORTED_SYMBOLS = ["wait"];
+
+/** Implicit wait utilities. */
+this.wait = {};
+
+/**
+ * Runs a promise-like function off the main thread until it is resolved
+ * through |resolve| or |rejected| callbacks.  The function is guaranteed
+ * to be run at least once, irregardless of the timeout.
+ *
+ * The |func| is evaluated every |interval| for as long as its runtime
+ * duration does not exceed |interval|.  Evaluations occur sequentially,
+ * meaning that evaluations of |func| are queued if the runtime evaluation
+ * duration of |func| is greater than |interval|.
+ *
+ * |func| is given two arguments, |resolve| and |reject|, of which one
+ * must be called for the evaluation to complete.  Calling |resolve| with
+ * an argument indicates that the expected wait condition was met and
+ * will return the passed value to the caller.  Conversely, calling
+ * |reject| will evaluate |func| again until the |timeout| duration has
+ * elapsed or |func| throws.  The passed value to |reject| will also be
+ * returned to the caller once the wait has expired.
+ *
+ * Usage:
+ *
+ *     let els = wait.until((resolve, reject) => {
+ *       let res = document.querySelectorAll("p");
+ *       if (res.length > 0) {
+ *         resolve(Array.from(res));
+ *       } else {
+ *         reject([]);
+ *       }
+ *     });
+ *
+ * @param {function(resolve: function(?), reject: 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.  The default
+ *     is 2000 milliseconds.
+ * @param {number=} interval
+ *     Duration between each poll of |func| in milliseconds.  Defaults to
+ *     10 milliseconds.
+ *
+ * @return {Promise: ?}
+ *     Yields the value passed to |func|'s |resolve| or |reject|
+ *     callbacks.
+ *
+ * @throws {?}
+ *     If |func| throws, its error is propagated.
+ */
+wait.until = function (func, timeout = 2000, interval = 10) {
+  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+  return new Promise((resolve, reject) => {
+    const start = new Date().getTime();
+    const end = start + timeout;
+
+    let evalFn = () => {
+      new Promise(func).then(resolve, rejected => {
+        if (error.isError(rejected)) {
+          throw rejected;
+        }
+
+        // return if timeout is 0, allowing |func| to be evaluated at least once
+        if (start == end || new Date().getTime() >= end) {
+          resolve(rejected);
+        }
+      }).catch(reject);
+    };
+
+    // the repeating slack timer waits |interval|
+    // before invoking |evalFn|
+    evalFn();
+
+    timer.init(evalFn, interval, Ci.nsITimer.TYPE_REPEATING_SLACK);
+
+  // cancel timer and propagate result
+  }).then(res => {
+    timer.cancel();
+    return res;
+  }, err => {
+    timer.cancel();
+    throw err;
+  });
+};