Bug 972017 Part 4 - Hook up the OT sdk to the direct calling window for Loop. r=nperriault

This commit is contained in:
Mark Banner 2014-10-03 22:42:02 +01:00
Родитель 2a779406c7
Коммит 863c847e79
16 изменённых файлов: 1049 добавлений и 100 удалений

Просмотреть файл

@ -33,6 +33,7 @@
<script type="text/javascript" src="loop/shared/js/actions.js"></script>
<script type="text/javascript" src="loop/shared/js/validate.js"></script>
<script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
<script type="text/javascript" src="loop/shared/js/otSdkDriver.js"></script>
<script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
<script type="text/javascript" src="loop/js/conversationViews.js"></script>
<script type="text/javascript" src="loop/shared/js/websocket.js"></script>

Просмотреть файл

@ -227,6 +227,8 @@ loop.Client = (function($) {
* @param {Function} cb Callback(err, result)
*/
setupOutgoingCall: function(calleeIds, callType, cb) {
// For direct calls, we only ever use the logged-in session. Direct
// calls by guests aren't valid.
this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA,
"/calls", "POST", {
calleeId: calleeIds,

Просмотреть файл

@ -540,9 +540,15 @@ loop.conversation = (function(mozL10n) {
var dispatcher = new loop.Dispatcher();
var client = new loop.Client();
var sdkDriver = new loop.OTSdkDriver({
dispatcher: dispatcher,
sdk: OT
});
var conversationStore = new loop.store.ConversationStore({}, {
client: client,
dispatcher: dispatcher
dispatcher: dispatcher,
sdkDriver: sdkDriver
});
// XXX For now key this on the pref, but this should really be

Просмотреть файл

@ -540,9 +540,15 @@ loop.conversation = (function(mozL10n) {
var dispatcher = new loop.Dispatcher();
var client = new loop.Client();
var sdkDriver = new loop.OTSdkDriver({
dispatcher: dispatcher,
sdk: OT
});
var conversationStore = new loop.store.ConversationStore({}, {
client: client,
dispatcher: dispatcher
dispatcher: dispatcher,
sdkDriver: sdkDriver
});
// XXX For now key this on the pref, but this should really be

Просмотреть файл

@ -158,6 +158,15 @@ loop.conversationViews = (function(mozL10n) {
*/
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
// The SDK needs to know about the configuration and the elements to use
// for display. So the best way seems to pass the information here - ideally
// the sdk wouldn't need to know this, but we can't change that.
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
},
componentWillUnmount: function() {
@ -165,9 +174,41 @@ loop.conversationViews = (function(mozL10n) {
window.removeEventListener('resize', this.updateVideoContainer);
},
/**
* Returns either the required DOMNode
*
* @param {String} className The name of the class to get the element for.
*/
_getElement: function(className) {
return this.getDOMNode().querySelector(className);
},
/**
* Returns the required configuration for publishing video on the sdk.
*/
_getPublisherConfig: function() {
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
return {
insertMode: "append",
width: "100%",
height: "100%",
publishVideo: this.props.video.enabled,
style: {
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off"
}
}
},
/**
* Used to update the video container whenever the orientation or size of the
* display area changes.
*/
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
var localStreamParent = this._getElement('.local .OT_publisher');
var remoteStreamParent = this._getElement('.remote .OT_subscriber');
if (localStreamParent) {
localStreamParent.style.width = "100%";
}
@ -176,13 +217,26 @@ loop.conversationViews = (function(mozL10n) {
}
},
/**
* Hangs up the call.
*/
hangup: function() {
this.props.dispatcher.dispatch(
new sharedActions.HangupCall());
},
/**
* Used to control publishing a stream - i.e. to mute a stream
*
* @param {String} type The type of stream, e.g. "audio" or "video".
* @param {Boolean} enabled True to enable the stream, false otherwise.
*/
publishStream: function(type, enabled) {
// XXX Add this as part of bug 972017.
this.props.dispatcher.dispatch(
new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
render: function() {
@ -286,7 +340,8 @@ loop.conversationViews = (function(mozL10n) {
case CALL_STATES.ONGOING: {
return (OngoingConversationView({
dispatcher: this.props.dispatcher,
video: {enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
video: {enabled: this.state.videoMuted},
audio: {enabled: this.state.audioMuted}}
)
);
}

Просмотреть файл

@ -158,6 +158,15 @@ loop.conversationViews = (function(mozL10n) {
*/
window.addEventListener('orientationchange', this.updateVideoContainer);
window.addEventListener('resize', this.updateVideoContainer);
// The SDK needs to know about the configuration and the elements to use
// for display. So the best way seems to pass the information here - ideally
// the sdk wouldn't need to know this, but we can't change that.
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
},
componentWillUnmount: function() {
@ -165,9 +174,41 @@ loop.conversationViews = (function(mozL10n) {
window.removeEventListener('resize', this.updateVideoContainer);
},
/**
* Returns either the required DOMNode
*
* @param {String} className The name of the class to get the element for.
*/
_getElement: function(className) {
return this.getDOMNode().querySelector(className);
},
/**
* Returns the required configuration for publishing video on the sdk.
*/
_getPublisherConfig: function() {
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
return {
insertMode: "append",
width: "100%",
height: "100%",
publishVideo: this.props.video.enabled,
style: {
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off"
}
}
},
/**
* Used to update the video container whenever the orientation or size of the
* display area changes.
*/
updateVideoContainer: function() {
var localStreamParent = document.querySelector('.local .OT_publisher');
var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
var localStreamParent = this._getElement('.local .OT_publisher');
var remoteStreamParent = this._getElement('.remote .OT_subscriber');
if (localStreamParent) {
localStreamParent.style.width = "100%";
}
@ -176,13 +217,26 @@ loop.conversationViews = (function(mozL10n) {
}
},
/**
* Hangs up the call.
*/
hangup: function() {
this.props.dispatcher.dispatch(
new sharedActions.HangupCall());
},
/**
* Used to control publishing a stream - i.e. to mute a stream
*
* @param {String} type The type of stream, e.g. "audio" or "video".
* @param {Boolean} enabled True to enable the stream, false otherwise.
*/
publishStream: function(type, enabled) {
// XXX Add this as part of bug 972017.
this.props.dispatcher.dispatch(
new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
render: function() {
@ -286,7 +340,8 @@ loop.conversationViews = (function(mozL10n) {
case CALL_STATES.ONGOING: {
return (<OngoingConversationView
dispatcher={this.props.dispatcher}
video={{enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
video={{enabled: this.state.videoMuted}}
audio={{enabled: this.state.audioMuted}}
/>
);
}

Просмотреть файл

@ -69,6 +69,12 @@ loop.shared.actions = (function() {
HangupCall: Action.define("hangupCall", {
}),
/**
* Used to indicate the peer hung up the call.
*/
PeerHungupCall: Action.define("peerHungupCall", {
}),
/**
* Used for notifying of connection progress state changes.
* The connection refers to the overall connection flow as indicated
@ -85,6 +91,35 @@ loop.shared.actions = (function() {
ConnectionFailure: Action.define("connectionFailure", {
// A string relating to the reason the connection failed.
reason: String
}),
/**
* Used by the ongoing views to notify stores about the elements
* required for the sdk.
*/
SetupStreamElements: Action.define("setupStreamElements", {
// The configuration for the publisher/subscribe options
publisherConfig: Object,
// The local stream element
getLocalElementFunc: Function,
// The remote stream element
getRemoteElementFunc: Function
}),
/**
* Used for notifying that the media is now up for the call.
*/
MediaConnected: Action.define("mediaConnected", {
}),
/**
* Used to mute or unmute a stream
*/
SetMute: Action.define("setMute", {
// The part of the stream to enable, e.g. "audio" or "video"
type: String,
// Whether or not to enable the stream.
enabled: Boolean
})
};
})();

Просмотреть файл

@ -8,7 +8,7 @@ var loop = loop || {};
loop.store = (function() {
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
/**
* Websocket states taken from:
@ -67,7 +67,7 @@ loop.store = (function() {
calleeId: undefined,
// The call type for the call.
// XXX Don't hard-code, this comes from the data in bug 1072323
callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
callType: CALL_TYPES.AUDIO_VIDEO,
// Call Connection information
// The call id from the loop-server
@ -81,7 +81,11 @@ loop.store = (function() {
// SDK session ID
sessionId: undefined,
// SDK session token
sessionToken: undefined
sessionToken: undefined,
// If the audio is muted
audioMuted: true,
// If the video is muted
videoMuted: true
},
/**
@ -104,9 +108,13 @@ loop.store = (function() {
if (!options.client) {
throw new Error("Missing option client");
}
if (!options.sdkDriver) {
throw new Error("Missing option sdkDriver");
}
this.client = options.client;
this.dispatcher = options.dispatcher;
this.sdkDriver = options.sdkDriver;
this.dispatcher.register(this, [
"connectionFailure",
@ -114,8 +122,11 @@ loop.store = (function() {
"gatherCallData",
"connectCall",
"hangupCall",
"peerHungupCall",
"cancelCall",
"retryCall"
"retryCall",
"mediaConnected",
"setMute"
]);
},
@ -126,6 +137,7 @@ loop.store = (function() {
* @param {sharedActions.ConnectionFailure} actionData The action data.
*/
connectionFailure: function(actionData) {
this._endSession();
this.set({
callState: CALL_STATES.TERMINATED,
callStateReason: actionData.reason
@ -152,7 +164,15 @@ loop.store = (function() {
this.set({callState: CALL_STATES.ALERTING});
break;
}
case WS_STATES.CONNECTING:
case WS_STATES.CONNECTING: {
this.sdkDriver.connectSession({
apiKey: this.get("apiKey"),
sessionId: this.get("sessionId"),
sessionToken: this.get("sessionToken")
});
this.set({callState: CALL_STATES.ONGOING});
break;
}
case WS_STATES.HALF_CONNECTED:
case WS_STATES.CONNECTED: {
this.set({callState: CALL_STATES.ONGOING});
@ -179,6 +199,8 @@ loop.store = (function() {
callState: CALL_STATES.GATHER
});
this.videoMuted = this.get("callType") !== CALL_TYPES.AUDIO_VIDEO;
if (this.get("outgoing")) {
this._setupOutgoingCall();
} // XXX Else, other types aren't supported yet.
@ -200,15 +222,20 @@ loop.store = (function() {
* Hangs up an ongoing call.
*/
hangupCall: function() {
// XXX Stop the SDK once we add it.
// Ensure the websocket has been disconnected.
if (this._websocket) {
// Let the server know the user has hung up.
this._websocket.mediaFail();
this._ensureWebSocketDisconnected();
}
this._endSession();
this.set({callState: CALL_STATES.FINISHED});
},
/**
* The peer hungup the call.
*/
peerHungupCall: function() {
this._endSession();
this.set({callState: CALL_STATES.FINISHED});
},
@ -217,24 +244,15 @@ loop.store = (function() {
*/
cancelCall: function() {
var callState = this.get("callState");
if (callState === CALL_STATES.TERMINATED) {
// All we need to do is close the window.
this.set({callState: CALL_STATES.CLOSE});
return;
if (this._websocket &&
(callState === CALL_STATES.CONNECTING ||
callState === CALL_STATES.ALERTING)) {
// Let the server know the user has hung up.
this._websocket.cancel();
}
if (callState === CALL_STATES.CONNECTING ||
callState === CALL_STATES.ALERTING) {
if (this._websocket) {
// Let the server know the user has hung up.
this._websocket.cancel();
this._ensureWebSocketDisconnected();
}
this.set({callState: CALL_STATES.CLOSE});
return;
}
console.log("Unsupported cancel in state", callState);
this._endSession();
this.set({callState: CALL_STATES.CLOSE});
},
/**
@ -253,6 +271,23 @@ loop.store = (function() {
}
},
/**
* Notifies that all media is now connected
*/
mediaConnected: function() {
this._websocket.mediaUp();
},
/**
* Records the mute state for the stream.
*
* @param {sharedActions.setMute} actionData The mute state for the stream type.
*/
setMute: function(actionData) {
var muteType = actionData.type + "Muted";
this.set(muteType, actionData.enabled);
},
/**
* Obtains the outgoing call data from the server and handles the
* result.
@ -308,14 +343,17 @@ loop.store = (function() {
},
/**
* Ensures the websocket gets disconnected.
* Ensures the session is ended and the websocket is disconnected.
*/
_ensureWebSocketDisconnected: function() {
this.stopListening(this._websocket);
_endSession: function(nextState) {
this.sdkDriver.disconnectSession();
if (this._websocket) {
this.stopListening(this._websocket);
// Now close the websocket.
this._websocket.close();
delete this._websocket;
// Now close the websocket.
this._websocket.close();
delete this._websocket;
}
},
/**

Просмотреть файл

@ -0,0 +1,237 @@
/* 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.OTSdkDriver = (function() {
var sharedActions = loop.shared.actions;
/**
* This is a wrapper for the OT sdk. It is used to translate the SDK events into
* actions, and instruct the SDK what to do as a result of actions.
*/
var OTSdkDriver = function(options) {
if (!options.dispatcher) {
throw new Error("Missing option dispatcher");
}
if (!options.sdk) {
throw new Error("Missing option sdk");
}
this.dispatcher = options.dispatcher;
this.sdk = options.sdk;
this.dispatcher.register(this, [
"setupStreamElements",
"setMute"
]);
};
OTSdkDriver.prototype = {
/**
* Handles the setupStreamElements action. Saves the required data and
* kicks off the initialising of the publisher.
*
* @param {sharedActions.SetupStreamElements} actionData The data associated
* with the action. See action.js.
*/
setupStreamElements: function(actionData) {
this.getLocalElement = actionData.getLocalElementFunc;
this.getRemoteElement = actionData.getRemoteElementFunc;
this.publisherConfig = actionData.publisherConfig;
// At this state we init the publisher, even though we might be waiting for
// the initial connect of the session. This saves time when setting up
// the media.
this.publisher = this.sdk.initPublisher(this.getLocalElement(),
this.publisherConfig,
this._onPublishComplete.bind(this));
},
/**
* Handles the setMute action. Informs the published stream to mute
* or unmute audio as appropriate.
*
* @param {sharedActions.SetMute} actionData The data associated with the
* action. See action.js.
*/
setMute: function(actionData) {
if (actionData.type === "audio") {
this.publisher.publishAudio(actionData.enabled);
} else {
this.publisher.publishVideo(actionData.enabled);
}
},
/**
* Connects a session for the SDK, listening to the required events.
*
* sessionData items:
* - sessionId: The OT session ID
* - apiKey: The OT API key
* - sessionToken: The token for the OT session
*
* @param {Object} sessionData The session data for setting up the OT session.
*/
connectSession: function(sessionData) {
this.session = this.sdk.initSession(sessionData.sessionId);
this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
this.session.on("connectionDestroyed",
this._onConnectionDestroyed.bind(this));
this.session.on("sessionDisconnected",
this._onSessionDisconnected.bind(this));
// This starts the actual session connection.
this.session.connect(sessionData.apiKey, sessionData.sessionToken,
this._onConnectionComplete.bind(this));
},
/**
* Disconnects the sdk session.
*/
disconnectSession: function() {
if (this.session) {
this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this));
this.session.off("connectionDestroyed",
this._onConnectionDestroyed.bind(this));
this.session.off("sessionDisconnected",
this._onSessionDisconnected.bind(this));
this.session.disconnect();
delete this.session;
}
if (this.publisher) {
this.publisher.destroy();
delete this.publisher;
}
// Also, tidy these variables ready for next time.
delete this._sessionConnected;
delete this._publisherReady;
delete this._publishedLocalStream;
delete this._subscribedRemoteStream;
},
/**
* Called once the session has finished connecting.
*
* @param {Error} error An OT error object, null if there was no error.
*/
_onConnectionComplete: function(error) {
if (error) {
console.error("Failed to complete connection", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "couldNotConnect"
}));
return;
}
this._sessionConnected = true;
this._maybePublishLocalStream();
},
/**
* Handles the connection event for a peer's connection being dropped.
*
* @param {SessionDisconnectEvent} event The event details
* https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
*/
_onConnectionDestroyed: function(event) {
var action;
if (event.reason === "clientDisconnected") {
action = new sharedActions.PeerHungupCall();
} else {
// Strictly speaking this isn't a failure on our part, but since our
// flow requires a full reconnection, then we just treat this as
// if a failure of our end had occurred.
action = new sharedActions.ConnectionFailure({
reason: "peerNetworkDisconnected"
});
}
this.dispatcher.dispatch(action);
},
/**
* Handles the session event for the connection for this client being
* destroyed.
*
* @param {SessionDisconnectEvent} event The event details:
* https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
*/
_onSessionDisconnected: function(event) {
// We only need to worry about the network disconnected reason here.
if (event.reason === "networkDisconnected") {
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "networkDisconnected"
}));
}
},
/**
* Handles the event when the remote stream is created.
*
* @param {StreamEvent} event The event details:
* https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
*/
_onRemoteStreamCreated: function(event) {
this.session.subscribe(event.stream,
this.getRemoteElement(), this.publisherConfig);
this._subscribedRemoteStream = true;
if (this._checkAllStreamsConnected()) {
this.dispatcher.dispatch(new sharedActions.MediaConnected());
}
},
/**
* Handles the publishing being complete.
*
* @param {Error} error An OT error object, null if there was no error.
*/
_onPublishComplete: function(error) {
if (error) {
console.error("Failed to initialize publisher", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "noMedia"
}));
return;
}
this._publisherReady = true;
this._maybePublishLocalStream();
},
/**
* Publishes the local stream if the session is connected
* and the publisher is ready.
*/
_maybePublishLocalStream: function() {
if (this._sessionConnected && this._publisherReady) {
// We are clear to publish the stream to the session.
this.session.publish(this.publisher);
// Now record the fact, and check if we've got all media yet.
this._publishedLocalStream = true;
if (this._checkAllStreamsConnected()) {
this.dispatcher.dispatch(new sharedActions.MediaConnected());
}
}
},
/**
* Used to check if both local and remote streams are available
* and send an action if they are.
*/
_checkAllStreamsConnected: function() {
return this._publishedLocalStream &&
this._subscribedRemoteStream;
}
};
return OTSdkDriver;
})();

Просмотреть файл

@ -59,6 +59,7 @@ browser.jar:
content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js)
content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
content/browser/loop/shared/js/validate.js (content/shared/js/validate.js)

Просмотреть файл

@ -161,26 +161,120 @@ describe("loop.conversationViews", function () {
});
});
describe("OngoingConversationView", function() {
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
loop.conversationViews.OngoingConversationView(props));
}
it("should dispatch a setupStreamElements action when the view is created",
function() {
view = mountTestComponent({
dispatcher: dispatcher
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setupStreamElements"));
});
it("should dispatch a hangupCall action when the hangup button is pressed",
function() {
view = mountTestComponent({
dispatcher: dispatcher
});
var hangupBtn = view.getDOMNode().querySelector('.btn-hangup');
React.addons.TestUtils.Simulate.click(hangupBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "hangupCall"));
});
it("should dispatch a setMute action when the audio mute button is pressed",
function() {
view = mountTestComponent({
dispatcher: dispatcher,
audio: {enabled: false}
});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
React.addons.TestUtils.Simulate.click(muteBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setMute"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("enabled", true));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("type", "audio"));
});
it("should dispatch a setMute action when the video mute button is pressed",
function() {
view = mountTestComponent({
dispatcher: dispatcher,
video: {enabled: true}
});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
React.addons.TestUtils.Simulate.click(muteBtn);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "setMute"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("enabled", false));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("type", "video"));
});
it("should set the mute button as mute off", function() {
view = mountTestComponent({
dispatcher: dispatcher,
video: {enabled: true}
});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
expect(muteBtn.classList.contains("muted")).eql(false);
});
it("should set the mute button as mute on", function() {
view = mountTestComponent({
dispatcher: dispatcher,
audio: {enabled: false}
});
var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
expect(muteBtn.classList.contains("muted")).eql(true);
});
});
describe("OutgoingConversationView", function() {
var store;
function mountTestComponent() {
return TestUtils.renderIntoDocument(
loop.conversationViews.OutgoingConversationView({
dispatcher: dispatcher,
store: store
}));
}
beforeEach(function() {
store = new loop.store.ConversationStore({}, {
dispatcher: dispatcher,
client: {}
});
navigator.mozLoop = {
getLoopCharPref: function() { return "fake"; },
appVersionInfo: sinon.spy()
};
store = new loop.store.ConversationStore({}, {
dispatcher: dispatcher,
client: {},
sdkDriver: {}
});
});
afterEach(function() {

Просмотреть файл

@ -143,7 +143,8 @@ describe("loop.conversation", function() {
dispatcher = new loop.Dispatcher();
store = new loop.store.ConversationStore({}, {
client: client,
dispatcher: dispatcher
dispatcher: dispatcher,
sdkDriver: {}
});
});

Просмотреть файл

@ -42,6 +42,7 @@
<script src="../../content/shared/js/actions.js"></script>
<script src="../../content/shared/js/validate.js"></script>
<script src="../../content/shared/js/dispatcher.js"></script>
<script src="../../content/shared/js/otSdkDriver.js"></script>
<script src="../../content/js/client.js"></script>
<script src="../../content/js/conversationViews.js"></script>
<script src="../../content/js/conversation.js"></script>

Просмотреть файл

@ -10,8 +10,9 @@ describe("loop.ConversationStore", function () {
var WS_STATES = loop.store.WS_STATES;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var sandbox, dispatcher, client, store, fakeSessionData;
var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
var connectPromise, resolveConnectPromise, rejectConnectPromise;
var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
function checkFailures(done, f) {
try {
@ -29,9 +30,25 @@ describe("loop.ConversationStore", function () {
client = {
setupOutgoingCall: sinon.stub()
};
sdkDriver = {
connectSession: sinon.stub(),
disconnectSession: sinon.stub()
};
wsCancelSpy = sinon.spy();
wsCloseSpy = sinon.spy();
wsMediaUpSpy = sinon.spy();
fakeWebsocket = {
cancel: wsCancelSpy,
close: wsCloseSpy,
mediaUp: wsMediaUpSpy
};
store = new loop.store.ConversationStore({}, {
client: client,
dispatcher: dispatcher
dispatcher: dispatcher,
sdkDriver: sdkDriver
});
fakeSessionData = {
apiKey: "fakeKey",
@ -63,18 +80,51 @@ describe("loop.ConversationStore", function () {
describe("#initialize", function() {
it("should throw an error if the dispatcher is missing", function() {
expect(function() {
new loop.store.ConversationStore({}, {client: client});
new loop.store.ConversationStore({}, {
client: client,
sdkDriver: sdkDriver
});
}).to.Throw(/dispatcher/);
});
it("should throw an error if the client is missing", function() {
expect(function() {
new loop.store.ConversationStore({}, {dispatcher: dispatcher});
new loop.store.ConversationStore({}, {
dispatcher: dispatcher,
sdkDriver: sdkDriver
});
}).to.Throw(/client/);
});
it("should throw an error if the sdkDriver is missing", function() {
expect(function() {
new loop.store.ConversationStore({}, {
client: client,
dispatcher: dispatcher
});
}).to.Throw(/sdkDriver/);
});
});
describe("#connectionFailure", function() {
beforeEach(function() {
store._websocket = fakeWebsocket;
});
it("should disconnect the session", function() {
dispatcher.dispatch(
new sharedActions.ConnectionFailure({reason: "fake"}));
sinon.assert.calledOnce(sdkDriver.disconnectSession);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(
new sharedActions.ConnectionFailure({reason: "fake"}));
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the state to 'terminated'", function() {
store.set({callState: CALL_STATES.ALERTING});
@ -119,35 +169,29 @@ describe("loop.ConversationStore", function () {
});
describe("progress: connecting", function() {
it("should change the state to 'ongoing'", function() {
beforeEach(function() {
store.set({callState: CALL_STATES.ALERTING});
});
it("should change the state to 'ongoing'", function() {
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
});
});
describe("progress: half-connected", function() {
it("should change the state to 'ongoing'", function() {
store.set({callState: CALL_STATES.ALERTING});
it("should connect the session", function() {
store.set(fakeSessionData);
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.HALF_CONNECTED}));
new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
});
});
describe("progress: connecting", function() {
it("should change the state to 'ongoing'", function() {
store.set({callState: CALL_STATES.ALERTING});
dispatcher.dispatch(
new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTED}));
expect(store.get("callState")).eql(CALL_STATES.ONGOING);
sinon.assert.calledOnce(sdkDriver.connectSession);
sinon.assert.calledWithExactly(sdkDriver.connectSession, {
apiKey: "fakeKey",
sessionId: "321456",
sessionToken: "341256"
});
});
});
});
@ -332,6 +376,12 @@ describe("loop.ConversationStore", function () {
store.set({callState: CALL_STATES.ONGOING});
});
it("should disconnect the session", function() {
dispatcher.dispatch(new sharedActions.HangupCall());
sinon.assert.calledOnce(sdkDriver.disconnectSession);
});
it("should send a media-fail message to the websocket if it is open", function() {
dispatcher.dispatch(new sharedActions.HangupCall());
@ -351,7 +401,69 @@ describe("loop.ConversationStore", function () {
});
});
describe("#peerHungupCall", function() {
var wsMediaFailSpy, wsCloseSpy;
beforeEach(function() {
wsMediaFailSpy = sinon.spy();
wsCloseSpy = sinon.spy();
store._websocket = {
mediaFail: wsMediaFailSpy,
close: wsCloseSpy
};
store.set({callState: CALL_STATES.ONGOING});
});
it("should disconnect the session", function() {
dispatcher.dispatch(new sharedActions.PeerHungupCall());
sinon.assert.calledOnce(sdkDriver.disconnectSession);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(new sharedActions.PeerHungupCall());
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the callState to finished", function() {
dispatcher.dispatch(new sharedActions.PeerHungupCall());
expect(store.get("callState")).eql(CALL_STATES.FINISHED);
});
});
describe("#cancelCall", function() {
beforeEach(function() {
store._websocket = fakeWebsocket;
store.set({callState: CALL_STATES.CONNECTING});
});
it("should disconnect the session", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(sdkDriver.disconnectSession);
});
it("should send a cancel message to the websocket if it is open", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCancelSpy);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the state to close if the call is connecting", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
expect(store.get("callState")).eql(CALL_STATES.CLOSE);
});
it("should set the state to close if the call has terminated already", function() {
store.set({callState: CALL_STATES.TERMINATED});
@ -360,37 +472,6 @@ describe("loop.ConversationStore", function () {
expect(store.get("callState")).eql(CALL_STATES.CLOSE);
});
describe("whilst connecting", function() {
var wsCancelSpy, wsCloseSpy;
beforeEach(function() {
wsCancelSpy = sinon.spy();
wsCloseSpy = sinon.spy();
store._websocket = {
cancel: wsCancelSpy,
close: wsCloseSpy
};
store.set({callState: CALL_STATES.CONNECTING});
});
it("should send a cancel message to the websocket if it is open", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCancelSpy);
});
it("should ensure the websocket is closed", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
sinon.assert.calledOnce(wsCloseSpy);
});
it("should set the state to close if the call is connecting", function() {
dispatcher.dispatch(new sharedActions.CancelCall());
expect(store.get("callState")).eql(CALL_STATES.CLOSE);
});
});
});
describe("#retryCall", function() {
@ -418,6 +499,40 @@ describe("loop.ConversationStore", function () {
});
});
describe("#mediaConnected", function() {
it("should send mediaUp via the websocket", function() {
store._websocket = fakeWebsocket;
dispatcher.dispatch(new sharedActions.MediaConnected());
sinon.assert.calledOnce(wsMediaUpSpy);
});
});
describe("#setMute", function() {
it("should save the mute state for the audio stream", function() {
store.set({"audioMuted": false});
dispatcher.dispatch(new sharedActions.SetMute({
type: "audio",
enabled: true
}));
expect(store.get("audioMuted")).eql(true);
});
it("should save the mute state for the video stream", function() {
store.set({"videoMuted": true});
dispatcher.dispatch(new sharedActions.SetMute({
type: "video",
enabled: false
}));
expect(store.get("videoMuted")).eql(false);
});
});
describe("Events", function() {
describe("Websocket progress", function() {
beforeEach(function() {

Просмотреть файл

@ -42,6 +42,7 @@
<script src="../../content/shared/js/validate.js"></script>
<script src="../../content/shared/js/actions.js"></script>
<script src="../../content/shared/js/dispatcher.js"></script>
<script src="../../content/shared/js/otSdkDriver.js"></script>
<script src="../../content/shared/js/conversationStore.js"></script>
<!-- Test scripts -->
@ -54,6 +55,7 @@
<script src="validate_test.js"></script>
<script src="dispatcher_test.js"></script>
<script src="conversationStore_test.js"></script>
<script src="otSdkDriver_test.js"></script>
<script>
mocha.run(function () {
$("#mocha").append("<p id='complete'>Complete.</p>");

Просмотреть файл

@ -0,0 +1,300 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
var expect = chai.expect;
describe("loop.OTSdkDriver", function () {
"use strict";
var sharedActions = loop.shared.actions;
var sandbox;
var dispatcher, driver, publisher, sdk, session, sessionData;
var fakeLocalElement, fakeRemoteElement, publisherConfig;
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeLocalElement = {fake: 1};
fakeRemoteElement = {fake: 2};
publisherConfig = {
fake: "config"
};
sessionData = {
apiKey: "1234567890",
sessionId: "3216549870",
sessionToken: "1357924680"
};
dispatcher = new loop.Dispatcher();
session = _.extend({
connect: sinon.stub(),
disconnect: sinon.stub(),
publish: sinon.stub(),
subscribe: sinon.stub()
}, Backbone.Events);
publisher = {
destroy: sinon.stub(),
publishAudio: sinon.stub(),
publishVideo: sinon.stub()
};
sdk = {
initPublisher: sinon.stub(),
initSession: sinon.stub().returns(session)
};
driver = new loop.OTSdkDriver({
dispatcher: dispatcher,
sdk: sdk
});
});
afterEach(function() {
sandbox.restore();
});
describe("Constructor", function() {
it("should throw an error if the dispatcher is missing", function() {
expect(function() {
new loop.OTSdkDriver({sdk: sdk});
}).to.Throw(/dispatcher/);
});
it("should throw an error if the sdk is missing", function() {
expect(function() {
new loop.OTSdkDriver({dispatcher: dispatcher});
}).to.Throw(/sdk/);
});
});
describe("#setupStreamElements", function() {
it("should call initPublisher", function() {
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.calledOnce(sdk.initPublisher);
sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
});
describe("On Publisher Complete", function() {
it("should publish the stream if the connection is ready", function() {
sdk.initPublisher.callsArgWith(2, null);
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.calledOnce(session.publish);
});
it("should dispatch connectionFailure if connecting failed", function() {
sdk.initPublisher.callsArgWith(2, new Error("Failure"));
// Special stub, as we want to use the dispatcher, but also know that
// we've been called correctly for the second dispatch.
var dispatchStub = (function() {
var originalDispatch = dispatcher.dispatch.bind(dispatcher);
return sandbox.stub(dispatcher, "dispatch", function(action) {
originalDispatch(action);
});
}());
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.called(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "noMedia"));
});
});
});
describe("#setMute", function() {
beforeEach(function() {
sdk.initPublisher.returns(publisher);
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
});
it("should publishAudio with the correct enabled value", function() {
dispatcher.dispatch(new sharedActions.SetMute({
type: "audio",
enabled: false
}));
sinon.assert.calledOnce(publisher.publishAudio);
sinon.assert.calledWithExactly(publisher.publishAudio, false);
});
it("should publishVideo with the correct enabled value", function() {
dispatcher.dispatch(new sharedActions.SetMute({
type: "video",
enabled: true
}));
sinon.assert.calledOnce(publisher.publishVideo);
sinon.assert.calledWithExactly(publisher.publishVideo, true);
});
});
describe("#connectSession", function() {
it("should initialise a new session", function() {
driver.connectSession(sessionData);
sinon.assert.calledOnce(sdk.initSession);
sinon.assert.calledWithExactly(sdk.initSession, "3216549870");
});
it("should connect the session", function () {
driver.connectSession(sessionData);
sinon.assert.calledOnce(session.connect);
sinon.assert.calledWith(session.connect, "1234567890", "1357924680");
});
describe("On connection complete", function() {
it("should publish the stream if the publisher is ready", function() {
driver._publisherReady = true;
session.connect.callsArg(2);
driver.connectSession(sessionData);
sinon.assert.calledOnce(session.publish);
});
it("should dispatch connectionFailure if connecting failed", function() {
session.connect.callsArgWith(2, new Error("Failure"));
sandbox.stub(dispatcher, "dispatch");
driver.connectSession(sessionData);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "couldNotConnect"));
});
});
});
describe("#disconnectionSession", function() {
it("should disconnect the session", function() {
driver.session = session;
driver.disconnectSession();
sinon.assert.calledOnce(session.disconnect);
});
it("should destroy the publisher", function() {
driver.publisher = publisher;
driver.disconnectSession();
sinon.assert.calledOnce(publisher.destroy);
});
});
describe("Events", function() {
beforeEach(function() {
driver.connectSession(sessionData);
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sandbox.stub(dispatcher, "dispatch");
});
describe("connectionDestroyed", function() {
it("should dispatch a peerHungupCall action if the client disconnected", function() {
session.trigger("connectionDestroyed", {
reason: "clientDisconnected"
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "peerHungupCall"));
});
it("should dispatch a connectionFailure action if the connection failed", function() {
session.trigger("connectionDestroyed", {
reason: "networkDisconnected"
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "peerNetworkDisconnected"));
});
});
describe("sessionDisconnected", function() {
it("should dispatch a connectionFailure action if the session was disconnected",
function() {
session.trigger("sessionDisconnected", {
reason: "networkDisconnected"
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "networkDisconnected"));
});
});
describe("streamCreated", function() {
var fakeStream;
beforeEach(function() {
fakeStream = {
fakeStream: 3
};
});
it("should subscribe to the stream", function() {
session.trigger("streamCreated", {stream: fakeStream});
sinon.assert.calledOnce(session.subscribe);
sinon.assert.calledWithExactly(session.subscribe,
fakeStream, fakeRemoteElement, publisherConfig);
});
it("should dispach a mediaConnected action if both streams are up", function() {
driver._publishedLocalStream = true;
session.trigger("streamCreated", {stream: fakeStream});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "mediaConnected"));
});
});
});
});