summaryrefslogtreecommitdiffstats
path: root/testing/marionette/sync.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--testing/marionette/sync.js650
1 files changed, 650 insertions, 0 deletions
diff --git a/testing/marionette/sync.js b/testing/marionette/sync.js
new file mode 100644
index 0000000000..4b135809c2
--- /dev/null
+++ b/testing/marionette/sync.js
@@ -0,0 +1,650 @@
+/* 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 EXPORTED_SYMBOLS = [
+ "executeSoon",
+ "DebounceCallback",
+ "IdlePromise",
+ "MessageManagerDestroyedPromise",
+ "PollPromise",
+ "Sleep",
+ "TimedPromise",
+ "waitForEvent",
+ "waitForLoadEvent",
+ "waitForMessage",
+ "waitForObserverTopic",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+
+ error: "chrome://marionette/content/error.js",
+ EventDispatcher:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ Log: "chrome://marionette/content/log.js",
+ truncate: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
+
+const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500;
+
+/**
+ * Dispatch a function to be executed on the main thread.
+ *
+ * @param {function} func
+ * Function to be executed.
+ */
+function executeSoon(func) {
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+
+ Services.tm.dispatchToMainThread(func);
+}
+
+/**
+ * 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 = new PollPromise((resolve, reject) => {
+ * let res = document.querySelectorAll("p");
+ * if (res.length > 0) {
+ * resolve(Array.from(res));
+ * } else {
+ * reject([]);
+ * }
+ * }, {timeout: 1000});
+ *
+ * @param {Condition} func
+ * Function to run off the main thread.
+ * @param {number=} [timeout] timeout
+ * Desired timeout if wanted. If 0 or less than the runtime evaluation
+ * time of ``func``, ``func`` is guaranteed to run at least once.
+ * Defaults to using no timeout.
+ * @param {number=} [interval=10] 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.
+ * @throws {TypeError}
+ * If `timeout` or `interval`` are not numbers.
+ * @throws {RangeError}
+ * If `timeout` or `interval` are not unsigned integers.
+ */
+function PollPromise(func, { timeout = null, interval = 10 } = {}) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+ if (timeout != null && typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (typeof interval != "number") {
+ throw new TypeError();
+ }
+ if (
+ (timeout && (!Number.isInteger(timeout) || timeout < 0)) ||
+ !Number.isInteger(interval) ||
+ interval < 0
+ ) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let start, end;
+
+ if (Number.isInteger(timeout)) {
+ start = new Date().getTime();
+ end = start + timeout;
+ }
+
+ let evalFn = () => {
+ new Promise(func)
+ .then(resolve, rejected => {
+ if (error.isError(rejected)) {
+ throw rejected;
+ }
+
+ // return if there is a timeout and set to 0,
+ // allowing |func| to be evaluated at least once
+ if (
+ typeof end != "undefined" &&
+ (start == end || new Date().getTime() >= end)
+ ) {
+ resolve(rejected);
+ }
+ })
+ .catch(reject);
+ };
+
+ // the repeating slack timer waits |interval|
+ // before invoking |evalFn|
+ evalFn();
+
+ timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Represents the timed, eventual completion (or failure) of an
+ * asynchronous operation, and its resulting value.
+ *
+ * In contrast to a regular Promise, it times out after ``timeout``.
+ *
+ * @param {Condition} func
+ * Function to run, which will have its ``reject``
+ * callback invoked after the ``timeout`` duration is reached.
+ * It is given two callbacks: ``resolve(value)`` and
+ * ``reject(error)``.
+ * @param {timeout=} timeout
+ * ``condition``'s ``reject`` callback will be called
+ * after this timeout, given in milliseconds.
+ * By default 1500 ms in an optimised build and 4500 ms in
+ * debug builds.
+ * @param {Error=} [throws=TimeoutError] throws
+ * When the ``timeout`` is hit, this error class will be
+ * thrown. If it is null, no error is thrown and the promise is
+ * instead resolved on timeout.
+ *
+ * @return {Promise.<*>}
+ * Timed promise.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+function TimedPromise(
+ fn,
+ { timeout = PROMISE_TIMEOUT, throws = error.TimeoutError } = {}
+) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof fn != "function") {
+ throw new TypeError();
+ }
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let trace;
+
+ // Reject only if |throws| is given. Otherwise it is assumed that
+ // the user is OK with the promise timing out.
+ let bail = () => {
+ if (throws !== null) {
+ let err = new throws();
+ reject(err);
+ } else {
+ logger.warn(`TimedPromise timed out after ${timeout} ms`, trace);
+ resolve();
+ }
+ };
+
+ trace = error.stack();
+ timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT);
+
+ try {
+ fn(resolve, reject);
+ } catch (e) {
+ reject(e);
+ }
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Pauses for the given duration.
+ *
+ * @param {number} timeout
+ * Duration to wait before fulfilling promise in milliseconds.
+ *
+ * @return {Promise}
+ * Promise that fulfills when the `timeout` is elapsed.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+function Sleep(timeout) {
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise(resolve => {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang
+ timer.cancel();
+ resolve();
+ },
+ timeout,
+ TYPE_ONE_SHOT
+ );
+ });
+}
+
+/**
+ * Detects when the specified message manager has been destroyed.
+ *
+ * One can observe the removal and detachment of a content browser
+ * (`<xul:browser>`) or a chrome window by its message manager
+ * disconnecting.
+ *
+ * When a browser is associated with a tab, this is safer than only
+ * relying on the event `TabClose` which signalises the _intent to_
+ * remove a tab and consequently would lead to the destruction of
+ * the content browser and its browser message manager.
+ *
+ * When closing a chrome window it is safer than only relying on
+ * the event 'unload' which signalises the _intent to_ close the
+ * chrome window and consequently would lead to the destruction of
+ * the window and its window message manager.
+ *
+ * @param {MessageListenerManager} messageManager
+ * The message manager to observe for its disconnect state.
+ * Use the browser message manager when closing a content browser,
+ * and the window message manager when closing a chrome window.
+ *
+ * @return {Promise}
+ * A promise that resolves when the message manager has been destroyed.
+ */
+function MessageManagerDestroyedPromise(messageManager) {
+ return new Promise(resolve => {
+ function observe(subject, topic) {
+ logger.trace(`Received observer notification ${topic}`);
+
+ if (subject == messageManager) {
+ Services.obs.removeObserver(this, "message-manager-disconnect");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observe, "message-manager-disconnect");
+ });
+}
+
+/**
+ * Throttle until the main thread is idle and `window` has performed
+ * an animation frame (in that order).
+ *
+ * @param {ChromeWindow} win
+ * Window to request the animation frame from.
+ *
+ * @return Promise
+ */
+function IdlePromise(win) {
+ const animationFramePromise = new Promise(resolve => {
+ executeSoon(() => {
+ win.requestAnimationFrame(resolve);
+ });
+ });
+
+ // Abort if the underlying window gets closed
+ const windowClosedPromise = new PollPromise(resolve => {
+ if (win.closed) {
+ resolve();
+ }
+ });
+
+ return Promise.race([animationFramePromise, windowClosedPromise]);
+}
+
+/**
+ * Wraps a callback function, that, as long as it continues to be
+ * invoked, will not be triggered. The given function will be
+ * called after the timeout duration is reached, after no more
+ * events fire.
+ *
+ * This class implements the {@link EventListener} interface,
+ * which means it can be used interchangably with `addEventHandler`.
+ *
+ * Debouncing events can be useful when dealing with e.g. DOM events
+ * that fire at a high rate. It is generally advisable to avoid
+ * computationally expensive operations such as DOM modifications
+ * under these circumstances.
+ *
+ * One such high frequenecy event is `resize` that can fire multiple
+ * times before the window reaches its final dimensions. In order
+ * to delay an operation until the window has completed resizing,
+ * it is possible to use this technique to only invoke the callback
+ * after the last event has fired::
+ *
+ * let cb = new DebounceCallback(event => {
+ * // fires after the final resize event
+ * console.log("resize", event);
+ * });
+ * window.addEventListener("resize", cb);
+ *
+ * Note that it is not possible to use this synchronisation primitive
+ * with `addEventListener(..., {once: true})`.
+ *
+ * @param {function(Event)} fn
+ * Callback function that is guaranteed to be invoked once only,
+ * after `timeout`.
+ * @param {number=} [timeout = 250] timeout
+ * Time since last event firing, before `fn` will be invoked.
+ */
+class DebounceCallback {
+ constructor(fn, { timeout = 250 } = {}) {
+ if (typeof fn != "function" || typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ this.fn = fn;
+ this.timeout = timeout;
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+
+ handleEvent(ev) {
+ this.timer.cancel();
+ this.timer.initWithCallback(
+ () => {
+ this.timer.cancel();
+ this.fn(ev);
+ },
+ this.timeout,
+ TYPE_ONE_SHOT
+ );
+ }
+}
+this.DebounceCallback = DebounceCallback;
+
+/**
+ * Wait for an event to be fired on a specified element.
+ *
+ * This method has been duplicated from BrowserTestUtils.jsm.
+ *
+ * Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next event, since this is probably a bug in the test.
+ *
+ * Usage::
+ *
+ * let promiseEvent = waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now wait until the Promise is fulfilled
+ * let receivedEvent = await promiseEvent;
+ *
+ * The promise resolution/rejection handler for the returned promise is
+ * guaranteed not to be called until the next event tick after the event
+ * listener gets called, so that all other event listeners for the element
+ * are executed before the handler is executed::
+ *
+ * let promiseEvent = waitForEvent(element, "eventName");
+ * // Same event tick here.
+ * await promiseEvent;
+ * // Next event tick here.
+ *
+ * If some code, such like adding yet another event listener, needs to be
+ * executed in the same event tick, use raw addEventListener instead and
+ * place the code inside the event listener::
+ *
+ * element.addEventListener("load", () => {
+ * // Add yet another event listener in the same event tick as the load
+ * // event listener.
+ * p = waitForEvent(element, "ready");
+ * }, { once: true });
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {Object=} options
+ * Extra options.
+ * @param {boolean=} options.capture
+ * True to use a capturing listener.
+ * @param {function(Event)=} options.checkFn
+ * Called with the ``Event`` object as argument, should return ``true``
+ * if the event is the expected one, or ``false`` if it should be
+ * ignored and listening should continue. If not specified, the first
+ * event with the specified name resolves the returned promise.
+ * @param {boolean=} options.wantsUntrusted
+ * True to receive synthetic events dispatched by web content.
+ *
+ * @return {Promise.<Event>}
+ * Promise which resolves to the received ``Event`` object, or rejects
+ * in case of a failure.
+ */
+function waitForEvent(
+ subject,
+ eventName,
+ { capture = false, checkFn = null, wantsUntrusted = false } = {}
+) {
+ if (subject == null || !("addEventListener" in subject)) {
+ throw new TypeError();
+ }
+ if (typeof eventName != "string") {
+ throw new TypeError();
+ }
+ if (capture != null && typeof capture != "boolean") {
+ throw new TypeError();
+ }
+ if (checkFn != null && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+ if (wantsUntrusted != null && typeof wantsUntrusted != "boolean") {
+ throw new TypeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ subject.addEventListener(
+ eventName,
+ function listener(event) {
+ logger.trace(`Received DOM event ${event.type} for ${event.target}`);
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ executeSoon(() => resolve(event));
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ executeSoon(() => reject(ex));
+ }
+ },
+ capture,
+ wantsUntrusted
+ );
+ });
+}
+
+/**
+ * Wait for a load event to be fired on a specific browsing context.
+ * The supported events are:
+ * - beforeunload
+ * - DOMContentLoaded
+ * - hashchange
+ * - pagehide
+ * - pageshow
+ * - popstate
+ *
+ * @param {string} eventName
+ * The specific load event name to wait for.
+ * @param {function(): BrowsingContext} browsingContextFn
+ * A function that returns the reference to the browsing context for which
+ * the load event should be fired.
+ *
+ * @return {Promise.<Object>}
+ * Promise which resolves when the load event has been fired
+ */
+function waitForLoadEvent(eventName, browsingContextFn) {
+ let onPageLoad;
+ return new Promise(resolve => {
+ onPageLoad = (_, data) => {
+ logger.trace(`Received event ${data.type} for ${data.documentURI}`);
+ if (
+ data.browsingContext === browsingContextFn() &&
+ data.type === eventName
+ ) {
+ EventDispatcher.off("page-load", onPageLoad);
+ resolve(data);
+ }
+ };
+ EventDispatcher.on("page-load", onPageLoad);
+ });
+}
+
+/**
+ * Wait for a message to be fired from a particular message manager.
+ *
+ * This method has been duplicated from BrowserTestUtils.jsm.
+ *
+ * @param {nsIMessageManager} messageManager
+ * The message manager that should be used.
+ * @param {string} messageName
+ * The message to wait for.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(Message)=} options.checkFn
+ * Called with the ``Message`` object as argument, should return ``true``
+ * if the message is the expected one, or ``false`` if it should be
+ * ignored and listening should continue. If not specified, the first
+ * message with the specified name resolves the returned promise.
+ *
+ * @return {Promise.<Object>}
+ * Promise which resolves to the data property of the received
+ * ``Message``.
+ */
+function waitForMessage(
+ messageManager,
+ messageName,
+ { checkFn = undefined } = {}
+) {
+ if (messageManager == null || !("addMessageListener" in messageManager)) {
+ throw new TypeError();
+ }
+ if (typeof messageName != "string") {
+ throw new TypeError();
+ }
+ if (checkFn && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise(resolve => {
+ messageManager.addMessageListener(messageName, function onMessage(msg) {
+ logger.trace(`Received ${messageName} for ${msg.target}`);
+ if (checkFn && !checkFn(msg)) {
+ return;
+ }
+ messageManager.removeMessageListener(messageName, onMessage);
+ resolve(msg.data);
+ });
+ });
+}
+
+/**
+ * Wait for the specified observer topic to be observed.
+ *
+ * This method has been duplicated from TestUtils.jsm.
+ *
+ * Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @param {string} topic
+ * The topic to observe.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(String,Object)=} options.checkFn
+ * Called with ``subject``, and ``data`` as arguments, should return true
+ * if the notification is the expected one, or false if it should be
+ * ignored and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @return {Promise.<Array<String, Object>>}
+ * Promise which resolves to an array of ``subject``, and ``data`` from
+ * the observed notification.
+ */
+function waitForObserverTopic(topic, { checkFn = null } = {}) {
+ if (typeof topic != "string") {
+ throw new TypeError();
+ }
+ if (checkFn != null && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observer(subject, topic, data) {
+ logger.trace(`Received observer notification ${topic}`);
+ try {
+ if (checkFn && !checkFn(subject, data)) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve({ subject, data });
+ } catch (ex) {
+ Services.obs.removeObserver(observer, topic);
+ reject(ex);
+ }
+ }, topic);
+ });
+}