Bug 1020448 - Add Loop pending call timeout for the link clicker UI. r=Standard8,ui-review=dhenein
authorNicolas Perriault <nperriault@gmail.com>
Wed, 16 Jul 2014 15:15:08 +0100
changeset 216314 a73ed0f644d6cddae4828fc8844655d2df399271
parent 216313 ceef79a9fcb90302422541f72e92aadcfae767c9
child 216315 79a9cefa3538ba94f2b6833b302342d4c132ddd5
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)
reviewersStandard8
bugs1020448
milestone33.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 1020448 - Add Loop pending call timeout for the link clicker UI. r=Standard8,ui-review=dhenein
browser/components/loop/content/shared/js/models.js
browser/components/loop/standalone/Makefile
browser/components/loop/standalone/README.md
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/l10n/data.ini
browser/components/loop/test/desktop-local/conversation_test.js
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/webapp_test.js
--- a/browser/components/loop/content/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: {
+      connected:    false,     // Session connected flag
       ongoing:      false,     // Ongoing call flag
       callerId:     undefined, // Loop caller id
       loopToken:    undefined, // Loop conversation token
       loopVersion:  undefined, // Loop version for /calls/ information. This
                                // is the version received from the push
                                // notification and is used by the server to
                                // determine the pending calls
       sessionId:    undefined, // OT session id
@@ -34,30 +35,52 @@ loop.shared.models = (function() {
 
     /**
      * SDK session object.
      * @type {XXX}
      */
     session: undefined,
 
     /**
+     * Pending call timeout value.
+     * @type {Number}
+     */
+    pendingCallTimeout: undefined,
+
+    /**
+     * Pending call timer.
+     * @type {Number}
+     */
+    _pendingCallTimer: undefined,
+
+    /**
      * Constructor.
      *
-     * Required options:
-     * - {OT} sdk: SDK object.
+     * Options:
+     *
+     * Required:
+     * - {OT} sdk: OT SDK object.
+     *
+     * Optional:
+     * - {Number} pendingCallTimeout: Pending call timeout in milliseconds
+     *                                (default: 20000).
      *
      * @param  {Object} attributes Attributes object.
      * @param  {Object} options    Options object.
      */
     initialize: function(attributes, options) {
       options = options || {};
       if (!options.sdk) {
         throw new Error("missing required sdk");
       }
       this.sdk = options.sdk;
+      this.pendingCallTimeout = options.pendingCallTimeout || 20000;
+
+      // Ensure that any pending call timer is cleared on disconnect/error
+      this.on("session:ended session:error", this._clearPendingCallTimer, this);
     },
 
     /**
      * Initiates a conversation, requesting call session information to the Loop
      * server and updates appropriately the current model attributes with the
      * data.
      *
      * Available options:
@@ -74,31 +97,48 @@ loop.shared.models = (function() {
      *
      * - `session:ready` when the session information have been successfully
      *   retrieved from the server;
      * - `session:error` when the request failed.
      *
      * @param {Object} options Options object
      */
     initiate: function(options) {
+      options = options || {};
+
+      // Outgoing call has never reached destination, closing - see bug 1020448
+      function handleOutgoingCallTimeout() {
+        /*jshint validthis:true */
+        if (!this.get("ongoing")) {
+          this.trigger("timeout").endSession();
+        }
+      }
+
       function handleResult(err, sessionData) {
         /*jshint validthis:true */
+        this._clearPendingCallTimer();
+
         if (err) {
           this.trigger("session:error", new Error(
             "Retrieval of session information failed: HTTP " + err));
           return;
         }
 
-        // XXX For incoming calls we might have more than one call queued.
-        // For now, we'll just assume the first call is the right information.
-        // We'll probably really want to be getting this data from the
-        // background worker on the desktop client.
-        // Bug 990714 should fix this.
-        if (!options.outgoing)
+        if (options.outgoing) {
+          // Setup pending call timeout.
+          this._pendingCallTimer = setTimeout(
+            handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
+        } else {
+          // XXX For incoming calls we might have more than one call queued.
+          // For now, we'll just assume the first call is the right information.
+          // We'll probably really want to be getting this data from the
+          // background worker on the desktop client.
+          // Bug 990714 should fix this.
           sessionData = sessionData[0];
+        }
 
         this.setReady(sessionData);
       }
 
       if (options.outgoing) {
         options.client.requestCallInfo(this.get("loopToken"), options.callType,
           handleResult.bind(this));
       }
@@ -151,18 +191,27 @@ loop.shared.models = (function() {
                            this._onConnectCompletion.bind(this));
     },
 
     /**
      * Ends current session.
      */
     endSession: function() {
       this.session.disconnect();
-      this.once("session:ended", this.stopListening, this);
-      this.set("ongoing", false);
+      this.set("ongoing", false)
+          .once("session:ended", this.stopListening, this);
+    },
+
+    /**
+     * Clears current pending call timer, if any.
+     */
+    _clearPendingCallTimer: function() {
+      if (this._pendingCallTimer) {
+        clearTimeout(this._pendingCallTimer);
+      }
     },
 
     /**
      * Manages connection status
      * triggers apropriate event for connection error/success
      * http://tokbox.com/opentok/tutorials/connect-session/js/
      * http://tokbox.com/opentok/tutorials/hello-world/js/
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html
@@ -170,62 +219,68 @@ loop.shared.models = (function() {
      * @param {error|null} error
      */
     _onConnectCompletion: function(error) {
       if (error) {
         this.trigger("session:connection-error", error);
         this.endSession();
       } else {
         this.trigger("session:connected");
-        this.set("ongoing", true);
+        this.set("connected", true);
       }
     },
 
     /**
      * New created streams are available.
      * http://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
      *
      * @param  {StreamEvent} event
      */
     _streamCreated: function(event) {
-      this.trigger("session:stream-created", event);
+      this.set("ongoing", true)
+          .trigger("session:stream-created", event);
     },
 
     /**
      * Local user hung up.
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
      *
      * @param  {SessionDisconnectEvent} event
      */
     _sessionDisconnected: function(event) {
-      this.trigger("session:ended");
-      this.set("ongoing", false);
+      this.set("connected", false)
+          .set("ongoing", false)
+          .trigger("session:ended");
     },
 
     /**
      * Peer hung up. Disconnects local session.
      * http://tokbox.com/opentok/libraries/client/js/reference/ConnectionEvent.html
      *
      * @param  {ConnectionEvent} event
      */
     _connectionDestroyed: function(event) {
-      this.trigger("session:peer-hungup", {
-        connectionId: event.connection.connectionId
-      });
+      this.set("connected", false)
+          .set("ongoing", false)
+          .trigger("session:peer-hungup", {
+            connectionId: event.connection.connectionId
+          });
       this.endSession();
     },
 
     /**
      * Network was disconnected.
      * http://tokbox.com/opentok/libraries/client/js/reference/ConnectionEvent.html
      *
      * @param {ConnectionEvent} event
      */
     _networkDisconnected: function(event) {
-      this.trigger("session:network-disconnected");
+      this.set("connected", false)
+          .set("ongoing", false)
+          .trigger("session:network-disconnected");
       this.endSession();
     },
   });
 
   /**
    * Notification model.
    */
   var NotificationModel = Backbone.Model.extend({
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -1,13 +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/.
 
 LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000})
+LOOP_PENDING_CALL_TIMEOUT := $(shell echo $${LOOP_PENDING_CALL_TIMEOUT-20000})
 NODE_LOCAL_BIN=./node_modules/.bin
 
 install:
 	@npm install
 
 test:
 	@echo "Not implemented yet."
 
@@ -16,9 +17,12 @@ lint:
 
 runserver: config
 	@node server.js
 
 frontend:
 	@echo "Not implemented yet."
 
 config:
-	@echo "var loop = loop || {};\nloop.config = {serverUrl: '`echo $(LOOP_SERVER_URL)`'};" > content/config.js
+	@echo "var loop = loop || {};" > content/config.js
+	@echo "loop.config = loop.config || {};" >> content/config.js
+	@echo "loop.config.serverUrl          = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+	@echo "loop.config.pendingCallTimeout = `echo $(LOOP_PENDING_CALL_TIMEOUT)`;" >> content/config.js
--- a/browser/components/loop/standalone/README.md
+++ b/browser/components/loop/standalone/README.md
@@ -11,21 +11,25 @@ Installation
 
     $ make install
 
 Configuration
 -------------
 
 You will need to generate a configuration file, you can do so with:
 
-	$ make config
+    $ make config
+
+It will read the configuration from the following env variables and generate the
+appropriate configuration file:
 
-It will read the configuration from the `LOOP_SERVER_URL` env variable and
-generate the appropriate configuration file. This setting defines the root url
-of the loop server, without trailing slash.
+- `LOOP_SERVER_URL` defines the root url of the loop server, without trailing
+  slash (default: `http://localhost:5000`).
+- `LOOP_PENDING_CALL_TIMEOUT` defines the amount of time a pending outgoing call
+  should be considered timed out, in milliseconds (default: `20000`).
 
 Usage
 -----
 
 For development, run a local static file server:
 
     $ make runserver
 
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -114,16 +114,18 @@ loop.webapp = (function($, _, OT) {
       "unsupportedBrowser":  "unsupportedBrowser",
       "call/ongoing/:token": "loadConversation",
       "call/:token":         "initiate"
     },
 
     initialize: function() {
       // Load default view
       this.loadView(new HomeView());
+
+      this.listenTo(this._conversation, "timeout", this._onTimeout);
     },
 
     /**
      * @override {loop.shared.router.BaseConversationRouter.startCall}
      */
     startCall: function() {
       if (!this._conversation.get("loopToken")) {
         this._notifier.errorL10n("missing_conversation_info");
@@ -141,16 +143,20 @@ loop.webapp = (function($, _, OT) {
     endCall: function() {
       var route = "home";
       if (this._conversation.get("loopToken")) {
         route = "call/" + this._conversation.get("loopToken");
       }
       this.navigate(route, {trigger: true});
     },
 
+    _onTimeout: function() {
+      this._notifier.errorL10n("call_timeout_notification_text");
+    },
+
     /**
      * Default entry point.
      */
     home: function() {
       this.loadView(new HomeView());
     },
 
     unsupportedDevice: function() {
@@ -208,18 +214,21 @@ loop.webapp = (function($, _, OT) {
   };
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new WebappHelper();
     router = new WebappRouter({
-      conversation: new sharedModels.ConversationModel({}, {sdk: OT}),
-      notifier: new sharedViews.NotificationListView({el: "#messages"})
+      notifier: new sharedViews.NotificationListView({el: "#messages"}),
+      conversation: new sharedModels.ConversationModel({}, {
+        sdk: OT,
+        pendingCallTimeout: loop.config.pendingCallTimeout
+      })
     });
     Backbone.history.start();
     if (helper.isIOS(navigator.platform)) {
       router.navigate("unsupportedDevice", {trigger: true});
     } else if (!OT.checkSystemRequirements()) {
       router.navigate("unsupportedBrowser", {trigger: true});
     }
   }
--- a/browser/components/loop/standalone/content/l10n/data.ini
+++ b/browser/components/loop/standalone/content/l10n/data.ini
@@ -1,10 +1,11 @@
 [en]
 call_has_ended=Your call has ended.
+call_timeout_notification_text=Your call did not go through.
 missing_conversation_info=Missing conversation information.
 network_disconnected=The network connection terminated abruptly.
 peer_ended_conversation=Your peer ended the conversation.
 unable_retrieve_call_info=Unable to retrieve conversation information.
 hangup_button_title=Hangup
 mute_local_audio_button_title=Mute your audio
 unmute_local_audio_button_title=Unute your audio
 mute_local_video_button_title=Mute your video
@@ -16,16 +17,17 @@ powered_by_webrtc=The audio and video co
 use_latest_firefox.innerHTML=To use Loop, please use the latest version of <a href="{{ff_url}}">Firefox</a>.
 incompatible_device=Incompatible device
 sorry_device_unsupported=Sorry, Loop does not currently support your device.
 use_firefox_windows_mac_linux=Please open this page using the latest Firefox on Windows, Android, Mac or Linux.
 connection_error_see_console_notification=Call failed; see console for details.
 
 [fr]
 call_has_ended=L'appel est terminé.
+call_timeout_notification_text=Votre appel n'a pas abouti.
 missing_conversation_info=Informations de communication manquantes.
 network_disconnected=La connexion réseau semble avoir été interrompue.
 peer_ended_conversation=Votre correspondant a mis fin à la communication.
 unable_retrieve_call_info=Impossible de récupérer les informations liées à cet appel.
 hangup_button_title=Terminer l'appel
 mute_local_audio_button_title=Couper la diffusion audio
 unmute_local_audio_button_title=Reprendre la diffusion audio
 mute_local_video_button_title=Couper la diffusion vidéo
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -91,17 +91,20 @@ describe("loop.conversation", function()
       sinon.assert.calledOnce(Backbone.history.start);
     });
   });
 
   describe("ConversationRouter", function() {
     var conversation;
 
     beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel({}, {sdk: {}});
+      conversation = new loop.shared.models.ConversationModel({}, {
+        sdk: {},
+        pendingCallTimeout: 1000
+      });
       sandbox.stub(conversation, "initiate");
     });
 
     describe("Routes", function() {
       var router;
 
       beforeEach(function() {
         router = new ConversationRouter({
@@ -280,17 +283,20 @@ describe("loop.conversation", function()
       });
     });
   });
 
   describe("IncomingCallView", function() {
     var conversation, view;
 
     beforeEach(function() {
-      conversation = new loop.shared.models.ConversationModel({}, {sdk: {}});
+      conversation = new loop.shared.models.ConversationModel({}, {
+        sdk: {},
+        pendingCallTimeout: 1000
+      });
       view = new loop.conversation.IncomingCallView({model: conversation});
     });
 
     describe("#handleAccept", function() {
       it("should trigger an 'accept' conversation model event" ,
         function(done) {
           conversation.once("accept", function() {
             done();
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -9,16 +9,17 @@ var expect = chai.expect;
 describe("loop.shared.models", function() {
   "use strict";
 
   var sharedModels = loop.shared.models,
       sandbox, fakeXHR, requests = [], fakeSDK, fakeSession, fakeSessionData;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    sandbox.useFakeTimers();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function(xhr) {
       requests.push(xhr);
     };
     fakeSessionData = {
       sessionId:    "sessionId",
@@ -41,38 +42,52 @@ describe("loop.shared.models", function(
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("ConversationModel", function() {
     describe("#initialize", function() {
       it("should require a sdk option", function() {
         expect(function() {
-          new sharedModels.ConversationModel();
+          new sharedModels.ConversationModel({}, {});
         }).to.Throw(Error, /missing required sdk/);
       });
+
+      it("should accept a pendingCallTimeout option", function() {
+        expect(new sharedModels.ConversationModel({}, {
+          sdk: {},
+          pendingCallTimeout: 1000
+        }).pendingCallTimeout).eql(1000);
+      });
     });
 
     describe("constructed", function() {
       var conversation, fakeClient, fakeBaseServerUrl,
           requestCallInfoStub, requestCallsInfoStub;
 
       beforeEach(function() {
-        conversation = new sharedModels.ConversationModel({}, {sdk: fakeSDK});
+        conversation = new sharedModels.ConversationModel({}, {
+          sdk: fakeSDK,
+          pendingCallTimeout: 1000
+        });
         conversation.set("loopToken", "fakeToken");
         fakeBaseServerUrl = "http://fakeBaseServerUrl";
         fakeClient = {
           requestCallInfo: sandbox.stub(),
           requestCallsInfo: sandbox.stub()
         };
         requestCallInfoStub = fakeClient.requestCallInfo;
         requestCallsInfoStub = fakeClient.requestCallsInfo;
       });
 
       describe("#initiate", function() {
+        beforeEach(function() {
+          sandbox.stub(conversation, "endSession");
+        });
+
         it("call requestCallInfo on the client for outgoing calls",
           function() {
             conversation.initiate({
               client: fakeClient,
               outgoing: true,
               callType: "audio"
             });
 
@@ -134,16 +149,45 @@ describe("loop.shared.models", function(
           conversation.on("session:error", function(err) {
             expect(err.message).to.match(/failed: HTTP 400 Bad Request; fake/);
             done();
           }).initiate({
             client: fakeClient,
             outgoing: true
           });
         });
+
+        it("should end the session on outgoing call timeout", function() {
+          requestCallInfoStub.callsArgWith(2, null, fakeSessionData);
+
+          conversation.initiate({
+            client: fakeClient,
+            outgoing: true
+          });
+
+          sandbox.clock.tick(1001);
+
+          sinon.assert.calledOnce(conversation.endSession);
+        });
+
+        it("should trigger a `timeout` event on outgoing call timeout",
+          function(done) {
+            requestCallInfoStub.callsArgWith(2, null, fakeSessionData);
+
+            conversation.once("timeout", function() {
+              done();
+            });
+
+            conversation.initiate({
+              client: fakeClient,
+              outgoing: true
+            });
+
+            sandbox.clock.tick(1001);
+          });
       });
 
       describe("#setReady", function() {
         it("should update conversation session information", function() {
           conversation.setReady(fakeSessionData);
 
           expect(conversation.get("sessionId")).eql("sessionId");
           expect(conversation.get("sessionToken")).eql("sessionToken");
@@ -156,18 +200,21 @@ describe("loop.shared.models", function(
           }).setReady(fakeSessionData);
         });
       });
 
       describe("#startSession", function() {
         var model;
 
         beforeEach(function() {
+          sandbox.stub(sharedModels.ConversationModel.prototype,
+                       "_clearPendingCallTimer");
           model = new sharedModels.ConversationModel(fakeSessionData, {
-            sdk: fakeSDK
+            sdk: fakeSDK,
+            pendingCallTimeout: 1000
           });
           model.startSession();
         });
 
         it("should start a session", function() {
           sinon.assert.calledOnce(fakeSDK.initSession);
         });
 
@@ -177,26 +224,26 @@ describe("loop.shared.models", function(
           model.startSession();
 
           sinon.assert.calledOnce(fakeSession.connect);
           sinon.assert.calledWithExactly(fakeSession.connect,
                         sinon.match.string, sinon.match.string,
                         sinon.match.func);
         });
 
-        it("should set ongoing to true when no error is called back",
+        it("should set connected to true when no error is called back",
             function() {
               fakeSession.connect = function(key, token, cb) {
                 cb(null);
               };
-              sinon.stub(model, "set");
+              sandbox.stub(model, "set");
 
               model.startSession();
 
-              sinon.assert.calledWith(model.set, "ongoing", true);
+              sinon.assert.calledWith(model.set, "connected", true);
             });
 
         it("should trigger session:connected when no error is called back",
             function() {
               fakeSession.connect = function(key, token, cb) {
                 cb(null);
               };
               sandbox.stub(model, "trigger");
@@ -210,17 +257,17 @@ describe("loop.shared.models", function(
 
           it("should trigger a fail event when an error is called back",
             function() {
               fakeSession.connect = function(key, token, cb) {
                 cb({
                   error: true
                 });
               };
-              sinon.stub(model, "endSession");
+              sandbox.stub(model, "endSession");
 
               model.startSession();
 
               sinon.assert.calledOnce(model.endSession);
               sinon.assert.calledWithExactly(model.endSession);
             });
 
           it("should trigger session:connection-error event when an error is" +
@@ -234,32 +281,59 @@ describe("loop.shared.models", function(
 
               model.startSession();
 
               sinon.assert.calledOnce(model.trigger);
               sinon.assert.calledWithExactly(model.trigger,
                           "session:connection-error", sinon.match.object);
             });
 
+          it("should set the connected attr to true on connection completed",
+            function() {
+              fakeSession.connect = function(key, token, cb) {
+                cb();
+              };
+
+              model.startSession();
+
+              expect(model.get("connected")).eql(true);
+            });
+
           it("should trigger a session:ended event on sessionDisconnected",
             function(done) {
               model.once("session:ended", function(){ done(); });
 
               fakeSession.trigger("sessionDisconnected", {reason: "ko"});
             });
 
+          it("should set the connected attribute to false on sessionDisconnected",
+            function() {
+              fakeSession.trigger("sessionDisconnected", {reason: "ko"});
+
+              expect(model.get("connected")).eql(false);
+            });
+
           it("should set the ongoing attribute to false on sessionDisconnected",
-            function(done) {
-              model.once("session:ended", function() {
-                expect(model.get("ongoing")).eql(false);
-                done();
-              });
+            function() {
+              fakeSession.trigger("sessionDisconnected", {reason: "ko"});
+
+              expect(model.get("ongoing")).eql(false);
+            });
+
+          it("should clear a pending timer on session:ended", function() {
+            model.trigger("session:ended");
 
-              fakeSession.trigger("sessionDisconnected", {reason: "ko"});
-            });
+            sinon.assert.calledOnce(model._clearPendingCallTimer);
+          });
+
+          it("should clear a pending timer on session:error", function() {
+            model.trigger("session:error");
+
+            sinon.assert.calledOnce(model._clearPendingCallTimer);
+          });
 
           describe("connectionDestroyed event received", function() {
             var fakeEvent = {reason: "ko", connection: {connectionId: 42}};
 
             it("should trigger a session:peer-hungup model event",
               function(done) {
                 model.once("session:peer-hungup", function(event) {
                   expect(event.connectionId).eql(42);
@@ -299,27 +373,34 @@ describe("loop.shared.models", function(
         });
       });
 
       describe("#endSession", function() {
         var model;
 
         beforeEach(function() {
           model = new sharedModels.ConversationModel(fakeSessionData, {
-            sdk: fakeSDK
+            sdk: fakeSDK,
+            pendingCallTimeout: 1000
           });
           model.startSession();
         });
 
         it("should disconnect current session", function() {
           model.endSession();
 
           sinon.assert.calledOnce(fakeSession.disconnect);
         });
 
+        it("should set the connected attribute to false", function() {
+          model.endSession();
+
+          expect(model.get("connected")).eql(false);
+        });
+
         it("should set the ongoing attribute to false", function() {
           model.endSession();
 
           expect(model.get("ongoing")).eql(false);
         });
 
         it("should stop listening to session events once the session is " +
            "actually disconnected", function() {
--- a/browser/components/loop/test/shared/router_test.js
+++ b/browser/components/loop/test/shared/router_test.js
@@ -97,17 +97,20 @@ describe("loop.shared.router", function(
 
     beforeEach(function() {
       TestRouter = loop.shared.router.BaseConversationRouter.extend({
         startCall: sandbox.spy(),
         endCall: sandbox.spy()
       });
       conversation = new loop.shared.models.ConversationModel({
         loopToken: "fakeToken"
-      }, {sdk: {}});
+      }, {
+        sdk: {},
+        pendingCallTimeout: 1000
+      });
     });
 
     describe("#constructor", function() {
       it("should require a ConversationModel instance", function() {
         expect(function() {
           new TestRouter();
         }).to.Throw(Error, /missing required conversation/);
       });
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -200,17 +200,18 @@ describe("loop.shared.views", function()
         publishAudio: sandbox.spy(),
         publishVideo: sandbox.spy()
       };
       fakeSDK = {
         initPublisher: sandbox.stub().returns(fakePublisher),
         initSession: sandbox.stub().returns(fakeSession)
       };
       model = new sharedModels.ConversationModel(fakeSessionData, {
-        sdk: fakeSDK
+        sdk: fakeSDK,
+        pendingCallTimeout: 1000
       });
     });
 
     describe("#componentDidMount", function() {
       it("should start a session", function() {
         sandbox.stub(model, "startSession");
 
         mountTestComponent({sdk: fakeSDK, model: model});
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -18,20 +18,22 @@ describe("loop.webapp", function() {
     sandbox = sinon.sandbox.create();
     notifier = {
       notify: sandbox.spy(),
       warn: sandbox.spy(),
       warnL10n: sandbox.spy(),
       error: sandbox.spy(),
       errorL10n: sandbox.spy(),
     };
+    loop.config.pendingCallTimeout = 1000;
   });
 
   afterEach(function() {
     sandbox.restore();
+    delete loop.config.pendingCallTimeout;
   });
 
   describe("#init", function() {
     var WebappRouter;
 
     beforeEach(function() {
       WebappRouter = loop.webapp.WebappRouter;
       sandbox.stub(WebappRouter.prototype, "navigate");
@@ -64,17 +66,20 @@ describe("loop.webapp", function() {
                                      "unsupportedBrowser", {trigger: true});
     });
   });
 
   describe("WebappRouter", function() {
     var router, conversation;
 
     beforeEach(function() {
-      conversation = new sharedModels.ConversationModel({}, {sdk: {}});
+      conversation = new sharedModels.ConversationModel({}, {
+        sdk: {},
+        pendingCallTimeout: 1000
+      });
       router = new loop.webapp.WebappRouter({
         conversation: conversation,
         notifier: notifier
       });
       sandbox.stub(router, "loadView");
       sandbox.stub(router, "loadReactComponent");
       sandbox.stub(router, "navigate");
     });
@@ -248,32 +253,37 @@ describe("loop.webapp", function() {
         });
     });
   });
 
   describe("ConversationFormView", function() {
     var conversation;
 
     beforeEach(function() {
-      conversation = new sharedModels.ConversationModel({}, {sdk: {}});
+      conversation = new sharedModels.ConversationModel({}, {
+        sdk: {},
+        pendingCallTimeout: 1000});
     });
 
     describe("#initialize", function() {
       it("should require a conversation option", function() {
         expect(function() {
           new loop.webapp.WebappRouter();
         }).to.Throw(Error, /missing required conversation/);
       });
     });
 
     describe("#initiate", function() {
       var conversation, initiate, view, fakeSubmitEvent;
 
       beforeEach(function() {
-        conversation = new sharedModels.ConversationModel({}, {sdk: {}});
+        conversation = new sharedModels.ConversationModel({}, {
+          sdk: {},
+          pendingCallTimeout: 1000
+        });
         view = new loop.webapp.ConversationFormView({
           model: conversation,
           notifier: notifier
         });
         fakeSubmitEvent = {preventDefault: sinon.spy()};
         initiate = sinon.stub(conversation, "initiate");
       });
 
@@ -302,17 +312,20 @@ describe("loop.webapp", function() {
     });
 
     describe("Events", function() {
       var conversation, view;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({
           loopToken: "fake"
-        }, {sdk: {}});
+        }, {
+          sdk: {},
+          pendingCallTimeout: 1000
+        });
         view = new loop.webapp.ConversationFormView({
           model: conversation,
           notifier: notifier
         });
       });
 
       it("should trigger a notication when a session:error model event is " +
          " received", function() {