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
--- 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;
+ });
+};