diff options
Diffstat (limited to 'dom/media/webrtc/tests/mochitests/pc.js')
-rw-r--r-- | dom/media/webrtc/tests/mochitests/pc.js | 2495 |
1 files changed, 2495 insertions, 0 deletions
diff --git a/dom/media/webrtc/tests/mochitests/pc.js b/dom/media/webrtc/tests/mochitests/pc.js new file mode 100644 index 0000000000..36a923fbed --- /dev/null +++ b/dom/media/webrtc/tests/mochitests/pc.js @@ -0,0 +1,2495 @@ +/* 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; + options.ssrc = "ssrc" in options ? options.ssrc : true; + + options.config_local = options.config_local || {}; + options.config_remote = options.config_remote || {}; + + if (!options.bundle) { + // Make sure neither end tries to use bundle-only! + options.config_local.bundlePolicy = "max-compat"; + options.config_remote.bundlePolicy = "max-compat"; + } + + if (iceServersArray.length) { + if (!options.turn_disabled_local && !options.config_local.iceServers) { + options.config_local.iceServers = iceServersArray; + } + if (!options.turn_disabled_remote && !options.config_remote.iceServers) { + options.config_remote.iceServers = iceServersArray; + } + } else if (typeof turnServers !== "undefined") { + if (!options.turn_disabled_local && turnServers.local) { + if (!options.config_local.hasOwnProperty("iceServers")) { + options.config_local.iceServers = turnServers.local.iceServers; + } + } + if (!options.turn_disabled_remote && turnServers.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; + } + + // 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([ + 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(pc + " 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 = async function (data, options) { + options = options || {}; + const source = + options.sourceChannel || + this.pcLocal.dataChannels[this.pcLocal.dataChannels.length - 1]; + const target = + options.targetChannel || + this.pcRemote.dataChannels[this.pcRemote.dataChannels.length - 1]; + source.bufferedAmountLowThreshold = options.bufferedAmountLowThreshold || 0; + + const getSizeInBytes = d => { + if (d instanceof Blob) { + return d.size; + } else if (d instanceof ArrayBuffer) { + return d.byteLength; + } else if (d instanceof String || typeof d === "string") { + return new TextEncoder().encode(d).length; + } else { + ok(false); + } + }; + + const expectedSizeInBytes = getSizeInBytes(data); + const bufferedAmount = source.bufferedAmount; + + source.send(data); + is( + source.bufferedAmount, + expectedSizeInBytes + bufferedAmount, + `Buffered amount should be ${expectedSizeInBytes}` + ); + + await new Promise(resolve => (source.onbufferedamountlow = resolve)); + + return new Promise(resolve => { + // Register event handler for the target channel + target.onmessage = e => { + is( + getSizeInBytes(e.data), + expectedSizeInBytes, + `Expected to receive the same number of bytes as we sent (${expectedSizeInBytes})` + ); + resolve({ channel: target, data: e.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("pcRemote expected data channel"); + 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 {RTCSessionDescriptionInit} 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(() => { + 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 {RTCSessionDescriptionInit} 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.ssrc) { + this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ + PC_LOCAL_REMOVE_SSRC_FROM_OFFER, + ]); + this.chain.insertAfterEach("PC_REMOTE_CREATE_ANSWER", [ + PC_REMOTE_REMOVE_SSRC_FROM_ANSWER, + ]); + } + 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 = async function () { + /* We have to modify the chain here to allow tests which modify the default + * test chain instantiating a PeerConnectionTest() */ + this.updateChainSteps(); + try { + await this.chain.execute(); + await this.close(); + } catch (e) { + const stack = + typeof e.stack === "string" + ? ` ${e.stack.split("\n").join(" ... ")}` + : ""; + ok(false, `Error in test execution: ${e} (${stack})`); + } +}; + +/** + * 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; + }, + + /** + * Returns the ordered attribute of the data channel + * + * @returns {bool} The ordered attribute + */ + get ordered() { + return this._channel.ordered; + }, + + /** + * Returns the maxPacketLifeTime attribute of the data channel + * + * @returns {number} The maxPacketLifeTime attribute + */ + get maxPacketLifeTime() { + return this._channel.maxPacketLifeTime; + }, + + /** + * Returns the maxRetransmits attribute of the data channel + * + * @returns {number} The maxRetransmits attribute + */ + get maxRetransmits() { + return this._channel.maxRetransmits; + }, + + /** + * Returns the readyState bit of the data channel + * + * @returns {String} The state of the channel + */ + get readyState() { + return this._channel.readyState; + }, + + get bufferedAmount() { + return this._channel.bufferedAmount; + }, + + /** + * 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() { + 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(data) { + info(this + ": Sending data '" + data + "'"); + this._channel.send(data); + }, + + /** + * Returns the string representation of the class + * + * @returns {String} The string representation + */ + toString() { + 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.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._sendStreams = []; + + this.expectedLocalTrackInfo = []; + this.remoteStreamsByTrackId = new Map(); + + 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(new Error("ICE failed")); + } + }; + + this._pc.onicegatheringstatechange = e => { + isnot( + typeof this._pc.iceGatheringState, + "undefined", + "iceGetheringState should not be undefined" + ); + var gatheringState = this._pc.iceGatheringState; + info( + this + + ": onicegatheringstatechange fired, new state is: " + + gatheringState + ); + }; + + 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 senders + * + * @returns {sequence<RTCRtpSender>} the senders + */ + getSenders() { + return this._pc.getSenders(); + }, + + /** + * Returns the getters + * + * @returns {sequence<RTCRtpReceiver>} the receivers + */ + getReceivers() { + return this._pc.getReceivers(); + }, + + /** + * Returns the local description. + * + * @returns {object} The local description + */ + get localDescription() { + return this._pc.localDescription; + }, + + /** + * Returns the remote description. + * + * @returns {object} The remote description + */ + get remoteDescription() { + return this._pc.remoteDescription; + }, + + /** + * 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(provider, options) { + this._pc.setIdentityProvider(provider, options); + }, + + elementPrefix: direction => { + return [this.label, direction].join("_"); + }, + + getMediaElementForTrack(track, direction) { + var prefix = this.elementPrefix(direction); + return getMediaElementForTrack(track, prefix); + }, + + createMediaElementForTrack(track, direction) { + var prefix = this.elementPrefix(direction); + return createMediaElementForTrack(track, prefix); + }, + + ensureMediaElement(track, direction) { + var prefix = this.elementPrefix(direction); + var element = this.getMediaElementForTrack(track, direction); + if (!element) { + element = this.createMediaElementForTrack(track, direction); + 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(); + }, + + addSendStream(stream) { + // The PeerConnection will not necessarily know about this stream + // automatically, because replaceTrack is not told about any streams the + // new track might be associated with. Only content really knows. + this._sendStreams.push(stream); + }, + + getStreamForSendTrack(track) { + return this._sendStreams.find(str => str.getTrackById(track.id)); + }, + + getStreamForRecvTrack(track) { + return this._pc.getRemoteStreams().find(s => !!s.getTrackById(track.id)); + }, + + /** + * 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(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"); + is( + this._pc.getSenders().pop(), + sender, + "Sender should be the last element in getSenders()" + ); + + ok(track.id, "track has id"); + ok(track.kind, "track has kind"); + ok(stream.id, "stream has id"); + this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id }); + this.addSendStream(stream); + + // 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(stream, useAddTransceiver) { + info("Got local media stream: (" + stream.id + ")"); + + this.expectNegotiationNeeded(); + if (useAddTransceiver) { + info("Using addTransceiver (on PC)."); + stream.getTracks().forEach(track => { + var transceiver = this._pc.addTransceiver(track, { streams: [stream] }); + is(transceiver.sender.track, track, "addTransceiver returns sender"); + }); + } + // In order to test both the addStream and addTrack APIs, we do half one + // way, half the other, at random. + else 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"); + }); + } + + this.addSendStream(stream); + + stream.getTracks().forEach(track => { + ok(track.id, "track has id"); + ok(track.kind, "track has kind"); + const sender = this._pc.getSenders().find(s => s.track == track); + ok(sender, "track has a sender"); + this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id }); + this.ensureMediaElement(track, "local"); + }); + + return this.observedNegotiationNeeded; + }, + + removeSender(index) { + var sender = this._pc.getSenders()[index]; + this.expectedLocalTrackInfo = this.expectedLocalTrackInfo.filter( + i => i.sender != sender + ); + this.expectNegotiationNeeded(); + this._pc.removeTrack(sender); + return this.observedNegotiationNeeded; + }, + + senderReplaceTrack(sender, withTrack, stream) { + const info = this.expectedLocalTrackInfo.find(i => i.sender == sender); + if (!info) { + return; // replaceTrack on a null track, probably + } + info.track = withTrack; + this.addSendStream(stream); + this.ensureMediaElement(withTrack, "local"); + return sender.replaceTrack(withTrack); + }, + + async getUserMedia(constraints) { + SpecialPowers.wrap(document).notifyUserGestureActivation(); + var stream = await getUserMedia(constraints); + if (constraints.audio) { + stream.getAudioTracks().forEach(track => { + info( + this + + " gUM local stream " + + stream.id + + " with audio track " + + track.id + ); + }); + } + if (constraints.video) { + stream.getVideoTracks().forEach(track => { + info( + this + + " gUM local stream " + + stream.id + + " with video track " + + track.id + ); + }); + } + return stream; + }, + + /** + * Requests all the media streams as specified in the constrains property. + * + * @param {array} constraintsList + * Array of constraints for GUM calls + */ + getAllUserMedia(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 => this.getUserMedia(constraints)) + ); + }, + + async getAllUserMediaAndAddStreams(constraintsList) { + var streams = await this.getAllUserMedia(constraintsList); + if (!streams) { + return; + } + return Promise.all(streams.map(stream => this.attachLocalStream(stream))); + }, + + async getAllUserMediaAndAddTransceivers(constraintsList) { + var streams = await this.getAllUserMedia(constraintsList); + if (!streams) { + return; + } + return Promise.all( + streams.map(stream => this.attachLocalStream(stream, true)) + ); + }, + + /** + * Create a new data channel instance. Also creates a promise called + * `this.nextDataChannel` that resolves when the next data channel arrives. + */ + expectDataChannel(message) { + this.nextDataChannel = new Promise(resolve => { + this.ondatachannel = e => { + ok(e.channel, message); + is( + e.channel.readyState, + "open", + "data channel in 'open' after 'ondatachannel'" + ); + 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(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); + is(channel.readyState, "connecting", "initial readyState is 'connecting'"); + var wrapper = new DataChannelWrapper(channel, this); + this.dataChannels.push(wrapper); + return wrapper; + }, + + /** + * Creates an offer and automatically handles the failure case. + */ + createOffer() { + 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() { + 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 + * RTCSessionDescriptionInit for the local description request + */ + setLocalDescription(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 + * RTCSessionDescriptionInit for the local description request + * @returns {Promise} + * A promise that resolves to the expected error + */ + setLocalDescriptionAndFail(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 + * RTCSessionDescriptionInit for the remote description request + */ + setRemoteDescription(desc) { + this.observedNegotiationNeeded = undefined; + // This has to be done before calling sRD, otherwise a candidate in flight + // could end up in the PC's operations queue before sRD resolves. + if (desc.type == "rollback") { + this.holdIceCandidates = new Promise( + r => (this.releaseIceCandidates = r) + ); + } + return this._pc.setRemoteDescription(desc).then(() => { + info(this + ": Successfully set remote description"); + if (desc.type != "rollback") { + 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 + * RTCSessionDescriptionInit for the remote description request + * @returns {Promise} + * a promise that resolve to the returned error + */ + setRemoteDescriptionAndFail(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() { + 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).includes(oldstate)) { + ok( + signalingStateTransitions[oldstate].includes(newstate), + 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); + }); + }, + + isTrackOnPC(track) { + return !!this.getStreamForRecvTrack(track); + }, + + allExpectedTracksAreObserved(expected, observed) { + return Object.keys(expected).every(trackId => observed[trackId]); + }, + + setupStreamEventHandlers(stream) { + const myTrackIds = new Set(stream.getTracks().map(t => t.id)); + + stream.addEventListener("addtrack", ({ track }) => { + ok( + !myTrackIds.has(track.id), + "Duplicate addtrack callback: " + + `stream id=${stream.id} track id=${track.id}` + ); + myTrackIds.add(track.id); + // addtrack events happen before track events, so the track callback hasn't + // heard about this yet. + let streams = this.remoteStreamsByTrackId.get(track.id); + ok( + !streams || !streams.has(stream.id), + `In addtrack for stream id=${stream.id}` + + `there should not have been a track event for track id=${track.id} ` + + " containing this stream yet." + ); + ok( + stream.getTracks().includes(track), + "In addtrack, stream id=" + + `${stream.id} should already contain track id=${track.id}` + ); + }); + + stream.addEventListener("removetrack", ({ track }) => { + ok( + myTrackIds.has(track.id), + "Duplicate removetrack callback: " + + `stream id=${stream.id} track id=${track.id}` + ); + myTrackIds.delete(track.id); + // Also remove the association from remoteStreamsByTrackId + const streams = this.remoteStreamsByTrackId.get(track.id); + ok( + streams, + `In removetrack for stream id=${stream.id}, track id=` + + `${track.id} should have had a track callback for the stream.` + ); + streams.delete(stream.id); + ok( + !stream.getTracks().includes(track), + "In removetrack, stream id=" + + `${stream.id} should not contain track id=${track.id}` + ); + }); + }, + + setupTrackEventHandler() { + this._pc.addEventListener("track", ({ track, streams }) => { + info(`${this}: 'ontrack' event fired for ${track.id}`); + ok(this.isTrackOnPC(track), `Found track ${track.id}`); + + let gratuitousEvent = true; + let streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id); + if (!streamsContainingTrack) { + gratuitousEvent = false; // Told us about a new track + this.remoteStreamsByTrackId.set(track.id, new Set()); + streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id); + } + + for (const stream of streams) { + ok( + stream.getTracks().includes(track), + `In track event, track id=${track.id}` + + ` should already be in stream id=${stream.id}` + ); + + if (!streamsContainingTrack.has(stream.id)) { + gratuitousEvent = false; // Told us about a new stream + streamsContainingTrack.add(stream.id); + this.setupStreamEventHandlers(stream); + } + } + + ok(!gratuitousEvent, "track event told us something new"); + + // So far, we've verified consistency between the current state of the + // streams, addtrack/removetrack events on the streams, and track events + // on the peerconnection. We have also verified that we have not gotten + // any gratuitous events. We have not done anything to verify that the + // current state of affairs matches what we were expecting it to. + + this.ensureMediaElement(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(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() { + 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).includes(oldstate)) { + 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].includes(newstate), + 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() { + 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() { + return this.iceConnected; + }, + + /** + * Setup a onicecandidate handler + * + * @param {object} test + * A PeerConnectionTest object to which the ice candidates gets + * forwarded. + */ + setupIceCandidateHandler(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"); + ok( + this._pc.iceGatheringState === "complete", + "ICE gathering state has reached complete" + ); + resolveEndOfTrickle(this.label); + return; + } + + info( + this.label + ": iceCandidate = " + JSON.stringify(anEvent.candidate) + ); + ok(anEvent.candidate.sdpMid.length, "SDP mid not empty"); + ok( + anEvent.candidate.usernameFragment.length, + "usernameFragment not empty" + ); + + 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() { + info( + `${this}: Checking local tracks ${JSON.stringify( + this.expectedLocalTrackInfo + )}` + ); + const sendersWithTrack = this._pc.getSenders().filter(({ track }) => track); + is( + sendersWithTrack.length, + this.expectedLocalTrackInfo.length, + "The number of senders with a track should be equal to the number of " + + "expected local tracks." + ); + + // expectedLocalTrackInfo is in the same order that the tracks were added, and + // so should the output of getSenders. + this.expectedLocalTrackInfo.forEach((info, i) => { + const sender = sendersWithTrack[i]; + is(sender, info.sender, `Sender ${i} should match`); + is(sender.track, info.track, `Track ${i} should match`); + }); + }, + + /** + * Checks that we are getting the media tracks we expect. + */ + checkMediaTracks() { + this.checkLocalMediaTracks(); + }, + + checkLocalMsids() { + const sdp = this.localDescription.sdp; + const msections = sdputils.getMSections(sdp); + const expectedStreamIdCounts = new Map(); + for (const { track, sender, streamId } of this.expectedLocalTrackInfo) { + const transceiver = this._pc + .getTransceivers() + .find(t => t.sender == sender); + ok(transceiver, "There should be a transceiver for each sender"); + if (transceiver.mid) { + const midFinder = new RegExp(`^a=mid:${transceiver.mid}$`, "m"); + const msection = msections.find(m => m.match(midFinder)); + ok( + msection, + `There should be a media section for mid = ${transceiver.mid}` + ); + ok( + msection.startsWith(`m=${track.kind}`), + `Media section should be of type ${track.kind}` + ); + const msidFinder = new RegExp(`^a=msid:${streamId} \\S+$`, "m"); + ok( + msection.match(msidFinder), + `Should find a=msid:${streamId} in media section` + + " (with any track id for now)" + ); + const count = expectedStreamIdCounts.get(streamId) || 0; + expectedStreamIdCounts.set(streamId, count + 1); + } + } + + // Check for any unexpected msids. + const allMsids = sdp.match(new RegExp("^a=msid:\\S+", "mg")); + if (!allMsids) { + return; + } + const allStreamIds = allMsids.map(msidAttr => + msidAttr.replace("a=msid:", "") + ); + allStreamIds.forEach(id => { + const count = expectedStreamIdCounts.get(id); + ok(count, `Unexpected stream id ${id} found in local description.`); + if (count) { + expectedStreamIdCounts.set(id, count - 1); + } + }); + }, + + /** + * 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(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} + * Returns a promise which yields a StatsReport object with RTP stats. + */ + async _waitForRtpFlow(target, rtpType) { + const { track } = target; + info(`_waitForRtpFlow(${track.id}, ${rtpType})`); + const packets = `packets${rtpType == "outbound-rtp" ? "Sent" : "Received"}`; + + const retryInterval = 500; // Time between stats checks + const timeout = 30000; // Timeout in ms + const retries = timeout / retryInterval; + + for (let i = 0; i < retries; i++) { + info(`Checking ${rtpType} for ${track.kind} track ${track.id} try ${i}`); + for (const rtp of (await target.getStats()).values()) { + if (rtp.type != rtpType) { + continue; + } + if (rtp.kind != track.kind) { + continue; + } + + const numPackets = rtp[packets]; + info(`Track ${track.id} has ${numPackets} ${packets}.`); + if (!numPackets) { + continue; + } + + ok(true, `RTP flowing for ${track.kind} track ${track.id}`); + return; + } + await wait(retryInterval); + } + throw new Error( + `Checking stats for track ${track.id} timed out after ${timeout} ms` + ); + }, + + /** + * Wait for inbound RTP packet flow for the given MediaStreamTrack. + * + * @param {object} receiver + * An RTCRtpReceiver to wait for data flow on. + * @returns {Promise} + * Returns a promise that resolves once data is flowing. + */ + async waitForInboundRtpFlow(receiver) { + return this._waitForRtpFlow(receiver, "inbound-rtp"); + }, + + /** + * Wait for outbound RTP packet flow for the given MediaStreamTrack. + * + * @param {object} sender + * An RTCRtpSender to wait for data flow on. + * @returns {Promise} + * Returns a promise that resolves once data is flowing. + */ + async waitForOutboundRtpFlow(sender) { + return this._waitForRtpFlow(sender, "outbound-rtp"); + }, + + getExpectedActiveReceivers() { + return this._pc + .getTransceivers() + .filter( + t => + !t.stopped && + t.currentDirection && + t.currentDirection != "inactive" && + t.currentDirection != "sendonly" + ) + .filter(({ receiver }) => receiver.track) + .map(({ mid, currentDirection, receiver }) => { + info( + `Found transceiver that should be receiving RTP: mid=${mid}` + + ` currentDirection=${currentDirection}` + + ` kind=${receiver.track.kind} track-id=${receiver.track.id}` + ); + return receiver; + }); + }, + + getExpectedSenders() { + return this._pc.getSenders().filter(({ track }) => track); + }, + + /** + * 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() { + const receivers = this.getExpectedActiveReceivers(); + return Promise.all([ + ...this.localMediaElements.map(el => this.waitForMediaElementFlow(el)), + ...this.remoteMediaElements + .filter(({ srcObject }) => + receivers.some(({ track }) => + srcObject.getTracks().some(t => t == track) + ) + ) + .map(el => this.waitForMediaElementFlow(el)), + ...receivers.map(receiver => this.waitForInboundRtpFlow(receiver)), + ...this.getExpectedSenders().map(sender => + this.waitForOutboundRtpFlow(sender) + ), + ]); + }, + + /** + * Check that correct audio (typically a flat tone) is flowing to this + * PeerConnection for each transceiver that should be receiving. 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/s from |from|. + */ + async checkReceivingToneFrom( + audiocontext, + from, + cancel = wait(60000, new Error("Tone not detected")) + ) { + let localTransceivers = this._pc + .getTransceivers() + .filter(t => t.mid) + .filter(t => t.receiver.track.kind == "audio") + .sort((t1, t2) => t1.mid < t2.mid); + let remoteTransceivers = from._pc + .getTransceivers() + .filter(t => t.mid) + .filter(t => t.receiver.track.kind == "audio") + .sort((t1, t2) => t1.mid < t2.mid); + + is( + localTransceivers.length, + remoteTransceivers.length, + "Same number of associated audio transceivers on remote and local." + ); + + for (let i = 0; i < localTransceivers.length; i++) { + is( + localTransceivers[i].mid, + remoteTransceivers[i].mid, + "Transceivers at index " + i + " have the same mid." + ); + + if (!remoteTransceivers[i].sender.track) { + continue; + } + + if ( + remoteTransceivers[i].currentDirection == "recvonly" || + remoteTransceivers[i].currentDirection == "inactive" + ) { + continue; + } + + let sendTrack = remoteTransceivers[i].sender.track; + let inputElem = from.getMediaElementForTrack(sendTrack, "local"); + ok( + inputElem, + "Remote wrapper should have a media element for track id " + + sendTrack.id + ); + let inputAudioStream = from.getStreamForSendTrack(sendTrack); + ok( + inputAudioStream, + "Remote wrapper should have a stream for track id " + sendTrack.id + ); + let inputAnalyser = new AudioStreamAnalyser( + audiocontext, + inputAudioStream + ); + + let recvTrack = localTransceivers[i].receiver.track; + let outputAudioStream = this.getStreamForRecvTrack(recvTrack); + ok( + outputAudioStream, + "Local wrapper should have a stream for track id " + recvTrack.id + ); + let outputAnalyser = new AudioStreamAnalyser( + audiocontext, + outputAudioStream + ); + + let error = null; + cancel.then(e => (error = e)); + + let indexOfMax = data => + data.reduce((max, val, i) => (val >= data[max] ? i : max), 0); + + await outputAnalyser.waitForAnalysisSuccess(() => { + if (error) { + throw error; + } + + let inputData = inputAnalyser.getByteFrequencyData(); + let outputData = outputAnalyser.getByteFrequencyData(); + + let inputMax = indexOfMax(inputData); + let outputMax = indexOfMax(outputData); + info( + `Comparing maxima; input[${inputMax}] = ${inputData[inputMax]},` + + ` output[${outputMax}] = ${outputData[outputMax]}` + ); + if (!inputData[inputMax] || !outputData[outputMax]) { + return false; + } + + // When the input and output maxima are within reasonable distance (2% of + // total length, which means ~10 for length 512) from each other, we can + // be sure that the input tone has made it through the peer connection. + info(`input data length: ${inputData.length}`); + return Math.abs(inputMax - outputMax) < inputData.length * 0.02; + }); + } + }, + + /** + * Check that stats are present by checking for known stats. + */ + async getStats(selector) { + const stats = await this._pc.getStats(selector); + const dict = {}; + for (const [k, v] of stats.entries()) { + dict[k] = v; + } + info(`${this}: Got stats: ${JSON.stringify(dict)}`); + return stats; + }, + + /** + * Checks that we are getting the media streams we expect. + * + * @param {object} stats + * The stats to check from this PeerConnectionWrapper + */ + checkStats(stats) { + const isRemote = ({ type }) => + ["remote-outbound-rtp", "remote-inbound-rtp"].includes(type); + var counters = {}; + for (let [key, res] of stats) { + info("Checking stats for " + key + " : " + res); + // validate stats + ok(res.id == key, "Coherent stats id"); + const now = performance.timeOrigin + performance.now(); + const minimum = performance.timeOrigin; + const type = isRemote(res) ? "rtcp" : "rtp"; + ok( + res.timestamp >= minimum, + `Valid ${type} timestamp ${res.timestamp} >= ${minimum} ( + ${res.timestamp - minimum} ms)` + ); + ok( + res.timestamp <= now, + `Valid ${type} timestamp ${res.timestamp} <= ${now} ( + ${res.timestamp - now} ms)` + ); + if (isRemote(res)) { + continue; + } + counters[res.type] = (counters[res.type] || 0) + 1; + + switch (res.type) { + case "inbound-rtp": + case "outbound-rtp": + { + // Inbound tracks won't have an ssrc if RTP is not flowing. + // (eg; negotiated inactive) + ok( + res.ssrc || res.type == "inbound-rtp", + "Outbound RTP stats has an ssrc." + ); + + if (res.ssrc) { + // ssrc is a 32 bit number returned as an unsigned long + ok(!/[^0-9]/.test(`${res.ssrc}`), "SSRC is numeric"); + ok(parseInt(res.ssrc) < Math.pow(2, 32), "SSRC is within limits"); + } + + if (res.type == "outbound-rtp") { + 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.get(res.remoteId); + ok(isRemote(rem), "Remote is rtcp"); + ok(rem.localId == res.id, "Remote backlink match"); + if (res.type == "outbound-rtp") { + ok(rem.type == "remote-inbound-rtp", "Rtcp is inbound"); + if (rem.packetsLost) { + ok( + rem.packetsLost >= 0, + "Rtcp packetsLost " + rem.packetsLost + " >= 0" + ); + ok( + rem.packetsLost < 1000, + "Rtcp packetsLost " + rem.packetsLost + " < 1000" + ); + } + if (!this.disableRtpCountChecking) { + // no guarantee which one is newer! + // Note: this must change when we add a timestamp field to remote RTCP reports + // and make rem.timestamp be the reception time + if (res.timestamp < rem.timestamp) { + info( + "REVERSED timestamps: rec:" + + rem.packetsReceived + + " time:" + + rem.timestamp + + " sent:" + + res.packetsSent + + " time:" + + res.timestamp + ); + } + } + if (rem.jitter) { + ok(rem.jitter >= 0, "Rtcp jitter " + rem.jitter + " >= 0"); + ok(rem.jitter < 5, "Rtcp jitter " + rem.jitter + " < 5 sec"); + } + if (rem.roundTripTime) { + ok( + rem.roundTripTime >= 0, + "Rtcp rtt " + rem.roundTripTime + " >= 0" + ); + ok( + rem.roundTripTime < 60, + "Rtcp rtt " + rem.roundTripTime + " < 1 min" + ); + } + } else { + ok(rem.type == "remote-outbound-rtp", "Rtcp is outbound"); + ok(rem.packetsSent !== undefined, "Rtcp packetsSent"); + ok(rem.bytesSent !== undefined, "Rtcp bytesSent"); + } + ok(rem.ssrc == res.ssrc, "Remote ssrc match"); + } else { + info("No rtcp info received yet"); + } + } + break; + } + } + + var nin = this._pc.getTransceivers().filter(t => { + return ( + !t.stopped && + t.currentDirection != "inactive" && + t.currentDirection != "sendonly" + ); + }).length; + const nout = Object.keys(this.expectedLocalTrackInfo).length; + var ndata = this.dataChannels.length; + + // TODO(Bug 957145): Restore stronger inbound-rtp test once Bug 948249 is fixed + //is((counters["inbound-rtp"] || 0), nin, "Have " + nin + " inbound-rtp stat(s)"); + ok( + (counters["inbound-rtp"] || 0) >= nin, + "Have at least " + nin + " inbound-rtp stat(s) *" + ); + + is( + counters["outbound-rtp"] || 0, + nout, + "Have " + nout + " outbound-rtp stat(s)" + ); + + var numLocalCandidates = counters["local-candidate"] || 0; + var numRemoteCandidates = counters["remote-candidate"] || 0; + // If there are no tracks, there will be no stats either. + if (nin + nout + ndata > 0) { + ok(numLocalCandidates, "Have local-candidate stat(s)"); + ok(numRemoteCandidates, "Have remote-candidate stat(s)"); + } else { + is(numLocalCandidates, 0, "Have no local-candidate stats"); + is(numRemoteCandidates, 0, "Have no remote-candidate 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(stats, expectedLocalCandidateType) { + let lId; + let rId; + for (let stat of stats.values()) { + if (stat.type == "candidate-pair" && 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.relayProtocol === "tcp" && candidateType === "relay") { + candidateType = "relay-tcp"; + } + + if (lCand.relayProtocol === "tls" && candidateType === "relay") { + candidateType = "relay-tls"; + } + + if (expectedLocalCandidateType === "srflx" && candidateType === "prflx") { + // Be forgiving of prflx when expecting srflx, since that can happen due + // to timing. + candidateType = "srflx"; + } + + 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} testOptions + * The test options object from the PeerConnectionTest + */ + checkStatsIceConnections(stats, testOptions) { + var numIceConnections = 0; + stats.forEach(stat => { + if (stat.type === "candidate-pair" && stat.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 { + var numAudioTransceivers = this._pc + .getTransceivers() + .filter(transceiver => { + return ( + !transceiver.stopped && transceiver.receiver.track.kind == "audio" + ); + }).length; + + var numVideoTransceivers = this._pc + .getTransceivers() + .filter(transceiver => { + return ( + !transceiver.stopped && transceiver.receiver.track.kind == "video" + ); + }).length; + + var numExpectedTransports = numAudioTransceivers + numVideoTransceivers; + 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() { + 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(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() { + 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() { + return "PeerConnectionWrapper (" + this.label + ")"; + }, +}; + +// haxx to prevent SimpleTest from failing at window.onload +function addLoadEvent() {} + +function loadScript(...scripts) { + return Promise.all( + scripts.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; + }); + }) + ); +} + +// Ensure SimpleTest.js is loaded before other scripts. +/* import-globals-from /testing/mochitest/tests/SimpleTest/SimpleTest.js */ +/* import-globals-from head.js */ +/* import-globals-from templates.js */ +/* import-globals-from turnConfig.js */ +/* import-globals-from dataChannel.js */ +/* import-globals-from network.js */ +/* import-globals-from sdpUtils.js */ + +var scriptsReady = loadScript("/tests/SimpleTest/SimpleTest.js").then(() => { + return loadScript( + "head.js", + "templates.js", + "turnConfig.js", + "dataChannel.js", + "network.js", + "sdpUtils.js" + ); +}); + +function createHTML(options) { + return scriptsReady.then(() => realCreateHTML(options)); +} + +var iceServerWebsocket; +var iceServersArray = []; + +var addTurnsSelfsignedCerts = () => { + var gUrl = SimpleTest.getTestFileURL("addTurnsSelfsignedCert.js"); + var gScript = SpecialPowers.loadChromeScript(gUrl); + var certs = []; + // If the ICE server is running TURNS, and includes a "cert" attribute in + // its JSON, we set up an override that will forgive things like + // self-signed for it. + iceServersArray.forEach(iceServer => { + if (iceServer.hasOwnProperty("cert")) { + iceServer.urls.forEach(url => { + if (url.startsWith("turns:")) { + // Assumes no port or params! + certs.push({ cert: iceServer.cert, hostname: url.substr(6) }); + } + }); + } + }); + + return new Promise((resolve, reject) => { + gScript.addMessageListener("certs-added", () => { + resolve(); + }); + + gScript.sendAsyncMessage("add-turns-certs", certs); + }); +}; + +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 => + SpecialPowers.pushPrefEnv({ + set: [["media.peerconnection.disable_http_proxy", !enable]], + }); + + 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); + }) + .then(addTurnsSelfsignedCerts); +}; + +async function runNetworkTest(testFunction, fixtureOptions = {}) { + let { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + await scriptsReady; + await runTestWhenReady(async options => { + await startNetworkAndTest(); + await setupIceServerConfig(fixtureOptions.useIceServer); + await testFunction(options); + await networkTestFinished(); + }); +} |