Bug 1381542 - refactored EventEmitter with old SDK event/core capabilities; r=ochameau draft
authorZER0 <zer0.kaos@gmail.com>
Mon, 17 Jul 2017 19:45:26 +0200
changeset 615438 2d0c6246f73aafda69dc903ff265296cc717b8b5
parent 613116 e99e3e62cfa8bbc7d430f64d4fbdf77ffb81e979
child 615439 b93a581d52c004da0660e01ecf0be4ca84e1acc4
push id70356
push userbmo:zer0@mozilla.com
push dateTue, 25 Jul 2017 23:53:23 +0000
reviewersochameau
bugs1381542
milestone56.0a1
Bug 1381542 - refactored EventEmitter with old SDK event/core capabilities; r=ochameau `EventEmitter` now can be used in the same way `sdk/event/core`, it also integrates some of the SDK capabilities. In addition the `emit` method is now unified in functionality, and is more consistent with the nodejs counterpart (the listener doesn't receive the event type anymore as first argument).
devtools/shared/event-emitter.js
devtools/shared/tests/mochitest/chrome.ini
devtools/shared/tests/mochitest/test_eventemitter_basic.html
devtools/shared/tests/unit/test_eventemitter_basic.js
devtools/shared/tests/unit/test_eventemitter_static.js
devtools/shared/tests/unit/xpcshell.ini
--- a/devtools/shared/event-emitter.js
+++ b/devtools/shared/event-emitter.js
@@ -36,18 +36,16 @@
     this.isWorker = false;
     const Cu = Components.utils;
     let console = Cu.import("resource://gre/modules/Console.jsm", {}).console;
     // Bug 1259045: This module is loaded early in firefox startup as a JSM,
     // but it doesn't depends on any real module. We can save a few cycles
     // and bytes by not loading Loader.jsm.
     let require = function (module) {
       switch (module) {
-        case "devtools/shared/defer":
-          return Cu.import("resource://gre/modules/Promise.jsm", {}).Promise.defer;
         case "Services":
           return Cu.import("resource://gre/modules/Services.jsm", {}).Services;
         case "devtools/shared/platform/stack": {
           let obj = {};
           Cu.import("resource://devtools/shared/platform/chrome/stack.js", obj);
           return obj;
         }
       }
@@ -55,199 +53,262 @@
     };
     factory.call(this, require, this, { exports: this }, console);
     this.EXPORTED_SYMBOLS = ["EventEmitter"];
   }
 }).call(this, function (require, exports, module, console) {
   // ⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠⚠
   // After this point the code may not use Cu.import, and should only
   // require() modules that are "clean-for-content".
-  let EventEmitter = this.EventEmitter = function () {};
-  module.exports = EventEmitter;
+
+  const BAD_LISTENER = "The event listener must be a function.";
+
+  const eventListeners = Symbol("EventEmitter/listeners");
+  const originalListener = Symbol("EventEmitter/original-listener");
+
+  class EventEmitter {
+    constructor() {
+      this[eventListeners] = new Map();
+    }
+
+    /**
+     * Registers an event `listener` that is called every time events of
+     * specified `type` is emitted on the given event `target`.
+     *
+     * @param {Object} target
+     *    Event target object.
+     * @param {String} type
+     *    The type of event.
+     * @param {Function} listener
+     *    The listener function that processes the event.
+     */
+    static on(target, type, listener) {
+      if (typeof listener !== "function") {
+        throw new Error(BAD_LISTENER);
+      }
+
+      if (!(eventListeners in target)) {
+        target[eventListeners] = new Map();
+      }
+
+      let events = target[eventListeners];
+
+      if (events.has(type)) {
+        events.get(type).add(listener);
+      } else {
+        events.set(type, new Set([listener]));
+      }
+    }
+
+    /**
+     * Removes an event `listener` for the given event `type` on the given event
+     * `target`. If no `listener` is passed removes all listeners of the given
+     * `type`. If `type` is not passed removes all the listeners of the given
+     * event `target`.
+     * @param {Object} target
+     *    The event target object.
+     * @param {String} type
+     *    The type of event.
+     * @param {Function} listener
+     *    The listener function that processes the event.
+     */
+    static off(target, type, listener) {
+      let length = arguments.length;
+      let events = target[eventListeners];
+
+      if (!events) {
+        return;
+      }
+
+      if (length === 3) {
+        let listenersForType = events.get(type);
+
+        if (listenersForType) {
+          if (listenersForType.has(listener)) {
+            listenersForType.delete(listener);
+          } else {
+            for (let value of listenersForType.values()) {
+              if (originalListener in value && value[originalListener] === listener) {
+                listenersForType.delete(value);
+                return;
+              }
+            }
+          }
+        }
+      } else if (length === 2) {
+        if (events.has(type)) {
+          events.get(type).clear();
+          events.delete(type);
+        }
+      } else if (length === 1) {
+        for (let listeners of events.values()) {
+          listeners.clear();
+          events.delete(type);
+        }
+      }
+    }
+
+    /**
+     * Registers an event `listener` that is called only the next time an event
+     * of the specified `type` is emitted on the given event `target`.
+     * @param {Object} target
+     *    Event target object.
+     * @param {String} type
+     *    The type of the event.
+     * @param {Function} listener
+     *    The listener function that processes the event.
+     */
+    static once(target, type, listener) {
+      return new Promise(resolve => {
+        let handler = (first, ...rest) => {
+          EventEmitter.off(target, type, handler);
+          if (listener) {
+            listener(first, ...rest);
+          }
+          resolve(first);
+        };
+
+        handler[originalListener] = listener;
+        EventEmitter.on(target, type, handler);
+      });
+    }
+
+    static emit(target, type, ...rest) {
+      logEvent(type, rest);
+
+      if (!(eventListeners in target) || !target[eventListeners].has(type)) {
+        return;
+      }
+
+      // Creating a temporary Set with the original listeners, to avoiding side effects
+      // in emit.
+      let listenersForType = new Set(target[eventListeners].get(type));
+
+      for (let listener of listenersForType) {
+        // If the object was destroyed during event emission, stop
+        // emitting.
+        if (!(eventListeners in target)) {
+          break;
+        }
+
+        // If listeners were removed during emission, make sure the
+        // event handler we're going to fire wasn't removed.
+        if (target[eventListeners].get(type) &&
+          target[eventListeners].get(type).has(listener)) {
+          try {
+            listener.call(target, ...rest);
+          } catch (ex) {
+            // Prevent a bad listener from interfering with the others.
+            let msg = ex + ": " + ex.stack;
+            console.error(msg);
+            dump(msg + "\n");
+          }
+        }
+      }
+    }
+
+    /**
+     * Returns a number of event listeners registered for the given event `type`
+     * on the given event `target`.
+     *
+     * @param {Object} target
+     *    Event target object.
+     * @param {String} type
+     *    The type of event.
+     * @return {Number}
+     *    The number of event listeners.
+     */
+    static count(target, type) {
+      if (eventListeners in target) {
+        let listenersForType = target[eventListeners].get(type);
+
+        if (listenersForType) {
+          return listenersForType.size;
+        }
+      }
+
+      return 0;
+    }
+
+    /**
+     * Decorate an object with event emitter functionality; basically using the
+     * class' prototype as mixin.
+     *
+     * @param Object target
+     *    The object to decorate.
+     * @return Object
+     *    The object given, mixed.
+     */
+    static decorate(target) {
+      let descriptors = Object.getOwnPropertyDescriptors(this.prototype);
+      delete descriptors.constructor;
+      return Object.defineProperties(target, descriptors);
+    }
+
+    on(...args) {
+      EventEmitter.on(this, ...args);
+    }
+
+    off(...args) {
+      EventEmitter.off(this, ...args);
+    }
+
+    once(...args) {
+      return EventEmitter.once(this, ...args);
+    }
+
+    emit(...args) {
+      EventEmitter.emit(this, ...args);
+    }
+  }
+
+  module.exports = this.EventEmitter = EventEmitter;
 
   // See comment in JSM module boilerplate when adding a new dependency.
   const Services = require("Services");
-  const defer = require("devtools/shared/defer");
   const { describeNthCaller } = require("devtools/shared/platform/stack");
   let loggingEnabled = true;
 
   if (!isWorker) {
     loggingEnabled = Services.prefs.getBoolPref("devtools.dump.emit");
     Services.prefs.addObserver("devtools.dump.emit", {
       observe: () => {
         loggingEnabled = Services.prefs.getBoolPref("devtools.dump.emit");
       }
     });
   }
 
-  /**
-   * Decorate an object with event emitter functionality.
-   *
-   * @param Object objectToDecorate
-   *        Bind all public methods of EventEmitter to
-   *        the objectToDecorate object.
-   * @return Object the object given.
-   */
-  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);
-
-    return objectToDecorate;
-  };
-
-  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);
-    },
+  function serialize(target) {
+    let out = String(target);
 
-    /**
-     * 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) {
-      let deferred = defer();
-
-      let handler = (_, first, ...rest) => {
-        this.off(event, handler);
-        if (listener) {
-          listener(event, first, ...rest);
-        }
-        deferred.resolve(first);
-      };
-
-      handler._originalListener = listener;
-      this.on(event, handler);
-
-      return deferred.promise;
-    },
-
-    /**
-     * 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;
+    if (target && target.nodeName) {
+      out += " (" + target.nodeName;
+      if (target.id) {
+        out += "#" + target.id;
       }
-      let listeners = this._eventEmitterListeners.get(event);
-      if (listeners) {
-        this._eventEmitterListeners.set(event, listeners.filter(l => {
-          return l !== listener && l._originalListener !== listener;
-        }));
+      if (target.className) {
+        out += "." + target.className;
       }
-    },
-
-    /**
-     * 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;
-        }
+      out += ")";
+    }
 
-        // 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);
-            dump(msg + "\n");
-          }
-        }
-      }
-    },
+    return out;
+  }
 
-    logEvent(event, args) {
-      if (!loggingEnabled) {
-        return;
-      }
+  function logEvent(type, 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;
+    let argsOut = "";
+    let description = describeNthCaller(2);
 
-          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.
-      }
+    // We need this try / catch to prevent any dead object errors.
+    try {
+      argsOut = args.map(serialize).join(", ");
+    } catch (e) {
+      // Object is dead so the toolbox is most likely shutting down,
+      // do nothing.
+    }
 
-      argOut += ")";
-      out += "emit" + argOut + " from " + description + "\n";
-
-      dump(out);
-    },
-  };
+    dump(`EMITTING: emit(${type}${argsOut}) from ${description}\n`);
+  }
 });
--- a/devtools/shared/tests/mochitest/chrome.ini
+++ b/devtools/shared/tests/mochitest/chrome.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 tags = devtools
 skip-if = os == 'android'
 
 [test_css-logic-getCssPath.html]
 [test_css-logic-getXPath.html]
-[test_eventemitter_basic.html]
 skip-if = os == 'linux' && debug # Bug 1205739
deleted file mode 100644
--- a/devtools/shared/tests/mochitest/test_eventemitter_basic.html
+++ /dev/null
@@ -1,194 +0,0 @@
-<!DOCTYPE html>
-<!--
-  Any copyright is dedicated to the Public Domain.
-  http://creativecommons.org/publicdomain/zero/1.0/
--->
-
-<html>
-
-  <head>
-    <meta charset="utf8">
-    <title></title>
-
-    <script type="application/javascript"
-            src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
-    <link rel="stylesheet" type="text/css"
-          href="chrome://mochikit/content/tests/SimpleTest/test.css">
-  </head>
-
-  <body>
-
-    <script type="application/javascript">
-      "use strict";
-
-      const { utils: Cu } = Components;
-      const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
-      const promise = require("promise");
-      const EventEmitter = require("devtools/shared/event-emitter");
-      const { Task } = require("devtools/shared/task");
-
-      SimpleTest.waitForExplicitFinish();
-
-      testEmitter();
-      testEmitter({});
-
-      Task.spawn(testPromise)
-          .catch(ok.bind(null, false))
-          .then(SimpleTest.finish);
-
-      function testEmitter(aObject) {
-        let emitter;
-
-        if (aObject) {
-          emitter = aObject;
-          EventEmitter.decorate(emitter);
-        } else {
-          emitter = new EventEmitter();
-        }
-
-        ok(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) {
-          is(eventName, "next", "Got event");
-          is(str1, "abc", "Argument 1 is correct");
-          is(str2, "def", "Argument 2 is correct");
-
-          ok(!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() {
-          ok(!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() {
-            ok(true, "c1 called");
-          }
-          function c2() {
-            ok(true, "c2 called");
-            emitter.off("tick", c3);
-          }
-          function c3() {
-            ok(false, "c3 should not be called");
-          }
-          function c4() {
-            ok(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");
-
-          ok(!enteredC1, "c1 should not be called");
-        }
-      }
-
-      function testPromise() {
-        let emitter = new EventEmitter();
-        let p = emitter.once("thing");
-
-        // Check that the promise is only resolved once event though we
-        // emit("thing") more than once
-        let firstCallbackCalled = false;
-        let check1 = p.then(arg => {
-          is(firstCallbackCalled, false, "first callback called only once");
-          firstCallbackCalled = true;
-          is(arg, "happened", "correct arg in promise");
-          return "rval from c1";
-        });
-
-        emitter.emit("thing", "happened", "ignored");
-
-        // Check that the promise is resolved asynchronously
-        let secondCallbackCalled = false;
-        let check2 = p.then(arg => {
-          ok(true, "second callback called");
-          is(arg, "happened", "correct arg in promise");
-          secondCallbackCalled = true;
-          is(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 => {
-          ok(arg === undefined, "no arg for foo event");
-          return "rval from c3";
-        });
-
-        pbar.then(() => {
-          ok(false, "pbar should not be called");
-        });
-
-        emitter.emit("foo");
-
-        is(secondCallbackCalled, false, "second callback not called yet");
-
-        return promise.all([ check1, check2, check3 ]).then(args => {
-          is(args[0], "rval from c1", "callback 1 done good");
-          is(args[1], "rval from c2", "callback 2 done good");
-          is(args[2], "rval from c3", "callback 3 done good");
-        });
-      }
-    </script>
-  </body>
-</html>
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_eventemitter_basic.js
@@ -0,0 +1,196 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+  ConsoleAPIListener
+} = require("devtools/server/actors/utils/webconsole-listeners");
+const EventEmitter = require("devtools/shared/event-emitter");
+const hasMethod = (target, method) =>
+  method in target && typeof target[method] === "function";
+
+const createNewEmitter = () => new EventEmitter();
+const decorateObject = () => EventEmitter.decorate({});
+
+let getEventEmitter = createNewEmitter;
+
+const TESTS = {
+  "test EventEmitter creation"() {
+    let emitter = getEventEmitter();
+    let isAnEmitter = emitter instanceof EventEmitter;
+
+    ok(emitter, "We have an event emitter");
+    ok(hasMethod(emitter, "on") &&
+        hasMethod(emitter, "off") &&
+        hasMethod(emitter, "once") &&
+        !hasMethod(emitter, "decorate") &&
+        !hasMethod(emitter, "count"),
+    `Event Emitter ${isAnEmitter ? "instance" : "mixin"} has the expected methods.`);
+  },
+
+  "test emitting events"(done) {
+    let emitter = getEventEmitter();
+
+    let beenHere1 = false;
+    let beenHere2 = false;
+
+    function next(str1, str2) {
+      equal(str1, "abc", "Argument 1 is correct");
+      equal(str2, "def", "Argument 2 is correct");
+
+      ok(!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() {
+      ok(!beenHere2, "\"once\" listener has been called once");
+      beenHere2 = true;
+      emitter.emit("onlyonce");
+
+      done();
+    }
+
+    emitter.on("next", next);
+    emitter.emit("next", "abc", "def");
+  },
+
+  "test Throwing exception in listener"(done) {
+    let emitter = getEventEmitter();
+    let listener = new ConsoleAPIListener(null, {
+      onConsoleAPICall(message) {
+        equal(message.level, "error");
+        equal(message.arguments[0], "foo: bar");
+        listener.destroy();
+        done();
+      }
+    });
+
+    listener.init();
+
+    function throwListener() {
+      emitter.off("throw-exception");
+      throw Object.create({
+        toString: () => "foo",
+        stack: "bar",
+      });
+    }
+
+    emitter.on("throw-exception", throwListener);
+    emitter.emit("throw-exception");
+  },
+
+  "test Kill it while emitting"(done) {
+    let emitter = getEventEmitter();
+
+    const c1 = () => ok(true, "c1 called");
+    const c2 = () => {
+      ok(true, "c2 called");
+      emitter.off("tick", c3);
+    };
+    const c3 = () => ok(false, "c3 should not be called");
+    const c4 = () => {
+      ok(true, "c4 called");
+      done();
+    };
+
+    emitter.on("tick", c1);
+    emitter.on("tick", c2);
+    emitter.on("tick", c3);
+    emitter.on("tick", c4);
+
+    emitter.emit("tick");
+  },
+
+  "test `off` after once"() {
+    let emitter = getEventEmitter();
+
+    let enteredC1 = false;
+    let c1 = () => (enteredC1 = true);
+
+    emitter.once("oao", c1);
+    emitter.off("oao", c1);
+
+    emitter.emit("oao");
+
+    ok(!enteredC1, "c1 should not be called");
+  },
+
+  "test promise"() {
+    let emitter = getEventEmitter();
+    let p = emitter.once("thing");
+
+    // Check that the promise is only resolved once event though we
+    // emit("thing") more than once
+    let firstCallbackCalled = false;
+    let check1 = p.then(arg => {
+      equal(firstCallbackCalled, false, "first callback called only once");
+      firstCallbackCalled = true;
+      equal(arg, "happened", "correct arg in promise");
+      return "rval from c1";
+    });
+
+    emitter.emit("thing", "happened", "ignored");
+
+    // Check that the promise is resolved asynchronously
+    let secondCallbackCalled = false;
+    let check2 = p.then(arg => {
+      ok(true, "second callback called");
+      equal(arg, "happened", "correct arg in promise");
+      secondCallbackCalled = true;
+      equal(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 => {
+      ok(arg === undefined, "no arg for foo event");
+      return "rval from c3";
+    });
+
+    pbar.then(() => {
+      ok(false, "pbar should not be called");
+    });
+
+    emitter.emit("foo");
+
+    equal(secondCallbackCalled, false, "second callback not called yet");
+
+    return Promise.all([ check1, check2, check3 ]).then(args => {
+      equal(args[0], "rval from c1", "callback 1 done good");
+      equal(args[1], "rval from c2", "callback 2 done good");
+      equal(args[2], "rval from c3", "callback 3 done good");
+    });
+  }
+};
+
+const run = (tests) => (async function () {
+  for (let name of Object.keys(tests)) {
+    do_print(name);
+    if (tests[name].length === 1) {
+      await (new Promise(resolve => tests[name](resolve)));
+    } else {
+      await tests[name]();
+    }
+  }
+});
+
+add_task(run(TESTS));
+add_task(() => (getEventEmitter = decorateObject));
+add_task(run(TESTS));
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_eventemitter_static.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+  ConsoleAPIListener
+} = require("devtools/server/actors/utils/webconsole-listeners");
+const { on, once, off, emit, count } = require("devtools/shared/event-emitter");
+
+const pass = (message) => ok(true, message);
+const fail = (message) => ok(false, message);
+
+const TESTS = {
+  "test add a listener"() {
+    let events = [ { name: "event#1" }, "event#2" ];
+    let target = { name: "target" };
+
+    on(target, "message", function (message) {
+      equal(this, target, "this is a target object");
+      equal(message, events.shift(), "message is emitted event");
+    });
+
+    emit(target, "message", events[0]);
+    emit(target, "message", events[0]);
+  },
+
+  "test that listener is unique per type"() {
+    let actual = [];
+    let target = {};
+    listener = () => actual.push(1);
+
+    on(target, "message", listener);
+    on(target, "message", listener);
+    on(target, "message", listener);
+    on(target, "foo", listener);
+    on(target, "foo", listener);
+
+    emit(target, "message");
+    deepEqual([ 1 ], actual, "only one message listener added");
+
+    emit(target, "foo");
+    deepEqual([ 1, 1 ], actual, "same listener added for other event");
+  },
+
+  "test event type matters"() {
+    let target = { name: "target" };
+    on(target, "message", () => fail("no event is expected"));
+    on(target, "done", () => pass("event is emitted"));
+
+    emit(target, "foo");
+    emit(target, "done");
+  },
+
+  "test all arguments are passed"() {
+    let foo = { name: "foo" }, bar = "bar";
+    let target = { name: "target" };
+
+    on(target, "message", (a, b) => {
+      equal(a, foo, "first argument passed");
+      equal(b, bar, "second argument passed");
+    });
+
+    emit(target, "message", foo, bar);
+  },
+
+  "test no side-effects in emit"() {
+    let target = { name: "target" };
+
+    on(target, "message", () => {
+      pass("first listener is called");
+
+      on(target, "message", () => fail("second listener is called"));
+    });
+    emit(target, "message");
+  },
+
+  "test can remove next listener"() {
+    let target = { name: "target"};
+
+    on(target, "data", () => {
+      pass("first listener called");
+      off(target, "data", fail);
+    });
+    on(target, "data", fail);
+
+    emit(target, "data", "Listener should be removed");
+  },
+
+  "test order of propagation"() {
+    let actual = [];
+    let target = { name: "target" };
+
+    on(target, "message", () => actual.push(1));
+    on(target, "message", () => actual.push(2));
+    on(target, "message", () => actual.push(3));
+    emit(target, "message");
+
+    deepEqual([ 1, 2, 3 ], actual, "called in order they were added");
+  },
+
+  "test remove a listener"() {
+    let target = { name: "target" };
+    let actual = [];
+
+    on(target, "message", function listener() {
+      actual.push(1);
+      on(target, "message", () => {
+        off(target, "message", listener);
+        actual.push(2);
+      });
+    });
+
+    emit(target, "message");
+    deepEqual([ 1 ], actual, "first listener called");
+
+    emit(target, "message");
+    deepEqual([ 1, 1, 2 ], actual, "second listener called");
+
+    emit(target, "message");
+    deepEqual([ 1, 1, 2, 2, 2 ], actual, "first listener removed");
+  },
+
+  "test remove all listeners for type"() {
+    let actual = [];
+    let target = { name: "target" };
+
+    on(target, "message", () => actual.push(1));
+    on(target, "message", () => actual.push(2));
+    on(target, "message", () => actual.push(3));
+    on(target, "bar", () => actual.push("b"));
+    off(target, "message");
+
+    emit(target, "message");
+    emit(target, "bar");
+
+    deepEqual([ "b" ], actual, "all message listeners were removed");
+  },
+
+  "test remove all listeners"() {
+    let actual = [];
+    let target = { name: "target" };
+
+    on(target, "message", () => actual.push(1));
+    on(target, "message", () => actual.push(2));
+    on(target, "message", () => actual.push(3));
+    on(target, "bar", () => actual.push("b"));
+
+    off(target);
+
+    emit(target, "message");
+    emit(target, "bar");
+
+    deepEqual([], actual, "all listeners events were removed");
+  },
+
+  "test falsy arguments are fine"() {
+    let type, listener, actual = [];
+    let target = { name: "target" };
+    on(target, "bar", () => actual.push(0));
+
+    off(target, "bar", listener);
+    emit(target, "bar");
+    deepEqual([ 0 ], actual, "3rd bad arg will keep listener");
+
+    off(target, type);
+    emit(target, "bar");
+    deepEqual([ 0, 0 ], actual, "2nd bad arg will keep listener");
+
+    off(target, type, listener);
+    emit(target, "bar");
+    deepEqual([ 0, 0, 0 ], actual, "2nd & 3rd bad args will keep listener");
+  },
+
+  "test unhandled exceptions"(done) {
+    let listener = new ConsoleAPIListener(null, {
+      onConsoleAPICall(message) {
+        equal(message.level, "error", "Got the first exception");
+        ok(message.arguments[0].startsWith("Error: Boom!"),
+          "unhandled exception is logged");
+
+        listener.destroy();
+        done();
+      }
+    });
+
+    listener.init();
+
+    let target = {};
+
+    on(target, "message", () => {
+      throw Error("Boom!");
+    });
+
+    emit(target, "message");
+  },
+
+  "test count"() {
+    let target = {};
+
+    equal(count(target, "foo"), 0, "no listeners for 'foo' events");
+    on(target, "foo", () => {});
+    equal(count(target, "foo"), 1, "listener registered");
+    on(target, "foo", () => {});
+    equal(count(target, "foo"), 2, "another listener registered");
+    off(target);
+    equal(count(target, "foo"), 0, "listeners unregistered");
+  },
+
+  async "test once"() {
+    let target = {};
+    let called = false;
+
+    let pFoo = once(target, "foo", value => {
+      ok(!called, "listener called only once");
+      equal(value, "bar", "correct argument was passed");
+    });
+    let pDone = once(target, "done");
+
+    emit(target, "foo", "bar");
+    emit(target, "foo", "baz");
+    emit(target, "done", "");
+
+    await Promise.all([pFoo, pDone]);
+  },
+
+  "test removing once"(done) {
+    let target = {};
+
+    once(target, "foo", fail);
+    once(target, "done", done);
+
+    off(target, "foo", fail);
+
+    emit(target, "foo", "listener was called");
+    emit(target, "done", "");
+  }
+};
+
+const run = (tests) => (async function () {
+  for (let name of Object.keys(tests)) {
+    do_print(name);
+    if (tests[name].length === 1) {
+      await (new Promise(resolve => tests[name](resolve)));
+    } else {
+      await tests[name]();
+    }
+  }
+});
+
+add_task(run(TESTS));
--- a/devtools/shared/tests/unit/xpcshell.ini
+++ b/devtools/shared/tests/unit/xpcshell.ini
@@ -9,16 +9,18 @@ support-files =
 [test_assert.js]
 [test_csslexer.js]
 [test_css-properties-db.js]
 # This test only enforces that the CSS database is up to date with nightly. The DB is
 # only used when inspecting a target that doesn't support the getCSSDatabase actor.
 # CSS properties are behind compile-time flags, and there is no automatic rebuild
 # process for uplifts, so this test breaks on uplift.
 run-if = nightly_build
+[test_eventemitter_basic.js]
+[test_eventemitter_static.js]
 [test_fetch-bom.js]
 [test_fetch-chrome.js]
 [test_fetch-file.js]
 [test_fetch-http.js]
 [test_fetch-resource.js]
 [test_flatten.js]
 [test_indentation.js]
 [test_independent_loaders.js]