Bug 1381542 - refactored EventEmitter with old SDK event/core capabilities; r=ochameau draft
authorZER0 <zer0.kaos@gmail.com>
Tue, 08 Aug 2017 17:40:01 +0200
changeset 644117 471a2952c04e70e3327e938b43431ddc06af0f2b
parent 644116 21fd331ca1126d3b4db4deeb0ebc5624fbb5394d
child 725492 35b627d80e2d1c2e4a60768befc57bc0b45e4e10
push id73309
push userbmo:zer0@mozilla.com
push dateThu, 10 Aug 2017 13:22:35 +0000
reviewersochameau
bugs1381542
milestone57.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). MozReview-Commit-ID: DNxnjwmcC4W
devtools/shared/event-emitter.js
devtools/shared/moz.build
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
new file mode 100644
--- /dev/null
+++ b/devtools/shared/event-emitter.js
@@ -0,0 +1,302 @@
+/* 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 BAD_LISTENER = "The event listener must be a function, or an object that has " +
+                     "`EventEmitter.handler` Symbol.";
+
+const eventListeners = Symbol("EventEmitter/listeners");
+const onceOriginalListener = Symbol("EventEmitter/once-original-listener");
+const handler = Symbol("EventEmitter/event-handler");
+
+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" && !isEventHandler(listener)) {
+      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) {
+      // Trying to remove from the `target` the `listener` specified for the
+      // event's `type` given.
+      let listenersForType = events.get(type);
+
+      // If we don't have listeners for the event's type, we bail out.
+      if (!listenersForType) {
+        return;
+      }
+
+      // If the listeners list contains the listener given, we just remove it.
+      if (listenersForType.has(listener)) {
+        listenersForType.delete(listener);
+      } else {
+        // If it's not present, there is still the possibility that the listener
+        // have been added using `once`, since the method wraps the original listener
+        // in another function.
+        // So we iterate all the listeners to check if any of them is a wrapper to
+        // the `listener` given.
+        for (let value of listenersForType.values()) {
+          if (onceOriginalListener in value && (
+            value[onceOriginalListener] === listener ||
+            value[onceOriginalListener].when === listener
+            )) {
+            listenersForType.delete(value);
+            break;
+          }
+        }
+      }
+    } else if (length === 2) {
+      // No listener was given, it means we're removing all the listeners from
+      // the given event's `type`.
+      if (events.has(type)) {
+        events.delete(type);
+      }
+    } else if (length === 1) {
+      // With only the `target` given, we're removing all the isteners from the object.
+      events.clear();
+    }
+  }
+
+  /**
+   * Registers an event `listener` that is called only the next time an event
+   * of the specified `type` is emitted on the given event `target`.
+   * It returns a promised resolved once the specified event `type` is emitted.
+   *
+   * @param {Object} target
+   *    Event target object.
+   * @param {String} type
+   *    The type of the event.
+   * @param {Function|Object} [listener]
+   *    The listener that processes the event.
+   * @return {Promise}
+   *    The promise resolved once the event `type` is emitted.
+   */
+  static once(target, type, listener) {
+    return new Promise(resolve => {
+      // This is the actual listener that will be added to the target's listener, it wraps
+      // the call to the original `listener` given.
+      let newListener = (first, ...rest) => {
+        // To prevent side effects we're removing the listener upfront.
+        EventEmitter.off(target, type, newListener);
+
+        if (listener) {
+          if (isEventHandler(listener)) {
+            // if the `listener` given is actually an object that handles the events
+            // using `EventEmitter.handler`, we want to call that function, passing also
+            // the event's type as first argument, and the `listener` (the object) as
+            // contextual object.
+            listener[handler](type, first, ...rest);
+          } else {
+            // Otherwise we'll just call it
+            listener.call(target, first, ...rest);
+          }
+        }
+
+        // We resolve the promise once the listener is called.
+        resolve(first);
+      };
+
+      newListener[onceOriginalListener] = listener;
+      EventEmitter.on(target, type, newListener);
+    });
+  }
+
+  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 {
+          if (isEventHandler(listener)) {
+            listener[handler](type, ...rest);
+          } else {
+            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);
+  }
+
+  static get handler() {
+    return handler;
+  }
+
+  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 = EventEmitter;
+
+const isEventHandler = (listener) =>
+  listener && handler in listener && typeof listener[handler] === "function";
+
+// See comment in JSM module boilerplate when adding a new dependency.
+const Services = require("Services");
+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");
+    }
+  });
+}
+
+function serialize(target) {
+  let out = String(target);
+
+  if (target && target.nodeName) {
+    out += " (" + target.nodeName;
+    if (target.id) {
+      out += "#" + target.id;
+    }
+    if (target.className) {
+      out += "." + target.className;
+    }
+    out += ")";
+  }
+
+  return out;
+}
+
+function logEvent(type, args) {
+  if (!loggingEnabled) {
+    return;
+  }
+
+  let argsOut = "";
+  let description = describeNthCaller(2);
+
+  // 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.
+  }
+
+  dump(`EMITTING: emit(${type}${argsOut}) from ${description}\n`);
+}
--- a/devtools/shared/moz.build
+++ b/devtools/shared/moz.build
@@ -47,16 +47,17 @@ DevToolsModules(
     'builtin-modules.js',
     'content-observer.js',
     'debounce.js',
     'defer.js',
     'deprecated-sync-thenables.js',
     'DevToolsUtils.js',
     'dom-node-constants.js',
     'dom-node-filter-constants.js',
+    'event-emitter.js',
     'extend.js',
     'flags.js',
     'generate-uuid.js',
     'indentation.js',
     'indexed-db.js',
     'l10n.js',
     'loader-plugin-raw.jsm',
     'Loader.jsm',
--- 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/old-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,216 @@
+/* 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";
+
+/**
+ * Each method of this object is a test; tests can be synchronous or asynchronous:
+ *
+ * 1. Plain functions are synchronous tests.
+ * 2. methods with `async` keyword are asynchronous tests.
+ * 3. methods with `done` as argument are asynchronous tests (`done` needs to be called to
+ *    finish the test).
+ */
+const TESTS = {
+  testEventEmitterCreation() {
+    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.`);
+  },
+
+  testEmittingEvents(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");
+  },
+
+  testThrowingExceptionInListener(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");
+  },
+
+  testKillItWhileEmitting(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");
+  },
+
+  testOffAfterOnce() {
+    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");
+  },
+
+  testPromise() {
+    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");
+    });
+  }
+};
+
+/**
+ * Create a runnable tests based on the tests descriptor given.
+ *
+ * @param {Object} tests
+ *  The tests descriptor object, contains the tests to run.
+ */
+const runnable = (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]();
+    }
+  }
+});
+
+// We want to run the same tests for both an instance of `EventEmitter` and an object
+// decorate with EventEmitter; therefore we create two strategies (`createNewEmitter` and
+// `decorateObject`) and a factory (`getEventEmitter`), where the factory is the actual
+// function used in the tests.
+
+const createNewEmitter = () => new EventEmitter();
+const decorateObject = () => EventEmitter.decorate({});
+
+// First iteration of the tests with a new instance of `EventEmitter`.
+let getEventEmitter = createNewEmitter;
+add_task(runnable(TESTS));
+// Second iteration of the tests with an object decorate using `EventEmitter`
+add_task(() => (getEventEmitter = decorateObject));
+add_task(runnable(TESTS));
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_eventemitter_static.js
@@ -0,0 +1,345 @@
+/* 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, handler } = require("devtools/shared/event-emitter");
+
+const pass = (message) => ok(true, message);
+const fail = (message) => ok(false, message);
+
+/**
+ * Each method of this object is a test; tests can be synchronous or asynchronous:
+ *
+ * 1. Plain method are synchronous tests.
+ * 2. methods with `async` keyword are asynchronous tests.
+ * 3. methods with `done` as argument are asynchronous tests (`done` needs to be called to
+ *    complete the test).
+ */
+const TESTS = {
+  testAddListener() {
+    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]);
+  },
+
+  testListenerIsUniquePerType() {
+    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");
+  },
+
+  testEventTypeMatters() {
+    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");
+  },
+
+  testAllArgumentsArePassed() {
+    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);
+  },
+
+  testNoSideEffectsInEmit() {
+    let target = { name: "target" };
+
+    on(target, "message", () => {
+      pass("first listener is called");
+
+      on(target, "message", () => fail("second listener is called"));
+    });
+    emit(target, "message");
+  },
+
+  testCanRemoveNextListener() {
+    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");
+  },
+
+  testOrderOfPropagation() {
+    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");
+  },
+
+  testRemoveListener() {
+    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");
+  },
+
+  testRemoveAllListenersForType() {
+    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");
+  },
+
+  testRemoveAllListeners() {
+    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");
+  },
+
+  testFalsyArgumentsAreFine() {
+    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");
+  },
+
+  testUnhandledExceptions(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");
+  },
+
+  testCount() {
+    let target = { name: "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 testOnce() {
+    let target = { name: "target" };
+    let called = false;
+
+    let pFoo = once(target, "foo", function (value) {
+      ok(!called, "listener called only once");
+      equal(value, "bar", "correct argument was passed");
+      equal(this, target, "the contextual object is correct");
+    });
+    let pDone = once(target, "done");
+
+    emit(target, "foo", "bar");
+    emit(target, "foo", "baz");
+    emit(target, "done", "");
+
+    await Promise.all([pFoo, pDone]);
+  },
+
+  testRemovingOnce(done) {
+    let target = { name: "target" };
+
+    once(target, "foo", fail);
+    once(target, "done", done);
+
+    off(target, "foo", fail);
+
+    emit(target, "foo", "listener was called");
+    emit(target, "done", "");
+  },
+
+  testAddListenerWithHandlerMethod() {
+    let target = { name: "target" };
+    let actual = [];
+    let listener = function (...args) {
+      equal(this, target, "the contextual object is correct for function listener");
+      deepEqual(args, [10, 20, 30], "arguments are properly passed");
+    };
+
+    let object = {
+      name: "target",
+      [handler](type, ...rest) {
+        actual.push(type);
+        equal(this, object, "the contextual object is correct for object listener");
+        deepEqual(rest, [10, 20, 30], "arguments are properly passed");
+      }
+    };
+
+    on(target, "foo", listener);
+    on(target, "bar", object);
+    on(target, "baz", object);
+
+    emit(target, "foo", 10, 20, 30);
+    emit(target, "bar", 10, 20, 30);
+    emit(target, "baz", 10, 20, 30);
+
+    deepEqual(actual, ["bar", "baz"], "object's listener called in the expected order");
+  },
+
+  testRemoveListenerWithHandlerMethod() {
+    let target = {};
+    let actual = [];
+
+    let object = {
+      [handler](type) {
+        actual.push(1);
+        on(target, "message", () => {
+          off(target, "message", object);
+          actual.push(2);
+        });
+      }
+    };
+
+    on(target, "message", object);
+
+    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");
+  },
+
+  async testOnceListenerWithHandlerMethod() {
+    let target = { name: "target" };
+    let called = false;
+
+    let object = {
+      [handler](type, value) {
+        ok(!called, "listener called only once");
+        equal(type, "foo", "event type is properly passed");
+        equal(value, "bar", "correct argument was passed");
+        equal(this, object, "the contextual object is correct for object listener");
+      }
+    };
+
+    let pFoo = once(target, "foo", object);
+
+    let pDone = once(target, "done");
+
+    emit(target, "foo", "bar");
+    emit(target, "foo", "baz");
+    emit(target, "done", "");
+
+    await Promise.all([pFoo, pDone]);
+  },
+
+};
+
+/**
+ * Create a runnable tests based on the tests descriptor given.
+ *
+ * @param {Object} tests
+ *  The tests descriptor object, contains the tests to run.
+ */
+const runnable = (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(runnable(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]