Merge m-c to fx-team a=merge
authorWes Kocher <wkocher@mozilla.com>
Wed, 20 Aug 2014 18:15:44 -0700
changeset 200674 dac8b4a0bd7c67737e2e670a2e2b0d4d69d53dfc
parent 200667 e7806c9c83f3edc29787133cfaef14ab3bd635c9 (current diff)
parent 200673 b91d006131d3c4ef73d37acd1ac0d85a87c19b72 (diff)
child 200761 479ec85d9e6f97997140564bdf8c80f418c04077
child 200813 098524c6392d03396a8e00bc9dfa237282b7b18d
push id27352
push usernigelbabu@gmail.com
push dateThu, 21 Aug 2014 05:17:11 +0000
treeherdermozilla-central@dac8b4a0bd7c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone34.0a1
first release with
nightly linux32
dac8b4a0bd7c / 34.0a1 / 20140821030201 / files
nightly linux64
dac8b4a0bd7c / 34.0a1 / 20140821030201 / files
nightly mac
dac8b4a0bd7c / 34.0a1 / 20140821030201 / files
nightly win32
dac8b4a0bd7c / 34.0a1 / 20140821030201 / files
nightly win64
dac8b4a0bd7c / 34.0a1 / 20140821030201 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge m-c to fx-team a=merge
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -285,16 +285,17 @@ toolbarpaletteitem > #personal-bookmarks
 }
 
 toolbarpaletteitem > #personal-bookmarks > #bookmarks-toolbar-placeholder,
 #personal-bookmarks[cui-areatype="menu-panel"] > #bookmarks-toolbar-placeholder,
 #personal-bookmarks[cui-areatype="toolbar"][overflowedItem=true] > #bookmarks-toolbar-placeholder {
   display: -moz-box;
 }
 
+#nav-bar-customization-target > #personal-bookmarks,
 toolbar:not(#TabsToolbar) > #wrapper-personal-bookmarks,
 toolbar:not(#TabsToolbar) > #personal-bookmarks {
   -moz-box-flex: 1;
 }
 
 #zoom-controls[cui-areatype="toolbar"]:not([overflowedItem=true]) > #zoom-reset-button > .toolbarbutton-text {
   display: -moz-box;
 }
--- a/browser/base/content/pageinfo/security.js
+++ b/browser/base/content/pageinfo/security.js
@@ -123,32 +123,32 @@ var security = {
     if (win) {
       win.gCookiesWindow.setFilter(eTLD);
       win.focus();
     }
     else
       window.openDialog("chrome://browser/content/preferences/cookies.xul",
                         "Browser:Cookies", "", {filterString : eTLD});
   },
