Bug 1356231 - Import DevTools event-emitter module to toolkit as a JSM. r=Mossop draft
authorAlexandre Poirot <poirot.alex@gmail.com>
Tue, 25 Apr 2017 18:54:35 +0200
changeset 569520 6cc705a8269e3edaaacb216e96fb28c31dc40860
parent 569501 abe5868346c7abb5b0bdf76f29bc3d9f839461f5
child 569521 16abc34573d666006b4240c982b4f984f12266ac
child 569571 0befad54f984aa2dec2e57c1ce2298c4f34a5922
push id56208
push userbmo:poirot.alex@gmail.com
push dateThu, 27 Apr 2017 15:59:22 +0000
reviewersMossop
bugs1356231
milestone55.0a1
Bug 1356231 - Import DevTools event-emitter module to toolkit as a JSM. r=Mossop MozReview-Commit-ID: 7sgCLkQczet
modules/libpref/init/all.js
toolkit/modules/EventEmitter.jsm
toolkit/modules/moz.build
toolkit/modules/tests/xpcshell/test_EventEmitter.js
toolkit/modules/tests/xpcshell/xpcshell.ini
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -994,16 +994,19 @@ pref("devtools.debugger.prompt-connectio
 pref("devtools.debugger.forbid-certified-apps", true);
 
 // DevTools default color unit
 pref("devtools.defaultColorUnit", "authored");
 
 // Used for devtools debugging
 pref("devtools.dump.emit", false);
 
+// Controls whether EventEmitter module throws dump message on each emit
+pref("toolkit.dump.emit", false);
+
 // Disable device discovery logging
 pref("devtools.discovery.log", false);
 // Whether to scan for DevTools devices via WiFi
 pref("devtools.remote.wifi.scan", true);
 // Whether UI options for controlling device visibility over WiFi are shown
 // N.B.: This does not set whether the device can be discovered via WiFi, only
 // whether the UI control to make such a choice is shown to the user
 pref("devtools.remote.wifi.visible", true);
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/EventEmitter.jsm
@@ -0,0 +1,212 @@
+/* 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 Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+                                  "resource://gre/modules/Console.jsm");
+
+this.EXPORTED_SYMBOLS = ["EventEmitter"];
+
+let EventEmitter = this.EventEmitter = function() {};
+
+let loggingEnabled = Services.prefs.getBoolPref("toolkit.dump.emit");
+Services.prefs.addObserver("toolkit.dump.emit", {
+  observe: () => {
+    loggingEnabled = Services.prefs.getBoolPref("toolkit.dump.emit");
+  }
+});
+
+/**
+ * Decorate an object with event emitter functionality.
+ *
+ * @param Object objectToDecorate
+ *        Bind all public methods of EventEmitter to
+ *        the objectToDecorate object.
+ */
+EventEmitter.decorate = function(objectToDecorate) {
+  let emitter = new EventEmitter();
+  objectToDecorate.on = emitter.on.bind(emitter);
+  objectToDecorate.off = emitter.off.bind(emitter);
+  objectToDecorate.once = emitter.once.bind(emitter);
+  objectToDecorate.emit = emitter.emit.bind(emitter);
+};
+
+function describeNthCaller(n) {
+  let caller = Components.stack;
+  // Do one extra iteration to skip this function.
+  while (n >= 0) {
+    --n;
+    caller = caller.caller;
+  }
+
+  let func = caller.name;
+  let file = caller.filename;
+  if (file.includes(" -> ")) {
+    file = caller.filename.split(/ -> /)[1];
+  }
+  let path = file + ":" + caller.lineNumber;
+
+  return func + "() -> " + path;
+}
+
+EventEmitter.prototype = {
+  /**
+   * Connect a listener.
+   *
+   * @param string event
+   *        The event name to which we're connecting.
+   * @param function listener
+   *        Called when the event is fired.
+   */
+  on(event, listener) {
+    if (!this._eventEmitterListeners) {
+      this._eventEmitterListeners = new Map();
+    }
+    if (!this._eventEmitterListeners.has(event)) {
+      this._eventEmitterListeners.set(event, []);
+    }
+    this._eventEmitterListeners.get(event).push(listener);
+  },
+
+  /**
+   * Listen for the next time an event is fired.
+   *
+   * @param string event
+   *        The event name to which we're connecting.
+   * @param function listener
+   *        (Optional) Called when the event is fired. Will be called at most
+   *        one time.
+   * @return promise
+   *        A promise which is resolved when the event next happens. The
+   *        resolution value of the promise is the first event argument. If
+   *        you need access to second or subsequent event arguments (it's rare
+   *        that this is needed) then use listener
+   */
+  once(event, listener) {
+    return new Promise(resolve => {
+      let handler = (_, first, ...rest) => {
+        this.off(event, handler);
+        if (listener) {
+          listener(event, first, ...rest);
+        }
+        resolve(first);
+      };
+
+      handler._originalListener = listener;
+      this.on(event, handler);
+    });
+  },
+
+  /**
+   * Remove a previously-registered event listener.  Works for events
+   * registered with either on or once.
+   *
+   * @param string event
+   *        The event name whose listener we're disconnecting.
+   * @param function listener
+   *        The listener to remove.
+   */
+  off(event, listener) {
+    if (!this._eventEmitterListeners) {
+      return;
+    }
+    let listeners = this._eventEmitterListeners.get(event);
+    if (listeners) {
+      this._eventEmitterListeners.set(event, listeners.filter(l => {
+        return l !== listener && l._originalListener !== listener;
+      }));
+    }
+  },
+
+  /**
+   * Emit an event.  All arguments to this method will
+   * be sent to listener functions.
+   */
+  emit(event) {
+    this.logEvent(event, arguments);
+
+    if (!this._eventEmitterListeners || !this._eventEmitterListeners.has(event)) {
+      return;
+    }
+
+    let originalListeners = this._eventEmitterListeners.get(event);
+    for (let listener of this._eventEmitterListeners.get(event)) {
+      // If the object was destroyed during event emission, stop
+      // emitting.
+      if (!this._eventEmitterListeners) {
+        break;
+      }
+
+      // If listeners were removed during emission, make sure the
+      // event handler we're going to fire wasn't removed.
+      if (originalListeners === this._eventEmitterListeners.get(event) ||
+        this._eventEmitterListeners.get(event).some(l => l === listener)) {
+        try {
+          listener.apply(null, arguments);
+        } catch (ex) {
+          // Prevent a bad listener from interfering with the others.
+          let msg = ex + ": " + ex.stack;
+          console.error(msg);
+          if (loggingEnabled) {
+            dump(msg + "\n");
+          }
+        }
+      }
+    }
+  },
+
+  logEvent(event, args) {
+    if (!loggingEnabled) {
+      return;
+    }
+
+    let description = describeNthCaller(2);
+
+    let argOut = "(";
+    if (args.length === 1) {
+      argOut += event;
+    }
+
+    let out = "EMITTING: ";
+
+    // We need this try / catch to prevent any dead object errors.
+    try {
+      for (let i = 1; i < args.length; i++) {
+        if (i === 1) {
+          argOut = "(" + event + ", ";
+        } else {
+          argOut += ", ";
+        }
+
+        let arg = args[i];
+        argOut += arg;
+
+        if (arg && arg.nodeName) {
+          argOut += " (" + arg.nodeName;
+          if (arg.id) {
+            argOut += "#" + arg.id;
+          }
+          if (arg.className) {
+            argOut += "." + arg.className;
+          }
+          argOut += ")";
+        }
+      }
+    } catch (e) {
+      // Object is dead so the toolbox is most likely shutting down,
+      // do nothing.
+    }
+
+    argOut += ")";
+    out += "emit" + argOut + " from " + description + "\n";
+
+    dump(out);
+  },
+};
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -188,16 +188,17 @@ EXTRA_JS_MODULES += [
     'CharsetMenu.jsm',
     'ClientID.jsm',
     'Color.jsm',
     'Console.jsm',
     'DateTimePickerHelper.jsm',
     'debug.js',
     'DeferredTask.jsm',
     'Deprecated.jsm',
+    'EventEmitter.jsm',
     'FileUtils.jsm',
     'Finder.jsm',
     'FinderHighlighter.jsm',
     'FinderIterator.jsm',
     'FormLikeFactory.jsm',
     'Geometry.jsm',
     'GMPInstallManager.jsm',
     'GMPUtils.jsm',
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_EventEmitter.js
@@ -0,0 +1,162 @@
+/* Any copyright do_check_eq dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = Components;
+
+Cu.import("resource://gre/modules/EventEmitter.jsm");
+
+add_task(function* test_extractFiles() {
+  testEmitter(new EventEmitter());
+
+  let decorated = {};
+  EventEmitter.decorate(decorated);
+  testEmitter(decorated);
+
+  yield testPromise();
+})
+
+
+function testEmitter(emitter) {
+  do_check_true(emitter, "We have an event emitter");
+
+  let beenHere1 = false;
+  let beenHere2 = false;
+
+  emitter.on("next", next);
+  emitter.emit("next", "abc", "def");
+
+  function next(eventName, str1, str2) {
+    do_check_eq(eventName, "next", "Got event");
+    do_check_eq(str1, "abc", "Argument 1 do_check_eq correct");
+    do_check_eq(str2, "def", "Argument 2 do_check_eq correct");
+
+    do_check_false(beenHere1, "first time in next callback");
+    beenHere1 = true;
+
+    emitter.off("next", next);
+
+    emitter.emit("next");
+
+    emitter.once("onlyonce", onlyOnce);
+
+    emitter.emit("onlyonce");
+    emitter.emit("onlyonce");
+  }
+
+  function onlyOnce() {
+    do_check_true(!beenHere2, "\"once\" listener has been called once");
+    beenHere2 = true;
+    emitter.emit("onlyonce");
+
+    testThrowingExceptionInListener();
+  }
+
+  function testThrowingExceptionInListener() {
+    function throwListener() {
+      emitter.off("throw-exception");
+      throw {
+        toString: () => "foo",
+        stack: "bar",
+      };
+    }
+
+    emitter.on("throw-exception", throwListener);
+    emitter.emit("throw-exception");
+
+    killItWhileEmitting();
+  }
+
+  function killItWhileEmitting() {
+    function c1() {
+      do_check_true(true, "c1 called");
+    }
+    function c2() {
+      do_check_true(true, "c2 called");
+      emitter.off("tick", c3);
+    }
+    function c3() {
+      do_check_true(false, "c3 should not be called");
+    }
+    function c4() {
+      do_check_true(true, "c4 called");
+    }
+
+    emitter.on("tick", c1);
+    emitter.on("tick", c2);
+    emitter.on("tick", c3);
+    emitter.on("tick", c4);
+
+    emitter.emit("tick");
+
+    offAfterOnce();
+  }
+
+  function offAfterOnce() {
+    let enteredC1 = false;
+
+    function c1() {
+      enteredC1 = true;
+    }
+
+    emitter.once("oao", c1);
+    emitter.off("oao", c1);
+
+    emitter.emit("oao");
+
+    do_check_false(enteredC1, "c1 should not be called");
+  }
+}
+
+function* testPromise() {
+  let emitter = new EventEmitter();
+  let p = emitter.once("thing");
+
+  // Check that the promise do_check_eq only resolved once event though we
+  // emit("thing") more than once
+  let firstCallbackCalled = false;
+  let check1 = p.then(arg => {
+    do_check_eq(firstCallbackCalled, false, "first callback called only once");
+    firstCallbackCalled = true;
+    do_check_eq(arg, "happened", "correct arg in promise");
+    return "rval from c1";
+  });
+
+  emitter.emit("thing", "happened", "ignored");
+
+  // Check that the promise do_check_eq resolved asynchronously
+  let secondCallbackCalled = false;
+  let check2 = p.then(arg => {
+    do_check_true(true, "second callback called");
+    do_check_eq(arg, "happened", "correct arg in promise");
+    secondCallbackCalled = true;
+    do_check_eq(arg, "happened", "correct arg in promise (a second time)");
+    return "rval from c2";
+  });
+
+  // Shouldn't call any of the above listeners
+  emitter.emit("thing", "trashinate");
+
+  // Check that we can still separate events with different names
+  // and that it works with no parameters
+  let pfoo = emitter.once("foo");
+  let pbar = emitter.once("bar");
+
+  let check3 = pfoo.then(arg => {
+    do_check_eq(arg, undefined, "no arg for foo event");
+    return "rval from c3";
+  });
+
+  pbar.then(() => {
+    do_check_true(false, "pbar should not be called");
+  });
+
+  emitter.emit("foo");
+
+  do_check_eq(secondCallbackCalled, false, "second callback not called yet");
+
+  return Promise.all([ check1, check2, check3 ]).then(args => {
+    do_check_eq(args[0], "rval from c1", "callback 1 done good");
+    do_check_eq(args[1], "rval from c2", "callback 2 done good");
+    do_check_eq(args[2], "rval from c3", "callback 3 done good");
+  });
+}
--- a/toolkit/modules/tests/xpcshell/xpcshell.ini
+++ b/toolkit/modules/tests/xpcshell/xpcshell.ini
@@ -69,8 +69,9 @@ skip-if = !updater
 reason = LOCALE is not defined without MOZ_UPDATER
 [test_UpdateUtils_updatechannel.js]
 [test_web_channel.js]
 [test_web_channel_broker.js]
 [test_ZipUtils.js]
 skip-if = toolkit == 'android'
 [test_Log_stackTrace.js]
 [test_servicerequest_xhr.js]
+[test_EventEmitter.js]