Bug 1065153 - Support call URLs with guest or FxA hawk session types, r=MattN
authorDan Mosedale <dmose@meer.net>
Thu, 18 Sep 2014 10:40:35 -0700
changeset 225376 1277219be1ca7ed6245560a38008097d14f5dedd
parent 225375 2d38bdaef1d42c733eae6afa3a55bfd49943eddf
child 225377 13a9118e44e4038bec7805d71e30577c1960060b
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1065153
milestone34.0a2
Bug 1065153 - Support call URLs with guest or FxA hawk session types, r=MattN
browser/components/loop/LoopStorage.jsm
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/client.js
browser/components/loop/test/desktop-local/client_test.js
browser/components/loop/test/xpcshell/test_loopapi_hawk_request.js
browser/components/loop/test/xpcshell/xpcshell.ini
--- a/browser/components/loop/LoopStorage.jsm
+++ b/browser/components/loop/LoopStorage.jsm
@@ -1,16 +1,25 @@
 /* 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"]);
+// Make it possible to load LoopStorage.jsm in xpcshell tests
+try {
+  Cu.importGlobalProperties(["indexedDB"]);
+} catch (ex) {
+  // don't write this is out in xpcshell, since it's expected there
+  if (typeof window !== 'undefined' && "console" in window) {
+    console.log("Failed to import indexedDB; if this isn't a unit test," +
+                " something is wrong", ex);
+  }
+}
 
 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();
 });
 
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -110,16 +110,18 @@ function injectLoopAPI(targetWindow) {
   let ringerStopper;
   let appVersionInfo;
   let contactsAPI;
 
   let api = {
     /**
      * Gets an object with data that represents the currently
      * authenticated user's identity.
+     *
+     * @return null if user not logged in; profile object otherwise
      */
     userProfile: {
       enumerable: true,
       get: function() {
         if (!MozLoopService.userProfile)
           return null;
         let userProfile = Cu.cloneInto({
           email: MozLoopService.userProfile.email,
@@ -373,29 +375,31 @@ function injectLoopAPI(targetWindow) {
      *    {
      *      code: 401,
      *      errno: 401,
      *      error: "Request failed",
      *      message: "invalid token"
      *    }
      *  - {String} The body of the response.
      *
+     * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for
+     *                                        the request.  This is one of the
+     *                                        LOOP_SESSION_TYPE members
      * @param {String} path The path to make the request to.
      * @param {String} method The request method, e.g. 'POST', 'GET'.
      * @param {Object} payloadObj An object which is converted to JSON and
      *                            transmitted with the request.
      * @param {Function} callback Called when the request completes.
      */
     hawkRequest: {
       enumerable: true,
       writable: true,
-      value: function(path, method, payloadObj, callback) {
-        // XXX: Bug 1065153 - Should take a sessionType parameter instead of hard-coding GUEST
+      value: function(sessionType, path, method, payloadObj, callback) {
         // XXX Should really return a DOM promise here.
-        MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, path, method, payloadObj).then((response) => {
+        MozLoopService.hawkRequest(sessionType, path, method, payloadObj).then((response) => {
           callback(null, response.body);
         }, hawkError => {
           // The hawkError.error property, while usually a string representing
           // an HTTP response status message, may also incorrectly be a native
           // error object that will cause the cloning function to fail.
           callback(Cu.cloneInto({
             error: (hawkError.error && typeof hawkError.error == "string")
                    ? hawkError.error : "Unexpected exception",
@@ -404,20 +408,19 @@ function injectLoopAPI(targetWindow) {
             errno: hawkError.errno,
           }, targetWindow));
         }).catch(Cu.reportError);
       }
     },
 
     LOOP_SESSION_TYPE: {
       enumerable: true,
-      writable: false,
-      value: function() {
-        return LOOP_SESSION_TYPE;
-      },
+      get: function() {
+        return Cu.cloneInto(LOOP_SESSION_TYPE, targetWindow);
+      }
     },
 
     logInToFxA: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.logInToFxA();
       }
@@ -445,21 +448,31 @@ function injectLoopAPI(targetWindow) {
      *   - OS: The operating system the application is running on
      */
     appVersionInfo: {
       enumerable: true,
       get: function() {
         if (!appVersionInfo) {
           let defaults = Services.prefs.getDefaultBranch(null);
 
-          appVersionInfo = Cu.cloneInto({
-            channel: defaults.getCharPref("app.update.channel"),
-            version: appInfo.version,
-            OS: appInfo.OS
-          }, targetWindow);
+          // If the lazy getter explodes, we're probably loaded in xpcshell,
+          // which doesn't have what we need, so log an error.
+          try {
+            appVersionInfo = Cu.cloneInto({
+              channel: defaults.getCharPref("app.update.channel"),
+              version: appInfo.version,
+              OS: appInfo.OS
+            }, targetWindow);
+          } catch (ex) {
+            // only log outside of xpcshell to avoid extra message noise
+            if (typeof window !== 'undefined' && "console" in window) {
+              console.log("Failed to construct appVersionInfo; if this isn't " +
+                          "an xpcshell unit test, something is wrong", ex);
+            }
+          }
         }
         return appVersionInfo;
       }
     },
 
     /**
      * Composes an email via the external protocol service.
      *
@@ -505,27 +518,33 @@ function injectLoopAPI(targetWindow) {
 
   let contentObj = Cu.createObjectIn(targetWindow);
   Object.defineProperties(contentObj, api);
   Object.seal(contentObj);
   Cu.makeObjectPropsNormal(contentObj);
   Services.obs.addObserver(onStatusChanged, "loop-status-changed", false);
   Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
 
-  targetWindow.navigator.wrappedJSObject.__defineGetter__("mozLoop", function() {
-    // We do this in a getter, so that we create these objects
-    // only on demand (this is a potential concern, since
-    // otherwise we might add one per iframe, and keep them
-    // alive for as long as the window is alive).
-    delete targetWindow.navigator.wrappedJSObject.mozLoop;
-    return targetWindow.navigator.wrappedJSObject.mozLoop = contentObj;
-  });
+  if ("navigator" in targetWindow) {
+    targetWindow.navigator.wrappedJSObject.__defineGetter__("mozLoop", function () {
+      // We do this in a getter, so that we create these objects
+      // only on demand (this is a potential concern, since
+      // otherwise we might add one per iframe, and keep them
+      // alive for as long as the window is alive).
+      delete targetWindow.navigator.wrappedJSObject.mozLoop;
+      return targetWindow.navigator.wrappedJSObject.mozLoop = contentObj;
+    });
 
-  // Handle window.close correctly on the panel and chatbox.
-  hookWindowCloseForPanelClose(targetWindow);
+    // Handle window.close correctly on the panel and chatbox.
+    hookWindowCloseForPanelClose(targetWindow);
+  } else {
+    // This isn't a window; but it should be a JS scope; used for testing
+    return targetWindow.mozLoop = contentObj;
+  }
+
 }
 
 function getChromeWindow(contentWin) {
   return contentWin.QueryInterface(Ci.nsIInterfaceRequestor)
                    .getInterface(Ci.nsIWebNavigation)
                    .QueryInterface(Ci.nsIDocShellTreeItem)
                    .rootTreeItem
                    .QueryInterface(Ci.nsIInterfaceRequestor)
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -698,16 +698,21 @@ this.MozLoopService = {
     gInitializeTimerFunc = value;
   },
 
   /**
    * Initialized the loop service, and starts registration with the
    * push and loop servers.
    */
   initialize: function() {
+
+    // Do this here, rather than immediately after definition, so that we can
+    // stub out API functions for unit testing
+    Object.freeze(this);
+
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled") ||
         Services.prefs.getBoolPref("loop.throttled")) {
       return;
     }
 
     // If expiresTime is in the future then kick-off registration.
     if (MozLoopServiceInternal.urlExpiryTimeIsInFuture()) {
@@ -1047,9 +1052,8 @@ this.MozLoopService = {
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
   hawkRequest: function(sessionType, path, method, payloadObj) {
     return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj);
   },
 };
-Object.freeze(this.MozLoopService);
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -103,39 +103,47 @@ loop.Client = (function($) {
      * - callUrlData an object of the obtained call url data if successful:
      * -- callUrl: The url of the call
      * -- expiresAt: The amount of hours until expiry of the url
      *
      * @param  {string} nickname the nickname of the future caller
      * @param  {Function} cb Callback(err, callUrlData)
      */
     _requestCallUrlInternal: function(nickname, cb) {
-      this.mozLoop.hawkRequest("/call-url/", "POST", {callerId: nickname},
-                               function (error, responseText) {
-        if (error) {
-          this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
-          this._failureHandler(cb, error);
-          return;
-        }
-
-        try {
-          var urlData = JSON.parse(responseText);
+      var sessionType;
+      if (this.mozLoop.userProfile) {
+        sessionType = this.mozLoop.LOOP_SESSION_TYPE.FXA;
+      } else {
+        sessionType = this.mozLoop.LOOP_SESSION_TYPE.GUEST;
+      }
+      
+      this.mozLoop.hawkRequest(sessionType, "/call-url/", "POST",
+                               {callerId: nickname},
+        function (error, responseText) {
+          if (error) {
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
+            this._failureHandler(cb, error);
+            return;
+          }
 
-          // This throws if the data is invalid, in which case only the failure
-          // telementry will be recorded.
-          var returnData = this._validate(urlData, expectedCallUrlProperties);
+          try {
+            var urlData = JSON.parse(responseText);
+
+            // This throws if the data is invalid, in which case only the failure
+            // telemetry will be recorded.
+            var returnData = this._validate(urlData, expectedCallUrlProperties);
 
-          this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", true);
-          cb(null, returnData);
-        } catch (err) {
-          this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
-          console.log("Error requesting call info", err);
-          cb(err);
-        }
-      }.bind(this));
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", true);
+            cb(null, returnData);
+          } catch (err) {
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
+            console.log("Error requesting call info", err);
+            cb(err);
+          }
+        }.bind(this));
     },
 
     /**
      * Block call URL based on the token identifier
      *
      * @param {string} token Conversation identifier used to block the URL
      * @param {function} cb Callback function used for handling an error
      *                      response. XXX The incoming call panel does not
@@ -149,35 +157,41 @@ loop.Client = (function($) {
           return;
         }
 
         this._deleteCallUrlInternal(token, cb);
       }.bind(this));
     },
 
     _deleteCallUrlInternal: function(token, cb) {
-      this.mozLoop.hawkRequest("/call-url/" + token, "DELETE", null,
-                               function (error, responseText) {
+      function deleteRequestCallback(error, responseText) {
         if (error) {
           this._failureHandler(cb, error);
           return;
         }
 
         try {
           cb(null);
         } catch (err) {
           console.log("Error deleting call info", err);
           cb(err);
         }
-      }.bind(this));
+      }
+
+      // XXX hard-coding of GUEST to be removed by 1065155
+      this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.GUEST,
+                               "/call-url/" + token, "DELETE", null,
+                               deleteRequestCallback.bind(this));
     },
 
     /**
      * Requests a call URL from the Loop server. It will note the
-     * expiry time for the url with the mozLoop api.
+     * expiry time for the url with the mozLoop api.  It will select the
+     * appropriate hawk session to use based on whether or not the user
+     * is currently logged into a Firefox account profile.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      * - callUrlData an object of the obtained call url data if successful:
      * -- callUrl: The url of the call
      * -- expiresAt: The amount of hours until expiry of the url
      *
      * @param  {String} simplepushUrl a registered Simple Push URL
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -30,17 +30,22 @@ describe("loop.Client", function() {
     mozLoop = {
       getLoopCharPref: sandbox.stub()
         .returns(null)
         .withArgs("hawk-session-token")
         .returns(fakeToken),
       ensureRegistered: sinon.stub().callsArgWith(0, null),
       noteCallUrlExpiry: sinon.spy(),
       hawkRequest: sinon.stub(),
-      telemetryAdd: sinon.spy(),
+      LOOP_SESSION_TYPE: {
+        GUEST: 1,
+        FXA: 2
+      },
+      userProfile: null,
+      telemetryAdd: sinon.spy()
     };
     // Alias for clearer tests.
     hawkRequestStub = mozLoop.hawkRequest;
     client = new loop.Client({
       mozLoop: mozLoop
     });
   });
 
@@ -65,35 +70,36 @@ describe("loop.Client", function() {
         sinon.assert.calledWithExactly(callback, "offline");
       });
 
       it("should make a delete call to /call-url/{fakeToken}", function() {
         client.deleteCallUrl(fakeToken, callback);
 
         sinon.assert.calledOnce(hawkRequestStub);
         sinon.assert.calledWith(hawkRequestStub,
+                                mozLoop.LOOP_SESSION_TYPE.GUEST,
                                 "/call-url/" + fakeToken, "DELETE");
       });
 
       it("should call the callback with null when the request succeeds",
          function() {
 
            // Sets up the hawkRequest stub to trigger the callback with no error
            // and the url.
-           hawkRequestStub.callsArgWith(3, null);
+           hawkRequestStub.callsArgWith(4, null);
 
            client.deleteCallUrl(fakeToken, callback);
 
            sinon.assert.calledWithExactly(callback, null);
          });
 
       it("should send an error when the request fails", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
-        hawkRequestStub.callsArgWith(3, fakeErrorRes);
+        hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.deleteCallUrl(fakeToken, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
           return /400.*invalid token/.test(err.message);
         }));
       });
@@ -114,64 +120,86 @@ describe("loop.Client", function() {
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithExactly(callback, "offline");
       });
 
       it("should post to /call-url/", function() {
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(hawkRequestStub);
-        sinon.assert.calledWith(hawkRequestStub,
-                                "/call-url/", "POST", {callerId: "foo"});
+        sinon.assert.calledWithExactly(hawkRequestStub, sinon.match.number,
+          "/call-url/", "POST", {callerId: "foo"}, sinon.match.func);
+      });
+
+      it("should send a sessionType of LOOP_SESSION_TYPE.GUEST when " +
+         "mozLoop.userProfile returns null", function() {
+        mozLoop.userProfile = null;
+
+        client.requestCallUrl("foo", callback);
+
+        sinon.assert.calledOnce(hawkRequestStub);
+        sinon.assert.calledWithExactly(hawkRequestStub,
+          mozLoop.LOOP_SESSION_TYPE.GUEST, "/call-url/", "POST",
+          {callerId: "foo"}, sinon.match.func);
+      });
+
+      it("should send a sessionType of LOOP_SESSION_TYPE.FXA when " +
+         "mozLoop.userProfile returns an object", function () {
+        mozLoop.userProfile = {};
+
+        client.requestCallUrl("foo", callback);
+
+        sinon.assert.calledOnce(hawkRequestStub);
+        sinon.assert.calledWithExactly(hawkRequestStub,
+          mozLoop.LOOP_SESSION_TYPE.FXA, "/call-url/", "POST",
+          {callerId: "foo"}, sinon.match.func);
       });
 
       it("should call the callback with the url when the request succeeds",
         function() {
           var callUrlData = {
             "callUrl": "fakeCallUrl",
             "expiresAt": 60
           };
 
           // Sets up the hawkRequest stub to trigger the callback with no error
           // and the url.
-          hawkRequestStub.callsArgWith(3, null,
-            JSON.stringify(callUrlData));
+          hawkRequestStub.callsArgWith(4, null, JSON.stringify(callUrlData));
 
           client.requestCallUrl("foo", callback);
 
           sinon.assert.calledWithExactly(callback, null, callUrlData);
         });
 
       it("should not update call url expiry when the request succeeds",
         function() {
           var callUrlData = {
             "callUrl": "fakeCallUrl",
             "expiresAt": 6000
           };
 
           // Sets up the hawkRequest stub to trigger the callback with no error
           // and the url.
-          hawkRequestStub.callsArgWith(3, null,
-            JSON.stringify(callUrlData));
+          hawkRequestStub.callsArgWith(4, null, JSON.stringify(callUrlData));
 
           client.requestCallUrl("foo", callback);
 
           sinon.assert.notCalled(mozLoop.noteCallUrlExpiry);
         });
 
       it("should call mozLoop.telemetryAdd when the request succeeds",
         function(done) {
           var callUrlData = {
             "callUrl": "fakeCallUrl",
             "expiresAt": 60
           };
 
           // Sets up the hawkRequest stub to trigger the callback with no error
           // and the url.
-          hawkRequestStub.callsArgWith(3, null,
+          hawkRequestStub.callsArgWith(4, null,
             JSON.stringify(callUrlData));
 
           client.requestCallUrl("foo", function(err) {
             expect(err).to.be.null;
 
             sinon.assert.calledOnce(mozLoop.telemetryAdd);
             sinon.assert.calledWith(mozLoop.telemetryAdd,
                                     "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
@@ -179,44 +207,44 @@ describe("loop.Client", function() {
 
             done();
           });
         });
 
       it("should send an error when the request fails", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
-        hawkRequestStub.callsArgWith(3, fakeErrorRes);
+        hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
           return /400.*invalid token/.test(err.message);
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
-        hawkRequestStub.callsArgWith(3, null, "{}");
+        hawkRequestStub.callsArgWith(4, null, "{}");
 
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
           return /Invalid data received/.test(err.message);
         }));
       });
 
       it("should call mozLoop.telemetryAdd when the request fails",
         function(done) {
           // Sets up the hawkRequest stub to trigger the callback with
           // an error
-          hawkRequestStub.callsArgWith(3, fakeErrorRes);
+          hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
           client.requestCallUrl("foo", function(err) {
             expect(err).not.to.be.null;
 
             sinon.assert.calledOnce(mozLoop.telemetryAdd);
             sinon.assert.calledWith(mozLoop.telemetryAdd,
                                     "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
                                     false);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_loopapi_hawk_request.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Unit tests for the hawkRequest API
+ */
+
+"use strict";
+
+Cu.import("resource:///modules/loop/MozLoopAPI.jsm");
+
+let sandbox;
+function assertInSandbox(expr, msg_opt) {
+  Assert.ok(Cu.evalInSandbox(expr, sandbox), msg_opt);
+}
+
+sandbox = Cu.Sandbox("about:looppanel", { wantXrays: false } );
+injectLoopAPI(sandbox, true);
+
+add_task(function* hawk_session_scope_constants() {
+  assertInSandbox("typeof mozLoop.LOOP_SESSION_TYPE !== 'undefined'");
+
+  assertInSandbox("mozLoop.LOOP_SESSION_TYPE.GUEST === 1");
+
+  assertInSandbox("mozLoop.LOOP_SESSION_TYPE.FXA === 2");
+});
+
+function generateSessionTypeVerificationStub(desiredSessionType) {
+
+  function hawkRequestStub(sessionType, path, method, payloadObj, callback) {
+    return new Promise(function (resolve, reject) {
+      Assert.equal(desiredSessionType, sessionType);
+
+      resolve();
+    });
+  };
+
+  return hawkRequestStub;
+}
+
+const origHawkRequest = MozLoopService.oldHawkRequest;
+do_register_cleanup(function() {
+  MozLoopService.hawkRequest = origHawkRequest;
+});
+
+add_task(function* hawk_request_scope_passthrough() {
+
+  // add a stub that verifies the parameter we want
+  MozLoopService.hawkRequest =
+    generateSessionTypeVerificationStub(sandbox.mozLoop.LOOP_SESSION_TYPE.FXA);
+
+  // call mozLoop.hawkRequest, which calls MozLoopAPI.hawkRequest, which calls
+  // MozLoopService.hawkRequest
+  Cu.evalInSandbox(
+    "mozLoop.hawkRequest(mozLoop.LOOP_SESSION_TYPE.FXA," +
+                       " 'call-url/fakeToken', 'POST', {}, function() {})",
+    sandbox);
+
+  MozLoopService.hawkRequest =
+    generateSessionTypeVerificationStub(sandbox.mozLoop.LOOP_SESSION_TYPE.GUEST);
+
+  Cu.evalInSandbox(
+    "mozLoop.hawkRequest(mozLoop.LOOP_SESSION_TYPE.GUEST," +
+    " 'call-url/fakeToken', 'POST', {}, function() {})",
+    sandbox);
+
+});
+
+function run_test() {
+  run_next_test();
+}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -1,13 +1,14 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 
+[test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]