2511 lines
74 KiB
JavaScript
2511 lines
74 KiB
JavaScript
/* 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;
|
|
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 {
|
|
ok(
|
|
numIceConnections >= 1,
|
|
`stats ICE connections should be at least 1`
|
|
);
|
|
ok(numIceConnections <= 2, `stats ICE connections should be at most 2`);
|
|
}
|
|
} 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 (this.dataChannels.length) {
|
|
++numExpectedTransports;
|
|
}
|
|
|
|
info(
|
|
"expected audio + video + data transports: " + numExpectedTransports
|
|
);
|
|
if (!testOptions.rtcpmux) {
|
|
// Without rtcp mux, the expected number of transports doubles, but
|
|
// there's no good way to check whether the rtcp transports are ready,
|
|
// since there's no way to expose the state of those extra transports.
|
|
ok(
|
|
numIceConnections >= numExpectedTransports,
|
|
`stats ICE connections should be at least ${numExpectedTransports}`
|
|
);
|
|
ok(
|
|
numIceConnections <= numExpectedTransports * 2,
|
|
`stats ICE connections should be at most ${numExpectedTransports * 2}`
|
|
);
|
|
} else {
|
|
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();
|
|
});
|
|
}
|