summaryrefslogtreecommitdiffstats
path: root/dom/media/tests/mochitest/pc.js
diff options
context:
space:
mode:
Diffstat (limited to 'dom/media/tests/mochitest/pc.js')
-rw-r--r--dom/media/tests/mochitest/pc.js1878
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))
+ )
+ );
+}