-  
+
   /**
    * Open the login manager window
    */
   viewPasswords : function()
   {
     var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
                        .getService(Components.interfaces.nsIWindowMediator);
     var win = wm.getMostRecentWindow("Toolkit:PasswordManager");
     if (win) {
       win.setFilter(this._getSecurityInfo().hostName);
       win.focus();
     }
     else
       window.openDialog("chrome://passwordmgr/content/passwordManager.xul",
-                        "Toolkit:PasswordManager", "", 
+                        "Toolkit:PasswordManager", "",
                         {filterString : this._getSecurityInfo().hostName});
   },
 
   _cert : null
 };
 
 function securityOnLoad() {
   var info = security._getSecurityInfo();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/LoopContacts.jsm
@@ -0,0 +1,834 @@
+/* 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 {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+                                  "resource://gre/modules/devtools/Console.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
+                                  "resource:///modules/loop/LoopStorage.jsm");
+XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
+  const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+  return new EventEmitter();
+});
+
+this.EXPORTED_SYMBOLS = ["LoopContacts"];
+
+const kObjectStoreName = "contacts";
+
+/*
+ * The table used to store contacts information contains two identifiers,
+ * both of which can be used to look up entries in the table. The table
+ * key path (primary index, which must be unique) is "_guid", and is
+ * automatically generated by IndexedDB when an entry is first inserted.
+ * The other identifier, "id", is the supposedly unique key assigned to this
+ * entry by whatever service generated it (e.g., Google Contacts). While
+ * this key should, in theory, be completely unique, we don't use it
+ * as the key path to avoid generating errors when an external database
+ * violates this constraint. This second ID is referred to as the "serviceId".
+ */
+const kKeyPath = "_guid";
+const kServiceIdIndex = "id";
+
+/**
+ * Contacts validation.
+ *
+ * To allow for future integration with the Contacts API and/ or potential
+ * integration with contact synchronization across devices (including Firefox OS
+ * devices), we are using objects with properties having the same names and
+ * structure as those used by mozContact.
+ *
+ * See https://developer.mozilla.org/en-US/docs/Web/API/mozContact for more
+ * information.
+ */
+const kFieldTypeString = "string";
+const kFieldTypeNumber = "number";
+const kFieldTypeNumberOrString = "number|string";
+const kFieldTypeArray = "array";
+const kFieldTypeBool = "boolean";
+const kContactFields = {
+  "id": {
+    // Because "id" is externally generated, it might be numeric
+    type: kFieldTypeNumberOrString
+  },
+  "published": {
+    // mozContact, from which we are derived, defines dates as
+    // "a Date object, which will eventually be converted to a
+    // long long" -- to be forwards compatible, we allow both
+    // formats for now.
+    type: kFieldTypeNumberOrString
+  },
+  "updated": {
+    // mozContact, from which we are derived, defines dates as
+    // "a Date object, which will eventually be converted to a
+    // long long" -- to be forwards compatible, we allow both
+    // formats for now.
+    type: kFieldTypeNumberOrString
+  },
+  "bday": {
+    // mozContact, from which we are derived, defines dates as
+    // "a Date object, which will eventually be converted to a
+    // long long" -- to be forwards compatible, we allow both
+    // formats for now.
+    type: kFieldTypeNumberOrString
+  },
+  "blocked": {
+    type: kFieldTypeBool
+  },
+  "adr": {
+    type: kFieldTypeArray,
+    contains: {
+      "countryName": {
+        type: kFieldTypeString
+      },
+      "locality": {
+        type: kFieldTypeString
+      },
+      "postalCode": {
+        // In some (but not all) locations, postal codes can be strictly numeric
+        type: kFieldTypeNumberOrString
+      },
+      "pref": {
+        type: kFieldTypeBool
+      },
+      "region": {
+        type: kFieldTypeString
+      },
+      "streetAddress": {
+        type: kFieldTypeString
+      },
+      "type": {
+        type: kFieldTypeArray,
+        contains: kFieldTypeString
+      }
+    }
+  },
+  "email": {
+    type: kFieldTypeArray,
+    contains: {
+      "pref": {
+        type: kFieldTypeBool
+      },
+      "type": {
+        type: kFieldTypeArray,
+        contains: kFieldTypeString
+      },
+      "value": {
+        type: kFieldTypeString
+      }
+    }
+  },
+  "tel": {
+    type: kFieldTypeArray,
+    contains: {
+      "pref": {
+        type: kFieldTypeBool
+      },
+      "type": {
+        type: kFieldTypeArray,
+        contains: kFieldTypeString
+      },
+      "value": {
+        type: kFieldTypeString
+      }
+    }
+  },
+  "name": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "honorificPrefix": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "givenName": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "additionalName": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "familyName": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "honorificSuffix": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "category": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "org": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "jobTitle": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  },
+  "note": {
+    type: kFieldTypeArray,
+    contains: kFieldTypeString
+  }
+};
+
+/**
+ * Compares the properties contained in an object to the definition as defined in
+ * `kContactFields`.
+ * If a property is encountered that is not found in the spec, an Error is thrown.
+ * If a property is encountered with an invalid value, an Error is thrown.
+ *
+ * Please read the spec at https://wiki.mozilla.org/Loop/Architecture/Address_Book
+ * for more information.
+ *
+ * @param {Object} obj The contact object, or part of it when called recursively
+ * @param {Object} def The definition of properties to validate against. Defaults
+ *                     to `kContactFields`
+ */
+const validateContact = function(obj, def = kContactFields) {
+  for (let propName of Object.getOwnPropertyNames(obj)) {
+    // Ignore internal properties.
+    if (propName.startsWith("_")) {
+      continue;
+    }
+
+    let propDef = def[propName];
+    if (!propDef) {
+      throw new Error("Field '" + propName + "' is not supported for contacts");
+    }
+
+    let val = obj[propName];
+
+    switch (propDef.type) {
+      case kFieldTypeString:
+        if (typeof val != kFieldTypeString) {
+          throw new Error("Field '" + propName + "' must be of type String");
+        }
+        break;
+      case kFieldTypeNumberOrString:
+        let type = typeof val;
+        if (type != kFieldTypeNumber && type != kFieldTypeString) {
+          throw new Error("Field '" + propName + "' must be of type Number or String");
+        }
+        break;
+      case kFieldTypeBool:
+        if (typeof val != kFieldTypeBool) {
+          throw new Error("Field '" + propName + "' must be of type Boolean");
+        }
+        break;
+      case kFieldTypeArray:
+        if (!Array.isArray(val)) {
+          throw new Error("Field '" + propName + "' must be an Array");
+        }
+
+        let contains = propDef.contains;
+        // If the type of `contains` is a scalar value, it means that the array
+        // consists of items of only that type.
+        let isScalarCheck = (typeof contains == kFieldTypeString);
+        for (let arrayValue of val) {
+          if (isScalarCheck) {
+            if (typeof arrayValue != contains) {
+              throw new Error("Field '" + propName + "' must be of type " + contains);
+            }
+          } else {
+            validateContact(arrayValue, contains);
+          }
+        }
+        break;
+    }
+  }
+};
+
+/**
+ * Provides a method to perform multiple operations in a single transaction on the
+ * contacts store.
+ *
+ * @param {String}   operation Name of an operation supported by `IDBObjectStore`
+ * @param {Array}    data      List of objects that will be passed to the object
+ *                             store operation
+ * @param {Function} callback  Function that will be invoked once the operations
+ *                             have finished. The first argument passed will be
+ *                             an `Error` object or `null`. The second argument
+ *                             will be the `data` Array, if all operations finished
+ *                             successfully.
+ */
+const batch = function(operation, data, callback) {
+  let processed = [];
+  if (!LoopContactsInternal.hasOwnProperty(operation) ||
+    typeof LoopContactsInternal[operation] != 'function') {
+    callback(new Error ("LoopContactsInternal does not contain a '" +
+             operation + "' method"));
+    return;
+  }
+  LoopStorage.asyncForEach(data, (item, next) => {
+    LoopContactsInternal[operation](item, (err, result) => {
+      if (err) {
+        next(err);
+        return;
+      }
+      processed.push(result);
+      next();
+    });
+  }, err => {
+    if (err) {
+      callback(err, processed);
+      return;
+    }
+    callback(null, processed);
+  });
+}
+
+/**
+ * Extend a `target` object with the properties defined in `source`.
+ *
+ * @param {Object} target The target object to receive properties defined in `source`
+ * @param {Object} source The source object to copy properties from
+ */
+const extend = function(target, source) {
+  for (let key of Object.getOwnPropertyNames(source)) {
+    target[key] = source[key];
+  }
+  return target;
+};
+
+LoopStorage.on("upgrade", function(e, db) {
+  if (db.objectStoreNames.contains(kObjectStoreName)) {
+    return;
+  }
+
+  // Create the 'contacts' store as it doesn't exist yet.
+  let store = db.createObjectStore(kObjectStoreName, {
+    keyPath: kKeyPath,
+    autoIncrement: true
+  });
+  store.createIndex(kServiceIdIndex, kServiceIdIndex, {unique: false});
+});
+
+/**
+ * The Contacts class.
+ *
+ * Each method that is a member of this class requires the last argument to be a
+ * callback Function. MozLoopAPI will cause things to break if this invariant is
+ * violated. You'll notice this as well in the documentation for each method.
+ */
+let LoopContactsInternal = Object.freeze({
+  /**
+   * Add a contact to the data store.
+   *
+   * @param {Object}   details  An object that will be added to the data store
+   *                            as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
+   *                            for more information of this objects' structure
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the contact object, if it was stored successfully.
+   */
+  add: function(details, callback) {
+    if (!(kServiceIdIndex in details)) {
+      callback(new Error("No '" + kServiceIdIndex + "' field present"));
+      return;
+    }
+    try {
+      validateContact(details);
+    } catch (ex) {
+      callback(ex);
+      return;
+    }
+
+    LoopStorage.getStore(kObjectStoreName, (err, store) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      let contact = extend({}, details);
+      let now = Date.now();
+      // The data source should have included "published" and "updated" values
+      // for any imported records, and we need to keep track of those dated for
+      // sync purposes (i.e., when we add functionality to push local changes to
+      // a remote server from which we originally got a contact). We also need
+      // to track the time at which *we* added and most recently changed the
+      // contact, so as to determine whether the local or the remote store has
+      // fresher data.
+      //
+      // For clarity: the fields "published" and "updated" indicate when the
+      // *remote* data source published and updated the contact. The fields
+      // "_date_add" and "_date_lch" track when the *local* data source
+      // created and updated the contact.
+      contact.published = contact.published ? new Date(contact.published).getTime() : now;
+      contact.updated = contact.updated ? new Date(contact.updated).getTime() : now;
+      contact._date_add = contact._date_lch = now;
+
+      let request;
+      try {
+        request = store.add(contact);
+      } catch (ex) {
+        callback(ex);
+        return;
+      }
+
+      request.onsuccess = event => {
+        contact[kKeyPath] = event.target.result;
+        eventEmitter.emit("add", contact);
+        callback(null, contact);
+      };
+
+      request.onerror = event => callback(event.target.error);
+    }, "readwrite");
+  },
+
+  /**
+   * Add a batch of contacts to the data store.
+   *
+   * @param {Array}    contacts A list of contact objects to be added
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the list of added contacts.
+   */
+  addMany: function(contacts, callback) {
+    batch("add", contacts, callback);
+  },
+
+  /**
+   * Remove a contact from the data store.
+   *
+   * @param {String}   guid     String identifier of the contact to remove
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the result of the operation.
+   */
+  remove: function(guid, callback) {
+    this.get(guid, (err, contact) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      LoopStorage.getStore(kObjectStoreName, (err, store) => {
+        if (err) {
+          callback(err);
+          return;
+        }
+
+        let request;
+        try {
+          request = store.delete(guid);
+        } catch (ex) {
+          callback(ex);
+          return;
+        }
+
+        request.onsuccess = event => {
+          eventEmitter.emit("remove", contact);
+          callback(null, event.target.result);
+        };
+        request.onerror = event => callback(event.target.error);
+      }, "readwrite");
+    });
+  },
+
+  /**
+   * Remove a batch of contacts from the data store.
+   *
+   * @param {Array}    guids    A list of IDs of the contacts to remove
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the list of IDs, if successfull.
+   */
+  removeMany: function(guids, callback) {
+    batch("remove", guids, callback);
+  },
+
+  /**
+   * Remove _all_ contacts from the data store.
+   * CAUTION: this method will clear the whole data store - you won't have any
+   *          contacts left!
+   *
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the result of the operation, if successfull.
+   */
+  removeAll: function(callback) {
+    LoopStorage.getStore(kObjectStoreName, (err, store) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      let request;
+      try {
+        request = store.clear();
+      } catch (ex) {
+        callback(ex);
+        return;
+      }
+
+      request.onsuccess = event => {
+        eventEmitter.emit("removeAll", event.target.result);
+        callback(null, event.target.result);
+      };
+      request.onerror = event => callback(event.target.error);
+    }, "readwrite");
+  },
+
+  /**
+   * Retrieve a specific contact from the data store.
+   *
+   * @param {String}   guid     String identifier of the contact to retrieve
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the contact object, if successful.
+   */
+  get: function(guid, callback) {
+    LoopStorage.getStore(kObjectStoreName, (err, store) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      let request;
+      try {
+        request = store.get(guid);
+      } catch (ex) {
+        callback(ex);
+        return;
+      }
+
+      request.onsuccess = event => {
+        if (!event.target.result) {
+          callback(new Error("Contact with " + kKeyPath + " '" +
+                             guid + "' could not be found"));;
+          return;
+        }
+        let contact = extend({}, event.target.result);
+        contact[kKeyPath] = guid;
+        callback(null, contact);
+      };
+      request.onerror = event => callback(event.target.error);
+    });
+  },
+
+  /**
+   * Retrieve a specific contact from the data store using the kServiceIdIndex
+   * property.
+   *
+   * @param {String}   serviceId String identifier of the contact to retrieve
+   * @param {Function} callback  Function that will be invoked once the operation
+   *                             finished. The first argument passed will be an
+   *                             `Error` object or `null`. The second argument will
+   *                             be the contact object, if successfull.
+   */
+  getByServiceId: function(serviceId, callback) {
+    LoopStorage.getStore(kObjectStoreName, (err, store) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      let index = store.index(kServiceIdIndex);
+      let request;
+      try {
+        request = index.get(serviceId);
+      } catch (ex) {
+        callback(ex);
+        return;
+      }
+
+      request.onsuccess = event => {
+        if (!event.target.result) {
+          callback(new Error("Contact with " + kServiceIdIndex + " '" +
+                             serviceId + "' could not be found"));
+          return;
+        }
+
+        let contact = extend({}, event.target.result);
+        callback(null, contact);
+      };
+      request.onerror = event => callback(event.target.error);
+    });
+  },
+
+  /**
+   * Retrieve _all_ contacts from the data store.
+   * CAUTION: If the amount of contacts is very large (say > 100000), this method
+   *          may slow down your application!
+   *
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be an `Array` of contact objects, if successfull.
+   */
+  getAll: function(callback) {
+    LoopStorage.getStore(kObjectStoreName, (err, store) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      let cursorRequest = store.openCursor();
+      let contactsList = [];
+
+      cursorRequest.onsuccess = event => {
+        let cursor = event.target.result;
+        // No more results, return the list.
+        if (!cursor) {
+          callback(null, contactsList);
+          return;
+        }
+
+        let contact = extend({}, cursor.value);
+        contact[kKeyPath] = cursor.key;
+        contactsList.push(contact);
+
+        cursor.continue();
+      };
+
+      cursorRequest.onerror = event => callback(event.target.error);
+    });
+  },
+
+  /**
+   * Retrieve an arbitrary amount of contacts from the data store.
+   * CAUTION: If the amount of contacts is very large (say > 1000), this method
+   *          may slow down your application!
+   *
+   * @param {Array}    guids    List of contact IDs to retrieve contact objects of
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be an `Array` of contact objects, if successfull.
+   */
+  getMany: function(guids, callback) {
+    let contacts = [];
+    LoopStorage.asyncParallel(guids, (guid, next) => {
+      this.get(guid, (err, contact) => {
+        if (err) {
+          next(err);
+          return;
+        }
+        contacts.push(contact);
+        next();
+      });
+    }, err => {
+      callback(err, !err ? contacts : null);
+    });
+  },
+
+  /**
+   * Update a specific contact in the data store.
+   * The contact object is modified by replacing the fields passed in the `details`
+   * param and any fields not passed in are left unchanged.
+   *
+   * @param {Object}   details  An object that will be updated in the data store
+   *                            as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
+   *                            for more information of this objects' structure
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the contact object, if successfull.
+   */
+  update: function(details, callback) {
+    if (!(kKeyPath in details)) {
+      callback(new Error("No '" + kKeyPath + "' field present"));
+      return;
+    }
+    try {
+      validateContact(details);
+    } catch (ex) {
+      callback(ex);
+      return;
+    }
+
+    let guid = details[kKeyPath];
+
+    this.get(guid, (err, contact) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      LoopStorage.getStore(kObjectStoreName, (err, store) => {
+        if (err) {
+          callback(err);
+          return;
+        }
+
+        let previous = extend({}, contact);
+        // Update the contact with properties provided by `details`.
+        extend(contact, details);
+
+        details._date_lch = Date.now();
+        let request;
+        try {
+          request = store.put(contact);
+        } catch (ex) {
+          callback(ex);
+          return;
+        }
+
+        request.onsuccess = event => {
+          eventEmitter.emit("update", contact, previous);
+          callback(null, event.target.result);
+        };
+        request.onerror = event => callback(event.target.error);
+      }, "readwrite");
+    });
+  },
+
+  /**
+   * Block a specific contact in the data store.
+   *
+   * @param {String}   guid     String identifier of the contact to block
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the contact object, if successfull.
+   */
+  block: function(guid, callback) {
+    this.get(guid, (err, contact) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      contact.blocked = true;
+      this.update(contact, callback);
+    });
+  },
+
+  /**
+   * Un-block a specific contact in the data store.
+   *
+   * @param {String}   guid     String identifier of the contact to unblock
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the contact object, if successfull.
+   */
+  unblock: function(guid, callback) {
+    this.get(guid, (err, contact) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      contact.blocked = false;
+      this.update(contact, callback);
+    });
+  },
+
+  /**
+   * Import a list of (new) contacts from an external data source.
+   *
+   * @param {Object}   options  Property bag of options for the importer
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be the result of the operation, if successfull.
+   */
+  startImport: function(options, callback) {
+    //TODO in bug 972000.
+    callback(new Error("Not implemented yet!"));
+  },
+
+  /**
+   * Search through the data store for contacts that match a certain (sub-)string.
+   *
+   * @param {String}   query    Needle to search for in our haystack of contacts
+   * @param {Function} callback Function that will be invoked once the operation
+   *                            finished. The first argument passed will be an
+   *                            `Error` object or `null`. The second argument will
+   *                            be an `Array` of contact objects, if successfull.
+   */
+  search: function(query, callback) {
+    //TODO in bug 1037114.
+    callback(new Error("Not implemented yet!"));
+  }
+});
+
+/**
+ * Public Loop Contacts API.
+ *
+ * LoopContacts implements the EventEmitter interface by exposing three methods -
+ * `on`, `once` and `off` - to subscribe to events.
+ * At this point the following events may be subscribed to:
+ *  - 'add':       A new contact object was successfully added to the data store.
+ *  - 'remove':    A contact was successfully removed from the data store.
+ *  - 'removeAll': All contacts were successfully removed from the data store.
+ *  - 'update':    A contact object was successfully updated with changed
+ *                 properties in the data store.
+ */
+this.LoopContacts = Object.freeze({
+  add: function(details, callback) {
+    return LoopContactsInternal.add(details, callback);
+  },
+
+  addMany: function(contacts, callback) {
+    return LoopContactsInternal.addMany(contacts, callback);
+  },
+
+  remove: function(guid, callback) {
+    return LoopContactsInternal.remove(guid, callback);
+  },
+
+  removeMany: function(guids, callback) {
+    return LoopContactsInternal.removeMany(guids, callback);
+  },
+
+  removeAll: function(callback) {
+    return LoopContactsInternal.removeAll(callback);
+  },
+
+  get: function(guid, callback) {
+    return LoopContactsInternal.get(guid, callback);
+  },
+
+  getByServiceId: function(serviceId, callback) {
+    return LoopContactsInternal.getByServiceId(serviceId, callback);
+  },
+
+  getAll: function(callback) {
+    return LoopContactsInternal.getAll(callback);
+  },
+
+  getMany: function(guids, callback) {
+    return LoopContactsInternal.getMany(guids, callback);
+  },
+
+  update: function(details, callback) {
+    return LoopContactsInternal.update(details, callback);
+  },
+
+  block: function(guid, callback) {
+    return LoopContactsInternal.block(guid, callback);
+  },
+
+  unblock: function(guid, callback) {
+    return LoopContactsInternal.unblock(guid, callback);
+  },
+
+  startImport: function(options, callback) {
+    return LoopContactsInternal.startImport(options, callback);
+  },
+
+  search: function(query, callback) {
+    return LoopContactsInternal.search(query, callback);
+  },
+
+  on: (...params) => eventEmitter.on(...params),
+
+  once: (...params) => eventEmitter.once(...params),
+
+  off: (...params) => eventEmitter.off(...params)
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/LoopStorage.jsm
@@ -0,0 +1,319 @@
+/* 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 {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.importGlobalProperties(["indexedDB"]);
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
+  const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+  return new EventEmitter();
+});
+
+this.EXPORTED_SYMBOLS = ["LoopStorage"];
+
+const kDatabaseName = "loop";
+const kDatabaseVersion = 1;
+
+let gWaitForOpenCallbacks = new Set();
+let gDatabase = null;
+let gClosed = false;
+
+/**
+ * Properly shut the database instance down. This is done on application shutdown.
+ */
+const closeDatabase = function() {
+  Services.obs.removeObserver(closeDatabase, "quit-application");
+  if (!gDatabase) {
+    return;
+  }
+  gDatabase.close();
+  gDatabase = null;
+  gClosed = true;
+};
+
+/**
+ * Open a connection to the IndexedDB database.
+ * This function is different than IndexedDBHelper.jsm provides, as it ensures
+ * only one connection is open during the lifetime of this API. Callbacks are
+ * queued when a connection attempt is in progress and are invoked once the
+ * connection is established.
+ *
+ * @param {Function} onOpen Callback to be invoked once a database connection is
+ *                          established. It takes an Error object as first argument
+ *                          and the database connection object as second argument,
+ *                          if successful.
+ */
+const ensureDatabaseOpen = function(onOpen) {
+  if (gClosed) {
+    onOpen(new Error("Database already closed"));
+    return;
+  }
+
+  if (gDatabase) {
+    onOpen(null, gDatabase);
+    return;
+  }
+
+  if (!gWaitForOpenCallbacks.has(onOpen)) {
+    gWaitForOpenCallbacks.add(onOpen);
+
+    if (gWaitForOpenCallbacks.size !== 1) {
+      return;
+    }
+  }
+
+  let invokeCallbacks = err => {
+    for (let callback of gWaitForOpenCallbacks) {
+      callback(err, gDatabase);
+    }
+    gWaitForOpenCallbacks.clear();
+  };
+
+  let openRequest = indexedDB.open(kDatabaseName, kDatabaseVersion);
+
+  openRequest.onblocked = function(event) {
+    invokeCallbacks(new Error("Database cannot be upgraded cause in use: " + event.target.error));
+  };
+
+  openRequest.onerror = function(event) {
+    // Try to delete the old database so that we can start this process over
+    // next time.
+    indexedDB.deleteDatabase(kDatabaseName);
+    invokeCallbacks(new Error("Error while opening database: " + event.target.errorCode));
+  };
+
+  openRequest.onupgradeneeded = function(event) {
+    let db = event.target.result;
+    eventEmitter.emit("upgrade", db, event.oldVersion, kDatabaseVersion);
+  };
+
+  openRequest.onsuccess = function(event) {
+    gDatabase = event.target.result;
+    invokeCallbacks();
+    // Close the database instance properly on application shutdown.
+    Services.obs.addObserver(closeDatabase, "quit-application", false);
+  };
+};
+
+/**
+ * Start a transaction on the loop database and return it.
+ *
+ * @param {String}   store    Name of the object store to start a transaction on
+ * @param {Function} callback Callback to be invoked once a database connection
+ *                            is established and a transaction can be started.
+ *                            It takes an Error object as first argument and the
+ *                            transaction object as second argument.
+ * @param {String}   mode     Mode of the transaction. May be 'readonly' or 'readwrite'
+ *
+ * @note we can't use a Promise here, as they are resolved after a spin of the
+ *       event loop; the transaction will have finished by then and no operations
+ *       are possible anymore, yielding an error.
+ */
+const getTransaction = function(store, callback, mode) {
+  ensureDatabaseOpen((err, db) => {
+    if (err) {
+      callback(err);
+      return;
+    }
+
+    let trans;
+    try {
+      trans = db.transaction(store, mode);
+    } catch(ex) {
+      callback(ex);
+      return;
+    }
+    callback(null, trans);
+  });
+};
+
+/**
+ * Start a transaction on the loop database and return the requested store.
+ *
+ * @param {String}   store    Name of the object store to retrieve
+ * @param {Function} callback Callback to be invoked once a database connection
+ *                            is established and a transaction can be started.
+ *                            It takes an Error object as first argument and the
+ *                            store object as second argument.
+ * @param {String}   mode     Mode of the transaction. May be 'readonly' or 'readwrite'
+ *
+ * @note we can't use a Promise here, as they are resolved after a spin of the
+ *       event loop; the transaction will have finished by then and no operations
+ *       are possible anymore, yielding an error.
+ */
+const getStore = function(store, callback, mode) {
+  getTransaction(store, (err, trans) => {
+    if (err) {
+      callback(err);
+      return;
+    }
+
+    callback(null, trans.objectStore(store));
+  }, mode);
+};
+
+/**
+ * Public Loop Storage API.
+ *
+ * Since IndexedDB transaction can not stand a spin of the event loop _before_
+ * using a IDBTransaction object, we can't use Promise.jsm promises. Therefore
+ * LoopStorage provides two async helper functions, `asyncForEach` and `asyncParallel`.
+ *
+ * LoopStorage implements the EventEmitter interface by exposing two methods, `on`
+ * and `off`, to subscribe to events.
+ * At this point only the `upgrade` event will be emitted. This happens when the
+ * database is loaded in memory and consumers will be able to change its structure.
+ */
+this.LoopStorage = Object.freeze({
+  /**
+   * Open a connection to the IndexedDB database and return the database object.
+   *
+   * @param {Function} callback Callback to be invoked once a database connection
+   *                            is established. It takes an Error object as first
+   *                            argument and the database connection object as
+   *                            second argument, if successful.
+   */
+  getSingleton: function(callback) {
+    ensureDatabaseOpen(callback);
+  },
+
+  /**
+   * Start a transaction on the loop database and return it.
+   * If only two arguments are passed, the default mode will be assumed and the
+   * second argument is assumed to be a callback.
+   *
+   * @param {String}   store    Name of the object store to start a transaction on
+   * @param {Function} callback Callback to be invoked once a database connection
+   *                            is established and a transaction can be started.
+   *                            It takes an Error object as first argument and the
+   *                            transaction object as second argument.
+   * @param {String}   mode     Mode of the transaction. May be 'readonly' or 'readwrite'
+   *
+   * @note we can't use a Promise here, as they are resolved after a spin of the
+   *       event loop; the transaction will have finished by then and no operations
+   *       are possible anymore, yielding an error.
+   */
+  getTransaction: function(store, callback, mode = "readonly") {
+    getTransaction(store, callback, mode);
+  },
+
+  /**
+   * Start a transaction on the loop database and return the requested store.
+   * If only two arguments are passed, the default mode will be assumed and the
+   * second argument is assumed to be a callback.
+   *
+   * @param {String}   store    Name of the object store to retrieve
+   * @param {Function} callback Callback to be invoked once a database connection
+   *                            is established and a transaction can be started.
+   *                            It takes an Error object as first argument and the
+   *                            store object as second argument.
+   * @param {String}   mode     Mode of the transaction. May be 'readonly' or 'readwrite'
+   *
+   * @note we can't use a Promise here, as they are resolved after a spin of the
+   *       event loop; the transaction will have finished by then and no operations
+   *       are possible anymore, yielding an error.
+   */
+  getStore: function(store, callback, mode = "readonly") {
+    getStore(store, callback, mode);
+  },
+
+  /**
+   * Perform an async function in serial on each of the list items and call a
+   * callback Function when all list items are done.
+   * IMPORTANT: only use this iteration method if you are sure that the operations
+   * performed in `onItem` are guaranteed to be async in the success case.
+   *
+   * @param {Array}    list     Non-empty list of items to iterate
+   * @param {Function} onItem   Callback to invoke for each item in the list. It
+   *                            takes the item is first argument and a callback
+   *                            function as second, which is to be invoked once
+   *                            the consumer is done with its async operation. If
+   *                            an error is passed as the first argument to this
+   *                            callback function, the iteration will stop and
+   *                            `onDone` callback will be invoked with that error.
+   * @param {callback} onDone   Callback to invoke when the list is completed or
+   *                            on error. It takes an Error object as first
+   *                            argument.
+   */
+  asyncForEach: function(list, onItem, onDone) {
+    let i = 0;
+    let len = list.length;
+
+    if (!len) {
+      onDone(new Error("Argument error: empty list"));
+      return;
+    }
+
+    onItem(list[i], function handler(err) {
+      if (err) {
+        onDone(err);
+        return;
+      }
+
+      i++;
+      if (i < len) {
+        onItem(list[i], handler, i);
+      } else {
+        onDone();
+      }
+    }, i);
+  },
+
+  /**
+   * Perform an async function in parallel on each of the list items and call a
+   * callback Function when all list items are done.
+   * IMPORTANT: only use this iteration method if you are sure that the operations
+   * performed in `onItem` are guaranteed to be async in the success case.
+   *
+   * @param {Array}    list     Non-empty list of items to iterate
+   * @param {Function} onItem   Callback to invoke for each item in the list. It
+   *                            takes the item is first argument and a callback
+   *                            function as second, which is to be invoked once
+   *                            the consumer is done with its async operation. If
+   *                            an error is passed as the first argument to this
+   *                            callback function, the iteration will stop and
+   *                            `onDone` callback will be invoked with that error.
+   * @param {callback} onDone   Callback to invoke when the list is completed or
+   *                            on error. It takes an Error object as first
+   *                            argument.
+   */
+  asyncParallel: function(list, onItem, onDone) {
+    let i = 0;
+    let done = 0;
+    let callbackCalled = false;
+    let len = list.length;
+
+    if (!len) {
+      onDone(new Error("Argument error: empty list"));
+      return;
+    }
+
+    for (; i < len; ++i) {
+      onItem(list[i], function handler(err) {
+        if (callbackCalled) {
+          return;
+        }
+
+        if (err) {
+          onDone(err);
+          callbackCalled = true;
+          return;
+        }
+
+        if (++done === len) {
+          onDone();
+          callbackCalled = true;
+        }
+      }, i);
+    }
+  },
+
+  on: (...params) => eventEmitter.on(...params),
+
+  off: (...params) => eventEmitter.off(...params)
+});
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -4,41 +4,99 @@
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource:///modules/loop/MozLoopService.jsm");
+Cu.import("resource:///modules/loop/LoopContacts.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
                                         "resource://gre/modules/MozSocialAPI.jsm");
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"]
            .getService(Ci.nsIXULAppInfo)
            .QueryInterface(Ci.nsIXULRuntime);
 });
 XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
                                          "@mozilla.org/widget/clipboardhelper;1",
                                          "nsIClipboardHelper");
 this.EXPORTED_SYMBOLS = ["injectLoopAPI"];
 
 /**
+ * Trying to clone an Error object into a different container will yield an error.
+ * We can work around this by copying the properties we care about onto a regular
+ * object.
+ *
+ * @param {Error}        error        Error object to copy
+ * @param {nsIDOMWindow} targetWindow The content window to attach the API
+ */
+const cloneErrorObject = function(error, targetWindow) {
+  let obj = new targetWindow.Error();
+  for (let prop of Object.getOwnPropertyNames(error)) {
+    obj[prop] = String(error[prop]);
+  }
+  return obj;
+};
+
+/**
+ * Inject any API containing _only_ function properties into the given window.
+ *
+ * @param {Object}       api          Object containing functions that need to
+ *                                    be exposed to content
+ * @param {nsIDOMWindow} targetWindow The content window to attach the API
+ */
+const injectObjectAPI = function(api, targetWindow) {
+  let injectedAPI = {};
+  // Wrap all the methods in `api` to help results passed to callbacks get
+  // through the priv => unpriv barrier with `Cu.cloneInto()`.
+  Object.keys(api).forEach(func => {
+    injectedAPI[func] = function(...params) {
+      let callback = params.pop();
+      api[func](...params, function(...results) {
+        results = results.map(result => {
+          if (result && typeof result == "object") {
+            // Inspect for an error this way, because the Error object is special.
+            if (result.constructor.name == "Error") {
+              return cloneErrorObject(result.message)
+            }
+            return Cu.cloneInto(result, targetWindow);
+          }
+          return result;
+        });
+        callback(...results);
+      });
+    };
+  });
+
+  let contentObj = Cu.cloneInto(injectedAPI, targetWindow, {cloneFunctions: true});
+  // Since we deny preventExtensions on XrayWrappers, because Xray semantics make
+  // it difficult to act like an object has actually been frozen, we try to seal
+  // the `contentObj` without Xrays.
+  try {
+    Object.seal(Cu.waiveXrays(contentObj));
+  } catch (ex) {}
+  return contentObj;
+};
+
+/**
  * Inject the loop API into the given window.  The caller must be sure the
  * window is a loop content window (eg, a panel, chatwindow, or similar).
  *
  * See the documentation on the individual functions for details of the API.
  *
  * @param {nsIDOMWindow} targetWindow The content window to attach the API.
  */
 function injectLoopAPI(targetWindow) {
   let ringer;
   let ringerStopper;
   let appVersionInfo;
+  let contactsAPI;
 
   let api = {
     /**
      * Sets and gets the "do not disturb" mode activation flag.
      */
     doNotDisturb: {
       enumerable: true,
       get: function() {
@@ -57,16 +115,31 @@ function injectLoopAPI(targetWindow) {
     locale: {
       enumerable: true,
       get: function() {
         return MozLoopService.locale;
       }
     },
 
     /**
+     * Returns the contacts API.
+     *
+     * @returns {Object} The contacts API object
+     */
+    contacts: {
+      enumerable: true,
+      get: function() {
+        if (contactsAPI) {
+          return contactsAPI;
+        }
+        return contactsAPI = injectObjectAPI(LoopContacts, targetWindow);
+      }
+    },
+
+    /**
      * Returns translated strings associated with an element. Designed
      * for use with l10n.js
      *
      * @param {String} key The element id
      * @returns {Object} A JSON string containing the localized
      *                   attribute/value pairs for the element.
      */
     getStrings: {
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -8,16 +8,18 @@ JAR_MANIFESTS += ['jar.mn']
 
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
 
 BROWSER_CHROME_MANIFESTS += [
     'test/mochitest/browser.ini',
 ]
 
 EXTRA_JS_MODULES.loop += [
+    'LoopContacts.jsm',
+    'LoopStorage.jsm',
     'MozLoopAPI.jsm',
     'MozLoopPushHandler.jsm',
     'MozLoopWorker.js',
 ]
 
 EXTRA_PP_JS_MODULES.loop += [
     'MozLoopService.jsm',
 ]
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -2,12 +2,13 @@
 support-files =
     head.js
     loop_fxa.sjs
     ../../../../base/content/test/general/browser_fxa_oauth.html
 
 [browser_fxa_login.js]
 skip-if = !debug
 [browser_loop_fxa_server.js]
+[browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 skip-if = buildapp == 'mulet'
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_LoopContacts.js
@@ -0,0 +1,405 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {LoopContacts} = Cu.import("resource:///modules/loop/LoopContacts.jsm", {});
+
+const kContacts = [{
+  id: 1,
+  name: ["Ally Avocado"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "ally@mail.com"
+  }],
+  category: ["google"],
+  published: 1406798311748,
+  updated: 1406798311748
+},{
+  id: 2,
+  name: ["Bob Banana"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "bob@gmail.com"
+  }],
+  category: ["local"],
+  published: 1406798311748,
+  updated: 1406798311748
+}, {
+  id: 3,
+  name: ["Caitlin Cantaloupe"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "caitlin.cant@hotmail.com"
+  }],
+  category: ["local"],
+  published: 1406798311748,
+  updated: 1406798311748
+}, {
+  id: 4,
+  name: ["Dave Dragonfruit"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "dd@dragons.net"
+  }],
+  category: ["google"],
+  published: 1406798311748,
+  updated: 1406798311748
+}];
+
+const kDanglingContact = {
+  id: 5,
+  name: ["Ellie Eggplant"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "ellie@yahoo.com"
+  }],
+  category: ["google"],
+  blocked: true,
+  published: 1406798311748,
+  updated: 1406798311748
+};
+
+const promiseLoadContacts = function() {
+  let deferred = Promise.defer();
+
+  LoopContacts.removeAll(err => {
+    if (err) {
+      deferred.reject(err);
+      return;
+    }
+
+    gExpectedAdds.push(...kContacts);
+    LoopContacts.addMany(kContacts, (err, contacts) => {
+      if (err) {
+        deferred.reject(err);
+        return;
+      }
+      deferred.resolve(contacts);
+    });
+  });
+
+  return deferred.promise;
+};
+
+// Get a copy of a contact without private properties.
+const normalizeContact = function(contact) {
+  let result = {};
+  // Get a copy of contact without private properties.
+  for (let prop of Object.getOwnPropertyNames(contact)) {
+    if (!prop.startsWith("_")) {
+      result[prop] = contact[prop]
+    }
+  }
+  return result;
+};
+
+const compareContacts = function(contact1, contact2) {
+  Assert.ok("_guid" in contact1, "First contact should have an ID.");
+  Assert.deepEqual(normalizeContact(contact1), normalizeContact(contact2));
+};
+
+// LoopContacts emits various events. Test if they work as expected here.
+let gExpectedAdds = [];
+let gExpectedRemovals = [];
+let gExpectedUpdates = [];
+
+const onContactAdded = function(e, contact) {
+  let expectedIds = gExpectedAdds.map(contact => contact.id);
+  let idx = expectedIds.indexOf(contact.id);
+  Assert.ok(idx > -1, "Added contact should be expected");
+  let expected = gExpectedAdds[idx];
+  compareContacts(contact, expected);
+  gExpectedAdds.splice(idx, 1);
+};
+
+const onContactRemoved = function(e, contact) {
+  let idx = gExpectedRemovals.indexOf(contact._guid);
+  Assert.ok(idx > -1, "Removed contact should be expected");
+  gExpectedRemovals.splice(idx, 1);
+};
+
+const onContactUpdated = function(e, contact) {
+  let idx = gExpectedUpdates.indexOf(contact._guid);
+  Assert.ok(idx > -1, "Updated contact should be expected");
+  gExpectedUpdates.splice(idx, 1);
+};
+
+LoopContacts.on("add", onContactAdded);
+LoopContacts.on("remove", onContactRemoved);
+LoopContacts.on("update", onContactUpdated);
+
+registerCleanupFunction(function () {
+  LoopContacts.removeAll(() => {});
+  LoopContacts.off("add", onContactAdded);
+  LoopContacts.off("remove", onContactRemoved);
+  LoopContacts.off("update", onContactUpdated);
+});
+
+// Test adding a contact.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+  for (let i = 0, l = contacts.length; i < l; ++i) {
+    compareContacts(contacts[i], kContacts[i]);
+  }
+
+  // Add a contact.
+  let deferred = Promise.defer();
+  gExpectedAdds.push(kDanglingContact);
+  LoopContacts.add(kDanglingContact, (err, contact) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    compareContacts(contact, kDanglingContact);
+
+    // Check if it's persisted.
+    LoopContacts.get(contact._guid, (err, contact) => {
+      Assert.ok(!err, "There shouldn't be an error");
+      compareContacts(contact, kDanglingContact);
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+});
+
+// Test removing all contacts.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+
+  let deferred = Promise.defer();
+  LoopContacts.removeAll(function(err) {
+    Assert.ok(!err, "There shouldn't be an error");
+    LoopContacts.getAll(function(err, found) {
+      Assert.ok(!err, "There shouldn't be an error");
+      Assert.equal(found.length, 0, "There shouldn't be any contacts left");
+      deferred.resolve();
+    })
+  });
+  yield deferred.promise;
+});
+
+// Test retrieving a contact.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+
+  // Get a single contact.
+  let deferred = Promise.defer();
+  LoopContacts.get(contacts[1]._guid, (err, contact) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    compareContacts(contact, kContacts[1]);
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Get a single contact by id.
+  let deferred = Promise.defer();
+  LoopContacts.getByServiceId(2, (err, contact) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    compareContacts(contact, kContacts[1]);
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Get a couple of contacts.
+  let deferred = Promise.defer();
+  let toRetrieve = [contacts[0], contacts[2], contacts[3]];
+  LoopContacts.getMany(toRetrieve.map(contact => contact._guid), (err, result) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    Assert.equal(result.length, toRetrieve.length, "Result list should be the same " +
+                 "size as the list of items to retrieve");
+    for (let contact of toRetrieve) {
+      let found = result.filter(c => c._guid == contact._guid);
+      Assert.ok(found.length, "Contact " + contact._guid + " should be in the list");
+      compareContacts(found[0], contact);
+    }
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Get all contacts.
+  deferred = Promise.defer();
+  LoopContacts.getAll((err, contacts) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    for (let i = 0, l = contacts.length; i < l; ++i) {
+      compareContacts(contacts[i], kContacts[i]);
+    }
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Get a non-existent contact.
+  deferred = Promise.defer();
+  LoopContacts.get(1000, err => {
+    Assert.ok(err, "There should be an error");
+    Assert.equal(err.message, "Contact with _guid '1000' could not be found",
+                 "Error message should be correct");
+    deferred.resolve();
+  });
+  yield deferred.promise;
+});
+
+// Test removing a contact.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+
+  // Remove a single contact.
+  let deferred = Promise.defer();
+  let toRemove = contacts[2]._guid;
+  gExpectedRemovals.push(toRemove);
+  LoopContacts.remove(toRemove, err => {
+    Assert.ok(!err, "There shouldn't be an error");
+
+    LoopContacts.get(toRemove, err => {
+      Assert.ok(err, "There should be an error");
+      Assert.equal(err.message, "Contact with _guid '" + toRemove + "' could not be found",
+                   "Error message should be correct");
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+
+  // Remove a non-existing contact.
+  deferred = Promise.defer();
+  LoopContacts.remove(1000, err => {
+    Assert.ok(err, "There should be an error");
+    Assert.equal(err.message, "Contact with _guid '1000' could not be found",
+                 "Error message should be correct");
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Remove multiple contacts.
+  deferred = Promise.defer();
+  toRemove = [contacts[0]._guid, contacts[1]._guid];
+  gExpectedRemovals.push(...toRemove);
+  LoopContacts.removeMany(toRemove, err => {
+    Assert.ok(!err, "There shouldn't be an error");
+
+    LoopContacts.getAll((err, contacts) => {
+      Assert.ok(!err, "There shouldn't be an error");
+      let ids = contacts.map(contact => contact._guid);
+      Assert.equal(ids.indexOf(toRemove[0]), -1, "Contact '" + toRemove[0] +
+                                                 "' shouldn't be there");
+      Assert.equal(ids.indexOf(toRemove[1]), -1, "Contact '" + toRemove[1] +
+                                                 "' shouldn't be there");
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+});
+
+// Test updating a contact.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+
+  const newBday = (new Date(403920000000)).toISOString();
+
+  // Update a single contact.
+  let deferred = Promise.defer();
+  let toUpdate = {
+    _guid: contacts[2]._guid,
+    bday: newBday
+  };
+  gExpectedUpdates.push(contacts[2]._guid);
+  LoopContacts.update(toUpdate, (err, result) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    Assert.equal(result, toUpdate._guid, "Result should be the same as the contact ID");
+
+    LoopContacts.get(toUpdate._guid, (err, contact) => {
+      Assert.ok(!err, "There shouldn't be an error");
+      Assert.equal(contact.bday, newBday, "Birthday should be the same");
+      // Check that all other properties were left intact.
+      contacts[2].bday = newBday;
+      compareContacts(contact, contacts[2]);
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+
+  // Update a non-existing contact.
+  deferred = Promise.defer();
+  toUpdate = {
+    _guid: 1000,
+    bday: newBday
+  };
+  LoopContacts.update(toUpdate, err => {
+    Assert.ok(err, "There should be an error");
+    Assert.equal(err.message, "Contact with _guid '1000' could not be found",
+                 "Error message should be correct");
+    deferred.resolve();
+  });
+  yield deferred.promise;
+});
+
+// Test blocking and unblocking a contact.
+add_task(function* () {
+  let contacts = yield promiseLoadContacts();
+
+  // Block contact.
+  let deferred = Promise.defer();
+  let toBlock = contacts[1]._guid;
+  gExpectedUpdates.push(toBlock);
+  LoopContacts.block(toBlock, (err, result) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    Assert.equal(result, toBlock, "Result should be the same as the contact ID");
+
+    LoopContacts.get(toBlock, (err, contact) => {
+      Assert.ok(!err, "There shouldn't be an error");
+      Assert.strictEqual(contact.blocked, true, "Blocked status should be set");
+      // Check that all other properties were left intact.
+      delete contact.blocked;
+      compareContacts(contact, contacts[1]);
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+
+  // Block a non-existing contact.
+  deferred = Promise.defer();
+  LoopContacts.block(1000, err => {
+    Assert.ok(err, "There should be an error");
+    Assert.equal(err.message, "Contact with _guid '1000' could not be found",
+                 "Error message should be correct");
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Unblock a contact.
+  deferred = Promise.defer();
+  let toUnblock = contacts[1]._guid;
+  gExpectedUpdates.push(toUnblock);
+  LoopContacts.unblock(toUnblock, (err, result) => {
+    Assert.ok(!err, "There shouldn't be an error");
+    Assert.equal(result, toUnblock, "Result should be the same as the contact ID");
+
+    LoopContacts.get(toUnblock, (err, contact) => {
+      Assert.ok(!err, "There shouldn't be an error");
+      Assert.strictEqual(contact.blocked, false, "Blocked status should be set");
+      // Check that all other properties were left intact.
+      delete contact.blocked;
+      compareContacts(contact, contacts[1]);
+      deferred.resolve();
+    });
+  });
+  yield deferred.promise;
+
+  // Unblock a non-existing contact.
+  deferred = Promise.defer();
+  LoopContacts.unblock(1000, err => {
+    Assert.ok(err, "There should be an error");
+    Assert.equal(err.message, "Contact with _guid '1000' could not be found",
+                 "Error message should be correct");
+    deferred.resolve();
+  });
+  yield deferred.promise;
+});
+
+// Test if the event emitter implementation doesn't leak and is working as expected.
+add_task(function* () {
+  yield promiseLoadContacts();
+
+  Assert.strictEqual(gExpectedAdds.length, 0, "No contact additions should be expected anymore");
+  Assert.strictEqual(gExpectedRemovals.length, 0, "No contact removals should be expected anymore");
+  Assert.strictEqual(gExpectedUpdates.length, 0, "No contact updates should be expected anymore");
+});
--- a/browser/components/preferences/advanced.js
+++ b/browser/components/preferences/advanced.js
@@ -409,17 +409,17 @@ var gAdvancedPane = {
                    allowVisible     : false,
                    prefilledHost    : "",
                    permissionType   : "offline-app",
                    manageCapability : Components.interfaces.nsIPermissionManager.DENY_ACTION,
                    windowTitle      : bundlePreferences.getString("offlinepermissionstitle"),
                    introText        : bundlePreferences.getString("offlinepermissionstext") };
     document.documentElement.openWindow("Browser:Permissions",
                                         "chrome://browser/content/preferences/permissions.xul",
-                                        "", params);
+                                        "resizable", params);
   },
 
   // XXX: duplicated in browser.js
   _getOfflineAppUsage: function (host, groups)
   {
     var cacheService = Components.classes["@mozilla.org/network/application-cache-service;1"].
                        getService(Components.interfaces.nsIApplicationCacheService);
     if (!groups)
--- a/browser/components/preferences/content.js
+++ b/browser/components/preferences/content.js
@@ -57,17 +57,17 @@ var gContentPane = {
   showPopupExceptions: function ()
   {
     var bundlePreferences = document.getElementById("bundlePreferences");
     var params = { blockVisible: false, sessionVisible: false, allowVisible: true, prefilledHost: "", permissionType: "popup" };
     params.windowTitle = bundlePreferences.getString("popuppermissionstitle");
     params.introText = bundlePreferences.getString("popuppermissionstext");
     document.documentElement.openWindow("Browser:Permissions",
                                         "chrome://browser/content/preferences/permissions.xul",
-                                        "", params);
+                                        "resizable", params);
   },
 
 
   // FONTS
 
   /**
    * Populates the default font list in UI.
    */
@@ -182,17 +182,17 @@ var gContentPane = {
   /**
    * Displays the translation exceptions dialog where specific site and language
    * translation preferences can be set.
    */
   showTranslationExceptions: function ()
   {
     document.documentElement.openWindow("Browser:TranslationExceptions",
                                         "chrome://browser/content/preferences/translation.xul",
-                                        "", null);
+                                        "resizable", null);
   },
 
   openTranslationProviderAttribution: function ()
   {
     Components.utils.import("resource:///modules/translation/Translation.jsm");
     Translation.openProviderAttribution();
   }
 };
--- a/browser/components/preferences/cookies.xul
+++ b/browser/components/preferences/cookies.xul
@@ -104,11 +104,10 @@
               label="&button.removeallcookies.label;" accesskey="&button.removeallcookies.accesskey;"
               oncommand="gCookiesWindow.deleteAllCookies();"/>
       <spacer flex="1"/>
 #ifndef XP_MACOSX
       <button oncommand="close();" icon="close"
               label="&button.close.label;" accesskey="&button.close.accesskey;"/>
 #endif
     </hbox>
-    <resizer type="window" dir="bottomend"/>
   </hbox>
 </window>
--- a/browser/components/preferences/in-content/subdialogs.js
+++ b/browser/components/preferences/in-content/subdialogs.js
@@ -57,25 +57,26 @@ let gSubDialog = {
       'xml-stylesheet',
       'href="' + aStylesheetURL + '" type="text/css"'
     );
     this._frame.contentDocument.insertBefore(contentStylesheet,
                                              this._frame.contentDocument.documentElement);
   },
 
   open: function(aURL, aFeatures = null, aParams = null, aClosingCallback = null) {
-    let features = aFeatures || "modal,centerscreen,resizable=no";
+    let features = (!!aFeatures ? aFeatures + "," : "") + "resizable,dialog=no,centerscreen";
     let dialog = window.openDialog(aURL, "dialogFrame", features, aParams);
     if (aClosingCallback) {
       this._closingCallback = aClosingCallback.bind(dialog);
     }
+    features = features.replace(/,/g, "&");
     let featureParams = new URLSearchParams(features.toLowerCase());
     this._box.setAttribute("resizable", featureParams.has("resizable") &&
                                         featureParams.get("resizable") != "no" &&
-                                        featureParams.get("resizable") != 0);
+                                        featureParams.get("resizable") != "0");
     return dialog;
   },
 
   close: function(aEvent = null) {
     if (this._closingCallback) {
       try {
         this._closingCallback.call(null, aEvent);
       } catch (ex) {
--- a/browser/components/preferences/permissions.xul
+++ b/browser/components/preferences/permissions.xul
@@ -73,11 +73,10 @@
               accesskey="&removeallpermissions.accesskey;" 
               oncommand="gPermissionManager.onAllPermissionsDeleted();"/>
       <spacer flex="1"/>
 #ifndef XP_MACOSX
       <button oncommand="close();" icon="close"
               label="&button.close.label;" accesskey="&button.close.accesskey;"/>
 #endif
     </hbox>
-    <resizer type="window" dir="bottomend"/>
   </hbox>
 </window>
--- a/browser/components/preferences/privacy.js
+++ b/browser/components/preferences/privacy.js
@@ -469,27 +469,27 @@ var gPrivacyPane = {
                    sessionVisible : true, 
                    allowVisible   : true, 
                    prefilledHost  : "", 
                    permissionType : "cookie",
                    windowTitle    : bundlePreferences.getString("cookiepermissionstitle"),
                    introText      : bundlePreferences.getString("cookiepermissionstext") };
     document.documentElement.openWindow("Browser:Permissions",
                                         "chrome://browser/content/preferences/permissions.xul",
-                                        "", params);
+                                        "resizable", params);
   },
 
   /**
    * Displays all the user's cookies in a dialog.
    */  
   showCookies: function (aCategory)
   {
     document.documentElement.openWindow("Browser:Cookies",
                                         "chrome://browser/content/preferences/cookies.xul",
-                                        "", null);
+                                        "resizable", null);
   },
 
   // CLEAR PRIVATE DATA
 
   /*
    * Preferences:
    *
    * privacy.sanitize.sanitizeOnShutdown
--- a/browser/components/preferences/security.js
+++ b/browser/components/preferences/security.js
@@ -106,17 +106,17 @@ var gSecurityPane = {
   /**
    * Displays a dialog in which the user can view and modify the list of sites
    * where passwords are never saved.
    */
   showPasswordExceptions: function ()
   {
     document.documentElement.openWindow("Toolkit:PasswordManagerExceptions",
                                         "chrome://passwordmgr/content/passwordManagerExceptions.xul",
-                                        "", null);
+                                        "resizable", null);
   },
 
   /**
    * Initializes master password UI: the "use master password" checkbox, selects
    * the master password button to show, and enables/disables it as necessary.
    * The master password is controlled by various bits of NSS functionality, so
    * the UI for it can't be controlled by the normal preference bindings.
    */
@@ -216,12 +216,12 @@ var gSecurityPane = {
   /**
    * Shows the sites where the user has saved passwords and the associated login
    * information.
    */
   showPasswords: function ()
   {
     document.documentElement.openWindow("Toolkit:PasswordManager",
                                         "chrome://passwordmgr/content/passwordManager.xul",
-                                        "", null);
+                                        "resizable", null);
   }
 
 };
--- a/browser/components/preferences/translation.xul
+++ b/browser/components/preferences/translation.xul
@@ -80,11 +80,10 @@
               accesskey="&removeAllSites.accesskey;"
               oncommand="gTranslationExceptions.onAllSitesDeleted();"/>
       <spacer flex="1"/>
 #ifndef XP_MACOSX
       <button oncommand="close();" icon="close"
               label="&button.close.label;" accesskey="&button.close.accesskey;"/>
 #endif
     </hbox>
-    <resizer type="window" dir="bottomend"/>
   </hbox>
 </window>
--- a/browser/devtools/styleeditor/StyleEditorUI.jsm
+++ b/browser/devtools/styleeditor/StyleEditorUI.jsm
@@ -28,16 +28,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
 const { PrefObserver, PREF_ORIG_SOURCES } = require("devtools/styleeditor/utils");
 const csscoverage = require("devtools/server/actors/csscoverage");
 const console = require("resource://gre/modules/devtools/Console.jsm").console;
 
 const LOAD_ERROR = "error-load";
 const STYLE_EDITOR_TEMPLATE = "stylesheet";
+const SELECTOR_HIGHLIGHTER_TYPE = "SelectorHighlighter";
 const PREF_MEDIA_SIDEBAR = "devtools.styleeditor.showMediaSidebar";
 const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.mediaSidebarWidth";
 const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth";
 
 /**
  * StyleEditorUI is controls and builds the UI of the Style Editor, including
  * maintaining a list of editors for each stylesheet on a debuggee.
  *
@@ -106,28 +107,34 @@ StyleEditorUI.prototype = {
    * Index of selected stylesheet in document.styleSheets
    */
   get selectedStyleSheetIndex() {
     return this.selectedEditor ?
            this.selectedEditor.styleSheet.styleSheetIndex : -1;
   },
 
   /**
-   * Initiates the style editor ui creation and the inspector front to get
-   * reference to the walker.
+   * Initiates the style editor ui creation, the inspector front to get
+   * reference to the walker and the selector highlighter if available
    */
   initialize: function() {
-    let toolbox = gDevTools.getToolbox(this._target);
-    return toolbox.initInspector().then(() => {
+    return Task.spawn(function*() {
+      let toolbox = gDevTools.getToolbox(this._target);
+      yield toolbox.initInspector();
       this._walker = toolbox.walker;
-    }).then(() => {
+
+      let hUtils = toolbox.highlighterUtils;
+      if (hUtils.hasCustomHighlighter(SELECTOR_HIGHLIGHTER_TYPE)) {
+        this._highlighter =
+          yield hUtils.getHighlighterByType(SELECTOR_HIGHLIGHTER_TYPE);
+      }
+    }.bind(this)).then(() => {
       this.createUI();
       this._debuggee.getStyleSheets().then((styleSheets) => {
-        this._resetStyleSheetList(styleSheets);
-
+        this._resetStyleSheetList(styleSheets); 
         this._target.on("will-navigate", this._clear);
         this._target.on("navigate", this._onNewDocument);
       });
     });
   },
 
   /**
    * Build the initial UI and wire buttons with event handlers.
@@ -287,18 +294,18 @@ StyleEditorUI.prototype = {
   _addStyleSheetEditor: function(styleSheet, file, isNew) {
     // recall location of saved file for this sheet after page reload
     let identifier = this.getStyleSheetIdentifier(styleSheet);
     let savedFile = this.savedLocations[identifier];
     if (savedFile && !file) {
       file = savedFile;
     }
 
-    let editor =
-      new StyleSheetEditor(styleSheet, this._window, file, isNew, this._walker);
+    let editor = new StyleSheetEditor(styleSheet, this._window, file, isNew,
+                                      this._walker, this._highlighter);
 
     editor.on("property-change", this._summaryChange.bind(this, editor));
     editor.on("media-rules-changed", this._updateMediaList.bind(this, editor));
     editor.on("linked-css-file", this._summaryChange.bind(this, editor));
     editor.on("linked-css-file-error", this._summaryChange.bind(this, editor));
     editor.on("error", this._onError);
 
     this.editors.push(editor);
@@ -815,16 +822,21 @@ StyleEditorUI.prototype = {
    *         Location object with 'line', 'column', and 'source' properties.
    */
   _jumpToLocation: function(location) {
     let source = location.styleSheet || location.source;
     this.selectStyleSheet(source, location.line - 1, location.column - 1);
   },
 
   destroy: function() {
+    if (this._highlighter) {
+      this._highlighter.finalize();
+      this._highlighter = null;
+    }
+
     this._clearStyleSheetEditors();
 
     let sidebar = this._panelDoc.querySelector(".splitview-controller");
     let sidebarWidth = sidebar.getAttribute("width");
     Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth);
 
     this._optionsMenu.removeEventListener("popupshowing",
                                           this._onOptionsPopupShowing);
--- a/browser/devtools/styleeditor/StyleSheetEditor.jsm
+++ b/browser/devtools/styleeditor/StyleSheetEditor.jsm
@@ -15,16 +15,17 @@ const require = Cu.import("resource://gr
 const Editor  = require("devtools/sourceeditor/editor");
 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 const {CssLogic} = require("devtools/styleinspector/css-logic");
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/devtools/event-emitter.js");
 Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm");
 
 const LOAD_ERROR = "error-load";
 const SAVE_ERROR = "error-save";
 
 // max update frequency in ms (avoid potential typing lag and/or flicker)
 // @see StyleEditor.updateStylesheet
@@ -41,16 +42,20 @@ const TRANSITION_PREF = "devtools.stylee
 const CHECK_LINKED_SHEET_DELAY=500;
 
 // How many times to check for linked file changes
 const MAX_CHECK_COUNT=10;
 
 // The classname used to show a line that is not used
 const UNUSED_CLASS = "cm-unused-line";
 
+// How much time should the mouse be still before the selector at that position
+// gets highlighted?
+const SELECTOR_HIGHLIGHT_TIMEOUT = 500;
+
 /**
  * StyleSheetEditor controls the editor linked to a particular StyleSheet
  * object.
  *
  * Emits events:
  *   'property-change': A property on the underlying stylesheet has changed
  *   'source-editor-load': The source editor for this editor has been loaded
  *   'error': An error has occured
@@ -60,26 +65,30 @@ const UNUSED_CLASS = "cm-unused-line";
  * @param {DOMWindow}  win
  *        panel window for style editor
  * @param {nsIFile}  file
  *        Optional file that the sheet was imported from
  * @param {boolean} isNew
  *        Optional whether the sheet was created by the user
  * @param {Walker} walker
  *        Optional walker used for selectors autocompletion
+ * @param {CustomHighlighterFront} highlighter
+ *        Optional highlighter front for the SelectorHighligher used to
+ *        highlight selectors
  */
-function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
+function StyleSheetEditor(styleSheet, win, file, isNew, walker, highlighter) {
   EventEmitter.decorate(this);
 
   this.styleSheet = styleSheet;
   this._inputElement = null;
   this.sourceEditor = null;
   this._window = win;
   this._isNew = isNew;
   this.walker = walker;
+  this.highlighter = highlighter;
 
   this._state = {   // state to use when inputElement attaches
     text: "",
     selection: {
       start: {line: 0, ch: 0},
       end: {line: 0, ch: 0}
     },
     topIndex: 0              // the first visible line
@@ -94,16 +103,17 @@ function StyleSheetEditor(styleSheet, wi
   this._onPropertyChange = this._onPropertyChange.bind(this);
   this._onError = this._onError.bind(this);
   this._onMediaRuleMatchesChange = this._onMediaRuleMatchesChange.bind(this);
   this._onMediaRulesChanged = this._onMediaRulesChanged.bind(this)
   this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this);
   this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this);
   this.saveToFile = this.saveToFile.bind(this);
   this.updateStyleSheet = this.updateStyleSheet.bind(this);
+  this._onMouseMove = this._onMouseMove.bind(this);
 
   this._focusOnSourceEditorReady = false;
   this.cssSheet.on("property-change", this._onPropertyChange);
   this.styleSheet.on("error", this._onError);
   this.mediaRules = [];
   if (this.cssSheet.getMediaRules) {
     this.cssSheet.getMediaRules().then(this._onMediaRulesChanged);
   }
@@ -372,16 +382,20 @@ StyleSheetEditor.prototype = {
         this._focusOnSourceEditorReady = false;
         sourceEditor.focus();
       }
 
       sourceEditor.setFirstVisibleLine(this._state.topIndex);
       sourceEditor.setSelection(this._state.selection.start,
                                 this._state.selection.end);
 
+      if (this.highlighter && this.walker) {
+        sourceEditor.container.addEventListener("mousemove", this._onMouseMove);
+      }
+
       this.emit("source-editor-load");
     });
   },
 
   /**
    * Get the source editor for this editor.
    *
    * @return {Promise}
@@ -465,16 +479,62 @@ StyleSheetEditor.prototype = {
     }
 
     let transitionsEnabled = Services.prefs.getBoolPref(TRANSITION_PREF);
 
     this.styleSheet.update(this._state.text, transitionsEnabled);
   },
 
   /**
+   * Handle mousemove events, calling _highlightSelectorAt after a delay only
+   * and reseting the delay everytime.
+   */
+  _onMouseMove: function(e) {
+    this.highlighter.hide();
+
+    if (this.mouseMoveTimeout) {
+      this._window.clearTimeout(this.mouseMoveTimeout);
+      this.mouseMoveTimeout = null;
+    }
+
+    this.mouseMoveTimeout = this._window.setTimeout(() => {
+      this._highlightSelectorAt(e.clientX, e.clientY);
+    }, SELECTOR_HIGHLIGHT_TIMEOUT);
+  },
+
+  /**
+   * Highlight nodes matching the selector found at coordinates x,y in the
+   * editor, if any.
+   *
+   * @param {Number} x
+   * @param {Number} y
+   */
+  _highlightSelectorAt: Task.async(function*(x, y) {
+    // Need to catch parsing exceptions as long as bug 1051900 isn't fixed
+    let info;
+    try {
+      let pos = this.sourceEditor.getPositionFromCoords({left: x, top: y});
+      info = this.sourceEditor.getInfoAt(pos);
+    } catch (e) {}
+    if (!info || info.state !== "selector") {
+      return;
+    }
+
+    let node = yield this.walker.getStyleSheetOwnerNode(this.styleSheet.actorID);
+    yield this.highlighter.show(node, {
+      selector: info.selector,
+      hideInfoBar: true,
+      showOnly: "border",
+      region: "border"
+    });
+
+    this.emit("node-highlighted");
+  }),
+
+  /**
    * Save the editor contents into a file and set savedFile property.
    * A file picker UI will open if file is not set and editor is not headless.
    *
    * @param mixed file
    *        Optional nsIFile or string representing the filename to save in the
    *        background, no UI will be displayed.
    *        If not specified, the original style sheet URI is used.
    *        To implement 'Save' instead of 'Save as', you can pass savedFile here.
@@ -634,16 +694,20 @@ StyleSheetEditor.prototype = {
   /**
    * Clean up for this editor.
    */
   destroy: function() {
     if (this._sourceEditor) {
       this._sourceEditor.off("dirty-change", this._onPropertyChange);
       this._sourceEditor.off("save", this.saveToFile);
       this._sourceEditor.off("change", this.updateStyleSheet);
+      if (this.highlighter && this.walker && this._sourceEditor.container) {
+        this._sourceEditor.container.removeEventListener("mousemove",
+          this._onMouseMove);
+      }
       this._sourceEditor.destroy();
     }
     this.cssSheet.off("property-change", this._onPropertyChange);
     this.cssSheet.off("media-rules-changed", this._onMediaRulesChanged);
     this.styleSheet.off("error", this._onError);
   }
 }
 
--- a/browser/devtools/styleeditor/test/browser.ini
+++ b/browser/devtools/styleeditor/test/browser.ini
@@ -44,16 +44,17 @@ support-files =
 [browser_styleeditor_bug_740541_iframes.js]
 skip-if = os == "linux" || "mac" # bug 949355
 [browser_styleeditor_bug_851132_middle_click.js]
 [browser_styleeditor_bug_870339.js]
 [browser_styleeditor_cmd_edit.js]
 [browser_styleeditor_enabled.js]
 [browser_styleeditor_fetch-from-cache.js]
 [browser_styleeditor_filesave.js]
+[browser_styleeditor_highlight-selector.js]
 [browser_styleeditor_import.js]
 [browser_styleeditor_import_rule.js]
 [browser_styleeditor_init.js]
 [browser_styleeditor_inline_friendly_names.js]
 [browser_styleeditor_loading.js]
 [browser_styleeditor_media_sidebar.js]
 [browser_styleeditor_media_sidebar_sourcemaps.js]
 [browser_styleeditor_new.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/styleeditor/test/browser_styleeditor_highlight-selector.js
@@ -0,0 +1,48 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that hovering over a simple selector in the style-editor requests the
+// highlighting of the corresponding nodes
+
+waitForExplicitFinish();
+
+const TEST_URL = "data:text/html;charset=utf8," +
+                 "<style>div{color:red}</style><div>highlighter test</div>";
+
+let test = asyncTest(function*() {
+  let {UI} = yield addTabAndOpenStyleEditors(1, null, TEST_URL);
+  let editor = UI.editors[0];
+
+  // Mock the highlighter so we can locally assert that things happened
+  // correctly instead of accessing the highlighter elements
+  editor.highlighter = {
+    isShown: false,
+    options: null,
+
+    show: function(node, options) {
+      this.isShown = true;
+      this.options = options;
+      return promise.resolve();
+    },
+
+    hide: function() {
+      this.isShown = false;
+    }
+  };
+
+  info("Expecting a node-highlighted event");
+  let onHighlighted = editor.once("node-highlighted");
+
+  info("Simulate a mousemove event on the div selector");
+  editor._onMouseMove({clientX: 40, clientY: 10});
+  yield onHighlighted;
+
+  ok(editor.highlighter.isShown, "The highlighter is now shown");
+  is(editor.highlighter.options.selector, "div", "The selector is correct");
+
+  info("Simulate a mousemove event elsewhere in the editor");
+  editor._onMouseMove({clientX: 0, clientY: 0});
+
+  ok(!editor.highlighter.isShown, "The highlighter is now hidden");
+});
--- a/browser/themes/shared/incontentprefs/preferences.css
+++ b/browser/themes/shared/incontentprefs/preferences.css
@@ -309,21 +309,16 @@ description > html|a {
 
 #dialogFrame {
   -moz-box-flex: 1;
   /* Default dialog dimensions */
   height: 30em;
   width: 66ch;
 }
 
-/* needs to be removed with bug 1035625 */
-:-moz-any(dialog, window, prefwindow) resizer {
-  display: none;
-}
-
 tree:not(#rejectsTree) {
   min-height: 15em;
 }
 
 :-moz-any(dialog, window, prefwindow) groupbox {
   -moz-margin-start: 8px;
   -moz-margin-end: 8px;
 }
--- a/mobile/android/base/favicons/LoadFaviconTask.java
+++ b/mobile/android/base/favicons/LoadFaviconTask.java
@@ -214,16 +214,18 @@ public class LoadFaviconTask {
         }
 
         LoadFaviconResult result = null;
 
         try {
             result = downloadAndDecodeImage(targetFaviconURI);
         } catch (Exception e) {
             Log.e(LOGTAG, "Error reading favicon", e);
+        } catch (OutOfMemoryError e) {
+            Log.e(LOGTAG, "Insufficient memory to process favicon");
         }
 
         return result;
     }
 
     /**
      * Download the Favicon from the given URL and pass it to the decoder function.
      *
@@ -242,16 +244,36 @@ public class LoadFaviconTask {
             return null;
         }
 
         HttpEntity entity = response.getEntity();
         if (entity == null) {
             return null;
         }
 
+        // Decode the image from the fetched response.
+        try {
+            return decodeImageFromResponse(entity);
+        } finally {
+            // Close the stream and free related resources.
+            entity.consumeContent();
+        }
+    }
+
+    /**
+     * Copies the favicon stream to a buffer and decodes downloaded content  into bitmaps using the
+     * FaviconDecoder.
+     *
+     * @param entity HttpEntity containing the favicon stream to decode.
+     * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or
+     *         null if no or corrupt data were received.
+     * @throws IOException If attempts to fully read the stream result in such an exception, such as
+     *                     in the event of a transient connection failure.
+     */
+    private LoadFaviconResult decodeImageFromResponse(HttpEntity entity) throws IOException {
         // This may not be provided, but if it is, it's useful.
         final long entityReportedLength = entity.getContentLength();
         int bufferSize;
         if (entityReportedLength > 0) {
             // The size was reported and sane, so let's use that.
             // Integer overflow should not be a problem for Favicon sizes...
             bufferSize = (int) entityReportedLength + 1;
         } else {
--- a/toolkit/components/passwordmgr/content/passwordManager.xul
+++ b/toolkit/components/passwordmgr/content/passwordManager.xul
@@ -108,11 +108,10 @@
   <hbox align="end">
     <hbox class="actionButtons" flex="1">
       <spacer flex="1"/>
 #ifndef XP_MACOSX
       <button oncommand="close();" icon="close"
               label="&closebutton.label;" accesskey="&closebutton.accesskey;"/>
 #endif
     </hbox>
-    <resizer type="window" dir="bottomend"/>
   </hbox>
 </window>
--- a/toolkit/devtools/server/actors/highlighter.js
+++ b/toolkit/devtools/server/actors/highlighter.js
@@ -26,21 +26,25 @@ const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-
 let HELPER_SHEET = ".__fx-devtools-hide-shortcut__ { visibility: hidden !important } ";
 HELPER_SHEET += ":-moz-devtools-highlighted { outline: 2px dashed #F06!important; outline-offset: -2px!important } ";
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const SVG_NS = "http://www.w3.org/2000/svg";
 const HIGHLIGHTER_PICKED_TIMER = 1000;
 const INFO_BAR_OFFSET = 5;
 // The minimum distance a line should be before it has an arrow marker-end
 const ARROW_LINE_MIN_DISTANCE = 10;
+// How many maximum nodes can be highlighted at the same time by the
+// SelectorHighlighter
+const MAX_HIGHLIGHTED_ELEMENTS = 100;
 
 // All possible highlighter classes
 let HIGHLIGHTER_CLASSES = exports.HIGHLIGHTER_CLASSES = {
   "BoxModelHighlighter": BoxModelHighlighter,
-  "CssTransformHighlighter": CssTransformHighlighter
+  "CssTransformHighlighter": CssTransformHighlighter,
+  "SelectorHighlighter": SelectorHighlighter
 };
 
 /**
  * The Highlighter is the server-side entry points for any tool that wishes to
  * highlight elements in some way in the content document.
  *
  * A little bit of vocabulary:
  * - <something>HighlighterActor classes are the actors that can be used from
@@ -1220,16 +1224,79 @@ CssTransformHighlighter.prototype = Heri
   },
 
   _showShapes: function() {
     this._svgRoot.removeAttribute("hidden");
   }
 });
 
 /**
+ * The SelectorHighlighter runs a given selector through querySelectorAll on the
+ * document of the provided context node and then uses the BoxModelHighlighter
+ * to highlight the matching nodes
+ */
+function SelectorHighlighter(tabActor) {
+  this.tabActor = tabActor;
+  this._highlighters = [];
+}
+
+SelectorHighlighter.prototype = {
+  /**
+   * Show BoxModelHighlighter on each node that matches that provided selector.
+   * @param {DOMNode} node A context node that is used to get the document on
+   * which querySelectorAll should be executed. This node will NOT be
+   * highlighted.
+   * @param {Object} options Should at least contain the 'selector' option, a
+   * string that will be used in querySelectorAll. On top of this, all of the
+   * valid options to BoxModelHighlighter.show are also valid here.
+   */
+  show: function(node, options={}) {
+    this.hide();
+
+    if (!isNodeValid(node) || !options.selector) {
+      return;
+    }
+
+    let nodes = [];
+    try {
+      nodes = [...node.ownerDocument.querySelectorAll(options.selector)];
+    } catch (e) {}
+
+    delete options.selector;
+
+    let i = 0;
+    for (let matchingNode of nodes) {
+      if (i >= MAX_HIGHLIGHTED_ELEMENTS) {
+        break;
+      }
+
+      let highlighter = new BoxModelHighlighter(this.tabActor);
+      if (options.fill) {
+        highlighter.regionFill[options.region || "border"] = options.fill;
+      }
+      highlighter.show(matchingNode, options);
+      this._highlighters.push(highlighter);
+      i ++;
+    }
+  },
+
+  hide: function() {
+    for (let highlighter of this._highlighters) {
+      highlighter.destroy();
+    }
+    this._highlighters = [];
+  },
+
+  destroy: function() {
+    this.hide();
+    this.tabActor = null;
+  }
+};
+
+/**
  * The SimpleOutlineHighlighter is a class that has the same API than the
  * BoxModelHighlighter, but adds a pseudo-class on the target element itself
  * to draw a simple outline.
  * It is used by the HighlighterActor too, but in case the more complex
  * BoxModelHighlighter can't be attached (which is the case for FirefoxOS and
  * Fennec targets for instance).
  */
 function SimpleOutlineHighlighter(tabActor) {
--- a/toolkit/devtools/server/actors/inspector.js
+++ b/toolkit/devtools/server/actors/inspector.js
@@ -2341,16 +2341,39 @@ var WalkerActor = protocol.ActorClass({
   }, {
     request: {
       objectActorID: Arg(0, "string")
     },
     response: {
       nodeFront: RetVal("nullable:disconnectedNode")
     }
   }),
+
+  /**
+   * Given an StyleSheetActor (identified by its ID), commonly used in the
+   * style-editor, get its ownerNode and return the corresponding walker's
+   * NodeActor
+   */
+  getStyleSheetOwnerNode: method(function(styleSheetActorID) {
+    let styleSheetActor = this.conn.getActor(styleSheetActorID);
+    let ownerNode = styleSheetActor.ownerNode;
+
+    if (!styleSheetActor || !ownerNode) {
+      return null;
+    }
+
+    return this.attachElement(ownerNode);
+  }, {
+    request: {
+      styleSheetActorID: Arg(0, "string")
+    },
+    response: {
+      ownerNode: RetVal("nullable:disconnectedNode")
+    }
+  }),
 });
 
 /**
  * Client side of the DOM walker.
  */
 var WalkerFront = exports.WalkerFront = protocol.FrontClass(WalkerActor, {
   // Set to true if cleanup should be requested after every mutation list.
   autoCleanup: true,
@@ -2482,16 +2505,24 @@ var WalkerFront = exports.WalkerFront = 
   getNodeActorFromObjectActor: protocol.custom(function(objectActorID) {
     return this._getNodeActorFromObjectActor(objectActorID).then(response => {
       return response ? response.node : null;
     });
   }, {
     impl: "_getNodeActorFromObjectActor"
   }),
 
+  getStyleSheetOwnerNode: protocol.custom(function(styleSheetActorID) {
+    return this._getStyleSheetOwnerNode(styleSheetActorID).then(response => {
+      return response ? response.node : null;
+    });
+  }, {
+    impl: "_getStyleSheetOwnerNode"
+  }),
+
   _releaseFront: function(node, force) {
     if (node.retained && !force) {
       node.reparent(null);
       this._retainedOrphans.add(node);
       return;
     }
 
     if (node.retained) {
--- a/toolkit/devtools/server/actors/root.js
+++ b/toolkit/devtools/server/actors/root.js
@@ -110,17 +110,18 @@ RootActor.prototype = {
     editOuterHTML: true,
     // Whether the server-side highlighter actor exists and can be used to
     // remotely highlight nodes (see server/actors/highlighter.js)
     highlightable: true,
     // Which custom highlighter does the server-side highlighter actor supports?
     // (see server/actors/highlighter.js)
     customHighlighters: [
       "BoxModelHighlighter",
-      "CssTransformHighlighter"
+      "CssTransformHighlighter",
+      "SelectorHighlighter"
     ],
     // Whether the inspector actor implements the getImageDataFromURL
     // method that returns data-uris for image URLs. This is used for image
     // tooltips for instance
     urlToImageDataResolver: true,
     networkMonitor: true,
     // Whether the storage inspector actor to inspect cookies, etc.
     storageInspector: true,
--- a/toolkit/devtools/server/actors/stylesheets.js
+++ b/toolkit/devtools/server/actors/stylesheets.js
@@ -425,16 +425,18 @@ let StyleSheetActor = protocol.ActorClas
    */
   get browser() {
     if (this.parentActor.parentActor) {
       return this.parentActor.parentActor.browser;
     }
     return null;
   },
 
+  get ownerNode() this.rawSheet.ownerNode,
+
   /**
    * URL of underlying stylesheet.
    */
   get href() this.rawSheet.href,
 
   /**
    * Retrieve the index (order) of stylesheet in the document.
    *
@@ -483,34 +485,33 @@ let StyleSheetActor = protocol.ActorClas
     catch (e) {
       // sheet isn't loaded yet
     }
 
     if (rules) {
       return promise.resolve(rules);
     }
 
-    let ownerNode = this.rawSheet.ownerNode;
-    if (!ownerNode) {
+    if (!this.ownerNode) {
       return promise.resolve([]);
     }
 
     if (this._cssRules) {
       return this._cssRules;
     }
 
     let deferred = promise.defer();
 
     let onSheetLoaded = (event) => {
-      ownerNode.removeEventListener("load", onSheetLoaded, false);
+      this.ownerNode.removeEventListener("load", onSheetLoaded, false);
 
       deferred.resolve(this.rawSheet.cssRules);
     };
 
-    ownerNode.addEventListener("load", onSheetLoaded, false);
+    this.ownerNode.addEventListener("load", onSheetLoaded, false);
 
     // cache so we don't add many listeners if this is called multiple times.
     this._cssRules = deferred.promise;
 
     return this._cssRules;
   },
 
   /**
@@ -521,23 +522,22 @@ let StyleSheetActor = protocol.ActorClas
    *        'styleSheetIndex' and 'parentActor' if it's @imported
    */
   form: function(detail) {
     if (detail === "actorid") {
       return this.actorID;
     }
 
     let docHref;
-    let ownerNode = this.rawSheet.ownerNode;
-    if (ownerNode) {
-      if (ownerNode instanceof Ci.nsIDOMHTMLDocument) {
-        docHref = ownerNode.location.href;
+    if (this.ownerNode) {
+      if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) {
+        docHref = this.ownerNode.location.href;
       }
-      else if (ownerNode.ownerDocument && ownerNode.ownerDocument.location) {
-        docHref = ownerNode.ownerDocument.location.href;
+      else if (this.ownerNode.ownerDocument && this.ownerNode.ownerDocument.location) {
+        docHref = this.ownerNode.ownerDocument.location.href;
       }
     }
 
     let form = {
       actor: this.actorID,  // actorID is set when this actor is added to a pool
       href: this.href,
       nodeHref: docHref,
       disabled: this.rawSheet.disabled,
@@ -606,17 +606,17 @@ let StyleSheetActor = protocol.ActorClas
    */
   _getText: function() {
     if (this.text) {
       return promise.resolve(this.text);
     }
 
     if (!this.href) {
       // this is an inline <style> sheet
-      let content = this.rawSheet.ownerNode.textContent;
+      let content = this.ownerNode.textContent;
       this.text = content;
       return promise.resolve(content);
     }
 
     let options = {
       window: this.window,
       loadFromCache: true,
       charset: this._getCSSCharset()
--- a/toolkit/devtools/server/tests/mochitest/chrome.ini
+++ b/toolkit/devtools/server/tests/mochitest/chrome.ini
@@ -40,16 +40,20 @@ skip-if = buildapp == 'mulet'
 [test_highlighter-boxmodel_02.html]
 skip-if = buildapp == 'mulet'
 [test_highlighter-csstransform_01.html]
 skip-if = buildapp == 'mulet'
 [test_highlighter-csstransform_02.html]
 skip-if = buildapp == 'mulet'
 [test_highlighter-csstransform_03.html]
 skip-if = buildapp == 'mulet'
+[test_highlighter-selector_01.html]
+skip-if = buildapp == 'mulet'
+[test_highlighter-selector_02.html]
+skip-if = buildapp == 'mulet'
 [test_inspector-changeattrs.html]
 [test_inspector-changevalue.html]
 [test_inspector-hide.html]
 [test_inspector-insert.html]
 [test_inspector-mutations-attr.html]
 [test_inspector-mutations-childlist.html]
 [test_inspector-mutations-frameload.html]
 [test_inspector-mutations-value.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/tests/mochitest/test_highlighter-selector_01.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the custom selector highlighter creates as many box-model highlighters
+as there are nodes that match the given selector
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Framerate actor test</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>
+  <div id="test-node">test node</div>
+  <ul>
+    <li class="item">item</li>
+    <li class="item">item</li>
+    <li class="item">item</li>
+    <li class="item">item</li>
+    <li class="item">item</li>
+  </ul>
+<pre id="test">
+<script type="application/javascript;version=1.8">
+SimpleTest.waitForExplicitFinish();
+
+window.onload = function() {
+  const Cu = Components.utils;
+  const Cc = Components.classes;
+  const Ci = Components.interfaces;
+
+  Cu.import("resource://gre/modules/Services.jsm");
+  Cu.import("resource://gre/modules/devtools/Loader.jsm");
+  Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
+  Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
+  Cu.import("resource://gre/modules/Task.jsm");
+  const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+  const {InspectorFront} = devtools.require("devtools/server/actors/inspector");
+
+  const TEST_DATA = [{
+    selector: "#test-node",
+    containerCount: 1
+  }, {
+    selector: null,
+    containerCount: 0,
+  }, {
+    selector: undefined,
+    containerCount: 0,
+  }, {
+    selector: ".invalid-class",
+    containerCount: 0
+  }, {
+    selector: ".item",
+    containerCount: 5
+  }, {
+    selector: "#test-node, ul, .item",
+    containerCount: 7
+  }];
+
+  DebuggerServer.init(() => true);
+  DebuggerServer.addBrowserActors();
+
+  let client = new DebuggerClient(DebuggerServer.connectPipe());
+  client.connect(() => {
+    client.listTabs(response => {
+      let form = response.tabs[response.selected];
+      let front = InspectorFront(client, form);
+
+      Task.spawn(function*() {
+        let walker = yield front.getWalker();
+        let highlighter = yield front.getHighlighterByType("SelectorHighlighter");
+
+        let browser = Services.wm.getMostRecentWindow("navigator:browser")
+                                 .gBrowser.selectedBrowser;
+
+        // Remove left-over highlighter contains from previous tests
+        for (let container of browser.parentNode
+          .querySelectorAll(".highlighter-container")) {
+          container.remove();
+        }
+
+        // The node given to SelectorHighlighter's show method is only used to
+        // know which document should matching nodes be searched in
+        let node = document.body;
+
+        for (let {selector, containerCount} of TEST_DATA) {
+          info("Showing the highlighter on " + selector + ". Expecting " +
+            containerCount + " highlighter containers");
+
+          yield highlighter.show(walker.frontForRawNode(node), {selector});
+
+          let containers = browser.parentNode.querySelectorAll(
+            ".highlighter-container");
+          is(containers.length, containerCount,
+            "The correct number of box-model highlighers were created");
+          yield highlighter.hide();
+        }
+
+        yield highlighter.finalize();
+      }).then(null, ok.bind(null, false)).then(() => {
+        client.close(() => {
+          DebuggerServer.destroy();
+          SimpleTest.finish();
+        });
+      });
+    });
+  });
+
+}
+</script>
+</pre>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/tests/mochitest/test_highlighter-selector_02.html
@@ -0,0 +1,100 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the custom selector highlighter creates highlighters for nodes in the
+right frame
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Framerate actor test</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>
+  <div class="root-level-node"></div>
+  <iframe src="data:text/html;charset=utf8,<div class='sub-level-node'></div>"></iframe>
+<pre id="test">
+<script type="application/javascript;version=1.8">
+SimpleTest.waitForExplicitFinish();
+
+window.onload = function() {
+  const Cu = Components.utils;
+  const Cc = Components.classes;
+  const Ci = Components.interfaces;
+
+  Cu.import("resource://gre/modules/Services.jsm");
+  Cu.import("resource://gre/modules/devtools/Loader.jsm");
+  Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
+  Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
+  Cu.import("resource://gre/modules/Task.jsm");
+  const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+  const {InspectorFront} = devtools.require("devtools/server/actors/inspector");
+
+  const TEST_DATA = [{
+    contextNode: document.body,
+    selector: ".root-level-node",
+    containerCount: 1
+  }, {
+    contextNode: document.body,
+    selector: ".sub-level-node",
+    containerCount: 0
+  }, {
+    contextNode: document.querySelector("iframe").contentDocument.body,
+    selector: ".root-level-node",
+    containerCount: 0
+  }, {
+    contextNode: document.querySelector("iframe").contentDocument.body,
+    selector: ".sub-level-node",
+    containerCount: 1
+  }];
+
+  DebuggerServer.init(() => true);
+  DebuggerServer.addBrowserActors();
+
+  let client = new DebuggerClient(DebuggerServer.connectPipe());
+  client.connect(() => {
+    client.listTabs(response => {
+      let form = response.tabs[response.selected];
+      let front = InspectorFront(client, form);
+
+      Task.spawn(function*() {
+        let walker = yield front.getWalker();
+        let highlighter = yield front.getHighlighterByType("SelectorHighlighter");
+
+        let browser = Services.wm.getMostRecentWindow("navigator:browser")
+                                 .gBrowser.selectedBrowser;
+
+        // Remove left-over highlighter contains from previous tests
+        for (let container of browser.parentNode
+          .querySelectorAll(".highlighter-container")) {
+          container.remove();
+        }
+
+        for (let {contextNode, selector, containerCount} of TEST_DATA) {
+          info("Showing the highlighter on " + selector + ". Expecting " +
+            containerCount + " highlighter containers");
+
+          yield highlighter.show(walker.frontForRawNode(contextNode), {selector});
+
+          let containers = browser.parentNode.querySelectorAll(
+            ".highlighter-container");
+          is(containers.length, containerCount,
+            "The correct number of box-model highlighers were created");
+          yield highlighter.hide();
+        }
+
+        yield highlighter.finalize();
+      }).then(null, ok.bind(null, false)).then(() => {
+        client.close(() => {
+          DebuggerServer.destroy();
+          SimpleTest.finish();
+        });
+      });
+    });
+  });
+
+}
+</script>
+</pre>
+</body>
+</html>