Bug 985596 - Refactored shared assets & tests. r=dmose
authorNicolas Perriault <nperriault@mozilla.com>
Thu, 29 May 2014 21:20:11 +0100
changeset 209614 7f1004fbff02b53b161c1e208ba3f9c8f543fb4e
parent 209613 5176bfeba30b2233d9b6b12719937caa19ddb1b7
child 209615 3a2a6ab3182236ce7395990be97bc2b1ef8f14bd
push id515
push userraliiev@mozilla.com
push dateMon, 06 Oct 2014 12:51:51 +0000
treeherdermozilla-release@267c7a481bef [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdmose
bugs985596
milestone32.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 985596 - Refactored shared assets & tests. r=dmose
browser/components/loop/content/shared/README.md
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/css/readme.html
browser/components/loop/content/shared/img/icon_32.png
browser/components/loop/content/shared/img/icon_64.png
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/router.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/libs/backbone-1.1.2.js
browser/components/loop/content/shared/libs/jquery-2.1.0.js
browser/components/loop/content/shared/libs/lodash-2.4.1.js
browser/components/loop/content/shared/libs/sdk.js
browser/components/loop/content/shared/libs/webl10n-20130617.js
browser/components/loop/standalone/.jshintignore
browser/components/loop/standalone/Makefile
browser/components/loop/standalone/README.md
browser/components/loop/standalone/content/css/webapp.css
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/l10n/data.ini
browser/components/loop/standalone/server.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/router_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/webapp_test.js
static/css/webapp.css
static/index.html
static/js/webapp.js
static/l10n/data.ini
static/shared/README.md
static/shared/css/common.css
static/shared/css/conversation.css
static/shared/css/panel.css
static/shared/css/readme.html
static/shared/img/icon_32.png
static/shared/img/icon_64.png
static/shared/js/models.js
static/shared/js/views.js
static/shared/libs/backbone-1.1.2.js
static/shared/libs/jquery-2.1.0.js
static/shared/libs/lodash-2.4.1.js
static/shared/libs/sdk.js
static/shared/libs/webl10n-20130617.js
test/webapp_test.js
rename from static/shared/README.md
rename to browser/components/loop/content/shared/README.md
rename from static/shared/css/common.css
rename to browser/components/loop/content/shared/css/common.css
rename from static/shared/css/conversation.css
rename to browser/components/loop/content/shared/css/conversation.css
rename from static/shared/css/panel.css
rename to browser/components/loop/content/shared/css/panel.css
rename from static/shared/css/readme.html
rename to browser/components/loop/content/shared/css/readme.html
rename from static/shared/img/icon_32.png
rename to browser/components/loop/content/shared/img/icon_32.png
rename from static/shared/img/icon_64.png
rename to browser/components/loop/content/shared/img/icon_64.png
rename from static/shared/js/models.js
rename to browser/components/loop/content/shared/js/models.js
--- a/static/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -9,16 +9,17 @@ loop.shared = loop.shared || {};
 loop.shared.models = (function() {
   "use strict";
 
   /**
    * Conversation model.
    */
   var ConversationModel = Backbone.Model.extend({
     defaults: {
+      callerId:     undefined, // Loop caller id
       loopToken:    undefined, // Loop conversation token
       sessionId:    undefined, // TB session id
       sessionToken: undefined, // TB session token
       apiKey:       undefined  // TB api key
     },
 
     /**
      * Initiates a conversation, requesting call session information to the Loop
@@ -26,22 +27,21 @@ loop.shared.models = (function() {
      * data.
      *
      * Triggered events:
      *
      * - `session:ready` when the session information have been successfully
      *   retrieved from the server;
      * - `session:error` when the request failed.
      *
-     * @param  {Object} baseServerUrl The server URL
-     * @throws {Error} If no baseServerUrl is given
-     * @throws {Error} If no conversation token is set
+     * @param  {String} baseServerUrl The server URL
+     * @throws {Error}  If no baseServerUrl is given
+     * @throws {Error}  If no conversation token is set
      */
     initiate: function(baseServerUrl) {
-
       if (!baseServerUrl) {
         throw new Error("baseServerUrl arg must be passed to initiate()");
       }
 
       if (!this.get("loopToken")) {
         throw new Error("missing required attribute loopToken");
       }
 
@@ -90,12 +90,31 @@ loop.shared.models = (function() {
         sessionId:    sessionData.sessionId,
         sessionToken: sessionData.sessionToken,
         apiKey:       sessionData.apiKey
       }).trigger("session:ready", this);
       return this;
     }
   });
 
+  /**
+   * Notification model.
+   */
+  var NotificationModel = Backbone.Model.extend({
+    defaults: {
+      level: "info",
+      message: ""
+    }
+  });
+
+  /**
+   * Notification collection
+   */
+  var NotificationCollection = Backbone.Collection.extend({
+    model: NotificationModel
+  });
+
   return {
-    ConversationModel: ConversationModel
+    ConversationModel: ConversationModel,
+    NotificationCollection: NotificationCollection,
+    NotificationModel: NotificationModel
   };
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/router.js
@@ -0,0 +1,37 @@
+/* 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/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.shared = loop.shared || {};
+loop.shared.router = (function() {
+  "use strict";
+
+  /**
+   * Base Router. Allows defining a main active view and ease toggling it when
+   * the active route changes.
+   *
+   * @link http://mikeygee.com/blog/backbone.html
+   */
+  var BaseRouter = Backbone.Router.extend({
+    activeView: undefined,
+
+    /**
+     * Loads and render current active view.
+     *
+     * @param {loop.shared.views.BaseView} view View.
+     */
+    loadView : function(view) {
+      if (this.activeView) {
+        this.activeView.hide();
+      }
+      this.activeView = view.render().show();
+    }
+  });
+
+  return {
+    BaseRouter: BaseRouter
+  };
+})();
rename from static/shared/js/views.js
rename to browser/components/loop/content/shared/js/views.js
--- a/static/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -1,16 +1,14 @@
 /* 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/. */
 
 /* global loop:true */
 
-// XXX This file needs unit tests.
-
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.views = (function(TB) {
   "use strict";
 
   /**
    * Base Backbone view.
    */
@@ -37,25 +35,33 @@ loop.shared.views = (function(TB) {
   });
 
   /**
    * Conversation view.
    */
   var ConversationView = BaseView.extend({
     el: "#conversation",
 
-    initialize: function() {
-      this.videoStyles = { width: "100%", height: "100%" };
+    videoStyles: { width: "100%", height: "100%" },
 
-      // XXX this feels like to be moved to the ConversationModel, but as it's
+    /**
+     * Establishes webrtc communication using TB sdk.
+     */
+    initialize: function(options) {
+      options = options || {};
+      if (!options.sdk) {
+        throw new Error("missing required sdk");
+      }
+      this.sdk = options.sdk;
+      // XXX: this feels like to be moved to the ConversationModel, but as it's
       // tighly coupled with the DOM (element ids to receive streams), we'd need
       // an abstraction we probably don't want yet.
-      this.session   = TB.initSession(this.model.get("sessionId"));
-      this.publisher = TB.initPublisher(this.model.get("apiKey"), "outgoing",
-                                        this.videoStyles);
+      this.session   = this.sdk.initSession(this.model.get("sessionId"));
+      this.publisher = this.sdk.initPublisher(this.model.get("apiKey"),
+                                              "outgoing", this.videoStyles);
 
       this.session.connect(this.model.get("apiKey"),
                            this.model.get("sessionToken"));
 
       this.listenTo(this.session, "sessionConnected", this._sessionConnected);
       this.listenTo(this.session, "streamCreated", this._streamCreated);
       this.listenTo(this.session, "connectionDestroyed", this._sessionEnded);
     },
@@ -80,13 +86,64 @@ loop.shared.views = (function(TB) {
         if (stream.connection.connectionId !==
             this.session.connection.connectionId) {
           this.session.subscribe(stream, "incoming", this.videoStyles);
         }
       }.bind(this));
     }
   });
 
+  /**
+   * Notification view.
+   */
+  var NotificationView = Backbone.View.extend({
+    template: _.template([
+      '<div class="alert alert-<%- level %>">',
+      '  <button class="close"></button>',
+      '  <p class="message"><%- message %></p>',
+      '</div>'
+    ].join("")),
+
+    events: {
+      "click .close": "dismiss"
+    },
+
+    dismiss: function(event) {
+      event.preventDefault();
+      this.$el.addClass("fade-out");
+      setTimeout(function() {
+        this.collection.remove(this.model);
+        this.remove();
+      }.bind(this), 500); // XXX make timeout value configurable
+    },
+
+    render: function() {
+      this.$el.html(this.template(this.model.toJSON()));
+      return this;
+    }
+  });
+
+  /**
+   * Notification list view.
+   */
+  var NotificationListView = Backbone.View.extend({
+    initialize: function() {
+      this.listenTo(this.collection, "reset add remove", this.render);
+    },
+
+    render: function() {
+      this.$el.html(this.collection.map(function(notification) {
+        return new NotificationView({
+          model: notification,
+          collection: this.collection
+        }).render().$el;
+      }.bind(this)));
+      return this;
+    }
+  });
+
   return {
     BaseView: BaseView,
-    ConversationView: ConversationView
+    ConversationView: ConversationView,
+    NotificationListView: NotificationListView,
+    NotificationView: NotificationView
   };
 })(window.TB);
rename from static/shared/libs/backbone-1.1.2.js
rename to browser/components/loop/content/shared/libs/backbone-1.1.2.js
rename from static/shared/libs/jquery-2.1.0.js
rename to browser/components/loop/content/shared/libs/jquery-2.1.0.js
rename from static/shared/libs/lodash-2.4.1.js
rename to browser/components/loop/content/shared/libs/lodash-2.4.1.js
rename from static/shared/libs/sdk.js
rename to browser/components/loop/content/shared/libs/sdk.js
rename from static/shared/libs/webl10n-20130617.js
rename to browser/components/loop/content/shared/libs/webl10n-20130617.js
--- a/browser/components/loop/standalone/.jshintignore
+++ b/browser/components/loop/standalone/.jshintignore
@@ -1,4 +1,4 @@
 node_modules
-static/shared/libs
+content/shared/libs
 test/shared/vendor
 
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -6,15 +6,15 @@ NODE_LOCAL_BIN=./node_modules/.bin
 
 install:
 	@npm install
 
 test:
 	@echo "Not implemented yet."
 
 lint:
-	@$(NODE_LOCAL_BIN)/jshint *.js static test
+	@$(NODE_LOCAL_BIN)/jshint *.js content test
 
 runserver:
 	@node server.js
 
 frontend:
 	@echo "Not implemented yet."
--- a/browser/components/loop/standalone/README.md
+++ b/browser/components/loop/standalone/README.md
@@ -15,21 +15,21 @@ Usage
 -----
 
 For development, run a local static file server:
 
     $ make runserver
 
 Then point your browser at:
 
-- `http://localhost:3000/static/` for public web contents,
+- `http://localhost:3000/content/` for all public webapp contents,
 - `http://localhost:3000/test/` for tests.
 
-**Note:** the provided static file server is **not** intended for production
-use.
+**Note:** the provided static file server for web contents is **not** intended
+for production use.
 
 Code linting
 ------------
 
     $ make lint
 
 License
 -------
rename from static/css/webapp.css
rename to browser/components/loop/standalone/content/css/webapp.css
rename from static/index.html
rename to browser/components/loop/standalone/content/index.html
--- a/static/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -51,16 +51,17 @@
     <script type="text/javascript" src="shared/libs/webl10n-20130617.js"></script>
     <script type="text/javascript" src="shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>
 
     <!-- app scripts -->
     <script type="text/javascript" src="shared/js/models.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
+    <script type="text/javascript" src="shared/js/router.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     window.addEventListener('localized', function() {
       document.documentElement.lang = document.webL10n.getLanguage();
       document.documentElement.dir = document.webL10n.getDirection();
     }, false);
