Bug 1319237 - Generalise wait condition utility. r=automatedtester, r=maja_zf, a=test-only
authorAndreas Tolfsen <ato@mozilla.com>
Mon, 21 Nov 2016 23:41:20 +0100
changeset 376219 74a25dd2c130358de26dac554c1907c9a59d917d
parent 376218 56a0e1f88578639a15e008ed4564408f8319bbd2
child 376220 50aa351e8ffd0d70639e3da3d04eb5c64feedf41
push id6996
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 20:48:21 +0000
treeherdermozilla-beta@d89512dab048 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersautomatedtester, maja_zf, test-only
bugs1319237
milestone53.0a2
Bug 1319237 - Generalise wait condition utility. r=automatedtester, r=maja_zf, a=test-only 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;
+  });
+};