Bug 1089547: simplify LoopRooms implementation, add support for events. r=Standard8 a=loop-only
authorMike de Boer <mdeboer@mozilla.com>
Wed, 29 Oct 2014 14:28:42 +0100
changeset 235101 b74d6c49038dfeeb3234aad6f9dedb353290c974
parent 235100 72bcb9582e285928aaaf33369b96aa56339a77fc
child 235102 6dca7a6a8f339586c8f6c741323b5254d4ebfd97
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, loop-only
bugs1089547
milestone35.0a2
Bug 1089547: simplify LoopRooms implementation, add support for events. r=Standard8 a=loop-only
browser/components/loop/LoopRooms.jsm
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/test/xpcshell/test_looprooms.js
browser/components/loop/test/xpcshell/test_rooms_create.js
browser/components/loop/test/xpcshell/test_rooms_getdata.js
browser/components/loop/test/xpcshell/xpcshell.ini
services/common/hawkclient.js
--- a/browser/components/loop/LoopRooms.jsm
+++ b/browser/components/loop/LoopRooms.jsm
@@ -1,343 +1,227 @@
 /* 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/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-
-XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
-                                  "resource:///modules/loop/MozLoopService.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "LOOP_SESSION_TYPE",
-                                  "resource:///modules/loop/MozLoopService.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
-                                  "resource:///modules/loop/MozLoopPushHandler.jsm");
+const {MozLoopService, LOOP_SESSION_TYPE} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
+  const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+  return new EventEmitter();
+});
 
 this.EXPORTED_SYMBOLS = ["LoopRooms", "roomsPushNotification"];
 
-let gRoomsListFetched = false;
-let gRooms = new Map();
-let gCallbacks = new Map();
+const roomsPushNotification = function(version, channelID) {
+  return LoopRoomsInternal.onNotification(version, channelID);
+};
+
+// Since the LoopRoomsInternal.rooms map as defined below is a local cache of
+// room objects that are retrieved from the server, this is list may become out
+// of date. The Push server may notify us of this event, which will set the global
+// 'dirty' flag to TRUE.
+let gDirty = true;
+
+/**
+ * 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;
+};
+
+/**
+ * The Rooms 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 LoopRoomsInternal = {
+  rooms: new Map(),
 
   /**
-   * Callback used to indicate changes to rooms data on the LoopServer.
+   * Fetch a list of rooms that the currently registered user is a member of.
    *
-   * @param {Object} version Version number assigned to this change set.
-   * @param {Object} channelID Notification channel identifier.
-   *
+   * @param {String}   [version] If set, we will fetch a list of changed rooms since
+   *                             `version`. Optional.
+   * @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 rooms, if it was fetched successfully.
    */