rename from static/js/webapp.js
rename to browser/components/loop/standalone/content/js/webapp.js
--- a/static/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -10,70 +10,74 @@ loop.webapp = (function($, TB) {
 
   /**
    * Base Loop server URL.
    *
    * XXX: should be configurable, but how?
    *
    * @type {String}
    */
-  var baseApiUrl = "http://localhost:5000";
+  var sharedModels = loop.shared.models,
+      sharedViews = loop.shared.views,
+      // XXX this one should be configurable
+      //     see https://bugzilla.mozilla.org/show_bug.cgi?id=987086
+      baseServerUrl = "http://localhost:5000";
 
   /**
    * App router.
-   * @type {loop.webapp.Router}
+   * @type {loop.webapp.WebappRouter}
    */
   var router;
 
   /**
    * Current conversation model instance.
    * @type {loop.shared.models.ConversationModel}
    */
   var conversation;
 
   /**
    * Homepage view.
    */
-  var HomeView = loop.shared.views.BaseView.extend({
+  var HomeView = sharedViews.BaseView.extend({
     el: "#home"
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
    */
-  var ConversationFormView = loop.shared.views.BaseView.extend({
+  var ConversationFormView = sharedViews.BaseView.extend({
     el: "#conversation-form",
 
     events: {
       "submit": "initiate"
     },
 
     initialize: function() {
       this.listenTo(this.model, "session:error", function(error) {
         // XXX: display a proper error notification to end user, probably
         //      reusing the BB notification system from the Loop desktop client.
         alert(error);
       });
     },
 
     initiate: function(event) {
       event.preventDefault();
-      this.model.initiate(baseApiUrl);
+      this.model.initiate(baseServerUrl);
     }
   });
 
   /**
-   * App Router. Allows defining a main active view and ease toggling it when
-   * the active route changes.
+   * Webapp Router. Allows defining a main active view and easily toggling it
+   * when the active route changes.
+   *
    * @link http://mikeygee.com/blog/backbone.html
    */
-  var Router = Backbone.Router.extend({
+  var WebappRouter = loop.shared.router.BaseRouter.extend({
     _conversation: undefined,
-    activeView: undefined,
 
     routes: {
       "": "home",
       "call/ongoing": "conversation",
       "call/:token": "initiate"
     },
 
     initialize: function(options) {
@@ -100,28 +104,16 @@ loop.webapp = (function($, TB) {
     /**
      * Navigates back to initiate when the call session has ended.
      */
     _onSessionEnded: function() {
       this.navigate("call/" + this._conversation.get("token"), {trigger: true});
     },
 
     /**
-     * Loads and render current active view.
-     *
-     * @param {loop.shared.BaseView} view View.
-     */
-    loadView : function(view) {
-      if (this.activeView) {
-        this.activeView.hide();
-      }
-      this.activeView = view.render().show();
-    },
-
-    /**
      * Default entry point.
      */
     home: function() {
       this.loadView(new HomeView());
     },
 
     /**
      * Loads conversation launcher view, setting the received conversation token
@@ -144,28 +136,31 @@ loop.webapp = (function($, TB) {
         if (loopToken) {
           return this.navigate("call/" + loopToken, {trigger: true});
         } else {
           // XXX: notify user that a call token is missing
           return this.navigate("home", {trigger: true});
         }
       }
       this.loadView(
-        new loop.shared.views.ConversationView({model: this._conversation}));
+        new sharedViews.ConversationView({
+          sdk: TB,
+          model: this._conversation
+        }));
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
-    conversation = new loop.shared.models.ConversationModel();
-    router = new Router({conversation: conversation});
+    conversation = new sharedModels.ConversationModel();
+    router = new WebappRouter({conversation: conversation});
     Backbone.history.start();
   }
 
   return {
     ConversationFormView: ConversationFormView,
     HomeView: HomeView,
     init: init,
-    Router: Router
+    WebappRouter: WebappRouter
   };
 })(jQuery, window.TB);
rename from static/l10n/data.ini
rename to browser/components/loop/standalone/content/l10n/data.ini
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -4,11 +4,11 @@
 
 var express = require('express');
 var app = express();
 
 app.use(express.static(__dirname + '/'));
 
 app.listen(3000);
 console.log("Serving repository root over HTTP at http://localhost:3000/");
-console.log("Static contents are available at http://localhost:3000/static/");
+console.log("Static contents are available at http://localhost:3000/content/");
 console.log("Tests are viewable at http://localhost:3000/test/");
 console.log("Use this for development only.");
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -1,51 +1,48 @@
 <!DOCTYPE html>
 <!-- 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/.  -->
 <html>
-
 <head>
   <meta charset="utf-8">
   <title>Loop mocha tests</title>
   <link rel="stylesheet" media="all" href="vendor/mocha-1.17.1.css">
-  <link rel="prefetch" type="application/l10n" href="../../static/l10n/data.ini">
+  <link rel="prefetch" type="application/l10n" href="../../content/l10n/data.ini">
 </head>
-
 <body>
-
   <div id="mocha">
     <p><a href=".">Index</a></p>
   </div>
   <div id="messages"></div>
   <div id="fixtures"></div>
 
   <!-- libs -->
-  <script src="../../static/shared/libs/webl10n-20130617.js"></script>
-  <script src="../../static/shared/libs/jquery-2.1.0.js"></script>
-  <script src="../../static/shared/libs/lodash-2.4.1.js"></script>
-  <script src="../../static/shared/libs/backbone-1.1.2.js"></script>
+  <script src="../../content/shared/libs/webl10n-20130617.js"></script>
+  <script src="../../content/shared/libs/jquery-2.1.0.js"></script>
+  <script src="../../content/shared/libs/lodash-2.4.1.js"></script>
+  <script src="../../content/shared/libs/backbone-1.1.2.js"></script>
 
   <!-- test dependencies -->
   <script src="vendor/mocha-1.17.1.js"></script>
   <script src="vendor/chai-1.9.0.js"></script>
   <script src="vendor/sinon-1.9.0.js"></script>
   <script>
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
-  <script src="../../static/shared/js/models.js"></script>
-  <script src="../../static/shared/js/views.js"></script>
-
+  <script src="../../content/shared/js/models.js"></script>
+  <script src="../../content/shared/js/views.js"></script>
+  <script src="../../content/shared/js/router.js"></script>
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="views_test.js"></script>
+  <script src="router_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p>Complete.</p>");
     });
   </script>
-
 </body>
 </html>
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -4,17 +4,18 @@
 
 /* global loop, sinon */
 
 var expect = chai.expect;
 
 describe("loop.shared.models", function() {
   "use strict";
 
-  var sandbox, fakeXHR, requests = [];
+  var sharedModels = loop.shared.models,
+      sandbox, fakeXHR, requests = [];
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function(xhr) {
       requests.push(xhr);
@@ -24,27 +25,26 @@ describe("loop.shared.models", function(
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("ConversationModel", function() {
     var conversation, fakeSessionData, fakeBaseServerUrl;
 
     beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel();
+      conversation = new sharedModels.ConversationModel();
       fakeSessionData = {
         sessionId:    "sessionId",
         sessionToken: "sessionToken",
         apiKey:       "apiKey"
       };
       fakeBaseServerUrl = "http://fakeBaseServerUrl";
     });
 
     describe("#initiate", function() {
-
       it("should throw an Error if no baseServerUrl argument is passed",
         function () {
           expect(function() {
             conversation.initiate();
           }).to.Throw(Error, /baseServerUrl/);
         });
 
       it("should prevent launching a conversation when token is missing",
@@ -64,18 +64,18 @@ describe("loop.shared.models", function(
           expect(requests[0].method.toLowerCase()).to.equal("post");
           expect(requests[0].url).to.match(
             new RegExp("^" + fakeBaseServerUrl + "/calls/fakeToken"));
         });
 
       it("should update conversation session information from server data",
         function() {
           conversation.set("loopToken", "fakeToken");
+          conversation.initiate(fakeBaseServerUrl);
 
-          conversation.initiate(fakeBaseServerUrl);
           requests[0].respond(200, {"Content-Type": "application/json"},
                                    JSON.stringify(fakeSessionData));
 
           expect(conversation.get("sessionId")).eql("sessionId");
           expect(conversation.get("sessionToken")).eql("sessionToken");
           expect(conversation.get("apiKey")).eql("apiKey");
         });
 
@@ -117,9 +117,8 @@ describe("loop.shared.models", function(
       it("should trigger a `session:ready` event", function(done) {
         conversation.on("session:ready", function() {
           done();
         }).setReady(fakeSessionData);
       });
     });
   });
 });
-
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/router_test.js
@@ -0,0 +1,40 @@
+/* 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/. */
+
+/* global loop, sinon */
+
+var expect = chai.expect;
+
+describe("loop.shared.router", function() {
+  "use strict";
+
+  var sandbox;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("BaseRouter", function() {
+    var router;
+
+    beforeEach(function() {
+      router = new loop.shared.router.BaseRouter();
+    });
+
+    describe("#loadView", function() {
+      it("should set the active view", function() {
+        var TestView = loop.shared.views.BaseView.extend({});
+        var view = new TestView();
+
+        router.loadView(view);
+
+        expect(router.activeView).eql(view);
+      });
+    });
+  });
+});
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -1,24 +1,150 @@
 /* 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/. */
 
-/* global sinon */
+/* global loop, sinon */
+
+var expect = chai.expect;
 
 describe("loop.shared.views", function() {
   "use strict";
 
-  var sandbox;
+  var sharedModels = loop.shared.models,
+      sharedViews = loop.shared.views,
+      sandbox;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    sandbox.useFakeTimers(); // exposes sandbox.clock as a fake timer
   });
 
   afterEach(function() {
+    $("#fixtures").empty();
     sandbox.restore();
   });
 
   describe("ConversationView", function() {
-    it("should be tested");
+    var fakeSDK, fakeSession;
+
+    beforeEach(function() {
+      fakeSession = _.extend({
+        connect: sandbox.spy()
+      }, Backbone.Events);
+      fakeSDK = {
+        initPublisher: sandbox.spy(),
+        initSession: sandbox.stub().returns(fakeSession)
+      };
+    });
+
+    describe("initialize", function() {
+      it("should require an sdk object", function() {
+        expect(function() {
+          new sharedViews.ConversationView();
+        }).to.Throw(Error, /sdk/);
+      });
+
+      it("should initiate a session", function() {
+        new sharedViews.ConversationView({
+          sdk: fakeSDK,
+          model: new sharedModels.ConversationModel()
+        });
+
+        sinon.assert.calledOnce(fakeSession.connect);
+      });
+
+      it("should trigger a session:ended model event when connectionDestroyed" +
+         " event is received/", function(done) {
+          // XXX remove when we implement proper notifications
+          sandbox.stub(window, "alert");
+          var model = new sharedModels.ConversationModel();
+          new sharedViews.ConversationView({
+            sdk: fakeSDK,
+            model: model
+          });
+
+          model.once("session:ended", function() {
+            done();
+          });
+
+          fakeSession.trigger("connectionDestroyed", {reason: "ko"});
+        });
+    });
+  });
+
+  describe("NotificationView", function() {
+    var collection, model, view;
+
+    beforeEach(function() {
+      $("#fixtures").append('<div id="test-notif"></div>');
+      model = new sharedModels.NotificationModel({
+        level: "error",
+        message: "plop"
+      });
+      collection = new sharedModels.NotificationCollection([model]);
+      view = new sharedViews.NotificationView({
+        el: $("#test-notif"),
+        collection: collection,
+        model: model
+      });
+    });
+
+    describe("#dismiss", function() {
+      it("should automatically dismiss notification after 500ms", function() {
+        view.render().dismiss({preventDefault: sandbox.spy()});
+
+        expect(view.$(".message").text()).eql("plop");
+
+        sandbox.clock.tick(500);
+
+        expect(collection).to.have.length.of(0);
+        expect($("#test-notif").html()).eql(undefined);
+      });
+    });
+
+    describe("#render", function() {
+      it("should render template with model attribute values", function() {
+        view.render();
+
+        expect(view.$(".message").text()).eql("plop");
+      });
+    });
+  });
+
+  describe("NotificationListView", function() {
+    describe("Collection events", function() {
+      var coll, testNotif, view;
+
+      beforeEach(function() {
+        sandbox.stub(sharedViews.NotificationListView.prototype, "render");
+        testNotif = new sharedModels.NotificationModel({
+          level: "error",
+          message: "plop"
+        });
+        coll = new sharedModels.NotificationCollection();
+        view = new sharedViews.NotificationListView({collection: coll});
+      });
+
+      it("should render on notification added to the collection", function() {
+        coll.add(testNotif);
+
+        sinon.assert.calledOnce(view.render);
+      });
+
+      it("should render on notification removed from the collection",
+        function() {
+          coll.add(testNotif);
+          coll.remove(testNotif);
+
+          sinon.assert.calledTwice(view.render);
+        });
+
+      it("should render on collection reset",
+        function() {
+          coll.reset();
+
+          sinon.assert.calledOnce(view.render);
+        });
+    });
   });
 });
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/standalone/index.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<!-- 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/.  -->
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Loop mocha tests</title>
+  <link rel="stylesheet" media="all" href="../shared/vendor/mocha-1.17.1.css">
+  <link rel="prefetch" type="application/l10n" href="../../content/l10n/data.ini">
+</head>
+<body>
+  <div id="mocha">
+    <p><a href=".">Index</a></p>
+    <p><a href="../shared/">Shared Tests</a></p>
+ </div>
+  <div id="messages"></div>
+  <div id="fixtures"></div>
+  <!-- libs -->
+  <script src="../../content/shared/libs/webl10n-20130617.js"></script>
+  <script src="../../content/shared/libs/jquery-2.1.0.js"></script>
+  <script src="../../content/shared/libs/lodash-2.4.1.js"></script>
+  <script src="../../content/shared/libs/backbone-1.1.2.js"></script>
+  <!-- test dependencies -->
+  <script src="../shared/vendor/mocha-1.17.1.js"></script>
+  <script src="../shared/vendor/chai-1.9.0.js"></script>
+  <script src="../shared/vendor/sinon-1.9.0.js"></script>
+  <script>
+    chai.Assertion.includeStack = true;
+    mocha.setup('bdd');
+  </script>
+  <!-- App scripts -->
+  <script src="../../content/shared/js/models.js"></script>
+  <script src="../../content/shared/js/views.js"></script>
+  <script src="../../content/shared/js/router.js"></script>
+  <script src="../../content/js/webapp.js"></script>
+  <!-- Test scripts -->
+  <script src="webapp_test.js"></script>
+  <script>
+    mocha.run(function () {
+      $("#mocha").append("<p>Complete.</p>");
+    });
+</script>
+</body>
+</html>
rename from test/webapp_test.js
rename to browser/components/loop/test/standalone/webapp_test.js
--- a/test/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -4,74 +4,75 @@
 
 /* global loop, sinon */
 
 var expect = chai.expect;
 
 describe("loop.webapp", function() {
   "use strict";
 
-  var sandbox;
+  var sharedModels = loop.shared.models,
+      sandbox;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
-  describe("Router", function() {
+  describe("WebappRouter", function() {
     var conversation, fakeSessionData;
 
     beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel();
+      conversation = new sharedModels.ConversationModel();
       fakeSessionData = {
         sessionId:    "sessionId",
         sessionToken: "sessionToken",
         apiKey:       "apiKey"
       };
     });
 
     describe("#constructor", function() {
       it("should require a ConversationModel instance", function() {
         expect(function() {
-          new loop.webapp.Router();
+          new loop.webapp.WebappRouter();
         }).to.Throw(Error, /missing required conversation/);
       });
 
       it("should load the HomeView", function() {
-        sandbox.stub(loop.webapp.Router.prototype, "loadView");
+        sandbox.stub(loop.webapp.WebappRouter.prototype, "loadView");
 
-        var router = new loop.webapp.Router({conversation: conversation});
+        var router = new loop.webapp.WebappRouter({conversation: conversation});
 
         sinon.assert.calledOnce(router.loadView);
         sinon.assert.calledWithMatch(router.loadView,
                                      {$el: {selector: "#home"}});
       });
     });
 
     describe("constructed", function() {
       var router;
 
       beforeEach(function() {
-        router = new loop.webapp.Router({conversation: conversation});
+        router = new loop.webapp.WebappRouter({conversation: conversation});
       });
 
       describe("#loadView", function() {
         // XXX hard to test as hell… functional?
         it("should load the passed view");
       });
     });
 
     describe("Routes", function() {
       var router;
 
       beforeEach(function() {
-        router = new loop.webapp.Router({conversation: conversation});
+        router = new loop.webapp.WebappRouter({conversation: conversation});
         sandbox.stub(router, "loadView");
       });
 
       describe("#home", function() {
         it("should load the HomeView", function() {
           router.home();
 
           sinon.assert.calledOnce(router.loadView);
@@ -127,61 +128,35 @@ describe("loop.webapp", function() {
           sinon.assert.calledWithMatch(router.navigate, "home");
         });
       });
     });
 
     describe("Events", function() {
       it("should navigate to call/ongoing once the call session is ready",
         function() {
-          sandbox.stub(loop.webapp.Router.prototype, "navigate");
-          var router = new loop.webapp.Router({conversation: conversation});
+          sandbox.stub(loop.webapp.WebappRouter.prototype, "navigate");
+          var router = new loop.webapp.WebappRouter({
+            conversation: conversation
+          });
 
           conversation.setReady(fakeSessionData);
 
           sinon.assert.calledOnce(router.navigate);
           sinon.assert.calledWith(router.navigate, "call/ongoing");
         });
     });
   });
 
-  describe("Router", function() {
-    var router, conversation;
-
-    beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel({
-        loopToken: "fake"
-      });
-      router = new loop.webapp.Router({conversation: conversation});
-    });
-
-    describe("#constructor", function() {
-      it("should define a default active view", function() {
-        expect(router.activeView).to.be.an.instanceOf(loop.webapp.HomeView);
-      });
-    });
-
-    describe("#loadView", function() {
-      it("should set the active view", function() {
-        router.loadView(new loop.webapp.ConversationFormView({
-          model: conversation
-        }));
-
-        expect(router.activeView).to.be.an.instanceOf(
-          loop.webapp.ConversationFormView);
-      });
-    });
-  });
-
   describe("ConversationFormView", function() {
     describe("#initiate", function() {
       var conversation, initiate, view, fakeSubmitEvent;
 
       beforeEach(function() {
-        conversation = new loop.shared.models.ConversationModel();
+        conversation = new sharedModels.ConversationModel();
         view = new loop.webapp.ConversationFormView({model: conversation});
         fakeSubmitEvent = {preventDefault: sinon.spy()};
       });
 
       it("should start the conversation establishment process", function() {
         initiate = sinon.stub(conversation, "initiate");
         conversation.set("loopToken", "fake");