summaryrefslogtreecommitdiffstats
path: root/dom/media/webrtc/tests/mochitests/pc.js
diff options
context:
space:
mode:
Diffstat (limited to 'dom/media/webrtc/tests/mochitests/pc.js')
-rw-r--r--dom/media/webrtc/tests/mochitests/pc.js2495
1 files changed, 2495 insertions, 0 deletions
diff --git a/dom/media/webrtc/tests/mochitests/pc.js b/dom/media/webrtc/tests/mochitests/pc.js
new file mode 100644
index 0000000000..36a923fbed
--- /dev/null
+++ b/dom/media/webrtc/tests/mochitests/pc.js
@@ -0,0 +1,2495 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const LOOPBACK_ADDR = "127.0.0.";
+
+const iceStateTransitions = {
+ new: ["checking", "closed"], //Note: 'failed' might need to added here
+ // even though it is not in the standard
+ checking: ["new", "connected", "failed", "closed"], //Note: do we need to
+ // allow 'completed' in
+ // here as well?
+ connected: ["new", "completed", "disconnected", "closed"],
+ completed: ["new", "disconnected", "closed"],
+ disconnected: ["new", "connected", "completed", "failed", "closed"],
+ failed: ["new", "disconnected", "closed"],
+ closed: [],
+};
+
+const signalingStateTransitions = {
+ stable: ["have-local-offer", "have-remote-offer", "closed"],
+ "have-local-offer": [
+ "have-remote-pranswer",
+ "stable",
+ "closed",
+ "have-local-offer",
+ ],
+ "have-remote-pranswer": ["stable", "closed", "have-remote-pranswer"],
+ "have-remote-offer": [
+ "have-local-pranswer",
+ "stable",
+ "closed",
+ "have-remote-offer",
+ ],
+ "have-local-pranswer": ["stable", "closed", "have-local-pranswer"],
+ closed: [],
+};
+
+var makeDefaultCommands = () => {
+ return [].concat(
+ commandsPeerConnectionInitial,
+ commandsGetUserMedia,
+ commandsPeerConnectionOfferAnswer
+ );
+};
+
+/**
+ * This class handles tests for peer connections.
+ *
+ * @constructor
+ * @param {object} [options={}]
+ * Optional options for the peer connection test
+ * @param {object} [options.commands=commandsPeerConnection]
+ * Commands to run for the test
+ * @param {bool} [options.is_local=true]
+ * true if this test should run the tests for the "local" side.
+ * @param {bool} [options.is_remote=true]
+ * true if this test should run the tests for the "remote" side.
+ * @param {object} [options.config_local=undefined]
+ * Configuration for the local peer connection instance
+ * @param {object} [options.config_remote=undefined]
+ * Configuration for the remote peer connection instance. If not defined
+ * the configuration from the local instance will be used
+ */
+function PeerConnectionTest(options) {
+ // If no options are specified make it an empty object
+ options = options || {};
+ options.commands = options.commands || makeDefaultCommands();
+ options.is_local = "is_local" in options ? options.is_local : true;
+ options.is_remote = "is_remote" in options ? options.is_remote : true;
+
+ options.h264 = "h264" in options ? options.h264 : false;
+ options.bundle = "bundle" in options ? options.bundle : true;
+ options.rtcpmux = "rtcpmux" in options ? options.rtcpmux : true;
+ options.opus = "opus" in options ? options.opus : true;
+ options.ssrc = "ssrc" in options ? options.ssrc : true;
+
+ options.config_local = options.config_local || {};
+ options.config_remote = options.config_remote || {};
+
+ if (!options.bundle) {
+ // Make sure neither end tries to use bundle-only!
+ options.config_local.bundlePolicy = "max-compat";
+ options.config_remote.bundlePolicy = "max-compat";
+ }
+
+ if (iceServersArray.length) {
+ if (!options.turn_disabled_local && !options.config_local.iceServers) {
+ options.config_local.iceServers = iceServersArray;
+ }
+ if (!options.turn_disabled_remote && !options.config_remote.iceServers) {
+ options.config_remote.iceServers = iceServersArray;
+ }
+ } else if (typeof turnServers !== "undefined") {
+ if (!options.turn_disabled_local && turnServers.local) {
+ if (!options.config_local.hasOwnProperty("iceServers")) {
+ options.config_local.iceServers = turnServers.local.iceServers;
+ }
+ }
+ if (!options.turn_disabled_remote && turnServers.remote) {
+ if (!options.config_remote.hasOwnProperty("iceServers")) {
+ options.config_remote.iceServers = turnServers.remote.iceServers;
+ }
+ }
+ }
+
+ if (options.is_local) {
+ this.pcLocal = new PeerConnectionWrapper("pcLocal", options.config_local);
+ } else {
+ this.pcLocal = null;
+ }
+
+ if (options.is_remote) {
+ this.pcRemote = new PeerConnectionWrapper(
+ "pcRemote",
+ options.config_remote || options.config_local
+ );
+ } else {
+ this.pcRemote = null;
+ }
+
+ // Create command chain instance and assign default commands
+ this.chain = new CommandChain(this, options.commands);
+
+ this.testOptions = options;
+}
+
+/** TODO: consider removing this dependency on timeouts */
+function timerGuard(p, time, message) {
+ return Promise.race([
+ p,
+ wait(time).then(() => {
+ throw new Error("timeout after " + time / 1000 + "s: " + message);
+ }),
+ ]);
+}
+
+/**
+ * Closes the peer connection if it is active
+ */
+PeerConnectionTest.prototype.closePC = function () {
+ info("Closing peer connections");
+
+ var closeIt = pc => {
+ if (!pc || pc.signalingState === "closed") {
+ return Promise.resolve();
+ }
+
+ var promise = Promise.all([
+ Promise.all(
+ pc._pc
+ .getReceivers()
+ .filter(receiver => receiver.track.readyState == "live")
+ .map(receiver => {
+ info(
+ "Waiting for track " +
+ receiver.track.id +
+ " (" +
+ receiver.track.kind +
+ ") to end."
+ );
+ return haveEvent(receiver.track, "ended", wait(50000)).then(
+ event => {
+ is(
+ event.target,
+ receiver.track,
+ "Event target should be the correct track"
+ );
+ info(pc + " ended fired for track " + receiver.track.id);
+ },
+ e =>
+ e
+ ? Promise.reject(e)
+ : ok(
+ false,
+ "ended never fired for track " + receiver.track.id
+ )
+ );
+ })
+ ),
+ ]);
+ pc.close();
+ return promise;
+ };
+
+ return timerGuard(
+ Promise.all([closeIt(this.pcLocal), closeIt(this.pcRemote)]),
+ 60000,
+ "failed to close peer connection"
+ );
+};
+
+/**
+ * Close the open data channels, followed by the underlying peer connection
+ */
+PeerConnectionTest.prototype.close = function () {
+ var allChannels = (this.pcLocal || this.pcRemote).dataChannels;
+ return timerGuard(
+ Promise.all(allChannels.map((channel, i) => this.closeDataChannels(i))),
+ 120000,
+ "failed to close data channels"
+ ).then(() => this.closePC());
+};
+
+/**
+ * Close the specified data channels
+ *
+ * @param {Number} index
+ * Index of the data channels to close on both sides
+ */
+PeerConnectionTest.prototype.closeDataChannels = function (index) {
+ info("closeDataChannels called with index: " + index);
+ var localChannel = null;
+ if (this.pcLocal) {
+ localChannel = this.pcLocal.dataChannels[index];
+ }
+ var remoteChannel = null;
+ if (this.pcRemote) {
+ remoteChannel = this.pcRemote.dataChannels[index];
+ }
+
+ // We need to setup all the close listeners before calling close
+ var setupClosePromise = channel => {
+ if (!channel) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ channel.onclose = () => {
+ is(
+ channel.readyState,
+ "closed",
+ name + " channel " + index + " closed"
+ );
+ resolve();
+ };
+ });
+ };
+
+ // make sure to setup close listeners before triggering any actions
+ var allClosed = Promise.all([
+ setupClosePromise(localChannel),
+ setupClosePromise(remoteChannel),
+ ]);
+ var complete = timerGuard(
+ allClosed,
+ 120000,
+ "failed to close data channel pair"
+ );
+
+ // triggering close on one side should suffice
+ if (remoteChannel) {
+ remoteChannel.close();
+ } else if (localChannel) {
+ localChannel.close();
+ }
+
+ return complete;
+};
+
+/**
+ * Send data (message or blob) to the other peer
+ *
+ * @param {String|Blob} data
+ * Data to send to the other peer. For Blobs the MIME type will be lost.
+ * @param {Object} [options={ }]
+ * Options to specify the data channels to be used
+ * @param {DataChannelWrapper} [options.sourceChannel=pcLocal.dataChannels[length - 1]]
+ * Data channel to use for sending the message
+ * @param {DataChannelWrapper} [options.targetChannel=pcRemote.dataChannels[length - 1]]
+ * Data channel to use for receiving the message
+ */
+PeerConnectionTest.prototype.send = async function (data, options) {
+ options = options || {};
+ const source =
+ options.sourceChannel ||
+ this.pcLocal.dataChannels[this.pcLocal.dataChannels.length - 1];
+ const target =
+ options.targetChannel ||
+ this.pcRemote.dataChannels[this.pcRemote.dataChannels.length - 1];
+ source.bufferedAmountLowThreshold = options.bufferedAmountLowThreshold || 0;
+
+ const getSizeInBytes = d => {
+ if (d instanceof Blob) {
+ return d.size;
+ } else if (d instanceof ArrayBuffer) {
+ return d.byteLength;
+ } else if (d instanceof String || typeof d === "string") {
+ return new TextEncoder().encode(d).length;
+ } else {
+ ok(false);
+ }
+ };
+
+ const expectedSizeInBytes = getSizeInBytes(data);
+ const bufferedAmount = source.bufferedAmount;
+
+ source.send(data);
+ is(
+ source.bufferedAmount,
+ expectedSizeInBytes + bufferedAmount,
+ `Buffered amount should be ${expectedSizeInBytes}`
+ );
+
+ await new Promise(resolve => (source.onbufferedamountlow = resolve));
+
+ return new Promise(resolve => {
+ // Register event handler for the target channel
+ target.onmessage = e => {
+ is(
+ getSizeInBytes(e.data),
+ expectedSizeInBytes,
+ `Expected to receive the same number of bytes as we sent (${expectedSizeInBytes})`
+ );
+ resolve({ channel: target, data: e.data });
+ };
+ });
+};
+
+/**
+ * Create a data channel
+ *
+ * @param {Dict} options
+ * Options for the data channel (see nsIPeerConnection)
+ */
+PeerConnectionTest.prototype.createDataChannel = function (options) {
+ var remotePromise;
+ if (!options.negotiated) {
+ this.pcRemote.expectDataChannel("pcRemote expected data channel");
+ remotePromise = this.pcRemote.nextDataChannel;
+ }
+
+ // Create the datachannel
+ var localChannel = this.pcLocal.createDataChannel(options);
+ var localPromise = localChannel.opened;
+
+ if (options.negotiated) {
+ remotePromise = localPromise.then(localChannel => {
+ // externally negotiated - we need to open from both ends
+ options.id = options.id || channel.id; // allow for no id on options
+ var remoteChannel = this.pcRemote.createDataChannel(options);
+ return remoteChannel.opened;
+ });
+ }
+
+ // pcRemote.observedNegotiationNeeded might be undefined if
+ // !options.negotiated, which means we just wait on pcLocal
+ return Promise.all([
+ this.pcLocal.observedNegotiationNeeded,
+ this.pcRemote.observedNegotiationNeeded,
+ ]).then(() => {
+ return Promise.all([localPromise, remotePromise]).then(result => {
+ return { local: result[0], remote: result[1] };
+ });
+ });
+};
+
+/**
+ * Creates an answer for the specified peer connection instance
+ * and automatically handles the failure case.
+ *
+ * @param {PeerConnectionWrapper} peer
+ * The peer connection wrapper to run the command on
+ */
+PeerConnectionTest.prototype.createAnswer = function (peer) {
+ return peer.createAnswer().then(answer => {
+ // make a copy so this does not get updated with ICE candidates
+ this.originalAnswer = new RTCSessionDescription(
+ JSON.parse(JSON.stringify(answer))
+ );
+ return answer;
+ });
+};
+
+/**
+ * Creates an offer for the specified peer connection instance
+ * and automatically handles the failure case.
+ *
+ * @param {PeerConnectionWrapper} peer
+ * The peer connection wrapper to run the command on
+ */
+PeerConnectionTest.prototype.createOffer = function (peer) {
+ return peer.createOffer().then(offer => {
+ // make a copy so this does not get updated with ICE candidates
+ this.originalOffer = new RTCSessionDescription(
+ JSON.parse(JSON.stringify(offer))
+ );
+ return offer;
+ });
+};
+
+/**
+ * Sets the local description for the specified peer connection instance
+ * and automatically handles the failure case.
+ *
+ * @param {PeerConnectionWrapper} peer
+ The peer connection wrapper to run the command on
+ * @param {RTCSessionDescriptionInit} desc
+ * Session description for the local description request
+ */
+PeerConnectionTest.prototype.setLocalDescription = function (
+ peer,
+ desc,
+ stateExpected
+) {
+ var eventFired = new Promise(resolve => {
+ peer.onsignalingstatechange = e => {
+ info(peer + ": 'signalingstatechange' event received");
+ var state = e.target.signalingState;
+ if (stateExpected === state) {
+ peer.setLocalDescStableEventDate = new Date();
+ resolve();
+ } else {
+ ok(
+ false,
+ "This event has either already fired or there has been a " +
+ "mismatch between event received " +
+ state +
+ " and event expected " +
+ stateExpected
+ );
+ }
+ };
+ });
+
+ var stateChanged = peer.setLocalDescription(desc).then(() => {
+ peer.setLocalDescDate = new Date();
+ });
+
+ peer.endOfTrickleSdp = peer.endOfTrickleIce
+ .then(() => {
+ return peer._pc.localDescription;
+ })
+ .catch(e => ok(false, "Sending EOC message failed: " + e));
+
+ return Promise.all([eventFired, stateChanged]);
+};
+
+/**
+ * Sets the media constraints for both peer connection instances.
+ *
+ * @param {object} constraintsLocal
+ * Media constrains for the local peer connection instance
+ * @param constraintsRemote
+ */
+PeerConnectionTest.prototype.setMediaConstraints = function (
+ constraintsLocal,
+ constraintsRemote
+) {
+ if (this.pcLocal) {
+ this.pcLocal.constraints = constraintsLocal;
+ }
+ if (this.pcRemote) {
+ this.pcRemote.constraints = constraintsRemote;
+ }
+};
+
+/**
+ * Sets the media options used on a createOffer call in the test.
+ *
+ * @param {object} options the media constraints to use on createOffer
+ */
+PeerConnectionTest.prototype.setOfferOptions = function (options) {
+ if (this.pcLocal) {
+ this.pcLocal.offerOptions = options;
+ }
+};
+
+/**
+ * Sets the remote description for the specified peer connection instance
+ * and automatically handles the failure case.
+ *
+ * @param {PeerConnectionWrapper} peer
+ The peer connection wrapper to run the command on
+ * @param {RTCSessionDescriptionInit} desc
+ * Session description for the remote description request
+ */
+PeerConnectionTest.prototype.setRemoteDescription = function (
+ peer,
+ desc,
+ stateExpected
+) {
+ var eventFired = new Promise(resolve => {
+ peer.onsignalingstatechange = e => {
+ info(peer + ": 'signalingstatechange' event received");
+ var state = e.target.signalingState;
+ if (stateExpected === state) {
+ peer.setRemoteDescStableEventDate = new Date();
+ resolve();
+ } else {
+ ok(
+ false,
+ "This event has either already fired or there has been a " +
+ "mismatch between event received " +
+ state +
+ " and event expected " +
+ stateExpected
+ );
+ }
+ };
+ });
+
+ var stateChanged = peer.setRemoteDescription(desc).then(() => {
+ peer.setRemoteDescDate = new Date();
+ peer.checkMediaTracks();
+ });
+
+ return Promise.all([eventFired, stateChanged]);
+};
+
+/**
+ * Adds and removes steps to/from the execution chain based on the configured
+ * testOptions.
+ */
+PeerConnectionTest.prototype.updateChainSteps = function () {
+ if (this.testOptions.h264) {
+ this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [
+ PC_LOCAL_REMOVE_ALL_BUT_H264_FROM_OFFER,
+ ]);
+ }
+ if (!this.testOptions.bundle) {
+ this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [
+ PC_LOCAL_REMOVE_BUNDLE_FROM_OFFER,
+ ]);
+ }
+ if (!this.testOptions.rtcpmux) {
+ this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [
+ PC_LOCAL_REMOVE_RTCPMUX_FROM_OFFER,
+ ]);
+ }
+ if (!this.testOptions.ssrc) {
+ this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [
+ PC_LOCAL_REMOVE_SSRC_FROM_OFFER,
+ ]);
+ this.chain.insertAfterEach("PC_REMOTE_CREATE_ANSWER", [
+ PC_REMOTE_REMOVE_SSRC_FROM_ANSWER,
+ ]);
+ }
+ if (!this.testOptions.is_local) {
+ this.chain.filterOut(/^PC_LOCAL/);
+ }
+ if (!this.testOptions.is_remote) {
+ this.chain.filterOut(/^PC_REMOTE/);
+ }
+};
+
+/**
+ * Start running the tests as assigned to the command chain.
+ */
+PeerConnectionTest.prototype.run = async function () {
+ /* We have to modify the chain here to allow tests which modify the default
+ * test chain instantiating a PeerConnectionTest() */
+ this.updateChainSteps();
+ try {
+ await this.chain.execute();
+ await this.close();
+ } catch (e) {
+ const stack =
+ typeof e.stack === "string"
+ ? ` ${e.stack.split("\n").join(" ... ")}`
+ : "";
+ ok(false, `Error in test execution: ${e} (${stack})`);
+ }
+};
+
+/**
+ * Routes ice candidates from one PCW to the other PCW
+ */
+PeerConnectionTest.prototype.iceCandidateHandler = function (
+ caller,
+ candidate
+) {
+ info("Received: " + JSON.stringify(candidate) + " from " + caller);
+
+ var target = null;
+ if (caller.includes("pcLocal")) {
+ if (this.pcRemote) {
+ target = this.pcRemote;
+ }
+ } else if (caller.includes("pcRemote")) {
+ if (this.pcLocal) {
+ target = this.pcLocal;
+ }
+ } else {
+ ok(false, "received event from unknown caller: " + caller);
+ return;
+ }
+
+ if (target) {
+ target.storeOrAddIceCandidate(candidate);
+ } else {
+ info("sending ice candidate to signaling server");
+ send_message({ type: "ice_candidate", ice_candidate: candidate });
+ }
+};
+
+/**
+ * Installs a polling function for the socket.io client to read
+ * all messages from the chat room into a message queue.
+ */
+PeerConnectionTest.prototype.setupSignalingClient = function () {
+ this.signalingMessageQueue = [];
+ this.signalingCallbacks = {};
+ this.signalingLoopRun = true;
+
+ var queueMessage = message => {
+ info("Received signaling message: " + JSON.stringify(message));
+ var fired = false;
+ Object.keys(this.signalingCallbacks).forEach(name => {
+ if (name === message.type) {
+ info("Invoking callback for message type: " + name);
+ this.signalingCallbacks[name](message);
+ fired = true;
+ }
+ });
+ if (!fired) {
+ this.signalingMessageQueue.push(message);
+ info(
+ "signalingMessageQueue.length: " + this.signalingMessageQueue.length
+ );
+ }
+ if (this.signalingLoopRun) {
+ wait_for_message().then(queueMessage);
+ } else {
+ info("Exiting signaling message event loop");
+ }
+ };
+ wait_for_message().then(queueMessage);
+};
+
+/**
+ * Sets a flag to stop reading further messages from the chat room.
+ */
+PeerConnectionTest.prototype.signalingMessagesFinished = function () {
+ this.signalingLoopRun = false;
+};
+
+/**
+ * Register a callback function to deliver messages from the chat room
+ * directly instead of storing them in the message queue.
+ *
+ * @param {string} messageType
+ * For which message types should the callback get invoked.
+ *
+ * @param {function} onMessage
+ * The function which gets invoked if a message of the messageType
+ * has been received from the chat room.
+ */
+PeerConnectionTest.prototype.registerSignalingCallback = function (
+ messageType,
+ onMessage
+) {
+ this.signalingCallbacks[messageType] = onMessage;
+};
+
+/**
+ * Searches the message queue for the first message of a given type
+ * and invokes the given callback function, or registers the callback
+ * function for future messages if the queue contains no such message.
+ *
+ * @param {string} messageType
+ * The type of message to search and register for.
+ */
+PeerConnectionTest.prototype.getSignalingMessage = function (messageType) {
+ var i = this.signalingMessageQueue.findIndex(m => m.type === messageType);
+ if (i >= 0) {
+ info(
+ "invoking callback on message " +
+ i +
+ " from message queue, for message type:" +
+ messageType
+ );
+ return Promise.resolve(this.signalingMessageQueue.splice(i, 1)[0]);
+ }
+ return new Promise(resolve =>
+ this.registerSignalingCallback(messageType, resolve)
+ );
+};
+
+/**
+ * This class acts as a wrapper around a DataChannel instance.
+ *
+ * @param dataChannel
+ * @param peerConnectionWrapper
+ * @constructor
+ */
+function DataChannelWrapper(dataChannel, peerConnectionWrapper) {
+ this._channel = dataChannel;
+ this._pc = peerConnectionWrapper;
+
+ info("Creating " + this);
+
+ /**
+ * Setup appropriate callbacks
+ */
+ createOneShotEventWrapper(this, this._channel, "close");
+ createOneShotEventWrapper(this, this._channel, "error");
+ createOneShotEventWrapper(this, this._channel, "message");
+ createOneShotEventWrapper(this, this._channel, "bufferedamountlow");
+
+ this.opened = timerGuard(
+ new Promise(resolve => {
+ this._channel.onopen = () => {
+ this._channel.onopen = unexpectedEvent(this, "onopen");
+ is(this.readyState, "open", "data channel is 'open' after 'onopen'");
+ resolve(this);
+ };
+ }),
+ 180000,
+ "channel didn't open in time"
+ );
+}
+
+DataChannelWrapper.prototype = {
+ /**
+ * Returns the binary type of the channel
+ *
+ * @returns {String} The binary type
+ */
+ get binaryType() {
+ return this._channel.binaryType;
+ },
+
+ /**
+ * Sets the binary type of the channel
+ *
+ * @param {String} type
+ * The new binary type of the channel
+ */
+ set binaryType(type) {
+ this._channel.binaryType = type;
+ },
+
+ /**
+ * Returns the label of the underlying data channel
+ *
+ * @returns {String} The label
+ */
+ get label() {
+ return this._channel.label;
+ },
+
+ /**
+ * Returns the protocol of the underlying data channel
+ *
+ * @returns {String} The protocol
+ */
+ get protocol() {
+ return this._channel.protocol;
+ },
+
+ /**
+ * Returns the id of the underlying data channel
+ *
+ * @returns {number} The stream id
+ */
+ get id() {
+ return this._channel.id;
+ },
+
+ /**
+ * Returns the reliable state of the underlying data channel
+ *
+ * @returns {bool} The stream's reliable state
+ */
+ get reliable() {
+ return this._channel.reliable;
+ },
+
+ /**
+ * Returns the ordered attribute of the data channel
+ *
+ * @returns {bool} The ordered attribute
+ */
+ get ordered() {
+ return this._channel.ordered;
+ },
+
+ /**
+ * Returns the maxPacketLifeTime attribute of the data channel
+ *
+ * @returns {number} The maxPacketLifeTime attribute
+ */
+ get maxPacketLifeTime() {
+ return this._channel.maxPacketLifeTime;
+ },
+
+ /**
+ * Returns the maxRetransmits attribute of the data channel
+ *
+ * @returns {number} The maxRetransmits attribute
+ */
+ get maxRetransmits() {
+ return this._channel.maxRetransmits;
+ },
+
+ /**
+ * Returns the readyState bit of the data channel
+ *
+ * @returns {String} The state of the channel
+ */
+ get readyState() {
+ return this._channel.readyState;
+ },
+
+ get bufferedAmount() {
+ return this._channel.bufferedAmount;
+ },
+
+ /**
+ * Sets the bufferlowthreshold of the channel
+ *
+ * @param {integer} amoutn
+ * The new threshold for the chanel
+ */
+ set bufferedAmountLowThreshold(amount) {
+ this._channel.bufferedAmountLowThreshold = amount;
+ },
+
+ /**
+ * Close the data channel
+ */
+ close() {
+ info(this + ": Closing channel");
+ this._channel.close();
+ },
+
+ /**
+ * Send data through the data channel
+ *
+ * @param {String|Object} data
+ * Data which has to be sent through the data channel
+ */
+ send(data) {
+ info(this + ": Sending data '" + data + "'");
+ this._channel.send(data);
+ },
+
+ /**
+ * Returns the string representation of the class
+ *
+ * @returns {String} The string representation
+ */
+ toString() {
+ return (
+ "DataChannelWrapper (" + this._pc.label + "_" + this._channel.label + ")"
+ );
+ },
+};
+
+/**
+ * This class acts as a wrapper around a PeerConnection instance.
+ *
+ * @constructor
+ * @param {string} label
+ * Description for the peer connection instance
+ * @param {object} configuration
+ * Configuration for the peer connection instance
+ */
+function PeerConnectionWrapper(label, configuration) {
+ this.configuration = configuration;
+ if (configuration && configuration.label_suffix) {
+ label = label + "_" + configuration.label_suffix;
+ }
+ this.label = label;
+
+ this.constraints = [];
+ this.offerOptions = {};
+
+ this.dataChannels = [];
+
+ this._local_ice_candidates = [];
+ this._remote_ice_candidates = [];
+ this.localRequiresTrickleIce = false;
+ this.remoteRequiresTrickleIce = false;
+ this.localMediaElements = [];
+ this.remoteMediaElements = [];
+ this.audioElementsOnly = false;
+
+ this._sendStreams = [];
+
+ this.expectedLocalTrackInfo = [];
+ this.remoteStreamsByTrackId = new Map();
+
+ this.disableRtpCountChecking = false;
+
+ this.iceConnectedResolve;
+ this.iceConnectedReject;
+ this.iceConnected = new Promise((resolve, reject) => {
+ this.iceConnectedResolve = resolve;
+ this.iceConnectedReject = reject;
+ });
+ this.iceCheckingRestartExpected = false;
+ this.iceCheckingIceRollbackExpected = false;
+
+ info("Creating " + this);
+ this._pc = new RTCPeerConnection(this.configuration);
+
+ /**
+ * Setup callback handlers
+ */
+ // This allows test to register their own callbacks for ICE connection state changes
+ this.ice_connection_callbacks = {};
+
+ this._pc.oniceconnectionstatechange = e => {
+ isnot(
+ typeof this._pc.iceConnectionState,
+ "undefined",
+ "iceConnectionState should not be undefined"
+ );
+ var iceState = this._pc.iceConnectionState;
+ info(
+ this + ": oniceconnectionstatechange fired, new state is: " + iceState
+ );
+ Object.keys(this.ice_connection_callbacks).forEach(name => {
+ this.ice_connection_callbacks[name]();
+ });
+ if (iceState === "connected") {
+ this.iceConnectedResolve();
+ } else if (iceState === "failed") {
+ this.iceConnectedReject(new Error("ICE failed"));
+ }
+ };
+
+ this._pc.onicegatheringstatechange = e => {
+ isnot(
+ typeof this._pc.iceGatheringState,
+ "undefined",
+ "iceGetheringState should not be undefined"
+ );
+ var gatheringState = this._pc.iceGatheringState;
+ info(
+ this +
+ ": onicegatheringstatechange fired, new state is: " +
+ gatheringState
+ );
+ };
+
+ createOneShotEventWrapper(this, this._pc, "datachannel");
+ this._pc.addEventListener("datachannel", e => {
+ var wrapper = new DataChannelWrapper(e.channel, this);
+ this.dataChannels.push(wrapper);
+ });
+
+ createOneShotEventWrapper(this, this._pc, "signalingstatechange");
+ createOneShotEventWrapper(this, this._pc, "negotiationneeded");
+}
+
+PeerConnectionWrapper.prototype = {
+ /**
+ * Returns the senders
+ *
+ * @returns {sequence<RTCRtpSender>} the senders
+ */
+ getSenders() {
+ return this._pc.getSenders();
+ },
+
+ /**
+ * Returns the getters
+ *
+ * @returns {sequence<RTCRtpReceiver>} the receivers
+ */
+ getReceivers() {
+ return this._pc.getReceivers();
+ },
+
+ /**
+ * Returns the local description.
+ *
+ * @returns {object} The local description
+ */
+ get localDescription() {
+ return this._pc.localDescription;
+ },
+
+ /**
+ * Returns the remote description.
+ *
+ * @returns {object} The remote description
+ */
+ get remoteDescription() {
+ return this._pc.remoteDescription;
+ },
+
+ /**
+ * Returns the signaling state.
+ *
+ * @returns {object} The local description
+ */
+ get signalingState() {
+ return this._pc.signalingState;
+ },
+ /**
+ * Returns the ICE connection state.
+ *
+ * @returns {object} The local description
+ */
+ get iceConnectionState() {
+ return this._pc.iceConnectionState;
+ },
+
+ setIdentityProvider(provider, options) {
+ this._pc.setIdentityProvider(provider, options);
+ },
+
+ elementPrefix: direction => {
+ return [this.label, direction].join("_");
+ },
+
+ getMediaElementForTrack(track, direction) {
+ var prefix = this.elementPrefix(direction);
+ return getMediaElementForTrack(track, prefix);
+ },
+
+ createMediaElementForTrack(track, direction) {
+ var prefix = this.elementPrefix(direction);
+ return createMediaElementForTrack(track, prefix);
+ },
+
+ ensureMediaElement(track, direction) {
+ var prefix = this.elementPrefix(direction);
+ var element = this.getMediaElementForTrack(track, direction);
+ if (!element) {
+ element = this.createMediaElementForTrack(track, direction);
+ if (direction == "local") {
+ this.localMediaElements.push(element);
+ } else if (direction == "remote") {
+ this.remoteMediaElements.push(element);
+ }
+ }
+
+ // We do this regardless, because sometimes we end up with a new stream with
+ // an old id (ie; the rollback tests cause the same stream to be added
+ // twice)
+ element.srcObject = new MediaStream([track]);
+ element.play();
+ },
+
+ addSendStream(stream) {
+ // The PeerConnection will not necessarily know about this stream
+ // automatically, because replaceTrack is not told about any streams the
+ // new track might be associated with. Only content really knows.
+ this._sendStreams.push(stream);
+ },
+
+ getStreamForSendTrack(track) {
+ return this._sendStreams.find(str => str.getTrackById(track.id));
+ },
+
+ getStreamForRecvTrack(track) {
+ return this._pc.getRemoteStreams().find(s => !!s.getTrackById(track.id));
+ },
+
+ /**
+ * Attaches a local track to this RTCPeerConnection using
+ * RTCPeerConnection.addTrack().
+ *
+ * Also creates a media element playing a MediaStream containing all
+ * tracks that have been added to `stream` using `attachLocalTrack()`.
+ *
+ * @param {MediaStreamTrack} track
+ * MediaStreamTrack to handle
+ * @param {MediaStream} stream
+ * MediaStream to use as container for `track` on remote side
+ */
+ attachLocalTrack(track, stream) {
+ info("Got a local " + track.kind + " track");
+
+ this.expectNegotiationNeeded();
+ var sender = this._pc.addTrack(track, stream);
+ is(sender.track, track, "addTrack returns sender");
+ is(
+ this._pc.getSenders().pop(),
+ sender,
+ "Sender should be the last element in getSenders()"
+ );
+
+ ok(track.id, "track has id");
+ ok(track.kind, "track has kind");
+ ok(stream.id, "stream has id");
+ this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id });
+ this.addSendStream(stream);
+
+ // This will create one media element per track, which might not be how
+ // we set up things with the RTCPeerConnection. It's the only way
+ // we can ensure all sent tracks are flowing however.
+ this.ensureMediaElement(track, "local");
+
+ return this.observedNegotiationNeeded;
+ },
+
+ /**
+ * Callback when we get local media. Also an appropriate HTML media element
+ * will be created and added to the content node.
+ *
+ * @param {MediaStream} stream
+ * Media stream to handle
+ */
+ attachLocalStream(stream, useAddTransceiver) {
+ info("Got local media stream: (" + stream.id + ")");
+
+ this.expectNegotiationNeeded();
+ if (useAddTransceiver) {
+ info("Using addTransceiver (on PC).");
+ stream.getTracks().forEach(track => {
+ var transceiver = this._pc.addTransceiver(track, { streams: [stream] });
+ is(transceiver.sender.track, track, "addTransceiver returns sender");
+ });
+ }
+ // In order to test both the addStream and addTrack APIs, we do half one
+ // way, half the other, at random.
+ else if (Math.random() < 0.5) {
+ info("Using addStream.");
+ this._pc.addStream(stream);
+ ok(
+ this._pc
+ .getSenders()
+ .find(sender => sender.track == stream.getTracks()[0]),
+ "addStream returns sender"
+ );
+ } else {
+ info("Using addTrack (on PC).");
+ stream.getTracks().forEach(track => {
+ var sender = this._pc.addTrack(track, stream);
+ is(sender.track, track, "addTrack returns sender");
+ });
+ }
+
+ this.addSendStream(stream);
+
+ stream.getTracks().forEach(track => {
+ ok(track.id, "track has id");
+ ok(track.kind, "track has kind");
+ const sender = this._pc.getSenders().find(s => s.track == track);
+ ok(sender, "track has a sender");
+ this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id });
+ this.ensureMediaElement(track, "local");
+ });
+
+ return this.observedNegotiationNeeded;
+ },
+
+ removeSender(index) {
+ var sender = this._pc.getSenders()[index];
+ this.expectedLocalTrackInfo = this.expectedLocalTrackInfo.filter(
+ i => i.sender != sender
+ );
+ this.expectNegotiationNeeded();
+ this._pc.removeTrack(sender);
+ return this.observedNegotiationNeeded;
+ },
+
+ senderReplaceTrack(sender, withTrack, stream) {
+ const info = this.expectedLocalTrackInfo.find(i => i.sender == sender);
+ if (!info) {
+ return; // replaceTrack on a null track, probably
+ }
+ info.track = withTrack;
+ this.addSendStream(stream);
+ this.ensureMediaElement(withTrack, "local");
+ return sender.replaceTrack(withTrack);
+ },
+
+ async getUserMedia(constraints) {
+ SpecialPowers.wrap(document).notifyUserGestureActivation();
+ var stream = await getUserMedia(constraints);
+ if (constraints.audio) {
+ stream.getAudioTracks().forEach(track => {
+ info(
+ this +
+ " gUM local stream " +
+ stream.id +
+ " with audio track " +
+ track.id
+ );
+ });
+ }
+ if (constraints.video) {
+ stream.getVideoTracks().forEach(track => {
+ info(
+ this +
+ " gUM local stream " +
+ stream.id +
+ " with video track " +
+ track.id
+ );
+ });
+ }
+ return stream;
+ },
+
+ /**
+ * Requests all the media streams as specified in the constrains property.
+ *
+ * @param {array} constraintsList
+ * Array of constraints for GUM calls
+ */
+ getAllUserMedia(constraintsList) {
+ if (constraintsList.length === 0) {
+ info("Skipping GUM: no UserMedia requested");
+ return Promise.resolve();
+ }
+
+ info("Get " + constraintsList.length + " local streams");
+ return Promise.all(
+ constraintsList.map(constraints => this.getUserMedia(constraints))
+ );
+ },
+
+ async getAllUserMediaAndAddStreams(constraintsList) {
+ var streams = await this.getAllUserMedia(constraintsList);
+ if (!streams) {
+ return;
+ }
+ return Promise.all(streams.map(stream => this.attachLocalStream(stream)));
+ },
+
+ async getAllUserMediaAndAddTransceivers(constraintsList) {
+ var streams = await this.getAllUserMedia(constraintsList);
+ if (!streams) {
+ return;
+ }
+ return Promise.all(
+ streams.map(stream => this.attachLocalStream(stream, true))
+ );
+ },
+
+ /**
+ * Create a new data channel instance. Also creates a promise called
+ * `this.nextDataChannel` that resolves when the next data channel arrives.
+ */
+ expectDataChannel(message) {
+ this.nextDataChannel = new Promise(resolve => {
+ this.ondatachannel = e => {
+ ok(e.channel, message);
+ is(
+ e.channel.readyState,
+ "open",
+ "data channel in 'open' after 'ondatachannel'"
+ );
+ resolve(e.channel);
+ };
+ });
+ },
+
+ /**
+ * Create a new data channel instance
+ *
+ * @param {Object} options
+ * Options which get forwarded to nsIPeerConnection.createDataChannel
+ * @returns {DataChannelWrapper} The created data channel
+ */
+ createDataChannel(options) {
+ var label = "channel_" + this.dataChannels.length;
+ info(this + ": Create data channel '" + label);
+
+ if (!this.dataChannels.length) {
+ this.expectNegotiationNeeded();
+ }
+ var channel = this._pc.createDataChannel(label, options);
+ is(channel.readyState, "connecting", "initial readyState is 'connecting'");
+ var wrapper = new DataChannelWrapper(channel, this);
+ this.dataChannels.push(wrapper);
+ return wrapper;
+ },
+
+ /**
+ * Creates an offer and automatically handles the failure case.
+ */
+ createOffer() {
+ return this._pc.createOffer(this.offerOptions).then(offer => {
+ info("Got offer: " + JSON.stringify(offer));
+ // note: this might get updated through ICE gathering
+ this._latest_offer = offer;
+ return offer;
+ });
+ },
+
+ /**
+ * Creates an answer and automatically handles the failure case.
+ */
+ createAnswer() {
+ return this._pc.createAnswer().then(answer => {
+ info(this + ": Got answer: " + JSON.stringify(answer));
+ this._last_answer = answer;
+ return answer;
+ });
+ },
+
+ /**
+ * Sets the local description and automatically handles the failure case.
+ *
+ * @param {object} desc
+ * RTCSessionDescriptionInit for the local description request
+ */
+ setLocalDescription(desc) {
+ this.observedNegotiationNeeded = undefined;
+ return this._pc.setLocalDescription(desc).then(() => {
+ info(this + ": Successfully set the local description");
+ });
+ },
+
+ /**
+ * Tries to set the local description and expect failure. Automatically
+ * causes the test case to fail if the call succeeds.
+ *
+ * @param {object} desc
+ * RTCSessionDescriptionInit for the local description request
+ * @returns {Promise}
+ * A promise that resolves to the expected error
+ */
+ setLocalDescriptionAndFail(desc) {
+ return this._pc
+ .setLocalDescription(desc)
+ .then(
+ generateErrorCallback("setLocalDescription should have failed."),
+ err => {
+ info(this + ": As expected, failed to set the local description");
+ return err;
+ }
+ );
+ },
+
+ /**
+ * Sets the remote description and automatically handles the failure case.
+ *
+ * @param {object} desc
+ * RTCSessionDescriptionInit for the remote description request
+ */
+ setRemoteDescription(desc) {
+ this.observedNegotiationNeeded = undefined;
+ // This has to be done before calling sRD, otherwise a candidate in flight
+ // could end up in the PC's operations queue before sRD resolves.
+ if (desc.type == "rollback") {
+ this.holdIceCandidates = new Promise(
+ r => (this.releaseIceCandidates = r)
+ );
+ }
+ return this._pc.setRemoteDescription(desc).then(() => {
+ info(this + ": Successfully set remote description");
+ if (desc.type != "rollback") {
+ this.releaseIceCandidates();
+ }
+ });
+ },
+
+ /**
+ * Tries to set the remote description and expect failure. Automatically
+ * causes the test case to fail if the call succeeds.
+ *
+ * @param {object} desc
+ * RTCSessionDescriptionInit for the remote description request
+ * @returns {Promise}
+ * a promise that resolve to the returned error
+ */
+ setRemoteDescriptionAndFail(desc) {
+ return this._pc
+ .setRemoteDescription(desc)
+ .then(
+ generateErrorCallback("setRemoteDescription should have failed."),
+ err => {
+ info(this + ": As expected, failed to set the remote description");
+ return err;
+ }
+ );
+ },
+
+ /**
+ * Registers a callback for the signaling state change and
+ * appends the new state to an array for logging it later.
+ */
+ logSignalingState() {
+ this.signalingStateLog = [this._pc.signalingState];
+ this._pc.addEventListener("signalingstatechange", e => {
+ var newstate = this._pc.signalingState;
+ var oldstate = this.signalingStateLog[this.signalingStateLog.length - 1];
+ if (Object.keys(signalingStateTransitions).includes(oldstate)) {
+ ok(
+ signalingStateTransitions[oldstate].includes(newstate),
+ this +
+ ": legal signaling state transition from " +
+ oldstate +
+ " to " +
+ newstate
+ );
+ } else {
+ ok(
+ false,
+ this +
+ ": old signaling state " +
+ oldstate +
+ " missing in signaling transition array"
+ );
+ }
+ this.signalingStateLog.push(newstate);
+ });
+ },
+
+ isTrackOnPC(track) {
+ return !!this.getStreamForRecvTrack(track);
+ },
+
+ allExpectedTracksAreObserved(expected, observed) {
+ return Object.keys(expected).every(trackId => observed[trackId]);
+ },
+
+ setupStreamEventHandlers(stream) {
+ const myTrackIds = new Set(stream.getTracks().map(t => t.id));
+
+ stream.addEventListener("addtrack", ({ track }) => {
+ ok(
+ !myTrackIds.has(track.id),
+ "Duplicate addtrack callback: " +
+ `stream id=${stream.id} track id=${track.id}`
+ );
+ myTrackIds.add(track.id);
+ // addtrack events happen before track events, so the track callback hasn't
+ // heard about this yet.
+ let streams = this.remoteStreamsByTrackId.get(track.id);
+ ok(
+ !streams || !streams.has(stream.id),
+ `In addtrack for stream id=${stream.id}` +
+ `there should not have been a track event for track id=${track.id} ` +
+ " containing this stream yet."
+ );
+ ok(
+ stream.getTracks().includes(track),
+ "In addtrack, stream id=" +
+ `${stream.id} should already contain track id=${track.id}`
+ );
+ });
+
+ stream.addEventListener("removetrack", ({ track }) => {
+ ok(
+ myTrackIds.has(track.id),
+ "Duplicate removetrack callback: " +
+ `stream id=${stream.id} track id=${track.id}`
+ );
+ myTrackIds.delete(track.id);
+ // Also remove the association from remoteStreamsByTrackId
+ const streams = this.remoteStreamsByTrackId.get(track.id);
+ ok(
+ streams,
+ `In removetrack for stream id=${stream.id}, track id=` +
+ `${track.id} should have had a track callback for the stream.`
+ );
+ streams.delete(stream.id);
+ ok(
+ !stream.getTracks().includes(track),
+ "In removetrack, stream id=" +
+ `${stream.id} should not contain track id=${track.id}`
+ );
+ });
+ },
+
+ setupTrackEventHandler() {
+ this._pc.addEventListener("track", ({ track, streams }) => {
+ info(`${this}: 'ontrack' event fired for ${track.id}`);
+ ok(this.isTrackOnPC(track), `Found track ${track.id}`);
+
+ let gratuitousEvent = true;
+ let streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id);
+ if (!streamsContainingTrack) {
+ gratuitousEvent = false; // Told us about a new track
+ this.remoteStreamsByTrackId.set(track.id, new Set());
+ streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id);
+ }
+
+ for (const stream of streams) {
+ ok(
+ stream.getTracks().includes(track),
+ `In track event, track id=${track.id}` +
+ ` should already be in stream id=${stream.id}`
+ );
+
+ if (!streamsContainingTrack.has(stream.id)) {
+ gratuitousEvent = false; // Told us about a new stream
+ streamsContainingTrack.add(stream.id);
+ this.setupStreamEventHandlers(stream);
+ }
+ }
+
+ ok(!gratuitousEvent, "track event told us something new");
+
+ // So far, we've verified consistency between the current state of the
+ // streams, addtrack/removetrack events on the streams, and track events
+ // on the peerconnection. We have also verified that we have not gotten
+ // any gratuitous events. We have not done anything to verify that the
+ // current state of affairs matches what we were expecting it to.
+
+ this.ensureMediaElement(track, "remote");
+ });
+ },
+
+ /**
+ * Either adds a given ICE candidate right away or stores it to be added
+ * later, depending on the state of the PeerConnection.
+ *
+ * @param {object} candidate
+ * The RTCIceCandidate to be added or stored
+ */
+ storeOrAddIceCandidate(candidate) {
+ this._remote_ice_candidates.push(candidate);
+ if (this.signalingState === "closed") {
+ info("Received ICE candidate for closed PeerConnection - discarding");
+ return;
+ }
+ this.holdIceCandidates
+ .then(() => {
+ info(this + ": adding ICE candidate " + JSON.stringify(candidate));
+ return this._pc.addIceCandidate(candidate);
+ })
+ .then(() => ok(true, this + " successfully added an ICE candidate"))
+ .catch(e =>
+ // The onicecandidate callback runs independent of the test steps
+ // and therefore errors thrown from in there don't get caught by the
+ // race of the Promises around our test steps.
+ // Note: as long as we are queuing ICE candidates until the success
+ // of sRD() this should never ever happen.
+ ok(false, this + " adding ICE candidate failed with: " + e.message)
+ );
+ },
+
+ /**
+ * Registers a callback for the ICE connection state change and
+ * appends the new state to an array for logging it later.
+ */
+ logIceConnectionState() {
+ this.iceConnectionLog = [this._pc.iceConnectionState];
+ this.ice_connection_callbacks.logIceStatus = () => {
+ var newstate = this._pc.iceConnectionState;
+ var oldstate = this.iceConnectionLog[this.iceConnectionLog.length - 1];
+ if (Object.keys(iceStateTransitions).includes(oldstate)) {
+ if (this.iceCheckingRestartExpected) {
+ is(
+ newstate,
+ "checking",
+ "iceconnectionstate event '" +
+ newstate +
+ "' matches expected state 'checking'"
+ );
+ this.iceCheckingRestartExpected = false;
+ } else if (this.iceCheckingIceRollbackExpected) {
+ is(
+ newstate,
+ "connected",
+ "iceconnectionstate event '" +
+ newstate +
+ "' matches expected state 'connected'"
+ );
+ this.iceCheckingIceRollbackExpected = false;
+ } else {
+ ok(
+ iceStateTransitions[oldstate].includes(newstate),
+ this +
+ ": legal ICE state transition from " +
+ oldstate +
+ " to " +
+ newstate
+ );
+ }
+ } else {
+ ok(
+ false,
+ this +
+ ": old ICE state " +
+ oldstate +
+ " missing in ICE transition array"
+ );
+ }
+ this.iceConnectionLog.push(newstate);
+ };
+ },
+
+ /**
+ * Resets the ICE connected Promise and allows ICE connection state monitoring
+ * to go backwards to 'checking'.
+ */
+ expectIceChecking() {
+ this.iceCheckingRestartExpected = true;
+ this.iceConnected = new Promise((resolve, reject) => {
+ this.iceConnectedResolve = resolve;
+ this.iceConnectedReject = reject;
+ });
+ },
+
+ /**
+ * Waits for ICE to either connect or fail.
+ *
+ * @returns {Promise}
+ * resolves when connected, rejects on failure
+ */
+ waitForIceConnected() {
+ return this.iceConnected;
+ },
+
+ /**
+ * Setup a onicecandidate handler
+ *
+ * @param {object} test
+ * A PeerConnectionTest object to which the ice candidates gets
+ * forwarded.
+ */
+ setupIceCandidateHandler(test, candidateHandler) {
+ candidateHandler = candidateHandler || test.iceCandidateHandler.bind(test);
+
+ var resolveEndOfTrickle;
+ this.endOfTrickleIce = new Promise(r => (resolveEndOfTrickle = r));
+ this.holdIceCandidates = new Promise(r => (this.releaseIceCandidates = r));
+
+ this._pc.onicecandidate = anEvent => {
+ if (!anEvent.candidate) {
+ this._pc.onicecandidate = () =>
+ ok(
+ false,
+ this.label + " received ICE candidate after end of trickle"
+ );
+ info(this.label + ": received end of trickle ICE event");
+ ok(
+ this._pc.iceGatheringState === "complete",
+ "ICE gathering state has reached complete"
+ );
+ resolveEndOfTrickle(this.label);
+ return;
+ }
+
+ info(
+ this.label + ": iceCandidate = " + JSON.stringify(anEvent.candidate)
+ );
+ ok(anEvent.candidate.sdpMid.length, "SDP mid not empty");
+ ok(
+ anEvent.candidate.usernameFragment.length,
+ "usernameFragment not empty"
+ );
+
+ ok(
+ typeof anEvent.candidate.sdpMLineIndex === "number",
+ "SDP MLine Index needs to exist"
+ );
+ this._local_ice_candidates.push(anEvent.candidate);
+ candidateHandler(this.label, anEvent.candidate);
+ };
+ },
+
+ checkLocalMediaTracks() {
+ info(
+ `${this}: Checking local tracks ${JSON.stringify(
+ this.expectedLocalTrackInfo
+ )}`
+ );
+ const sendersWithTrack = this._pc.getSenders().filter(({ track }) => track);
+ is(
+ sendersWithTrack.length,
+ this.expectedLocalTrackInfo.length,
+ "The number of senders with a track should be equal to the number of " +
+ "expected local tracks."
+ );
+
+ // expectedLocalTrackInfo is in the same order that the tracks were added, and
+ // so should the output of getSenders.
+ this.expectedLocalTrackInfo.forEach((info, i) => {
+ const sender = sendersWithTrack[i];
+ is(sender, info.sender, `Sender ${i} should match`);
+ is(sender.track, info.track, `Track ${i} should match`);
+ });
+ },
+
+ /**
+ * Checks that we are getting the media tracks we expect.
+ */
+ checkMediaTracks() {
+ this.checkLocalMediaTracks();
+ },
+
+ checkLocalMsids() {
+ const sdp = this.localDescription.sdp;
+ const msections = sdputils.getMSections(sdp);
+ const expectedStreamIdCounts = new Map();
+ for (const { track, sender, streamId } of this.expectedLocalTrackInfo) {
+ const transceiver = this._pc
+ .getTransceivers()
+ .find(t => t.sender == sender);
+ ok(transceiver, "There should be a transceiver for each sender");
+ if (transceiver.mid) {
+ const midFinder = new RegExp(`^a=mid:${transceiver.mid}$`, "m");
+ const msection = msections.find(m => m.match(midFinder));
+ ok(
+ msection,
+ `There should be a media section for mid = ${transceiver.mid}`
+ );
+ ok(
+ msection.startsWith(`m=${track.kind}`),
+ `Media section should be of type ${track.kind}`
+ );
+ const msidFinder = new RegExp(`^a=msid:${streamId} \\S+$`, "m");
+ ok(
+ msection.match(msidFinder),
+ `Should find a=msid:${streamId} in media section` +
+ " (with any track id for now)"
+ );
+ const count = expectedStreamIdCounts.get(streamId) || 0;
+ expectedStreamIdCounts.set(streamId, count + 1);
+ }
+ }
+
+ // Check for any unexpected msids.
+ const allMsids = sdp.match(new RegExp("^a=msid:\\S+", "mg"));
+ if (!allMsids) {
+ return;
+ }
+ const allStreamIds = allMsids.map(msidAttr =>
+ msidAttr.replace("a=msid:", "")
+ );
+ allStreamIds.forEach(id => {
+ const count = expectedStreamIdCounts.get(id);
+ ok(count, `Unexpected stream id ${id} found in local description.`);
+ if (count) {
+ expectedStreamIdCounts.set(id, count - 1);
+ }
+ });
+ },
+
+ /**
+ * Check that media flow is present for the given media element by checking
+ * that it reaches ready state HAVE_ENOUGH_DATA and progresses time further
+ * than the start of the check.
+ *
+ * This ensures, that the stream being played is producing
+ * data and, in case it contains a video track, that at least one video frame
+ * has been displayed.
+ *
+ * @param {HTMLMediaElement} track
+ * The media element to check
+ * @returns {Promise}
+ * A promise that resolves when media data is flowing.
+ */
+ waitForMediaElementFlow(element) {
+ info("Checking data flow for element: " + element.id);
+ is(
+ element.ended,
+ !element.srcObject.active,
+ "Element ended should be the inverse of the MediaStream's active state"
+ );
+ if (element.ended) {
+ is(
+ element.readyState,
+ element.HAVE_CURRENT_DATA,
+ "Element " + element.id + " is ended and should have had data"
+ );
+ return Promise.resolve();
+ }
+
+ const haveEnoughData = (
+ element.readyState == element.HAVE_ENOUGH_DATA
+ ? Promise.resolve()
+ : haveEvent(
+ element,
+ "canplay",
+ wait(60000, new Error("Timeout for element " + element.id))
+ )
+ ).then(_ => info("Element " + element.id + " has enough data."));
+
+ const startTime = element.currentTime;
+ const timeProgressed = timeout(
+ listenUntil(element, "timeupdate", _ => element.currentTime > startTime),
+ 60000,
+ "Element " + element.id + " should progress currentTime"
+ ).then();
+
+ return Promise.all([haveEnoughData, timeProgressed]);
+ },
+
+ /**
+ * Wait for RTP packet flow for the given MediaStreamTrack.
+ *
+ * @param {object} track
+ * A MediaStreamTrack to wait for data flow on.
+ * @returns {Promise}
+ * Returns a promise which yields a StatsReport object with RTP stats.
+ */
+ async _waitForRtpFlow(target, rtpType) {
+ const { track } = target;
+ info(`_waitForRtpFlow(${track.id}, ${rtpType})`);
+ const packets = `packets${rtpType == "outbound-rtp" ? "Sent" : "Received"}`;
+
+ const retryInterval = 500; // Time between stats checks
+ const timeout = 30000; // Timeout in ms
+ const retries = timeout / retryInterval;
+
+ for (let i = 0; i < retries; i++) {
+ info(`Checking ${rtpType} for ${track.kind} track ${track.id} try ${i}`);
+ for (const rtp of (await target.getStats()).values()) {
+ if (rtp.type != rtpType) {
+ continue;
+ }
+ if (rtp.kind != track.kind) {
+ continue;
+ }
+
+ const numPackets = rtp[packets];
+ info(`Track ${track.id} has ${numPackets} ${packets}.`);
+ if (!numPackets) {
+ continue;
+ }
+
+ ok(true, `RTP flowing for ${track.kind} track ${track.id}`);
+ return;
+ }
+ await wait(retryInterval);
+ }
+ throw new Error(
+ `Checking stats for track ${track.id} timed out after ${timeout} ms`
+ );
+ },
+
+ /**
+ * Wait for inbound RTP packet flow for the given MediaStreamTrack.
+ *
+ * @param {object} receiver
+ * An RTCRtpReceiver to wait for data flow on.
+ * @returns {Promise}
+ * Returns a promise that resolves once data is flowing.
+ */
+ async waitForInboundRtpFlow(receiver) {
+ return this._waitForRtpFlow(receiver, "inbound-rtp");
+ },
+
+ /**
+ * Wait for outbound RTP packet flow for the given MediaStreamTrack.
+ *
+ * @param {object} sender
+ * An RTCRtpSender to wait for data flow on.
+ * @returns {Promise}
+ * Returns a promise that resolves once data is flowing.
+ */
+ async waitForOutboundRtpFlow(sender) {
+ return this._waitForRtpFlow(sender, "outbound-rtp");
+ },
+
+ getExpectedActiveReceivers() {
+ return this._pc
+ .getTransceivers()
+ .filter(
+ t =>
+ !t.stopped &&
+ t.currentDirection &&
+ t.currentDirection != "inactive" &&
+ t.currentDirection != "sendonly"
+ )
+ .filter(({ receiver }) => receiver.track)
+ .map(({ mid, currentDirection, receiver }) => {
+ info(
+ `Found transceiver that should be receiving RTP: mid=${mid}` +
+ ` currentDirection=${currentDirection}` +
+ ` kind=${receiver.track.kind} track-id=${receiver.track.id}`
+ );
+ return receiver;
+ });
+ },
+
+ getExpectedSenders() {
+ return this._pc.getSenders().filter(({ track }) => track);
+ },
+
+ /**
+ * Wait for presence of video flow on all media elements and rtp flow on
+ * all sending and receiving track involved in this test.
+ *
+ * @returns {Promise}
+ * A promise that resolves when media flows for all elements and tracks
+ */
+ waitForMediaFlow() {
+ const receivers = this.getExpectedActiveReceivers();
+ return Promise.all([
+ ...this.localMediaElements.map(el => this.waitForMediaElementFlow(el)),
+ ...this.remoteMediaElements
+ .filter(({ srcObject }) =>
+ receivers.some(({ track }) =>
+ srcObject.getTracks().some(t => t == track)
+ )
+ )
+ .map(el => this.waitForMediaElementFlow(el)),
+ ...receivers.map(receiver => this.waitForInboundRtpFlow(receiver)),
+ ...this.getExpectedSenders().map(sender =>
+ this.waitForOutboundRtpFlow(sender)
+ ),
+ ]);
+ },
+
+ /**
+ * Check that correct audio (typically a flat tone) is flowing to this
+ * PeerConnection for each transceiver that should be receiving. Uses
+ * WebAudio AnalyserNodes to compare input and output audio data in the
+ * frequency domain.
+ *
+ * @param {object} from
+ * A PeerConnectionWrapper whose audio RTPSender we use as source for
+ * the audio flow check.
+ * @returns {Promise}
+ * A promise that resolves when we're receiving the tone/s from |from|.
+ */
+ async checkReceivingToneFrom(
+ audiocontext,
+ from,
+ cancel = wait(60000, new Error("Tone not detected"))
+ ) {
+ let localTransceivers = this._pc
+ .getTransceivers()
+ .filter(t => t.mid)
+ .filter(t => t.receiver.track.kind == "audio")
+ .sort((t1, t2) => t1.mid < t2.mid);
+ let remoteTransceivers = from._pc
+ .getTransceivers()
+ .filter(t => t.mid)
+ .filter(t => t.receiver.track.kind == "audio")
+ .sort((t1, t2) => t1.mid < t2.mid);
+
+ is(
+ localTransceivers.length,
+ remoteTransceivers.length,
+ "Same number of associated audio transceivers on remote and local."
+ );
+
+ for (let i = 0; i < localTransceivers.length; i++) {
+ is(
+ localTransceivers[i].mid,
+ remoteTransceivers[i].mid,
+ "Transceivers at index " + i + " have the same mid."
+ );
+
+ if (!remoteTransceivers[i].sender.track) {
+ continue;
+ }
+
+ if (
+ remoteTransceivers[i].currentDirection == "recvonly" ||
+ remoteTransceivers[i].currentDirection == "inactive"
+ ) {
+ continue;
+ }
+
+ let sendTrack = remoteTransceivers[i].sender.track;
+ let inputElem = from.getMediaElementForTrack(sendTrack, "local");
+ ok(
+ inputElem,
+ "Remote wrapper should have a media element for track id " +
+ sendTrack.id
+ );
+ let inputAudioStream = from.getStreamForSendTrack(sendTrack);
+ ok(
+ inputAudioStream,
+ "Remote wrapper should have a stream for track id " + sendTrack.id
+ );
+ let inputAnalyser = new AudioStreamAnalyser(
+ audiocontext,
+ inputAudioStream
+ );
+
+ let recvTrack = localTransceivers[i].receiver.track;
+ let outputAudioStream = this.getStreamForRecvTrack(recvTrack);
+ ok(
+ outputAudioStream,
+ "Local wrapper should have a stream for track id " + recvTrack.id
+ );
+ let outputAnalyser = new AudioStreamAnalyser(
+ audiocontext,
+ outputAudioStream
+ );
+
+ let error = null;
+ cancel.then(e => (error = e));
+
+ let indexOfMax = data =>
+ data.reduce((max, val, i) => (val >= data[max] ? i : max), 0);
+
+ await outputAnalyser.waitForAnalysisSuccess(() => {
+ if (error) {
+ throw error;
+ }
+
+ let inputData = inputAnalyser.getByteFrequencyData();
+ let outputData = outputAnalyser.getByteFrequencyData();
+
+ let inputMax = indexOfMax(inputData);
+ let outputMax = indexOfMax(outputData);
+ info(
+ `Comparing maxima; input[${inputMax}] = ${inputData[inputMax]},` +
+ ` output[${outputMax}] = ${outputData[outputMax]}`
+ );
+ if (!inputData[inputMax] || !outputData[outputMax]) {
+ return false;
+ }
+
+ // When the input and output maxima are within reasonable distance (2% of
+ // total length, which means ~10 for length 512) from each other, we can
+ // be sure that the input tone has made it through the peer connection.
+ info(`input data length: ${inputData.length}`);
+ return Math.abs(inputMax - outputMax) < inputData.length * 0.02;
+ });
+ }
+ },
+
+ /**
+ * Check that stats are present by checking for known stats.
+ */
+ async getStats(selector) {
+ const stats = await this._pc.getStats(selector);
+ const dict = {};
+ for (const [k, v] of stats.entries()) {
+ dict[k] = v;
+ }
+ info(`${this}: Got stats: ${JSON.stringify(dict)}`);
+ return stats;
+ },
+
+ /**
+ * Checks that we are getting the media streams we expect.
+ *
+ * @param {object} stats
+ * The stats to check from this PeerConnectionWrapper
+ */
+ checkStats(stats) {
+ const isRemote = ({ type }) =>
+ ["remote-outbound-rtp", "remote-inbound-rtp"].includes(type);
+ var counters = {};
+ for (let [key, res] of stats) {
+ info("Checking stats for " + key + " : " + res);
+ // validate stats
+ ok(res.id == key, "Coherent stats id");
+ const now = performance.timeOrigin + performance.now();
+ const minimum = performance.timeOrigin;
+ const type = isRemote(res) ? "rtcp" : "rtp";
+ ok(
+ res.timestamp >= minimum,
+ `Valid ${type} timestamp ${res.timestamp} >= ${minimum} (
+ ${res.timestamp - minimum} ms)`
+ );
+ ok(
+ res.timestamp <= now,
+ `Valid ${type} timestamp ${res.timestamp} <= ${now} (
+ ${res.timestamp - now} ms)`
+ );
+ if (isRemote(res)) {
+ continue;
+ }
+ counters[res.type] = (counters[res.type] || 0) + 1;
+
+ switch (res.type) {
+ case "inbound-rtp":
+ case "outbound-rtp":
+ {
+ // Inbound tracks won't have an ssrc if RTP is not flowing.
+ // (eg; negotiated inactive)
+ ok(
+ res.ssrc || res.type == "inbound-rtp",
+ "Outbound RTP stats has an ssrc."
+ );
+
+ if (res.ssrc) {
+ // ssrc is a 32 bit number returned as an unsigned long
+ ok(!/[^0-9]/.test(`${res.ssrc}`), "SSRC is numeric");
+ ok(parseInt(res.ssrc) < Math.pow(2, 32), "SSRC is within limits");
+ }
+
+ if (res.type == "outbound-rtp") {
+ ok(res.packetsSent !== undefined, "Rtp packetsSent");
+ // We assume minimum payload to be 1 byte (guess from RFC 3550)
+ ok(res.bytesSent >= res.packetsSent, "Rtp bytesSent");
+ } else {
+ ok(res.packetsReceived !== undefined, "Rtp packetsReceived");
+ ok(res.bytesReceived >= res.packetsReceived, "Rtp bytesReceived");
+ }
+ if (res.remoteId) {
+ var rem = stats.get(res.remoteId);
+ ok(isRemote(rem), "Remote is rtcp");
+ ok(rem.localId == res.id, "Remote backlink match");
+ if (res.type == "outbound-rtp") {
+ ok(rem.type == "remote-inbound-rtp", "Rtcp is inbound");
+ if (rem.packetsLost) {
+ ok(
+ rem.packetsLost >= 0,
+ "Rtcp packetsLost " + rem.packetsLost + " >= 0"
+ );
+ ok(
+ rem.packetsLost < 1000,
+ "Rtcp packetsLost " + rem.packetsLost + " < 1000"
+ );
+ }
+ if (!this.disableRtpCountChecking) {
+ // no guarantee which one is newer!
+ // Note: this must change when we add a timestamp field to remote RTCP reports
+ // and make rem.timestamp be the reception time
+ if (res.timestamp < rem.timestamp) {
+ info(
+ "REVERSED timestamps: rec:" +
+ rem.packetsReceived +
+ " time:" +
+ rem.timestamp +
+ " sent:" +
+ res.packetsSent +
+ " time:" +
+ res.timestamp
+ );
+ }
+ }
+ if (rem.jitter) {
+ ok(rem.jitter >= 0, "Rtcp jitter " + rem.jitter + " >= 0");
+ ok(rem.jitter < 5, "Rtcp jitter " + rem.jitter + " < 5 sec");
+ }
+ if (rem.roundTripTime) {
+ ok(
+ rem.roundTripTime >= 0,
+ "Rtcp rtt " + rem.roundTripTime + " >= 0"
+ );
+ ok(
+ rem.roundTripTime < 60,
+ "Rtcp rtt " + rem.roundTripTime + " < 1 min"
+ );
+ }
+ } else {
+ ok(rem.type == "remote-outbound-rtp", "Rtcp is outbound");
+ ok(rem.packetsSent !== undefined, "Rtcp packetsSent");
+ ok(rem.bytesSent !== undefined, "Rtcp bytesSent");
+ }
+ ok(rem.ssrc == res.ssrc, "Remote ssrc match");
+ } else {
+ info("No rtcp info received yet");
+ }
+ }
+ break;
+ }
+ }
+
+ var nin = this._pc.getTransceivers().filter(t => {
+ return (
+ !t.stopped &&
+ t.currentDirection != "inactive" &&
+ t.currentDirection != "sendonly"
+ );
+ }).length;
+ const nout = Object.keys(this.expectedLocalTrackInfo).length;
+ var ndata = this.dataChannels.length;
+
+ // TODO(Bug 957145): Restore stronger inbound-rtp test once Bug 948249 is fixed
+ //is((counters["inbound-rtp"] || 0), nin, "Have " + nin + " inbound-rtp stat(s)");
+ ok(
+ (counters["inbound-rtp"] || 0) >= nin,
+ "Have at least " + nin + " inbound-rtp stat(s) *"
+ );
+
+ is(
+ counters["outbound-rtp"] || 0,
+ nout,
+ "Have " + nout + " outbound-rtp stat(s)"
+ );
+
+ var numLocalCandidates = counters["local-candidate"] || 0;
+ var numRemoteCandidates = counters["remote-candidate"] || 0;
+ // If there are no tracks, there will be no stats either.
+ if (nin + nout + ndata > 0) {
+ ok(numLocalCandidates, "Have local-candidate stat(s)");
+ ok(numRemoteCandidates, "Have remote-candidate stat(s)");
+ } else {
+ is(numLocalCandidates, 0, "Have no local-candidate stats");
+ is(numRemoteCandidates, 0, "Have no remote-candidate stats");
+ }
+ },
+
+ /**
+ * Compares the Ice server configured for this PeerConnectionWrapper
+ * with the ICE candidates received in the RTCP stats.
+ *
+ * @param {object} stats
+ * The stats to be verified for relayed vs. direct connection.
+ */
+ checkStatsIceConnectionType(stats, expectedLocalCandidateType) {
+ let lId;
+ let rId;
+ for (let stat of stats.values()) {
+ if (stat.type == "candidate-pair" && stat.selected) {
+ lId = stat.localCandidateId;
+ rId = stat.remoteCandidateId;
+ break;
+ }
+ }
+ isnot(
+ lId,
+ undefined,
+ "Got local candidate ID " + lId + " for selected pair"
+ );
+ isnot(
+ rId,
+ undefined,
+ "Got remote candidate ID " + rId + " for selected pair"
+ );
+ let lCand = stats.get(lId);
+ let rCand = stats.get(rId);
+ if (!lCand || !rCand) {
+ ok(
+ false,
+ "failed to find candidatepair IDs or stats for local: " +
+ lId +
+ " remote: " +
+ rId
+ );
+ return;
+ }
+
+ info(
+ "checkStatsIceConnectionType verifying: local=" +
+ JSON.stringify(lCand) +
+ " remote=" +
+ JSON.stringify(rCand)
+ );
+ expectedLocalCandidateType = expectedLocalCandidateType || "host";
+ var candidateType = lCand.candidateType;
+ if (lCand.relayProtocol === "tcp" && candidateType === "relay") {
+ candidateType = "relay-tcp";
+ }
+
+ if (lCand.relayProtocol === "tls" && candidateType === "relay") {
+ candidateType = "relay-tls";
+ }
+
+ if (expectedLocalCandidateType === "srflx" && candidateType === "prflx") {
+ // Be forgiving of prflx when expecting srflx, since that can happen due
+ // to timing.
+ candidateType = "srflx";
+ }
+
+ is(
+ candidateType,
+ expectedLocalCandidateType,
+ "Local candidate type is what we expected for selected pair"
+ );
+ },
+
+ /**
+ * Compares amount of established ICE connection according to ICE candidate
+ * pairs in the stats reporting with the expected amount of connection based
+ * on the constraints.
+ *
+ * @param {object} stats
+ * The stats to check for ICE candidate pairs
+ * @param {object} testOptions
+ * The test options object from the PeerConnectionTest
+ */
+ checkStatsIceConnections(stats, testOptions) {
+ var numIceConnections = 0;
+ stats.forEach(stat => {
+ if (stat.type === "candidate-pair" && stat.selected) {
+ numIceConnections += 1;
+ }
+ });
+ info("ICE connections according to stats: " + numIceConnections);
+ isnot(
+ numIceConnections,
+ 0,
+ "Number of ICE connections according to stats is not zero"
+ );
+ if (testOptions.bundle) {
+ if (testOptions.rtcpmux) {
+ is(numIceConnections, 1, "stats reports exactly 1 ICE connection");
+ } else {
+ is(
+ numIceConnections,
+ 2,
+ "stats report exactly 2 ICE connections for media and RTCP"
+ );
+ }
+ } else {
+ var numAudioTransceivers = this._pc
+ .getTransceivers()
+ .filter(transceiver => {
+ return (
+ !transceiver.stopped && transceiver.receiver.track.kind == "audio"
+ );
+ }).length;
+
+ var numVideoTransceivers = this._pc
+ .getTransceivers()
+ .filter(transceiver => {
+ return (
+ !transceiver.stopped && transceiver.receiver.track.kind == "video"
+ );
+ }).length;
+
+ var numExpectedTransports = numAudioTransceivers + numVideoTransceivers;
+ if (!testOptions.rtcpmux) {
+ numExpectedTransports *= 2;
+ }
+
+ if (this.dataChannels.length) {
+ ++numExpectedTransports;
+ }
+
+ info(
+ "expected audio + video + data transports: " + numExpectedTransports
+ );
+ is(
+ numIceConnections,
+ numExpectedTransports,
+ "stats ICE connections matches expected A/V transports"
+ );
+ }
+ },
+
+ expectNegotiationNeeded() {
+ if (!this.observedNegotiationNeeded) {
+ this.observedNegotiationNeeded = new Promise(resolve => {
+ this.onnegotiationneeded = resolve;
+ });
+ }
+ },
+
+ /**
+ * Property-matching function for finding a certain stat in passed-in stats
+ *
+ * @param {object} stats
+ * The stats to check from this PeerConnectionWrapper
+ * @param {object} props
+ * The properties to look for
+ * @returns {boolean} Whether an entry containing all match-props was found.
+ */
+ hasStat(stats, props) {
+ for (let res of stats.values()) {
+ var match = true;
+ for (let prop in props) {
+ if (res[prop] !== props[prop]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Closes the connection
+ */
+ close() {
+ this._pc.close();
+ this.localMediaElements.forEach(e => e.pause());
+ info(this + ": Closed connection.");
+ },
+
+ /**
+ * Returns the string representation of the class
+ *
+ * @returns {String} The string representation
+ */
+ toString() {
+ return "PeerConnectionWrapper (" + this.label + ")";
+ },
+};
+
+// haxx to prevent SimpleTest from failing at window.onload
+function addLoadEvent() {}
+
+function loadScript(...scripts) {
+ return Promise.all(
+ scripts.map(script => {
+ var el = document.createElement("script");
+ if (typeof scriptRelativePath === "string" && script.charAt(0) !== "/") {
+ script = scriptRelativePath + script;
+ }
+ el.src = script;
+ document.head.appendChild(el);
+ return new Promise(r => {
+ el.onload = r;
+ el.onerror = r;
+ });
+ })
+ );
+}
+
+// Ensure SimpleTest.js is loaded before other scripts.
+/* import-globals-from /testing/mochitest/tests/SimpleTest/SimpleTest.js */
+/* import-globals-from head.js */
+/* import-globals-from templates.js */
+/* import-globals-from turnConfig.js */
+/* import-globals-from dataChannel.js */
+/* import-globals-from network.js */
+/* import-globals-from sdpUtils.js */
+
+var scriptsReady = loadScript("/tests/SimpleTest/SimpleTest.js").then(() => {
+ return loadScript(
+ "head.js",
+ "templates.js",
+ "turnConfig.js",
+ "dataChannel.js",
+ "network.js",
+ "sdpUtils.js"
+ );
+});
+
+function createHTML(options) {
+ return scriptsReady.then(() => realCreateHTML(options));
+}
+
+var iceServerWebsocket;
+var iceServersArray = [];
+
+var addTurnsSelfsignedCerts = () => {
+ var gUrl = SimpleTest.getTestFileURL("addTurnsSelfsignedCert.js");
+ var gScript = SpecialPowers.loadChromeScript(gUrl);
+ var certs = [];
+ // If the ICE server is running TURNS, and includes a "cert" attribute in
+ // its JSON, we set up an override that will forgive things like
+ // self-signed for it.
+ iceServersArray.forEach(iceServer => {
+ if (iceServer.hasOwnProperty("cert")) {
+ iceServer.urls.forEach(url => {
+ if (url.startsWith("turns:")) {
+ // Assumes no port or params!
+ certs.push({ cert: iceServer.cert, hostname: url.substr(6) });
+ }
+ });
+ }
+ });
+
+ return new Promise((resolve, reject) => {
+ gScript.addMessageListener("certs-added", () => {
+ resolve();
+ });
+
+ gScript.sendAsyncMessage("add-turns-certs", certs);
+ });
+};
+
+var setupIceServerConfig = useIceServer => {
+ // We disable ICE support for HTTP proxy when using a TURN server, because
+ // mochitest uses a fake HTTP proxy to serve content, which will eat our STUN
+ // packets for TURN TCP.
+ var enableHttpProxy = enable =>
+ SpecialPowers.pushPrefEnv({
+ set: [["media.peerconnection.disable_http_proxy", !enable]],
+ });
+
+ var spawnIceServer = () =>
+ new Promise((resolve, reject) => {
+ iceServerWebsocket = new WebSocket("ws://localhost:8191/");
+ iceServerWebsocket.onopen = event => {
+ info("websocket/process bridge open, starting ICE Server...");
+ iceServerWebsocket.send("iceserver");
+ };
+
+ iceServerWebsocket.onmessage = event => {
+ // The first message will contain the iceServers configuration, subsequent
+ // messages are just logging.
+ info("ICE Server: " + event.data);
+ resolve(event.data);
+ };
+
+ iceServerWebsocket.onerror = () => {
+ reject("ICE Server error: Is the ICE server websocket up?");
+ };
+
+ iceServerWebsocket.onclose = () => {
+ info("ICE Server websocket closed");
+ reject("ICE Server gone before getting configuration");
+ };
+ });
+
+ if (!useIceServer) {
+ info("Skipping ICE Server for this test");
+ return enableHttpProxy(true);
+ }
+
+ return enableHttpProxy(false)
+ .then(spawnIceServer)
+ .then(iceServersStr => {
+ iceServersArray = JSON.parse(iceServersStr);
+ })
+ .then(addTurnsSelfsignedCerts);
+};
+
+async function runNetworkTest(testFunction, fixtureOptions = {}) {
+ let { AppConstants } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+
+ await scriptsReady;
+ await runTestWhenReady(async options => {
+ await startNetworkAndTest();
+ await setupIceServerConfig(fixtureOptions.useIceServer);
+ await testFunction(options);
+ await networkTestFinished();
+ });
+}