Bug 1504756 - [marionette] Use waitForEvent() when waiting for events. r=ato
☠☠ backed out by 4ff41b70cbd8 ☠ ☠
authorHenrik Skupin <mail@hskupin.info>
Wed, 09 Jan 2019 18:22:19 +0000
changeset 510232 21658a2d0174439bfe8f72337d03bc4265e0105e
parent 510231 93ff3f0d95bdfddd415271537c149400e6bc7744
child 510233 58ab81d373b9ac3c504cc7af753204e6cf980a8a
push id10547
push userffxbld-merge
push dateMon, 21 Jan 2019 13:03:58 +0000
treeherdermozilla-beta@24ec1916bffe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersato
bugs1504756
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1504756 - [marionette] Use waitForEvent() when waiting for events. r=ato Depends on D13659 Differential Revision: https://phabricator.services.mozilla.com/D13660
testing/marionette/browser.js
testing/marionette/doc/internals/sync.rst
testing/marionette/driver.js
testing/marionette/sync.js
testing/marionette/test/unit/test_sync.js
testing/web-platform/meta/webdriver/tests/execute_script/promise.py.ini
--- a/testing/marionette/browser.js
+++ b/testing/marionette/browser.js
@@ -8,16 +8,17 @@
 const {WebElementEventTarget} = ChromeUtils.import("chrome://marionette/content/dom.js", {});
 ChromeUtils.import("chrome://marionette/content/element.js");
 const {
   NoSuchWindowError,
   UnsupportedOperationError,
 } = ChromeUtils.import("chrome://marionette/content/error.js", {});
 const {
   MessageManagerDestroyedPromise,
+  waitForEvent,
 } = ChromeUtils.import("chrome://marionette/content/sync.js", {});
 
 this.EXPORTED_SYMBOLS = ["browser", "Context", "WindowState"];
 
 /** @namespace */
 this.browser = {};
 
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
@@ -282,27 +283,23 @@ browser.Context = class {
 
   /**
    * Close the current window.
    *
    * @return {Promise}
    *     A promise which is resolved when the current window has been closed.
    */
   closeWindow() {
-    return new Promise(resolve => {
-      // Wait for the window message manager to be destroyed
-      let destroyed = new MessageManagerDestroyedPromise(
-          this.window.messageManager);
+    let destroyed = new MessageManagerDestroyedPromise(
+        this.window.messageManager);
+    let unloaded = waitForEvent(this.window, "unload");
 
-      this.window.addEventListener("unload", async () => {
-        await destroyed;
-        resolve();
-      }, {once: true});
-      this.window.close();
-    });
+    this.window.close();
+
+    return Promise.all([destroyed, unloaded]);
   }
 
   /**
    * Close the current tab.
    *
    * @return {Promise}
    *     A promise which is resolved when the current tab has been closed.
    *
@@ -314,40 +311,35 @@ browser.Context = class {
     // same if only one remaining tab is open, or no tab selected at all.
     if (!this.tabBrowser ||
         !this.tabBrowser.tabs ||
         this.tabBrowser.tabs.length === 1 ||
         !this.tab) {
       return this.closeWindow();
     }
 
-    return new Promise((resolve, reject) => {
-      // Wait for the browser message manager to be destroyed
-      let browserDetached = async () => {
-        await new MessageManagerDestroyedPromise(this.messageManager);
-        resolve();
-      };
+    let destroyed = new MessageManagerDestroyedPromise(this.messageManager);
+    let tabClosed;
+
+    if (this.tabBrowser.closeTab) {
+      // Fennec
+      tabClosed = waitForEvent(this.tabBrowser.deck, "TabClose");
+      this.tabBrowser.closeTab(this.tab);
 
-      if (this.tabBrowser.closeTab) {
-        // Fennec
-        this.tabBrowser.deck.addEventListener(
-            "TabClose", browserDetached, {once: true});
-        this.tabBrowser.closeTab(this.tab);
+    } else if (this.tabBrowser.removeTab) {
+      // Firefox
+      tabClosed = waitForEvent(this.tab, "TabClose");
+      this.tabBrowser.removeTab(this.tab);
 
-      } else if (this.tabBrowser.removeTab) {
-        // Firefox
-        this.tab.addEventListener(
-            "TabClose", browserDetached, {once: true});
-        this.tabBrowser.removeTab(this.tab);
+    } else {
+      throw new UnsupportedOperationError(
+        `closeTab() not supported in ${this.driver.appName}`);
+    }
 
-      } else {
-        reject(new UnsupportedOperationError(
-            `closeTab() not supported in ${this.driver.appName}`));
-      }
-    });
+    return Promise.all([destroyed, tabClosed]);
   }
 
   /**
    * Opens a tab with given URI.
    *
    * @param {string} uri
    *      URI to open.
    */
--- a/testing/marionette/doc/internals/sync.rst
+++ b/testing/marionette/doc/internals/sync.rst
@@ -11,8 +11,10 @@ Provides an assortment of synchronisatio
 .. js:autoclass:: PollPromise
   :members:
 
 .. js:autoclass:: Sleep
   :members:
 
 .. js:autoclass:: TimedPromise
   :members:
+
+.. js:autofunction:: waitForEvent
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -57,16 +57,17 @@ ChromeUtils.import("chrome://marionette/
 const {MarionettePrefs} = ChromeUtils.import("chrome://marionette/content/prefs.js", {});
 ChromeUtils.import("chrome://marionette/content/proxy.js");
 ChromeUtils.import("chrome://marionette/content/reftest.js");
 const {
   DebounceCallback,
   IdlePromise,
   PollPromise,
   TimedPromise,
+  waitForEvent,
 } = ChromeUtils.import("chrome://marionette/content/sync.js", {});
 
 XPCOMUtils.defineLazyGetter(this, "logger", Log.get);
 XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
 
 this.EXPORTED_SYMBOLS = ["GeckoDriver"];
 
 const APP_ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
@@ -3081,46 +3082,42 @@ GeckoDriver.prototype.fullscreenWindow =
 /**
  * Dismisses a currently displayed tab modal, or returns no such alert if
  * no modal is displayed.
  */
 GeckoDriver.prototype.dismissDialog = async function() {
   let win = assert.open(this.getCurrentWindow());
   this._checkIfAlertIsPresent();
 
-  await new Promise(resolve => {
-    win.addEventListener("DOMModalDialogClosed", async () => {
-      await new IdlePromise(win);
-      this.dialog = null;
-      resolve();
-    }, {once: true});
-
-    let {button0, button1} = this.dialog.ui;
-    (button1 ? button1 : button0).click();
-  });
+  let dialogClosed = waitForEvent(win, "DOMModalDialogClosed");
+
+  let {button0, button1} = this.dialog.ui;
+  (button1 ? button1 : button0).click();
+
+  await dialogClosed;
+
+  this.dialog = null;
 };
 
 /**
  * Accepts a currently displayed tab modal, or returns no such alert if
  * no modal is displayed.
  */
 GeckoDriver.prototype.acceptDialog = async function() {
   let win = assert.open(this.getCurrentWindow());
   this._checkIfAlertIsPresent();
 
-  await new Promise(resolve => {
-    win.addEventListener("DOMModalDialogClosed", async () => {
-      await new IdlePromise(win);
-      this.dialog = null;
-      resolve();
-    }, {once: true});
-
-    let {button0} = this.dialog.ui;
-    button0.click();
-  });
+  let dialogClosed = waitForEvent(win, "DOMModalDialogClosed");
+
+  let {button0} = this.dialog.ui;
+  button0.click();
+
+  await dialogClosed;
+
+  this.dialog = null;
 };
 
 /**
  * Returns the message shown in a currently displayed modal, or returns
  * a no such alert error if no modal is currently displayed.
  */
 GeckoDriver.prototype.getTextFromDialog = function() {
   assert.open(this.getCurrentWindow());
--- a/testing/marionette/sync.js
+++ b/testing/marionette/sync.js
@@ -20,16 +20,17 @@ XPCOMUtils.defineLazyGetter(this, "log",
 this.EXPORTED_SYMBOLS = [
   "executeSoon",
   "DebounceCallback",
   "IdlePromise",
   "MessageManagerDestroyedPromise",
   "PollPromise",
   "Sleep",
   "TimedPromise",
+  "waitForEvent",
 ];
 
 const {TYPE_ONE_SHOT, TYPE_REPEATING_SLACK} = Ci.nsITimer;
 
 const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500;
 
 
 /**
@@ -362,8 +363,104 @@ class DebounceCallback {
     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) {
+      log.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);
+  });
+}
--- a/testing/marionette/test/unit/test_sync.js
+++ b/testing/marionette/test/unit/test_sync.js
@@ -3,21 +3,64 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const {
   DebounceCallback,
   IdlePromise,
   PollPromise,
   Sleep,
   TimedPromise,
+  waitForEvent,
 } = ChromeUtils.import("chrome://marionette/content/sync.js", {});
 
 const DEFAULT_TIMEOUT = 2000;
 
 /**
+ * Mimic a DOM node for listening for events.
+ */
+class MockElement {
+  constructor() {
+    this.capture = false;
+    this.func = null;
+    this.eventName = null;
+    this.untrusted = false;
+  }
+
+  addEventListener(name, func, capture, untrusted) {
+    this.eventName = name;
+    this.func = func;
+    if (capture != null) {
+      this.capture = capture;
+    }
+    if (untrusted != null) {
+      this.untrusted = untrusted;
+    }
+  }
+
+  click() {
+    if (this.func) {
+      let details = {
+        capture: this.capture,
+        target: this,
+        type: this.eventName,
+        untrusted: this.untrusted,
+      };
+      this.func(details);
+    }
+  }
+
+  removeEventListener(name, func) {
+    this.capture = false;
+    this.func = null;
+    this.eventName = null;
+    this.untrusted = false;
+  }
+}
+
+/**
  * Mimics nsITimer, but instead of using a system clock you can
  * preprogram it to invoke the callback after a given number of ticks.
  */
 class MockTimer {
   constructor(ticksBeforeFiring) {
     this.goal = ticksBeforeFiring;
     this.ticks = 0;
     this.cancelled = false;
@@ -225,8 +268,88 @@ add_task(async function test_DebounceCal
   // we only expect the last one to fire
   debouncer.handleEvent(uniqueEvent);
   debouncer.handleEvent(uniqueEvent);
   debouncer.handleEvent(uniqueEvent);
 
   equal(ncalls, 1);
   ok(debouncer.timer.cancelled);
 });
+
+add_task(async function test_waitForEvent_subjectAndEventNameTypes() {
+  let element = new MockElement();
+
+  for (let subject of ["foo", 42, null, undefined, true, [], {}]) {
+    Assert.throws(() => waitForEvent(subject, "click"), /TypeError/);
+  }
+
+  for (let eventName of [42, null, undefined, true, [], {}]) {
+    Assert.throws(() => waitForEvent(element, eventName), /TypeError/);
+  }
+
+  let clicked = waitForEvent(element, "click");
+  element.click();
+  let event = await clicked;
+  equal(element, event.target);
+});
+
+add_task(async function test_waitForEvent_captureTypes() {
+  let element = new MockElement();
+
+  for (let capture of ["foo", 42, [], {}]) {
+    Assert.throws(() => waitForEvent(
+        element, "click", {capture}), /TypeError/);
+  }
+
+  for (let capture of [null, undefined, false, true]) {
+    let expected_capture = (capture == null) ? false : capture;
+
+    element = new MockElement();
+    let clicked = waitForEvent(element, "click", {capture});
+    element.click();
+    let event = await clicked;
+    equal(element, event.target);
+    equal(expected_capture, event.capture);
+  }
+});
+
+add_task(async function test_waitForEvent_checkFnTypes() {
+  let element = new MockElement();
+
+  for (let checkFn of ["foo", 42, true, [], {}]) {
+    Assert.throws(() => waitForEvent(
+        element, "click", {checkFn}), /TypeError/);
+  }
+
+  let count;
+  for (let checkFn of [null, undefined, event => count++ > 0]) {
+    let expected_count = (checkFn == null) ? 0 : 2;
+    count = 0;
+
+    element = new MockElement();
+    let clicked = waitForEvent(element, "click", {checkFn});
+    element.click();
+    element.click();
+    let event = await clicked;
+    equal(element, event.target);
+    equal(expected_count, count);
+  }
+});
+
+add_task(async function test_waitForEvent_wantsUntrustedTypes() {
+  let element = new MockElement();
+
+  for (let wantsUntrusted of ["foo", 42, [], {}]) {
+    Assert.throws(() => waitForEvent(
+        element, "click", {wantsUntrusted}), /TypeError/);
+  }
+
+  for (let wantsUntrusted of [null, undefined, false, true]) {
+    let expected_untrusted = (wantsUntrusted == null) ? false : wantsUntrusted;
+
+    element = new MockElement();
+    let clicked = waitForEvent(element, "click", {wantsUntrusted});
+    element.click();
+    let event = await clicked;
+    equal(element, event.target);
+    equal(expected_untrusted, event.untrusted);
+  }
+});
--- a/testing/web-platform/meta/webdriver/tests/execute_script/promise.py.ini
+++ b/testing/web-platform/meta/webdriver/tests/execute_script/promise.py.ini
@@ -1,10 +1,9 @@
 [promise.py]
-  expected: TIMEOUT
   [test_promise_timeout]
     expected: FAIL
 
   [test_promise_reject_timeout]
     expected: FAIL
 
   [test_promise_resolve_timeout]
     expected: FAIL