/* 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",
"checking",
"completed",
"disconnected",
"closed"],
completed: [
"new",
"checking",
"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.av1 =
"av1" in options ? options.av1 :
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);
throw new Error(
"Could not get size");
}
};
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 = 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 = 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.av1) {
this.chain.insertAfterEach(
"PC_LOCAL_CREATE_OFFER", [
PC_LOCAL_REMOVE_ALL_BUT_AV1_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 undefined;
// 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 undefined;
}
return Promise.all(streams.map(stream =>
this.attachLocalStream(stream)));
},
async getAllUserMediaAndAddTransceivers(constraintsList) {
var streams = await
this.getAllUserMedia(constraintsList);
if (!streams) {
return undefined;
}
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);
});
},
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._pc.getReceivers().some(r => r.track == 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._new_local_ice_candidates = [];
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);
this._new_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;
// eslint-disable-next-line promise/valid-params
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;
--> --------------------
--> maximum size reached
--> --------------------