-const roomsPushNotification = function(version, channelID) {
-    return LoopRoomsInternal.onNotification(version, channelID);
-  };
+  getAll: function(version = null, callback) {
+    if (!callback) {
+      callback = version;
+      version = null;
+    }
 
-let LoopRoomsInternal = {
-  getAll: function(callback) {
-    Task.spawn(function*() {
+    Task.spawn(function* () {
       yield MozLoopService.register();
 
-      if (gRoomsListFetched) {
-        callback(null, [...gRooms.values()]);
+      if (!gDirty) {
+        callback(null, [...this.rooms.values()]);
         return;
       }
+
       // Fetch the rooms from the server.
       let sessionType = MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
                         LOOP_SESSION_TYPE.GUEST;
-      let rooms = yield this.requestRoomList(sessionType);
-      // Add each room to our in-memory Map using a locally unique
-      // identifier.
-      for (let room of rooms) {
-        let id = MozLoopService.generateLocalID();
-        room.localRoomId = id;
-        // Next, request the detailed information for each room.
-        // If the request fails the room data will not be added to the map.
-        try {
-          let details = yield this.requestRoomDetails(room.roomToken, sessionType);
-          for (let attr in details) {
-            room[attr] = details[attr]
-          }
-          delete room.currSize; //This attribute will be eliminated in the next revision.
-          gRooms.set(id, room);
-        }
-        catch (error) {MozLoopService.log.warn(
-          "failed GETing room details for roomToken = " + room.roomToken + ": ", error)}
+      let url = "/rooms" + (version ? "?version=" + encodeURIComponent(version) : "");
+      let response = yield MozLoopService.hawkRequest(sessionType, url, "GET");
+      let roomsList = JSON.parse(response.body);
+      if (!Array.isArray(roomsList)) {
+        throw new Error("Missing array of rooms in response.");
       }
-      callback(null, [...gRooms.values()]);
-      return;
-      }.bind(this)).catch((error) => {MozLoopService.log.error("getAll error:", error);
-                                      callback(error)});
-    return;
-  },
 
-  getRoomData: function(localRoomId, callback) {
-    if (gRooms.has(localRoomId)) {
-      callback(null, gRooms.get(localRoomId));
-    } else {
-      callback(new Error("Room data not found or not fetched yet for room with ID " + localRoomId));
-    }
-    return;
+      // Next, request the detailed information for each room. If the request
+      // fails the room data will not be added to the map.
+      for (let room of roomsList) {
+        let eventName = this.rooms.has(room.roomToken) ? "update" : "add";
+        this.rooms.set(room.roomToken, room);
+        yield LoopRooms.promise("get", room.roomToken);
+      }
+
+      // Set the 'dirty' flag back to FALSE, since the list is as fresh as can be now.
+      gDirty = false;
+      callback(null, [...this.rooms.values()]);
+    }.bind(this)).catch(error => {
+      callback(error);
+    });
   },
 
   /**
-   * Request list of all rooms associated with this account.
+   * Request information about a specific room from the server. It will be
+   * returned from the cache if it's already in it.
    *
-   * @param {String} sessionType Indicates which hawkRequest endpoint to use.
-   *
-   * @returns {Promise} room list
+   * @param {String}   roomToken Room identifier
+   * @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 rooms, if it was fetched successfully.
    */
-  requestRoomList: function(sessionType) {
-    return MozLoopService.hawkRequest(sessionType, "/rooms", "GET")
-      .then(response => {
-        let roomsList = JSON.parse(response.body);
-        if (!Array.isArray(roomsList)) {
-          // Force a reject in the returned promise.
-          // To be caught by the caller using the returned Promise.
-          throw new Error("Missing array of rooms in response.");
-        }
-        return roomsList;
-      });
+  get: function(roomToken, callback) {
+    let room = this.rooms.has(roomToken) ? this.rooms.get(roomToken) : {};
+    // Check if we need to make a request to the server to collect more room data.
+    if (!room || gDirty || !("participants" in room)) {
+      let sessionType = MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
+                        LOOP_SESSION_TYPE.GUEST;
+      MozLoopService.hawkRequest(sessionType, "/rooms/" + encodeURIComponent(roomToken), "GET")
+        .then(response => {
+          let eventName = ("roomToken" in room) ? "add" : "update";
+          extend(room, JSON.parse(response.body));
+          // Remove the `currSize` for posterity.
+          if ("currSize" in room) {
+            delete room.currSize;
+          }
+          this.rooms.set(roomToken, room);
+
+          eventEmitter.emit(eventName, room);
+          callback(null, room);
+        }, err => callback(err)).catch(err => callback(err));
+    } else {
+      callback(null, room);
+    }
   },
 
   /**
-   * Request information about a specific room from the server.
-   *
-   * @param {Object} token Room identifier returned from the LoopServer.
-   * @param {String} sessionType Indicates which hawkRequest endpoint to use.
+   * Create a room.
    *
-   * @returns {Promise} room details
+   * @param {Object}   room     Properties to be sent to the LoopServer
+   * @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 room, if it was created successfully.
    */
-  requestRoomDetails: function(token, sessionType) {
-    return MozLoopService.hawkRequest(sessionType, "/rooms/" + token, "GET")
-      .then(response => JSON.parse(response.body));
+  create: function(room, callback) {
+    if (!("roomName" in room) || !("expiresIn" in room) ||
+        !("roomOwner" in room) || !("maxSize" in room)) {
+      callback(new Error("Missing required property to create a room"));
+      return;
+    }
+
+    let sessionType = MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
+                      LOOP_SESSION_TYPE.GUEST;
+
+    MozLoopService.hawkRequest(sessionType, "/rooms", "POST", room)
+      .then(response => {
+        let data = JSON.parse(response.body);
+        extend(room, data);
+        // Do not keep this value - it is a request to the server.
+        delete room.expiresIn;
+        this.rooms.set(room.roomToken, room);
+
+        eventEmitter.emit("add", room);
+        callback(null, room);
+      }, error => callback(error)).catch(error => callback(error));
   },
 
   /**
    * Callback used to indicate changes to rooms data on the LoopServer.
    *
-   * @param {Object} version Version number assigned to this change set.
-   * @param {Object} channelID Notification channel identifier.
-   *
+   * @param {String} version   Version number assigned to this change set.
+   * @param {String} channelID Notification channel identifier.
    */
   onNotification: function(version, channelID) {
-    return;
-  },
-
-  createRoom: function(props, callback) {
-    // Always create a basic room record and launch the window, attaching
-    // the localRoomId. Later errors will be returned via the registered callback.
-    let localRoomId = MozLoopService.generateLocalID((id) => {gRooms.has(id)})
-    let room = {localRoomId : localRoomId};
-    for (let prop in props) {
-      room[prop] = props[prop]
-    }
-
-    gRooms.set(localRoomId, room);
-    this.addCallback(localRoomId, "RoomCreated", callback);
-    MozLoopService.openChatWindow(null, "", "about:loopconversation#room/" + localRoomId);
-
-    if (!"roomName" in props ||
-        !"expiresIn" in props ||
-        !"roomOwner" in props ||
-        !"maxSize" in props) {
-      this.postCallback(localRoomId, "RoomCreated",
-                        new Error("missing required room create property"));
-      return localRoomId;
-    }
-
-    let sessionType = MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
-                                                   LOOP_SESSION_TYPE.GUEST;
-
-    MozLoopService.hawkRequest(sessionType, "/rooms", "POST", props).then(
-      (response) => {
-        let data = JSON.parse(response.body);
-        for (let attr in data) {
-          room[attr] = data[attr]
-        }
-        delete room.expiresIn; //Do not keep this value - it is a request to the server
-        this.postCallback(localRoomId, "RoomCreated", null, room);
-      },
-      (error) => {
-        this.postCallback(localRoomId, "RoomCreated", error);
-      });
-
-    return localRoomId;
-  },
-
-  /**
-   * Send an update to the callbacks registered for a specific localRoomId
-   * for a callback type.
-   *
-   * The result set is always saved. Then each
-   * callback function that has been registered when this function is
-   * called will be called with the result set. Any new callback that
-   * is regsitered via addCallback will receive a copy of the last
-   * saved result set when registered. This allows the posting operation
-   * to complete before the callback is registered in an asynchronous
-   * operation.
-   *
-   * Callbacsk must be of the form:
-   *    function (error, success) {...}
-   *
-   * @param {String} localRoomId Local room identifier.
-   * @param {String} callbackName callback type
-   * @param {?Error} error result or null.
-   * @param {?Object} success result if error argument is null.
-   */
-  postCallback: function(localRoomId, callbackName, error, success) {
-    let roomCallbacks = gCallbacks.get(localRoomId);
-    if (!roomCallbacks) {
-      // No callbacks have been registered or results posted for this room.
-      // Initialize a record for this room and callbackName, saving the
-      // result set.
-      gCallbacks.set(localRoomId, new Map([[
-        callbackName,
-        { callbackList: [], result: { error: error, success: success } }]]));
-      return;
-    }
-
-    let namedCallback = roomCallbacks.get(callbackName);
-    // A callback of this name has not been registered.
-    if (!namedCallback) {
-      roomCallbacks.set(
-        callbackName,
-        {callbackList: [], result: {error: error, success: success}});
-      return;
-    }
-
-    // Record the latest result set.
-    namedCallback.result = {error: error, success: success};
-
-    // Call each registerd callback passing the new result posted.
-    namedCallback.callbackList.forEach((callback) => {
-      callback(error, success);
-    });
-  },
-
-  addCallback: function(localRoomId, callbackName, callback) {
-    let roomCallbacks = gCallbacks.get(localRoomId);
-    if (!roomCallbacks) {
-      // No callbacks have been registered or results posted for this room.
-      // Initialize a record for this room and callbackName.
-      gCallbacks.set(localRoomId, new Map([[
-        callbackName,
-        {callbackList: [callback]}]]));
-      return;
-    }
-
-    let namedCallback = roomCallbacks.get(callbackName);
-    // A callback of this name has not been registered.
-    if (!namedCallback) {
-      roomCallbacks.set(
-        callbackName,
-        {callbackList: [callback]});
-      return;
-    }
-
-    // Add this callback if not already in the array
-    if (namedCallback.callbackList.indexOf(callback) >= 0) {
-      return;
-    }
-    namedCallback.callbackList.push(callback);
-
-    // If a result has been posted for this callback
-    // send it using this new callback function.
-    let result = namedCallback.result;
-    if (result) {
-      callback(result.error, result.success);
-    }
-  },
-
-  deleteCallback: function(localRoomId, callbackName, callback) {
-    let roomCallbacks = gCallbacks.get(localRoomId);
-    if (!roomCallbacks) {
-      return;
-    }
-
-    let namedCallback = roomCallbacks.get(callbackName);
-    if (!namedCallback) {
-      return;
-    }
-
-    let i = namedCallback.callbackList.indexOf(callback);
-    if (i >= 0) {
-      namedCallback.callbackList.splice(i, 1);
-    }
-
-    return;
+    gDirty = true;
+    this.getAll(version, () => {});
   },
 };
 Object.freeze(LoopRoomsInternal);
 
 /**
- * The LoopRooms class.
+ * Public Loop Rooms API.
  *
- * 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.
+ * LoopRooms 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 room object was successfully added to the data store.
+ *  - 'remove':    A room was successfully removed from the data store.
+ *  - 'update':    A room object was successfully updated with changed
+ *                 properties in the data store.
+ *
+ * See the internal code for the API documentation.
  */
 this.LoopRooms = {
-  /**
-   * Fetch a list of rooms that the currently registered user is a member 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 the list of rooms, if it was fetched successfully.
-   */
-  getAll: function(callback) {
-    return LoopRoomsInternal.getAll(callback);
+  getAll: function(version, callback) {
+    return LoopRoomsInternal.getAll(version, callback);
   },
 
-  /**
-   * Return the current stored version of the data for the indicated room.
-   *
-   * @param {String} localRoomId Local room identifier
-   * @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 rooms, if it was fetched successfully.
-   */
-  getRoomData: function(localRoomId, callback) {
-    return LoopRoomsInternal.getRoomData(localRoomId, callback);
+  get: function(roomToken, callback) {
+    return LoopRoomsInternal.get(roomToken, callback);
   },
 
-  /**
-   * Create a room. Will both open a chat window for the new room
-   * and perform an exchange with the LoopServer to create the room.
-   * for a callback type. Callback must be of the form:
-   *    function (error, success) {...}
-   *
-   * @param {Object} room properties to be sent to the LoopServer
-   * @param {Function} callback Must be of the form: function (error, success) {...}
-   *
-   * @returns {String} localRoomId assigned to this new room.
-   */
-  createRoom: function(roomProps, callback) {
-    return LoopRoomsInternal.createRoom(roomProps, callback);
+  create: function(options, callback) {
+    return LoopRoomsInternal.create(options, callback);
   },
 
-  /**
-   * Register a callback of a specified type with a localRoomId.
-   *
-   * @param {String} localRoomId Local room identifier.
-   * @param {String} callbackName callback type
-   * @param {Function} callback Must be of the form: function (error, success) {...}
-   */
-  addCallback: function(localRoomId, callbackName, callback) {
-    return LoopRoomsInternal.addCallback(localRoomId, callbackName, callback);
+  promise: function(method, ...params) {
+    return new Promise((resolve, reject) => {
+      this[method](...params, (error, result) => {
+        if (error) {
+          reject(error);
+        } else {
+          resolve(result);
+        }
+      });
+    });
   },
 
-  /**
-   * Un-register and delete a callback of a specified type for a localRoomId.
-   *
-   * @param {String} localRoomId Local room identifier.
-   * @param {String} callbackName callback type
-   * @param {Function} callback Previously passed to addCallback().
-   */
-  deleteCallback: function(localRoomId, callbackName, callback) {
-    return LoopRoomsInternal.deleteCallback(localRoomId, callbackName, callback);
-  },
+  on: (...params) => eventEmitter.on(...params),
+
+  once: (...params) => eventEmitter.once(...params),
+
+  off: (...params) => eventEmitter.off(...params)
 };
-Object.freeze(LoopRooms);
+Object.freeze(this.LoopRooms);
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -70,16 +70,24 @@ const cloneErrorObject = function(error,
  * @param {any}          value        Value or object to copy
  * @param {nsIDOMWindow} targetWindow The content window to copy to
  */
 const cloneValueInto = function(value, targetWindow) {
   if (!value || typeof value != "object") {
     return value;
   }
 
+  // Strip Function properties, since they can not be cloned across boundaries
+  // like this.
+  for (let prop of value) {
+    if (typeof value[prop] == "function") {
+      delete value[prop];
+    }
+  }
+
   // Inspect for an error this way, because the Error object is special.
   if (value.constructor.name == "Error") {
     return cloneErrorObject(value, targetWindow);
   }
 
   return Cu.cloneInto(value, targetWindow);
 };
 
@@ -171,18 +179,20 @@ function injectLoopAPI(targetWindow) {
           if (error.error instanceof Ci.nsIException) {
             MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
                                      "due to issues copying nsIException across boundaries.",
                                      error.error);
             delete error.error;
           }
 
           // We have to clone the error property since it may be an Error object.
+          if (error.hasOwnProperty("toString")) {
+            delete error.toString;
+          }
           errors[type] = Cu.cloneInto(error, targetWindow);
-
         }
         return Cu.cloneInto(errors, targetWindow);
       },
     },
 
     /**
      * Returns the current locale of the browser.
      *
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_looprooms.js
@@ -0,0 +1,175 @@
+/* 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/. */
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource:///modules/loop/LoopRooms.jsm");
+
+const kRooms = new Map([
+  ["_nxD4V4FflQ", {
+    roomToken: "_nxD4V4FflQ",
+    roomName: "First Room Name",
+    roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
+    maxSize: 2,
+    currSize: 0,
+    ctime: 1405517546
+  }],
+  ["QzBbvGmIZWU", {
+    roomToken: "QzBbvGmIZWU",
+    roomName: "Second Room Name",
+    roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU",
+    maxSize: 2,
+    currSize: 0,
+    ctime: 140551741
+  }],
+  ["3jKS_Els9IU", {
+    roomToken: "3jKS_Els9IU",
+    roomName: "Third Room Name",
+    roomUrl: "http://localhost:3000/rooms/3jKS_Els9IU",
+    maxSize: 3,
+    clientMaxSize: 2,
+    currSize: 1,
+    ctime: 1405518241
+  }]
+]);
+
+let roomDetail = {
+  roomName: "First Room Name",
+  roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
+  roomOwner: "Alexis",
+  maxSize: 2,
+  clientMaxSize: 2,
+  creationTime: 1405517546,
+  expiresAt: 1405534180,
+  participants: [{
+    displayName: "Alexis",
+    account: "alexis@example.com",
+    roomConnectionId: "2a1787a6-4a73-43b5-ae3e-906ec1e763cb"
+  }, {
+    displayName: "Adam",
+    roomConnectionId: "781f012b-f1ea-4ce1-9105-7cfc36fb4ec7"
+  }]
+};
+
+const kCreateRoomProps = {
+  roomName: "UX Discussion",
+  expiresIn: 5,
+  roomOwner: "Alexis",
+  maxSize: 2
+};
+
+const kCreateRoomData = {
+  roomToken: "_nxD4V4FflQ",
+  roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
+  expiresAt: 1405534180
+};
+
+add_task(function* setup_server() {
+  loopServer.registerPathHandler("/registration", (req, res) => {
+    res.setStatusLine(null, 200, "OK");
+    res.processAsync();
+    res.finish();
+  });
+
+  loopServer.registerPathHandler("/rooms", (req, res) => {
+    res.setStatusLine(null, 200, "OK");
+
+    if (req.method == "POST") {
+      Assert.ok(req.bodyInputStream, "POST request should have a payload");
+      let body = CommonUtils.readBytesFromInputStream(req.bodyInputStream);
+      let data = JSON.parse(body);
+      Assert.deepEqual(data, kCreateRoomProps);
+
+      res.write(JSON.stringify(kCreateRoomData));
+    } else {
+      res.write(JSON.stringify([...kRooms.values()]));
+    }
+
+    res.processAsync();
+    res.finish();
+  });
+
+  function returnRoomDetails(res, roomName) {
+    roomDetail.roomName = roomName;
+    res.setStatusLine(null, 200, "OK");
+    res.write(JSON.stringify(roomDetail));
+    res.processAsync();
+    res.finish();
+  }
+
+  // Add a request handler for each room in the list.
+  [...kRooms.values()].forEach(function(room) {
+    loopServer.registerPathHandler("/rooms/" + encodeURIComponent(room.roomToken), (req, res) => {
+      returnRoomDetails(res, room.roomName);
+    });
+  });
+
+  loopServer.registerPathHandler("/rooms/error401", (req, res) => {
+    res.setStatusLine(null, 401, "Not Found");
+    res.processAsync();
+    res.finish();
+  });
+
+  loopServer.registerPathHandler("/rooms/errorMalformed", (req, res) => {
+    res.setStatusLine(null, 200, "OK");
+    res.write("{\"some\": \"Syntax Error!\"}}}}}}");
+    res.processAsync();
+    res.finish();
+  });
+});
+
+const normalizeRoom = function(room) {
+  delete room.currSize;
+  if (!("participants" in room)) {
+    let name = room.roomName;
+    for (let key of Object.getOwnPropertyNames(roomDetail)) {
+      room[key] = roomDetail[key];
+    }
+    room.roomName = name;
+  }
+  return room;
+};
+
+const compareRooms = function(room1, room2) {
+  Assert.deepEqual(normalizeRoom(room1), normalizeRoom(room2));
+};
+
+add_task(function* test_getAllRooms() {
+  yield MozLoopService.register(mockPushHandler);
+
+  let rooms = yield LoopRooms.promise("getAll");
+  Assert.equal(rooms.length, 3);
+  for (let room of rooms) {
+    compareRooms(kRooms.get(room.roomToken), room);
+  }
+});
+
+add_task(function* test_getRoom() {
+  yield MozLoopService.register(mockPushHandler);
+
+  let roomToken = "_nxD4V4FflQ";
+  let room = yield LoopRooms.promise("get", roomToken);
+  Assert.deepEqual(room, kRooms.get(roomToken));
+});
+
+add_task(function* test_errorStates() {
+  yield Assert.rejects(LoopRooms.promise("get", "error401"), /Not Found/, "Fetching a non-existent room should fail");
+  yield Assert.rejects(LoopRooms.promise("get", "errorMalformed"), /SyntaxError/, "Wrong message format should reject");
+});
+
+add_task(function* test_createRoom() {
+  let eventCalled = false;
+  LoopRooms.once("add", (e, room) => {
+    compareRooms(room, kCreateRoomProps);
+    eventCalled = true;
+  });
+  let room = yield LoopRooms.promise("create", kCreateRoomProps);
+  compareRooms(room, kCreateRoomProps);
+  Assert.ok(eventCalled, "Event should have fired");
+});
+
+function run_test() {
+  setupFakeLoopServer();
+
+  run_next_test();
+}
deleted file mode 100644
--- a/browser/components/loop/test/xpcshell/test_rooms_create.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/* 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/. */
-
-Cu.import("resource://services-common/utils.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "Chat",
-                                  "resource:///modules/Chat.jsm");
-let hasTheseProps = function(a, b) {
-  for (let prop in a) {
-    if (a[prop] != b[prop]) {
-      return false;
-    }
-  }
-  return true;
-}
-
-let openChatOrig = Chat.open;
-
-add_test(function test_openRoomsWindow() {
-  let roomProps = {roomName: "UX Discussion",
-                   expiresIn: 5,
-                   roomOwner: "Alexis",
-                   maxSize: 2}
-
-  let roomData = {roomToken: "_nxD4V4FflQ",
-                  roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
-                  expiresAt: 1405534180}
-
-  loopServer.registerPathHandler("/rooms", (request, response) => {
-    if (!request.bodyInputStream) {
-      do_throw("empty request body");
-    }
-    let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
-    let data = JSON.parse(body);
-    do_check_true(hasTheseProps(roomProps, data));
-
-    response.setStatusLine(null, 200, "OK");
-    response.write(JSON.stringify(roomData));
-    response.processAsync();
-    response.finish();
-  });
-
-  MozLoopService.register(mockPushHandler).then(() => {
-    let opened = false;
-    let created = false;
-    let urlPieces = [];
-
-    Chat.open = function(contentWindow, origin, title, url) {
-      urlPieces = url.split('/');
-      do_check_eq(urlPieces[0], "about:loopconversation#room");
-      opened = true;
-    };
-
-    let returnedID = LoopRooms.createRoom(roomProps, (error, data) => {
-      do_check_false(error);
-      do_check_true(data);
-      do_check_true(hasTheseProps(roomData, data));
-      do_check_eq(data.localRoomId, urlPieces[1]);
-      created = true;
-    });
-
-    waitForCondition(function() created && opened).then(() => {
-      do_check_true(opened, "should open a chat window");
-      do_check_eq(returnedID, urlPieces[1]);
-
-      // Verify that a delayed callback, when attached,
-      // received the same data.
-      LoopRooms.addCallback(
-        urlPieces[1], "RoomCreated",
-        (error, data) => {
-          do_check_false(error);
-          do_check_true(data);
-          do_check_true(hasTheseProps(roomData, data));
-          do_check_eq(data.localRoomId, urlPieces[1]);
-        });
-
-      run_next_test();
-    }, () => {
-      do_throw("should have opened a chat window");
-    });
-
-  });
-});
-
-function run_test()
-{
-  setupFakeLoopServer();
-  mockPushHandler.registrationPushURL = kEndPointUrl;
-
-  loopServer.registerPathHandler("/registration", (request, response) => {
-    response.setStatusLine(null, 200, "OK");
-    response.processAsync();
-    response.finish();
-  });
-
-  do_register_cleanup(function() {
-    // Revert original Chat.open implementation
-    Chat.open = openChatOrig;
-  });
-
-  run_next_test();
-}
deleted file mode 100644
--- a/browser/components/loop/test/xpcshell/test_rooms_getdata.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/* 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/. */
-
-Cu.import("resource://services-common/utils.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "Chat",
-                                  "resource:///modules/Chat.jsm");
-let hasTheseProps = function(a, b) {
-  for (let prop in a) {
-    if (a[prop] != b[prop]) {
-      do_print("hasTheseProps fail: prop = " + prop);
-      return false;
-    }
-  }
-  return true;
-}
-
-let openChatOrig = Chat.open;
-
-add_test(function test_getAllRooms() {
-
- let roomList = [
-   { roomToken: "_nxD4V4FflQ",
-     roomName: "First Room Name",
-     roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
-     maxSize: 2,
-     currSize: 0,
-     ctime: 1405517546 },
-   { roomToken: "QzBbvGmIZWU",
-     roomName: "Second Room Name",
-     roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU",
-     maxSize: 2,
-     currSize: 0,
-     ctime: 140551741 },
-   { roomToken: "3jKS_Els9IU",
-     roomName: "Third Room Name",
-     roomUrl: "http://localhost:3000/rooms/3jKS_Els9IU",
-     maxSize: 3,
-     clientMaxSize: 2,
-     currSize: 1,
-     ctime: 1405518241 }
-  ]
-
-  let roomDetail = {
-    roomName: "First Room Name",
-    roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
-    roomOwner: "Alexis",
-    maxSize: 2,
-    clientMaxSize: 2,
-    creationTime: 1405517546,
-    expiresAt: 1405534180,
-    participants: [
-       { displayName: "Alexis", account: "alexis@example.com", roomConnectionId: "2a1787a6-4a73-43b5-ae3e-906ec1e763cb" },
-       { displayName: "Adam", roomConnectionId: "781f012b-f1ea-4ce1-9105-7cfc36fb4ec7" }
-     ]
-  }
-
-  loopServer.registerPathHandler("/rooms", (request, response) => {
-    response.setStatusLine(null, 200, "OK");
-    response.write(JSON.stringify(roomList));
-    response.processAsync();
-    response.finish();
-  });
-
-  let returnRoomDetails = function(response, roomName) {
-    roomDetail.roomName = roomName;
-    response.setStatusLine(null, 200, "OK");
-    response.write(JSON.stringify(roomDetail));
-    response.processAsync();
-    response.finish();
-  }
-
-  loopServer.registerPathHandler("/rooms/_nxD4V4FflQ", (request, response) => {
-    returnRoomDetails(response, "First Room Name");
-  });
-
-  loopServer.registerPathHandler("/rooms/QzBbvGmIZWU", (request, response) => {
-    returnRoomDetails(response, "Second Room Name");
-  });
-
-  loopServer.registerPathHandler("/rooms/3jKS_Els9IU", (request, response) => {
-    returnRoomDetails(response, "Third Room Name");
-  });
-
-  MozLoopService.register().then(() => {
-
-    LoopRooms.getAll((error, rooms) => {
-      do_check_false(error);
-      do_check_true(rooms);
-      do_check_eq(rooms.length, 3);
-      do_check_eq(rooms[0].roomName, "First Room Name");
-      do_check_eq(rooms[1].roomName, "Second Room Name");
-      do_check_eq(rooms[2].roomName, "Third Room Name");
-
-      let room = rooms[0];
-      do_check_true(room.localRoomId);
-      do_check_false(room.currSize);
-      delete roomList[0].currSize;
-      do_check_true(hasTheseProps(roomList[0], room));
-      delete roomDetail.roomName;
-      delete room.participants;
-      delete roomDetail.participants;
-      do_check_true(hasTheseProps(roomDetail, room));
-
-      LoopRooms.getRoomData(room.localRoomId, (error, roomData) => {
-        do_check_false(error);
-        do_check_true(hasTheseProps(room, roomData));
-
-        run_next_test();
-      });
-    });
-  });
-});
-
-function run_test() {
-  setupFakeLoopServer();
-  mockPushHandler.registrationPushURL = kEndPointUrl;
-
-  loopServer.registerPathHandler("/registration", (request, response) => {
-    response.setStatusLine(null, 200, "OK");
-    response.processAsync();
-    response.finish();
-  });
-
-  do_register_cleanup(function() {
-    // Revert original Chat.open implementation
-    Chat.open = openChatOrig;
-  });
-
-  run_next_test();
-}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -1,25 +1,24 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk'
 
 [test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
+[test_looprooms.js]
 [test_loopservice_directcall.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
 [test_loopservice_hawk_errors.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]
 [test_loopservice_restart.js]
 [test_loopservice_token_invalid.js]
 [test_loopservice_token_save.js]
 [test_loopservice_token_send.js]
 [test_loopservice_token_validation.js]
 [test_loopservice_busy.js]
-[test_rooms_getdata.js]
-[test_rooms_create.js]
--- a/services/common/hawkclient.js
+++ b/services/common/hawkclient.js
@@ -104,16 +104,17 @@ this.HawkClient.prototype = {
    */
   _constructError: function(restResponse, errorString) {
     let errorObj = {
       error: errorString,
       message: restResponse.statusText,
       code: restResponse.status,
       errno: restResponse.status
     };
+    errorObj.toString = function() this.code + ": " + this.message;
     let retryAfter = restResponse.headers && restResponse.headers["retry-after"];
     retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
     if (retryAfter) {
       errorObj.retryAfter = retryAfter;
       // and notify observers of the retry interval
       if (this.observerPrefix) {
         Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
       }