/* 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} the senders */ getSenders() { return this._pc.getSenders(); }, /** * Returns the getters * * @returns {sequence} 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(); }); }