diff options
Diffstat (limited to 'dom/media/tests/mochitest/pc.js')
-rw-r--r-- | dom/media/tests/mochitest/pc.js | 1878 |
1 files changed, 1878 insertions, 0 deletions
diff --git a/dom/media/tests/mochitest/pc.js b/dom/media/tests/mochitest/pc.js new file mode 100644 index 000000000..a9383358f --- /dev/null +++ b/dom/media/tests/mochitest/pc.js @@ -0,0 +1,1878 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const LOOPBACK_ADDR = "127.0.0."; + +const iceStateTransitions = { + "new": ["checking", "closed"], //Note: 'failed' might need to added here + // even though it is not in the standard + "checking": ["new", "connected", "failed", "closed"], //Note: do we need to + // allow 'completed' in + // here as well? + "connected": ["new", "completed", "disconnected", "closed"], + "completed": ["new", "disconnected", "closed"], + "disconnected": ["new", "connected", "completed", "failed", "closed"], + "failed": ["new", "disconnected", "closed"], + "closed": [] + } + +const signalingStateTransitions = { + "stable": ["have-local-offer", "have-remote-offer", "closed"], + "have-local-offer": ["have-remote-pranswer", "stable", "closed", "have-local-offer"], + "have-remote-pranswer": ["stable", "closed", "have-remote-pranswer"], + "have-remote-offer": ["have-local-pranswer", "stable", "closed", "have-remote-offer"], + "have-local-pranswer": ["stable", "closed", "have-local-pranswer"], + "closed": [] +} + +var makeDefaultCommands = () => { + return [].concat(commandsPeerConnectionInitial, + commandsGetUserMedia, + commandsPeerConnectionOfferAnswer); +}; + +/** + * This class handles tests for peer connections. + * + * @constructor + * @param {object} [options={}] + * Optional options for the peer connection test + * @param {object} [options.commands=commandsPeerConnection] + * Commands to run for the test + * @param {bool} [options.is_local=true] + * true if this test should run the tests for the "local" side. + * @param {bool} [options.is_remote=true] + * true if this test should run the tests for the "remote" side. + * @param {object} [options.config_local=undefined] + * Configuration for the local peer connection instance + * @param {object} [options.config_remote=undefined] + * Configuration for the remote peer connection instance. If not defined + * the configuration from the local instance will be used + */ +function PeerConnectionTest(options) { + // If no options are specified make it an empty object + options = options || { }; + options.commands = options.commands || makeDefaultCommands(); + options.is_local = "is_local" in options ? options.is_local : true; + options.is_remote = "is_remote" in options ? options.is_remote : true; + + options.h264 = "h264" in options ? options.h264 : false; + options.bundle = "bundle" in options ? options.bundle : true; + options.rtcpmux = "rtcpmux" in options ? options.rtcpmux : true; + options.opus = "opus" in options ? options.opus : true; + + if (iceServersArray.length) { + if (!options.turn_disabled_local) { + options.config_local = options.config_local || {} + options.config_local.iceServers = iceServersArray; + } + if (!options.turn_disabled_remote) { + options.config_remote = options.config_remote || {} + options.config_remote.iceServers = iceServersArray; + } + } + else if (typeof turnServers !== "undefined") { + if ((!options.turn_disabled_local) && (turnServers.local)) { + if (!options.hasOwnProperty("config_local")) { + options.config_local = {}; + } + if (!options.config_local.hasOwnProperty("iceServers")) { + options.config_local.iceServers = turnServers.local.iceServers; + } + } + if ((!options.turn_disabled_remote) && (turnServers.remote)) { + if (!options.hasOwnProperty("config_remote")) { + options.config_remote = {}; + } + if (!options.config_remote.hasOwnProperty("iceServers")) { + options.config_remote.iceServers = turnServers.remote.iceServers; + } + } + } + + if (options.is_local) { + this.pcLocal = new PeerConnectionWrapper('pcLocal', options.config_local); + } else { + this.pcLocal = null; + } + + if (options.is_remote) { + this.pcRemote = new PeerConnectionWrapper('pcRemote', options.config_remote || options.config_local); + } else { + this.pcRemote = null; + } + + options.steeplechase = !options.is_local || !options.is_remote; + + // Create command chain instance and assign default commands + this.chain = new CommandChain(this, options.commands); + + this.testOptions = options; +} + +/** TODO: consider removing this dependency on timeouts */ +function timerGuard(p, time, message) { + return Promise.race([ + p, + wait(time).then(() => { + throw new Error('timeout after ' + (time / 1000) + 's: ' + message); + }) + ]); +} + +/** + * Closes the peer connection if it is active + */ +PeerConnectionTest.prototype.closePC = function() { + info("Closing peer connections"); + + var closeIt = pc => { + if (!pc || pc.signalingState === "closed") { + return Promise.resolve(); + } + + var promise = Promise.all([ + new Promise(resolve => { + pc.onsignalingstatechange = e => { + is(e.target.signalingState, "closed", "signalingState is closed"); + resolve(); + }; + }), + Promise.all(pc._pc.getReceivers() + .filter(receiver => receiver.track.readyState == "live") + .map(receiver => { + info("Waiting for track " + receiver.track.id + " (" + + receiver.track.kind + ") to end."); + return haveEvent(receiver.track, "ended", wait(50000)) + .then(event => { + is(event.target, receiver.track, "Event target should be the correct track"); + info("ended fired for track " + receiver.track.id); + }, e => e ? Promise.reject(e) + : ok(false, "ended never fired for track " + + receiver.track.id)); + })) + ]); + pc.close(); + return promise; + }; + + return timerGuard(Promise.all([ + closeIt(this.pcLocal), + closeIt(this.pcRemote) + ]), 60000, "failed to close peer connection"); +}; + +/** + * Close the open data channels, followed by the underlying peer connection + */ +PeerConnectionTest.prototype.close = function() { + var allChannels = (this.pcLocal || this.pcRemote).dataChannels; + return timerGuard( + Promise.all(allChannels.map((channel, i) => this.closeDataChannels(i))), + 120000, "failed to close data channels") + .then(() => this.closePC()); +}; + +/** + * Close the specified data channels + * + * @param {Number} index + * Index of the data channels to close on both sides + */ +PeerConnectionTest.prototype.closeDataChannels = function(index) { + info("closeDataChannels called with index: " + index); + var localChannel = null; + if (this.pcLocal) { + localChannel = this.pcLocal.dataChannels[index]; + } + var remoteChannel = null; + if (this.pcRemote) { + remoteChannel = this.pcRemote.dataChannels[index]; + } + + // We need to setup all the close listeners before calling close + var setupClosePromise = channel => { + if (!channel) { + return Promise.resolve(); + } + return new Promise(resolve => { + channel.onclose = () => { + is(channel.readyState, "closed", name + " channel " + index + " closed"); + resolve(); + }; + }); + }; + + // make sure to setup close listeners before triggering any actions + var allClosed = Promise.all([ + setupClosePromise(localChannel), + setupClosePromise(remoteChannel) + ]); + var complete = timerGuard(allClosed, 120000, "failed to close data channel pair"); + + // triggering close on one side should suffice + if (remoteChannel) { + remoteChannel.close(); + } else if (localChannel) { + localChannel.close(); + } + + return complete; +}; + +/** + * Send data (message or blob) to the other peer + * + * @param {String|Blob} data + * Data to send to the other peer. For Blobs the MIME type will be lost. + * @param {Object} [options={ }] + * Options to specify the data channels to be used + * @param {DataChannelWrapper} [options.sourceChannel=pcLocal.dataChannels[length - 1]] + * Data channel to use for sending the message + * @param {DataChannelWrapper} [options.targetChannel=pcRemote.dataChannels[length - 1]] + * Data channel to use for receiving the message + */ +PeerConnectionTest.prototype.send = function(data, options) { + options = options || { }; + var source = options.sourceChannel || + this.pcLocal.dataChannels[this.pcLocal.dataChannels.length - 1]; + var target = options.targetChannel || + this.pcRemote.dataChannels[this.pcRemote.dataChannels.length - 1]; + var bufferedamount = options.bufferedAmountLowThreshold || 0; + var bufferlow_fired = true; // to make testing later easier + if (bufferedamount != 0) { + source.bufferedAmountLowThreshold = bufferedamount; + bufferlow_fired = false; + source.onbufferedamountlow = function() { + bufferlow_fired = true; + }; + } + + return new Promise(resolve => { + // Register event handler for the target channel + target.onmessage = e => { + ok(bufferlow_fired, "bufferedamountlow event fired"); + resolve({ channel: target, data: e.data }); + }; + + source.send(data); + }); +}; + +/** + * Create a data channel + * + * @param {Dict} options + * Options for the data channel (see nsIPeerConnection) + */ +PeerConnectionTest.prototype.createDataChannel = function(options) { + var remotePromise; + if (!options.negotiated) { + this.pcRemote.expectDataChannel(); + remotePromise = this.pcRemote.nextDataChannel; + } + + // Create the datachannel + var localChannel = this.pcLocal.createDataChannel(options) + var localPromise = localChannel.opened; + + if (options.negotiated) { + remotePromise = localPromise.then(localChannel => { + // externally negotiated - we need to open from both ends + options.id = options.id || channel.id; // allow for no id on options + var remoteChannel = this.pcRemote.createDataChannel(options); + return remoteChannel.opened; + }); + } + + // pcRemote.observedNegotiationNeeded might be undefined if + // !options.negotiated, which means we just wait on pcLocal + return Promise.all([this.pcLocal.observedNegotiationNeeded, + this.pcRemote.observedNegotiationNeeded]).then(() => { + return Promise.all([localPromise, remotePromise]).then(result => { + return { local: result[0], remote: result[1] }; + }); + }); +}; + +/** + * Creates an answer for the specified peer connection instance + * and automatically handles the failure case. + * + * @param {PeerConnectionWrapper} peer + * The peer connection wrapper to run the command on + */ +PeerConnectionTest.prototype.createAnswer = function(peer) { + return peer.createAnswer().then(answer => { + // make a copy so this does not get updated with ICE candidates + this.originalAnswer = new RTCSessionDescription(JSON.parse(JSON.stringify(answer))); + return answer; + }); +}; + +/** + * Creates an offer for the specified peer connection instance + * and automatically handles the failure case. + * + * @param {PeerConnectionWrapper} peer + * The peer connection wrapper to run the command on + */ +PeerConnectionTest.prototype.createOffer = function(peer) { + return peer.createOffer().then(offer => { + // make a copy so this does not get updated with ICE candidates + this.originalOffer = new RTCSessionDescription(JSON.parse(JSON.stringify(offer))); + return offer; + }); +}; + +/** + * Sets the local description for the specified peer connection instance + * and automatically handles the failure case. + * + * @param {PeerConnectionWrapper} peer + The peer connection wrapper to run the command on + * @param {RTCSessionDescription} desc + * Session description for the local description request + */ +PeerConnectionTest.prototype.setLocalDescription = +function(peer, desc, stateExpected) { + var eventFired = new Promise(resolve => { + peer.onsignalingstatechange = e => { + info(peer + ": 'signalingstatechange' event received"); + var state = e.target.signalingState; + if (stateExpected === state) { + peer.setLocalDescStableEventDate = new Date(); + resolve(); + } else { + ok(false, "This event has either already fired or there has been a " + + "mismatch between event received " + state + + " and event expected " + stateExpected); + } + }; + }); + + var stateChanged = peer.setLocalDescription(desc).then(() => { + peer.setLocalDescDate = new Date(); + }); + + peer.endOfTrickleSdp = peer.endOfTrickleIce.then(() => { + if (this.testOptions.steeplechase) { + send_message({"type": "end_of_trickle_ice"}); + } + return peer._pc.localDescription; + }) + .catch(e => ok(false, "Sending EOC message failed: " + e)); + + return Promise.all([eventFired, stateChanged]); +}; + +/** + * Sets the media constraints for both peer connection instances. + * + * @param {object} constraintsLocal + * Media constrains for the local peer connection instance + * @param constraintsRemote + */ +PeerConnectionTest.prototype.setMediaConstraints = +function(constraintsLocal, constraintsRemote) { + if (this.pcLocal) { + this.pcLocal.constraints = constraintsLocal; + } + if (this.pcRemote) { + this.pcRemote.constraints = constraintsRemote; + } +}; + +/** + * Sets the media options used on a createOffer call in the test. + * + * @param {object} options the media constraints to use on createOffer + */ +PeerConnectionTest.prototype.setOfferOptions = function(options) { + if (this.pcLocal) { + this.pcLocal.offerOptions = options; + } +}; + +/** + * Sets the remote description for the specified peer connection instance + * and automatically handles the failure case. + * + * @param {PeerConnectionWrapper} peer + The peer connection wrapper to run the command on + * @param {RTCSessionDescription} desc + * Session description for the remote description request + */ +PeerConnectionTest.prototype.setRemoteDescription = +function(peer, desc, stateExpected) { + var eventFired = new Promise(resolve => { + peer.onsignalingstatechange = e => { + info(peer + ": 'signalingstatechange' event received"); + var state = e.target.signalingState; + if (stateExpected === state) { + peer.setRemoteDescStableEventDate = new Date(); + resolve(); + } else { + ok(false, "This event has either already fired or there has been a " + + "mismatch between event received " + state + + " and event expected " + stateExpected); + } + }; + }); + + var stateChanged = peer.setRemoteDescription(desc).then(() => { + peer.setRemoteDescDate = new Date(); + peer.checkMediaTracks(); + }); + + return Promise.all([eventFired, stateChanged]); +}; + +/** + * Adds and removes steps to/from the execution chain based on the configured + * testOptions. + */ +PeerConnectionTest.prototype.updateChainSteps = function() { + if (this.testOptions.h264) { + this.chain.insertAfterEach( + 'PC_LOCAL_CREATE_OFFER', + [PC_LOCAL_REMOVE_ALL_BUT_H264_FROM_OFFER]); + } + if (!this.testOptions.bundle) { + this.chain.insertAfterEach( + 'PC_LOCAL_CREATE_OFFER', + [PC_LOCAL_REMOVE_BUNDLE_FROM_OFFER]); + } + if (!this.testOptions.rtcpmux) { + this.chain.insertAfterEach( + 'PC_LOCAL_CREATE_OFFER', + [PC_LOCAL_REMOVE_RTCPMUX_FROM_OFFER]); + } + if (!this.testOptions.is_local) { + this.chain.filterOut(/^PC_LOCAL/); + } + if (!this.testOptions.is_remote) { + this.chain.filterOut(/^PC_REMOTE/); + } +}; + +/** + * Start running the tests as assigned to the command chain. + */ +PeerConnectionTest.prototype.run = function() { + /* We have to modify the chain here to allow tests which modify the default + * test chain instantiating a PeerConnectionTest() */ + this.updateChainSteps(); + var finished = () => { + if (window.SimpleTest) { + networkTestFinished(); + } else { + finish(); + } + }; + return this.chain.execute() + .then(() => this.close()) + .catch(e => + ok(false, 'Error in test execution: ' + e + + ((typeof e.stack === 'string') ? + (' ' + e.stack.split('\n').join(' ... ')) : ''))) + .then(() => finished()) + .catch(e => + ok(false, "Error in finished()")); +}; + +/** + * Routes ice candidates from one PCW to the other PCW + */ +PeerConnectionTest.prototype.iceCandidateHandler = function(caller, candidate) { + info("Received: " + JSON.stringify(candidate) + " from " + caller); + + var target = null; + if (caller.includes("pcLocal")) { + if (this.pcRemote) { + target = this.pcRemote; + } + } else if (caller.includes("pcRemote")) { + if (this.pcLocal) { + target = this.pcLocal; + } + } else { + ok(false, "received event from unknown caller: " + caller); + return; + } + + if (target) { + target.storeOrAddIceCandidate(candidate); + } else { + info("sending ice candidate to signaling server"); + send_message({"type": "ice_candidate", "ice_candidate": candidate}); + } +}; + +/** + * Installs a polling function for the socket.io client to read + * all messages from the chat room into a message queue. + */ +PeerConnectionTest.prototype.setupSignalingClient = function() { + this.signalingMessageQueue = []; + this.signalingCallbacks = {}; + this.signalingLoopRun = true; + + var queueMessage = message => { + info("Received signaling message: " + JSON.stringify(message)); + var fired = false; + Object.keys(this.signalingCallbacks).forEach(name => { + if (name === message.type) { + info("Invoking callback for message type: " + name); + this.signalingCallbacks[name](message); + fired = true; + } + }); + if (!fired) { + this.signalingMessageQueue.push(message); + info("signalingMessageQueue.length: " + this.signalingMessageQueue.length); + } + if (this.signalingLoopRun) { + wait_for_message().then(queueMessage); + } else { + info("Exiting signaling message event loop"); + } + }; + wait_for_message().then(queueMessage); +} + +/** + * Sets a flag to stop reading further messages from the chat room. + */ +PeerConnectionTest.prototype.signalingMessagesFinished = function() { + this.signalingLoopRun = false; +} + +/** + * Register a callback function to deliver messages from the chat room + * directly instead of storing them in the message queue. + * + * @param {string} messageType + * For which message types should the callback get invoked. + * + * @param {function} onMessage + * The function which gets invoked if a message of the messageType + * has been received from the chat room. + */ +PeerConnectionTest.prototype.registerSignalingCallback = function(messageType, onMessage) { + this.signalingCallbacks[messageType] = onMessage; +}; + +/** + * Searches the message queue for the first message of a given type + * and invokes the given callback function, or registers the callback + * function for future messages if the queue contains no such message. + * + * @param {string} messageType + * The type of message to search and register for. + */ +PeerConnectionTest.prototype.getSignalingMessage = function(messageType) { + var i = this.signalingMessageQueue.findIndex(m => m.type === messageType); + if (i >= 0) { + info("invoking callback on message " + i + " from message queue, for message type:" + messageType); + return Promise.resolve(this.signalingMessageQueue.splice(i, 1)[0]); + } + return new Promise(resolve => + this.registerSignalingCallback(messageType, resolve)); +}; + + +/** + * This class acts as a wrapper around a DataChannel instance. + * + * @param dataChannel + * @param peerConnectionWrapper + * @constructor + */ +function DataChannelWrapper(dataChannel, peerConnectionWrapper) { + this._channel = dataChannel; + this._pc = peerConnectionWrapper; + + info("Creating " + this); + + /** + * Setup appropriate callbacks + */ + createOneShotEventWrapper(this, this._channel, 'close'); + createOneShotEventWrapper(this, this._channel, 'error'); + createOneShotEventWrapper(this, this._channel, 'message'); + createOneShotEventWrapper(this, this._channel, 'bufferedamountlow'); + + this.opened = timerGuard(new Promise(resolve => { + this._channel.onopen = () => { + this._channel.onopen = unexpectedEvent(this, 'onopen'); + is(this.readyState, "open", "data channel is 'open' after 'onopen'"); + resolve(this); + }; + }), 180000, "channel didn't open in time"); +} + +DataChannelWrapper.prototype = { + /** + * Returns the binary type of the channel + * + * @returns {String} The binary type + */ + get binaryType() { + return this._channel.binaryType; + }, + + /** + * Sets the binary type of the channel + * + * @param {String} type + * The new binary type of the channel + */ + set binaryType(type) { + this._channel.binaryType = type; + }, + + /** + * Returns the label of the underlying data channel + * + * @returns {String} The label + */ + get label() { + return this._channel.label; + }, + + /** + * Returns the protocol of the underlying data channel + * + * @returns {String} The protocol + */ + get protocol() { + return this._channel.protocol; + }, + + /** + * Returns the id of the underlying data channel + * + * @returns {number} The stream id + */ + get id() { + return this._channel.id; + }, + + /** + * Returns the reliable state of the underlying data channel + * + * @returns {bool} The stream's reliable state + */ + get reliable() { + return this._channel.reliable; + }, + + // ordered, maxRetransmits and maxRetransmitTime not exposed yet + + /** + * Returns the readyState bit of the data channel + * + * @returns {String} The state of the channel + */ + get readyState() { + return this._channel.readyState; + }, + + /** + * Sets the bufferlowthreshold of the channel + * + * @param {integer} amoutn + * The new threshold for the chanel + */ + set bufferedAmountLowThreshold(amount) { + this._channel.bufferedAmountLowThreshold = amount; + }, + + /** + * Close the data channel + */ + close : function () { + info(this + ": Closing channel"); + this._channel.close(); + }, + + /** + * Send data through the data channel + * + * @param {String|Object} data + * Data which has to be sent through the data channel + */ + send: function(data) { + info(this + ": Sending data '" + data + "'"); + this._channel.send(data); + }, + + /** + * Returns the string representation of the class + * + * @returns {String} The string representation + */ + toString: function() { + return "DataChannelWrapper (" + this._pc.label + '_' + this._channel.label + ")"; + } +}; + + +/** + * This class acts as a wrapper around a PeerConnection instance. + * + * @constructor + * @param {string} label + * Description for the peer connection instance + * @param {object} configuration + * Configuration for the peer connection instance + */ +function PeerConnectionWrapper(label, configuration) { + this.configuration = configuration; + if (configuration && configuration.label_suffix) { + label = label + "_" + configuration.label_suffix; + } + this.label = label; + this.whenCreated = Date.now(); + + this.constraints = [ ]; + this.offerOptions = {}; + + this.dataChannels = [ ]; + + this._local_ice_candidates = []; + this._remote_ice_candidates = []; + this.localRequiresTrickleIce = false; + this.remoteRequiresTrickleIce = false; + this.localMediaElements = []; + this.remoteMediaElements = []; + this.audioElementsOnly = false; + + this.expectedLocalTrackInfoById = {}; + this.expectedRemoteTrackInfoById = {}; + this.observedRemoteTrackInfoById = {}; + + this.disableRtpCountChecking = false; + + this.iceConnectedResolve; + this.iceConnectedReject; + this.iceConnected = new Promise((resolve, reject) => { + this.iceConnectedResolve = resolve; + this.iceConnectedReject = reject; + }); + this.iceCheckingRestartExpected = false; + this.iceCheckingIceRollbackExpected = false; + + info("Creating " + this); + this._pc = new RTCPeerConnection(this.configuration); + + /** + * Setup callback handlers + */ + // This allows test to register their own callbacks for ICE connection state changes + this.ice_connection_callbacks = {}; + + this._pc.oniceconnectionstatechange = e => { + isnot(typeof this._pc.iceConnectionState, "undefined", + "iceConnectionState should not be undefined"); + var iceState = this._pc.iceConnectionState; + info(this + ": oniceconnectionstatechange fired, new state is: " + iceState); + Object.keys(this.ice_connection_callbacks).forEach(name => { + this.ice_connection_callbacks[name](); + }); + if (iceState === "connected") { + this.iceConnectedResolve(); + } else if (iceState === "failed") { + this.iceConnectedReject(); + } + }; + + createOneShotEventWrapper(this, this._pc, 'datachannel'); + this._pc.addEventListener('datachannel', e => { + var wrapper = new DataChannelWrapper(e.channel, this); + this.dataChannels.push(wrapper); + }); + + createOneShotEventWrapper(this, this._pc, 'signalingstatechange'); + createOneShotEventWrapper(this, this._pc, 'negotiationneeded'); +} + +PeerConnectionWrapper.prototype = { + + /** + * Returns the local description. + * + * @returns {object} The local description + */ + get localDescription() { + return this._pc.localDescription; + }, + + /** + * Sets the local description. + * + * @param {object} desc + * The new local description + */ + set localDescription(desc) { + this._pc.localDescription = desc; + }, + + /** + * Returns the remote description. + * + * @returns {object} The remote description + */ + get remoteDescription() { + return this._pc.remoteDescription; + }, + + /** + * Sets the remote description. + * + * @param {object} desc + * The new remote description + */ + set remoteDescription(desc) { + this._pc.remoteDescription = desc; + }, + + /** + * Returns the signaling state. + * + * @returns {object} The local description + */ + get signalingState() { + return this._pc.signalingState; + }, + /** + * Returns the ICE connection state. + * + * @returns {object} The local description + */ + get iceConnectionState() { + return this._pc.iceConnectionState; + }, + + setIdentityProvider: function(provider, protocol, identity) { + this._pc.setIdentityProvider(provider, protocol, identity); + }, + + ensureMediaElement : function(track, direction) { + const idPrefix = [this.label, direction].join('_'); + var element = getMediaElementForTrack(track, idPrefix); + + if (!element) { + element = createMediaElementForTrack(track, idPrefix); + if (direction == "local") { + this.localMediaElements.push(element); + } else if (direction == "remote") { + this.remoteMediaElements.push(element); + } + } + + // We do this regardless, because sometimes we end up with a new stream with + // an old id (ie; the rollback tests cause the same stream to be added + // twice) + element.srcObject = new MediaStream([track]); + element.play(); + }, + + /** + * Attaches a local track to this RTCPeerConnection using + * RTCPeerConnection.addTrack(). + * + * Also creates a media element playing a MediaStream containing all + * tracks that have been added to `stream` using `attachLocalTrack()`. + * + * @param {MediaStreamTrack} track + * MediaStreamTrack to handle + * @param {MediaStream} stream + * MediaStream to use as container for `track` on remote side + */ + attachLocalTrack : function(track, stream) { + info("Got a local " + track.kind + " track"); + + this.expectNegotiationNeeded(); + var sender = this._pc.addTrack(track, stream); + is(sender.track, track, "addTrack returns sender"); + + ok(track.id, "track has id"); + ok(track.kind, "track has kind"); + ok(stream.id, "stream has id"); + this.expectedLocalTrackInfoById[track.id] = { + type: track.kind, + streamId: stream.id, + }; + + // This will create one media element per track, which might not be how + // we set up things with the RTCPeerConnection. It's the only way + // we can ensure all sent tracks are flowing however. + this.ensureMediaElement(track, "local"); + + return this.observedNegotiationNeeded; + }, + + /** + * Callback when we get local media. Also an appropriate HTML media element + * will be created and added to the content node. + * + * @param {MediaStream} stream + * Media stream to handle + */ + attachLocalStream : function(stream) { + info("Got local media stream: (" + stream.id + ")"); + + this.expectNegotiationNeeded(); + // In order to test both the addStream and addTrack APIs, we do half one + // way, half the other, at random. + if (Math.random() < 0.5) { + info("Using addStream."); + this._pc.addStream(stream); + ok(this._pc.getSenders().find(sender => sender.track == stream.getTracks()[0]), + "addStream returns sender"); + } else { + info("Using addTrack (on PC)."); + stream.getTracks().forEach(track => { + var sender = this._pc.addTrack(track, stream); + is(sender.track, track, "addTrack returns sender"); + }); + } + + stream.getTracks().forEach(track => { + ok(track.id, "track has id"); + ok(track.kind, "track has kind"); + this.expectedLocalTrackInfoById[track.id] = { + type: track.kind, + streamId: stream.id + }; + this.ensureMediaElement(track, "local"); + }); + }, + + removeSender : function(index) { + var sender = this._pc.getSenders()[index]; + delete this.expectedLocalTrackInfoById[sender.track.id]; + this.expectNegotiationNeeded(); + this._pc.removeTrack(sender); + return this.observedNegotiationNeeded; + }, + + senderReplaceTrack : function(index, withTrack, withStreamId) { + var sender = this._pc.getSenders()[index]; + delete this.expectedLocalTrackInfoById[sender.track.id]; + this.expectedLocalTrackInfoById[withTrack.id] = { + type: withTrack.kind, + streamId: withStreamId + }; + return sender.replaceTrack(withTrack); + }, + + /** + * Requests all the media streams as specified in the constrains property. + * + * @param {array} constraintsList + * Array of constraints for GUM calls + */ + getAllUserMedia : function(constraintsList) { + if (constraintsList.length === 0) { + info("Skipping GUM: no UserMedia requested"); + return Promise.resolve(); + } + + info("Get " + constraintsList.length + " local streams"); + return Promise.all(constraintsList.map(constraints => { + return getUserMedia(constraints).then(stream => { + if (constraints.audio) { + stream.getAudioTracks().map(track => { + info(this + " gUM local stream " + stream.id + + " with audio track " + track.id); + }); + } + if (constraints.video) { + stream.getVideoTracks().map(track => { + info(this + " gUM local stream " + stream.id + + " with video track " + track.id); + }); + } + return this.attachLocalStream(stream); + }); + })); + }, + + /** + * Create a new data channel instance. Also creates a promise called + * `this.nextDataChannel` that resolves when the next data channel arrives. + */ + expectDataChannel: function(message) { + this.nextDataChannel = new Promise(resolve => { + this.ondatachannel = e => { + ok(e.channel, message); + resolve(e.channel); + }; + }); + }, + + /** + * Create a new data channel instance + * + * @param {Object} options + * Options which get forwarded to nsIPeerConnection.createDataChannel + * @returns {DataChannelWrapper} The created data channel + */ + createDataChannel : function(options) { + var label = 'channel_' + this.dataChannels.length; + info(this + ": Create data channel '" + label); + + if (!this.dataChannels.length) { + this.expectNegotiationNeeded(); + } + var channel = this._pc.createDataChannel(label, options); + var wrapper = new DataChannelWrapper(channel, this); + this.dataChannels.push(wrapper); + return wrapper; + }, + + /** + * Creates an offer and automatically handles the failure case. + */ + createOffer : function() { + return this._pc.createOffer(this.offerOptions).then(offer => { + info("Got offer: " + JSON.stringify(offer)); + // note: this might get updated through ICE gathering + this._latest_offer = offer; + return offer; + }); + }, + + /** + * Creates an answer and automatically handles the failure case. + */ + createAnswer : function() { + return this._pc.createAnswer().then(answer => { + info(this + ": Got answer: " + JSON.stringify(answer)); + this._last_answer = answer; + return answer; + }); + }, + + /** + * Sets the local description and automatically handles the failure case. + * + * @param {object} desc + * RTCSessionDescription for the local description request + */ + setLocalDescription : function(desc) { + this.observedNegotiationNeeded = undefined; + return this._pc.setLocalDescription(desc).then(() => { + info(this + ": Successfully set the local description"); + }); + }, + + /** + * Tries to set the local description and expect failure. Automatically + * causes the test case to fail if the call succeeds. + * + * @param {object} desc + * RTCSessionDescription for the local description request + * @returns {Promise} + * A promise that resolves to the expected error + */ + setLocalDescriptionAndFail : function(desc) { + return this._pc.setLocalDescription(desc).then( + generateErrorCallback("setLocalDescription should have failed."), + err => { + info(this + ": As expected, failed to set the local description"); + return err; + }); + }, + + /** + * Sets the remote description and automatically handles the failure case. + * + * @param {object} desc + * RTCSessionDescription for the remote description request + */ + setRemoteDescription : function(desc) { + this.observedNegotiationNeeded = undefined; + return this._pc.setRemoteDescription(desc).then(() => { + info(this + ": Successfully set remote description"); + if (desc.type == "rollback") { + this.holdIceCandidates = new Promise(r => this.releaseIceCandidates = r); + + } else { + this.releaseIceCandidates(); + } + }); + }, + + /** + * Tries to set the remote description and expect failure. Automatically + * causes the test case to fail if the call succeeds. + * + * @param {object} desc + * RTCSessionDescription for the remote description request + * @returns {Promise} + * a promise that resolve to the returned error + */ + setRemoteDescriptionAndFail : function(desc) { + return this._pc.setRemoteDescription(desc).then( + generateErrorCallback("setRemoteDescription should have failed."), + err => { + info(this + ": As expected, failed to set the remote description"); + return err; + }); + }, + + /** + * Registers a callback for the signaling state change and + * appends the new state to an array for logging it later. + */ + logSignalingState: function() { + this.signalingStateLog = [this._pc.signalingState]; + this._pc.addEventListener('signalingstatechange', e => { + var newstate = this._pc.signalingState; + var oldstate = this.signalingStateLog[this.signalingStateLog.length - 1] + if (Object.keys(signalingStateTransitions).indexOf(oldstate) >= 0) { + ok(signalingStateTransitions[oldstate].indexOf(newstate) >= 0, this + ": legal signaling state transition from " + oldstate + " to " + newstate); + } else { + ok(false, this + ": old signaling state " + oldstate + " missing in signaling transition array"); + } + this.signalingStateLog.push(newstate); + }); + }, + + /** + * Checks whether a given track is expected, has not been observed yet, and + * is of the correct type. Then, moves the track from + * |expectedTrackInfoById| to |observedTrackInfoById|. + */ + checkTrackIsExpected : function(track, + expectedTrackInfoById, + observedTrackInfoById) { + ok(expectedTrackInfoById[track.id], "track id " + track.id + " was expected"); + ok(!observedTrackInfoById[track.id], "track id " + track.id + " was not yet observed"); + var observedKind = track.kind; + var expectedKind = expectedTrackInfoById[track.id].type; + is(observedKind, expectedKind, + "track id " + track.id + " was of kind " + + observedKind + ", which matches " + expectedKind); + observedTrackInfoById[track.id] = expectedTrackInfoById[track.id]; + }, + + isTrackOnPC: function(track) { + return this._pc.getRemoteStreams().some(s => !!s.getTrackById(track.id)); + }, + + allExpectedTracksAreObserved: function(expected, observed) { + return Object.keys(expected).every(trackId => observed[trackId]); + }, + + setupTrackEventHandler: function() { + this._pc.addEventListener('track', event => { + info(this + ": 'ontrack' event fired for " + JSON.stringify(event.track)); + + this.checkTrackIsExpected(event.track, + this.expectedRemoteTrackInfoById, + this.observedRemoteTrackInfoById); + ok(this.isTrackOnPC(event.track), "Found track " + event.track.id); + + this.ensureMediaElement(event.track, 'remote'); + }); + }, + + /** + * Either adds a given ICE candidate right away or stores it to be added + * later, depending on the state of the PeerConnection. + * + * @param {object} candidate + * The RTCIceCandidate to be added or stored + */ + storeOrAddIceCandidate : function(candidate) { + this._remote_ice_candidates.push(candidate); + if (this.signalingState === 'closed') { + info("Received ICE candidate for closed PeerConnection - discarding"); + return; + } + this.holdIceCandidates.then(() => { + info(this + ": adding ICE candidate " + JSON.stringify(candidate)); + return this._pc.addIceCandidate(candidate); + }) + .then(() => ok(true, this + " successfully added an ICE candidate")) + .catch(e => + // The onicecandidate callback runs independent of the test steps + // and therefore errors thrown from in there don't get caught by the + // race of the Promises around our test steps. + // Note: as long as we are queuing ICE candidates until the success + // of sRD() this should never ever happen. + ok(false, this + " adding ICE candidate failed with: " + e.message) + ); + }, + + /** + * Registers a callback for the ICE connection state change and + * appends the new state to an array for logging it later. + */ + logIceConnectionState: function() { + this.iceConnectionLog = [this._pc.iceConnectionState]; + this.ice_connection_callbacks.logIceStatus = () => { + var newstate = this._pc.iceConnectionState; + var oldstate = this.iceConnectionLog[this.iceConnectionLog.length - 1] + if (Object.keys(iceStateTransitions).indexOf(oldstate) != -1) { + if (this.iceCheckingRestartExpected) { + is(newstate, "checking", + "iceconnectionstate event \'" + newstate + + "\' matches expected state \'checking\'"); + this.iceCheckingRestartExpected = false; + } else if (this.iceCheckingIceRollbackExpected) { + is(newstate, "connected", + "iceconnectionstate event \'" + newstate + + "\' matches expected state \'connected\'"); + this.iceCheckingIceRollbackExpected = false; + } else { + ok(iceStateTransitions[oldstate].indexOf(newstate) != -1, this + ": legal ICE state transition from " + oldstate + " to " + newstate); + } + } else { + ok(false, this + ": old ICE state " + oldstate + " missing in ICE transition array"); + } + this.iceConnectionLog.push(newstate); + }; + }, + + /** + * Resets the ICE connected Promise and allows ICE connection state monitoring + * to go backwards to 'checking'. + */ + expectIceChecking : function() { + this.iceCheckingRestartExpected = true; + this.iceConnected = new Promise((resolve, reject) => { + this.iceConnectedResolve = resolve; + this.iceConnectedReject = reject; + }); + }, + + /** + * Waits for ICE to either connect or fail. + * + * @returns {Promise} + * resolves when connected, rejects on failure + */ + waitForIceConnected : function() { + return this.iceConnected; + }, + + /** + * Setup a onicecandidate handler + * + * @param {object} test + * A PeerConnectionTest object to which the ice candidates gets + * forwarded. + */ + setupIceCandidateHandler : function(test, candidateHandler) { + candidateHandler = candidateHandler || test.iceCandidateHandler.bind(test); + + var resolveEndOfTrickle; + this.endOfTrickleIce = new Promise(r => resolveEndOfTrickle = r); + this.holdIceCandidates = new Promise(r => this.releaseIceCandidates = r); + + this._pc.onicecandidate = anEvent => { + if (!anEvent.candidate) { + this._pc.onicecandidate = () => + ok(false, this.label + " received ICE candidate after end of trickle"); + info(this.label + ": received end of trickle ICE event"); + /* Bug 1193731. Accroding to WebRTC spec 4.3.1 the ICE Agent first sets + * the gathering state to completed (step 3.) before sending out the + * null newCandidate in step 4. */ + todo(this._pc.iceGatheringState === 'completed', + "ICE gathering state has reached completed"); + resolveEndOfTrickle(this.label); + return; + } + + info(this.label + ": iceCandidate = " + JSON.stringify(anEvent.candidate)); + ok(anEvent.candidate.candidate.length > 0, "ICE candidate contains candidate"); + ok(anEvent.candidate.sdpMid.length > 0, "SDP mid not empty"); + + // only check the m-section for the updated default addr that corresponds + // with this candidate. + var mSections = this.localDescription.sdp.split("\r\nm="); + sdputils.checkSdpCLineNotDefault( + mSections[anEvent.candidate.sdpMLineIndex+1], this.label + ); + + ok(typeof anEvent.candidate.sdpMLineIndex === 'number', "SDP MLine Index needs to exist"); + this._local_ice_candidates.push(anEvent.candidate); + candidateHandler(this.label, anEvent.candidate); + }; + }, + + checkLocalMediaTracks : function() { + var observed = {}; + info(this + " Checking local tracks " + JSON.stringify(this.expectedLocalTrackInfoById)); + this._pc.getSenders().forEach(sender => { + this.checkTrackIsExpected(sender.track, this.expectedLocalTrackInfoById, observed); + }); + + Object.keys(this.expectedLocalTrackInfoById).forEach( + id => ok(observed[id], this + " local id " + id + " was observed")); + }, + + /** + * Checks that we are getting the media tracks we expect. + */ + checkMediaTracks : function() { + this.checkLocalMediaTracks(); + + info(this + " Checking remote tracks " + + JSON.stringify(this.expectedRemoteTrackInfoById)); + + ok(this.allExpectedTracksAreObserved(this.expectedRemoteTrackInfoById, + this.observedRemoteTrackInfoById), + "All expected tracks have been observed" + + "\nexpected: " + JSON.stringify(this.expectedRemoteTrackInfoById) + + "\nobserved: " + JSON.stringify(this.observedRemoteTrackInfoById)); + }, + + checkMsids: function() { + var checkSdpForMsids = (desc, expectedTrackInfo, side) => { + Object.keys(expectedTrackInfo).forEach(trackId => { + var streamId = expectedTrackInfo[trackId].streamId; + ok(desc.sdp.match(new RegExp("a=msid:" + streamId + " " + trackId)), + this + ": " + side + " SDP contains stream " + streamId + + " and track " + trackId ); + }); + }; + + checkSdpForMsids(this.localDescription, this.expectedLocalTrackInfoById, + "local"); + checkSdpForMsids(this.remoteDescription, this.expectedRemoteTrackInfoById, + "remote"); + }, + + markRemoteTracksAsNegotiated: function() { + Object.values(this.observedRemoteTrackInfoById).forEach( + trackInfo => trackInfo.negotiated = true); + }, + + rollbackRemoteTracksIfNotNegotiated: function() { + Object.keys(this.observedRemoteTrackInfoById).forEach( + id => { + if (!this.observedRemoteTrackInfoById[id].negotiated) { + delete this.observedRemoteTrackInfoById[id]; + } + }); + }, + + /** + * Check that media flow is present for the given media element by checking + * that it reaches ready state HAVE_ENOUGH_DATA and progresses time further + * than the start of the check. + * + * This ensures, that the stream being played is producing + * data and, in case it contains a video track, that at least one video frame + * has been displayed. + * + * @param {HTMLMediaElement} track + * The media element to check + * @returns {Promise} + * A promise that resolves when media data is flowing. + */ + waitForMediaElementFlow : function(element) { + info("Checking data flow for element: " + element.id); + is(element.ended, !element.srcObject.active, + "Element ended should be the inverse of the MediaStream's active state"); + if (element.ended) { + is(element.readyState, element.HAVE_CURRENT_DATA, + "Element " + element.id + " is ended and should have had data"); + return Promise.resolve(); + } + + const haveEnoughData = (element.readyState == element.HAVE_ENOUGH_DATA ? + Promise.resolve() : + haveEvent(element, "canplay", wait(60000, + new Error("Timeout for element " + element.id)))) + .then(_ => info("Element " + element.id + " has enough data.")); + + const startTime = element.currentTime; + const timeProgressed = timeout( + listenUntil(element, "timeupdate", _ => element.currentTime > startTime), + 60000, "Element " + element.id + " should progress currentTime") + .then(); + + return Promise.all([haveEnoughData, timeProgressed]); + }, + + /** + * Wait for RTP packet flow for the given MediaStreamTrack. + * + * @param {object} track + * A MediaStreamTrack to wait for data flow on. + * @returns {Promise} + * A promise that resolves when media is flowing. + */ + waitForRtpFlow(track) { + var hasFlow = stats => { + var rtp = stats.get([...stats.keys()].find(key => + !stats.get(key).isRemote && stats.get(key).type.endsWith("boundrtp"))); + ok(rtp, "Should have RTP stats for track " + track.id); + if (!rtp) { + return false; + } + var nrPackets = rtp[rtp.type == "outboundrtp" ? "packetsSent" + : "packetsReceived"]; + info("Track " + track.id + " has " + nrPackets + " " + + rtp.type + " RTP packets."); + return nrPackets > 0; + }; + + info("Checking RTP packet flow for track " + track.id); + + var retry = (delay) => this._pc.getStats(track) + .then(stats => hasFlow(stats)? ok(true, "RTP flowing for track " + track.id) : + wait(delay).then(retry(1000))); + return retry(200); + }, + + /** + * Wait for presence of video flow on all media elements and rtp flow on + * all sending and receiving track involved in this test. + * + * @returns {Promise} + * A promise that resolves when media flows for all elements and tracks + */ + waitForMediaFlow : function() { + return Promise.all([].concat( + this.localMediaElements.map(element => this.waitForMediaElementFlow(element)), + Object.keys(this.expectedRemoteTrackInfoById) + .map(id => this.remoteMediaElements + .find(e => e.srcObject.getTracks().some(t => t.id == id))) + .map(e => this.waitForMediaElementFlow(e)), + this._pc.getSenders().map(sender => this.waitForRtpFlow(sender.track)), + this._pc.getReceivers().map(receiver => this.waitForRtpFlow(receiver.track)))); + }, + + /** + * Check that correct audio (typically a flat tone) is flowing to this + * PeerConnection. Uses WebAudio AnalyserNodes to compare input and output + * audio data in the frequency domain. + * + * @param {object} from + * A PeerConnectionWrapper whose audio RTPSender we use as source for + * the audio flow check. + * @returns {Promise} + * A promise that resolves when we're receiving the tone from |from|. + */ + checkReceivingToneFrom : function(audiocontext, from) { + var inputElem = from.localMediaElements[0]; + + // As input we use the stream of |from|'s first available audio sender. + var inputSenderTracks = from._pc.getSenders().map(sn => sn.track); + var inputAudioStream = from._pc.getLocalStreams() + .find(s => inputSenderTracks.some(t => t.kind == "audio" && s.getTrackById(t.id))); + var inputAnalyser = new AudioStreamAnalyser(audiocontext, inputAudioStream); + + // It would have been nice to have a working getReceivers() here, but until + // we do, let's use what remote streams we have. + var outputAudioStream = this._pc.getRemoteStreams() + .find(s => s.getAudioTracks().length > 0); + var outputAnalyser = new AudioStreamAnalyser(audiocontext, outputAudioStream); + + var maxWithIndex = (a, b, i) => (b >= a.value) ? { value: b, index: i } : a; + var initial = { value: -1, index: -1 }; + + return new Promise((resolve, reject) => inputElem.ontimeupdate = () => { + var inputData = inputAnalyser.getByteFrequencyData(); + var outputData = outputAnalyser.getByteFrequencyData(); + + var inputMax = inputData.reduce(maxWithIndex, initial); + var outputMax = outputData.reduce(maxWithIndex, initial); + info("Comparing maxima; input[" + inputMax.index + "] = " + inputMax.value + + ", output[" + outputMax.index + "] = " + outputMax.value); + if (!inputMax.value || !outputMax.value) { + return; + } + + // When the input and output maxima are within reasonable distance + // from each other, we can be sure that the input tone has made it + // through the peer connection. + if (Math.abs(inputMax.index - outputMax.index) < 10) { + ok(true, "input and output audio data matches"); + inputElem.ontimeupdate = null; + resolve(); + } + }); + }, + + /** + * Check that stats are present by checking for known stats. + */ + getStats : function(selector) { + return this._pc.getStats(selector).then(stats => { + info(this + ": Got stats: " + JSON.stringify(stats)); + this._last_stats = stats; + return stats; + }); + }, + + /** + * Checks that we are getting the media streams we expect. + * + * @param {object} stats + * The stats to check from this PeerConnectionWrapper + */ + checkStats : function(stats, twoMachines) { + const isWinXP = navigator.userAgent.indexOf("Windows NT 5.1") != -1; + + // Use spec way of enumerating stats + var counters = {}; + for (let [key, res] of stats) { + // validate stats + ok(res.id == key, "Coherent stats id"); + var nowish = Date.now() + 1000; // TODO: clock drift observed + var minimum = this.whenCreated - 1000; // on Windows XP (Bug 979649) + if (isWinXP) { + todo(false, "Can't reliably test rtcp timestamps on WinXP (Bug 979649)"); + } else if (!twoMachines) { + // Bug 1225729: On android, sometimes the first RTCP of the first + // test run gets this value, likely because no RTP has been sent yet. + if (res.timestamp != 2085978496000) { + ok(res.timestamp >= minimum, + "Valid " + (res.isRemote? "rtcp" : "rtp") + " timestamp " + + res.timestamp + " >= " + minimum + " (" + + (res.timestamp - minimum) + " ms)"); + ok(res.timestamp <= nowish, + "Valid " + (res.isRemote? "rtcp" : "rtp") + " timestamp " + + res.timestamp + " <= " + nowish + " (" + + (res.timestamp - nowish) + " ms)"); + } else { + info("Bug 1225729: Uninitialized timestamp (" + res.timestamp + + "), should be >=" + minimum + " and <= " + nowish); + } + } + if (res.isRemote) { + continue; + } + counters[res.type] = (counters[res.type] || 0) + 1; + + switch (res.type) { + case "inboundrtp": + case "outboundrtp": { + // ssrc is a 32 bit number returned as a string by spec + ok(res.ssrc.length > 0, "Ssrc has length"); + ok(res.ssrc.length < 11, "Ssrc not lengthy"); + ok(!/[^0-9]/.test(res.ssrc), "Ssrc numeric"); + ok(parseInt(res.ssrc) < Math.pow(2,32), "Ssrc within limits"); + + if (res.type == "outboundrtp") { + ok(res.packetsSent !== undefined, "Rtp packetsSent"); + // We assume minimum payload to be 1 byte (guess from RFC 3550) + ok(res.bytesSent >= res.packetsSent, "Rtp bytesSent"); + } else { + ok(res.packetsReceived !== undefined, "Rtp packetsReceived"); + ok(res.bytesReceived >= res.packetsReceived, "Rtp bytesReceived"); + } + if (res.remoteId) { + var rem = stats[res.remoteId]; + ok(rem.isRemote, "Remote is rtcp"); + ok(rem.remoteId == res.id, "Remote backlink match"); + if(res.type == "outboundrtp") { + ok(rem.type == "inboundrtp", "Rtcp is inbound"); + ok(rem.packetsReceived !== undefined, "Rtcp packetsReceived"); + ok(rem.packetsLost !== undefined, "Rtcp packetsLost"); + ok(rem.bytesReceived >= rem.packetsReceived, "Rtcp bytesReceived"); + if (!this.disableRtpCountChecking) { + ok(rem.packetsReceived <= res.packetsSent, "No more than sent packets"); + ok(rem.bytesReceived <= res.bytesSent, "No more than sent bytes"); + } + ok(rem.jitter !== undefined, "Rtcp jitter"); + ok(rem.mozRtt !== undefined, "Rtcp rtt"); + ok(rem.mozRtt >= 0, "Rtcp rtt " + rem.mozRtt + " >= 0"); + ok(rem.mozRtt < 60000, "Rtcp rtt " + rem.mozRtt + " < 1 min"); + } else { + ok(rem.type == "outboundrtp", "Rtcp is outbound"); + ok(rem.packetsSent !== undefined, "Rtcp packetsSent"); + // We may have received more than outdated Rtcp packetsSent + ok(rem.bytesSent >= rem.packetsSent, "Rtcp bytesSent"); + } + ok(rem.ssrc == res.ssrc, "Remote ssrc match"); + } else { + info("No rtcp info received yet"); + } + } + break; + } + } + + // Use legacy way of enumerating stats + var counters2 = {}; + for (let key in stats) { + if (!stats.hasOwnProperty(key)) { + continue; + } + var res = stats[key]; + if (!res.isRemote) { + counters2[res.type] = (counters2[res.type] || 0) + 1; + } + } + is(JSON.stringify(counters), JSON.stringify(counters2), + "Spec and legacy variant of RTCStatsReport enumeration agree"); + var nin = Object.keys(this.expectedRemoteTrackInfoById).length; + var nout = Object.keys(this.expectedLocalTrackInfoById).length; + var ndata = this.dataChannels.length; + + // TODO(Bug 957145): Restore stronger inboundrtp test once Bug 948249 is fixed + //is((counters["inboundrtp"] || 0), nin, "Have " + nin + " inboundrtp stat(s)"); + ok((counters.inboundrtp || 0) >= nin, "Have at least " + nin + " inboundrtp stat(s) *"); + + is(counters.outboundrtp || 0, nout, "Have " + nout + " outboundrtp stat(s)"); + + var numLocalCandidates = counters.localcandidate || 0; + var numRemoteCandidates = counters.remotecandidate || 0; + // If there are no tracks, there will be no stats either. + if (nin + nout + ndata > 0) { + ok(numLocalCandidates, "Have localcandidate stat(s)"); + ok(numRemoteCandidates, "Have remotecandidate stat(s)"); + } else { + is(numLocalCandidates, 0, "Have no localcandidate stats"); + is(numRemoteCandidates, 0, "Have no remotecandidate stats"); + } + }, + + /** + * Compares the Ice server configured for this PeerConnectionWrapper + * with the ICE candidates received in the RTCP stats. + * + * @param {object} stats + * The stats to be verified for relayed vs. direct connection. + */ + checkStatsIceConnectionType : function(stats, expectedLocalCandidateType) { + let lId; + let rId; + for (let stat of stats.values()) { + if (stat.type == "candidatepair" && stat.selected) { + lId = stat.localCandidateId; + rId = stat.remoteCandidateId; + break; + } + } + isnot(lId, undefined, "Got local candidate ID " + lId + " for selected pair"); + isnot(rId, undefined, "Got remote candidate ID " + rId + " for selected pair"); + let lCand = stats.get(lId); + let rCand = stats.get(rId); + if (!lCand || !rCand) { + ok(false, + "failed to find candidatepair IDs or stats for local: "+ lId +" remote: "+ rId); + return; + } + + info("checkStatsIceConnectionType verifying: local=" + + JSON.stringify(lCand) + " remote=" + JSON.stringify(rCand)); + expectedLocalCandidateType = expectedLocalCandidateType || "host"; + var candidateType = lCand.candidateType; + if ((lCand.mozLocalTransport === "tcp") && (candidateType === "relayed")) { + candidateType = "relayed-tcp"; + } + + if ((expectedLocalCandidateType === "serverreflexive") && + (candidateType === "peerreflexive")) { + // Be forgiving of prflx when expecting srflx, since that can happen due + // to timing. + candidateType = "serverreflexive"; + } + + is(candidateType, + expectedLocalCandidateType, + "Local candidate type is what we expected for selected pair"); + }, + + /** + * Compares amount of established ICE connection according to ICE candidate + * pairs in the stats reporting with the expected amount of connection based + * on the constraints. + * + * @param {object} stats + * The stats to check for ICE candidate pairs + * @param {object} counters + * The counters for media and data tracks based on constraints + * @param {object} testOptions + * The test options object from the PeerConnectionTest + */ + checkStatsIceConnections : function(stats, + offerConstraintsList, offerOptions, testOptions) { + var numIceConnections = 0; + Object.keys(stats).forEach(key => { + if ((stats[key].type === "candidatepair") && stats[key].selected) { + numIceConnections += 1; + } + }); + info("ICE connections according to stats: " + numIceConnections); + isnot(numIceConnections, 0, "Number of ICE connections according to stats is not zero"); + if (testOptions.bundle) { + if (testOptions.rtcpmux) { + is(numIceConnections, 1, "stats reports exactly 1 ICE connection"); + } else { + is(numIceConnections, 2, "stats report exactly 2 ICE connections for media and RTCP"); + } + } else { + // This code assumes that no media sections have been rejected due to + // codec mismatch or other unrecoverable negotiation failures. + var numAudioTracks = + sdputils.countTracksInConstraint('audio', offerConstraintsList) || + ((offerOptions && offerOptions.offerToReceiveAudio) ? 1 : 0); + + var numVideoTracks = + sdputils.countTracksInConstraint('video', offerConstraintsList) || + ((offerOptions && offerOptions.offerToReceiveVideo) ? 1 : 0); + + var numExpectedTransports = numAudioTracks + numVideoTracks; + if (!testOptions.rtcpmux) { + numExpectedTransports *= 2; + } + + if (this.dataChannels.length) { + ++numExpectedTransports; + } + + info("expected audio + video + data transports: " + numExpectedTransports); + is(numIceConnections, numExpectedTransports, "stats ICE connections matches expected A/V transports"); + } + }, + + expectNegotiationNeeded : function() { + if (!this.observedNegotiationNeeded) { + this.observedNegotiationNeeded = new Promise((resolve) => { + this.onnegotiationneeded = resolve; + }); + } + }, + + /** + * Property-matching function for finding a certain stat in passed-in stats + * + * @param {object} stats + * The stats to check from this PeerConnectionWrapper + * @param {object} props + * The properties to look for + * @returns {boolean} Whether an entry containing all match-props was found. + */ + hasStat : function(stats, props) { + for (let res of stats.values()) { + var match = true; + for (let prop in props) { + if (res[prop] !== props[prop]) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; + }, + + /** + * Closes the connection + */ + close : function() { + this._pc.close(); + this.localMediaElements.forEach(e => e.pause()); + info(this + ": Closed connection."); + }, + + /** + * Returns the string representation of the class + * + * @returns {String} The string representation + */ + toString : function() { + return "PeerConnectionWrapper (" + this.label + ")"; + } +}; + +// haxx to prevent SimpleTest from failing at window.onload +function addLoadEvent() {} + +var scriptsReady = Promise.all([ + "/tests/SimpleTest/SimpleTest.js", + "head.js", + "templates.js", + "turnConfig.js", + "dataChannel.js", + "network.js", + "sdpUtils.js" +].map(script => { + var el = document.createElement("script"); + if (typeof scriptRelativePath === 'string' && script.charAt(0) !== '/') { + script = scriptRelativePath + script; + } + el.src = script; + document.head.appendChild(el); + return new Promise(r => { el.onload = r; el.onerror = r; }); +})); + +function createHTML(options) { + return scriptsReady.then(() => realCreateHTML(options)); +} + +var iceServerWebsocket; +var iceServersArray = []; + +var setupIceServerConfig = useIceServer => { + // We disable ICE support for HTTP proxy when using a TURN server, because + // mochitest uses a fake HTTP proxy to serve content, which will eat our STUN + // packets for TURN TCP. + var enableHttpProxy = enable => new Promise(resolve => { + SpecialPowers.pushPrefEnv( + {'set': [['media.peerconnection.disable_http_proxy', !enable]]}, + resolve); + }); + + var spawnIceServer = () => new Promise( (resolve, reject) => { + iceServerWebsocket = new WebSocket("ws://localhost:8191/"); + iceServerWebsocket.onopen = (event) => { + info("websocket/process bridge open, starting ICE Server..."); + iceServerWebsocket.send("iceserver"); + } + + iceServerWebsocket.onmessage = event => { + // The first message will contain the iceServers configuration, subsequent + // messages are just logging. + info("ICE Server: " + event.data); + resolve(event.data); + } + + iceServerWebsocket.onerror = () => { + reject("ICE Server error: Is the ICE server websocket up?"); + } + + iceServerWebsocket.onclose = () => { + info("ICE Server websocket closed"); + reject("ICE Server gone before getting configuration"); + } + }); + + if (!useIceServer) { + info("Skipping ICE Server for this test"); + return enableHttpProxy(true); + } + + return enableHttpProxy(false) + .then(spawnIceServer) + .then(iceServersStr => { iceServersArray = JSON.parse(iceServersStr); }); +}; + +function runNetworkTest(testFunction, fixtureOptions) { + fixtureOptions = fixtureOptions || {} + return scriptsReady.then(() => + runTestWhenReady(options => + startNetworkAndTest() + .then(() => setupIceServerConfig(fixtureOptions.useIceServer)) + .then(() => testFunction(options)) + ) + ); +} |