You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

3877 lines
129 KiB

/*
* Copyright (c) 2017 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree.
*/
/* eslint-env node */
const chai = require('chai');
const expect = chai.expect;
const sinon = require('sinon');
chai.use(require('dirty-chai'));
chai.use(require('sinon-chai'));
const mockORTC = require('./ortcmock');
const mockGetUserMedia = require('./gummock');
const shimPeerConnection = require('../rtcpeerconnection');
const SDPUtils = require('sdp');
const FINGERPRINT_SHA256 = '00:00:00:00:00:00:00:00:00:00:00:00:00' +
':00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00';
const ICEUFRAG = 'someufrag';
const ICEPWD = 'somelongpwdwithenoughrandomness';
const SDP_BOILERPLATE = 'v=0\r\n' +
'o=- 166855176514521964 2 IN IP4 127.0.0.1\r\n' +
's=-\r\n' +
't=0 0\r\n' +
'a=msid-semantic:WMS *\r\n';
const MINIMAL_AUDIO_MLINE =
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendonly\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=ssrc:1001 cname:some\r\n';
// this detects that we are not running in a browser.
const mockWindow = typeof window === 'undefined';
describe('Edge shim', () => {
let RTCPeerConnection;
beforeEach(() => {
if (mockWindow) {
global.window = {setTimeout};
mockGetUserMedia(window);
mockORTC(window);
global.navigator = window.navigator;
}
RTCPeerConnection = shimPeerConnection(window, 15025);
});
beforeEach(() => {
let streams = [];
let release = () => {
streams.forEach((stream) => {
stream.getTracks().forEach((track) => {
track.stop();
});
});
streams = [];
};
let origGetUserMedia = navigator.getUserMedia.bind(navigator);
navigator.getUserMedia = (constraints, cb, eb) => {
origGetUserMedia(constraints, (stream) => {
streams.push(stream);
if (cb) {
cb.apply(null, [stream]);
}
}, eb);
};
navigator.getUserMedia.restore = () => {
navigator.getUserMedia = origGetUserMedia;
release();
};
let origMediaDevicesGetUserMedia =
navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = (constraints) => {
return origMediaDevicesGetUserMedia(constraints, (stream) => {
streams.push(stream);
return stream;
});
};
navigator.mediaDevices.getUserMedia.restore = () => {
navigator.mediaDevices.getUserMedia = origMediaDevicesGetUserMedia;
release();
};
});
afterEach(() => {
navigator.getUserMedia.restore();
navigator.mediaDevices.getUserMedia.restore();
});
describe('RTCPeerConnection constructor', () => {
it('throws a NotSupportedError when called with ' +
'rtcpMuxPolicy negotiate', () => {
const constructor = () => {
return new RTCPeerConnection({rtcpMuxPolicy: 'negotiate'});
};
expect(constructor).to.throw(/rtcpMuxPolicy/)
.that.has.property('name').that.equals('NotSupportedError');
});
describe('when RTCIceCandidatePoolSize is set', () => {
beforeEach(() => {
sinon.spy(window, 'RTCIceGatherer');
});
afterEach(() => {
window.RTCIceGatherer.restore();
});
it('creates an ICE Gatherer', () => {
new RTCPeerConnection({iceCandidatePoolSize: 1});
expect(window.RTCIceGatherer).to.have.been.calledOnce();
});
// TODO: those tests are convenient because they are sync and
// dont require createOffer-SLD before creating the gatherer.
it('sets default ICETransportPolicy on RTCIceGatherer', () => {
new RTCPeerConnection({iceCandidatePoolSize: 1});
expect(window.RTCIceGatherer).to.have.been.calledWith(sinon.match({
gatherPolicy: 'all'
}));
});
it('sets ICETransportPolicy=all on RTCIceGatherer', () => {
new RTCPeerConnection({iceCandidatePoolSize: 1,
iceTransportPolicy: 'all'});
expect(window.RTCIceGatherer).to.have.been.calledWith(sinon.match({
gatherPolicy: 'all'
}));
});
it('sets ICETransportPolicy=relay on RTCIceGatherer', () => {
new RTCPeerConnection({iceCandidatePoolSize: 1,
iceTransportPolicy: 'relay'});
expect(window.RTCIceGatherer).to.have.been.calledWith(sinon.match({
gatherPolicy: 'relay'
}));
});
});
});
describe('prototype', () => {
['icecandidate', 'addstream', 'removestream', 'track',
'signalingstatechange', 'iceconnectionstatechange',
'icegatheringstatechange', 'negotiationneeded'].forEach((name) => {
it('has on' + name + ' handler', () => {
expect(RTCPeerConnection.prototype)
.to.have.ownPropertyDescriptor('on' + name);
});
});
});
describe('close', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
it('sets the signalingState to closed', () => {
pc.close();
expect(pc.signalingState).to.equal('closed');
});
it('does not fire signalingstatechange', () => {
pc.onsignalingstatechange = sinon.stub();
pc.close();
expect(pc.onsignalingstatechange).not.to.have.been.calledWith();
});
});
describe('setLocalDescription', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
if (pc.signalingState !== 'closed') {
pc.close();
}
});
it('returns a promise', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(done);
});
it('calls the legacy success callback', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer, done, () => {});
});
});
it('throws an InvalidStateError when called after close', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
pc.close();
return pc.setLocalDescription(offer);
})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('throws an InvalidStateError when called after close ' +
'(callback)', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
pc.close();
return pc.setLocalDescription(offer, undefined, (e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
});
it('throws a TypeError when called with an ' +
'unsupported description type', (done) => {
pc.setLocalDescription({type: 'invalid'})
.catch((e) => {
expect(e.name).to.equal('TypeError');
done();
});
});
it('changes the signalingState to have-local-offer', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
expect(pc.localDescription.type).to.equal('offer');
expect(pc.signalingState = 'have-local-offer');
done();
});
});
it('calls the signalingstatechange event', () => {
pc.onsignalingstatechange = sinon.stub();
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
expect(pc.onsignalingstatechange).to.have.been.calledOnce();
});
});
describe('InvalidStateError is thrown when called with', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
it('an offer in signalingState have-remote-offer', (done) => {
pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
return pc.setLocalDescription({type: 'offer'});
})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('an answer in signalingState have-local-offer', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
return pc.setLocalDescription({type: 'answer'});
})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
});
describe('starts emitting ICE candidates', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
describe('calls', () => {
it('the onicecandidate callback', (done) => {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(pc.onicecandidate).to.have.been.calledWith();
done();
}
};
pc.onicecandidate = sinon.stub();
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer))
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
it('the icecandidate event listener', (done) => {
const stub = sinon.stub();
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(stub).to.have.been.calledWith();
done();
}
};
pc.addEventListener('icecandidate', stub);
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer))
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
});
it('updates localDescription.sdp with candidates', (done) => {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(SDPUtils.matchPrefix(pc.localDescription.sdp,
'a=candidate:').length).to.be.above(0);
expect(SDPUtils.matchPrefix(pc.localDescription.sdp,
'a=end-of-candidates')).to.have.length(1);
done();
}
};
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer))
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
it('changes iceGatheringState and emits icegatheringstatechange ' +
'event', (done) => {
let states = [];
pc.addEventListener('icegatheringstatechange', () => {
states.push(pc.iceGatheringState);
if (pc.iceGatheringState === 'complete') {
expect(states.length).to.equal(2);
expect(states).to.contain('gathering');
expect(states).to.contain('complete');
done();
}
});
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer))
.then(() => {
expect(pc.iceGatheringState).to.equal('new');
clock.tick(500);
});
});
it('does not serialize extra parameters in ' +
'RTCICECandidate.toJSON', (done) => {
const candidates = [];
pc.onicecandidate = (e) => {
candidates.push(e.candidate);
};
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
const reserialized = JSON.parse(JSON.stringify(candidates[0]));
expect(reserialized.candidate).to.be.a('string');
expect(reserialized.usernameFragment).to.be.a('string');
expect(reserialized.sdpMid).to.be.a('string');
expect(reserialized.sdpMLineIndex).to.equal(0);
expect(Object.keys(reserialized)).to.have.length(4);
done();
}
};
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer))
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
});
describe('after setRemoteDescription', () => {
beforeEach(() => {
sinon.spy(window.RTCIceTransport.prototype, 'start');
sinon.spy(window.RTCDtlsTransport.prototype, 'start');
});
afterEach(() => {
window.RTCIceTransport.prototype.start.restore();
window.RTCDtlsTransport.prototype.start.restore();
});
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
it('starts the ice transport', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
return pc.setLocalDescription(answer);
})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
expect(iceTransport.start).to.have.been.calledOnce();
expect(iceTransport.start).to.have.been.calledWith(
sinon.match.any,
sinon.match({
usernameFragment: '' + ICEUFRAG + '',
password: '' + ICEPWD + ''
})
);
done();
});
});
it('starts the dtls transport', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
return pc.setLocalDescription(answer);
})
.then(() => {
const receiver = pc.getReceivers()[0];
const dtlsTransport = receiver.transport;
expect(dtlsTransport.start).to.have.been.calledOnce();
expect(dtlsTransport.start).to.have.been.calledWith(
sinon.match({
role: 'auto',
fingerprints: sinon.match([
sinon.match({
algorithm: 'sha-256',
value: FINGERPRINT_SHA256
})
])
})
);
done();
});
});
});
});
describe('setRemoteDescription', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
if (pc.signalingState !== 'closed') {
pc.close();
}
});
it('returns a promise', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(done);
});
it('calls the legacy success callback', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp}, done, () => {});
});
it('changes the signalingState to have-remote-offer', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
expect(pc.signalingState = 'have-remote-offer');
done();
});
});
it('calls the signalingstatechange event', () => {
pc.onsignalingstatechange = sinon.stub();
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
expect(pc.onsignalingstatechange).to.have.been.calledOnce();
});
});
it('throws an InvalidStateError when called after close', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.close();
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('throws an InvalidStateError when called after close ' +
'(callback)', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.close();
pc.setRemoteDescription({type: 'offer', sdp: sdp}, undefined, (e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('throws a TypeError when called with an ' +
'unsupported description type', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'invalid', sdp: sdp})
.catch((e) => {
expect(e.name).to.equal('TypeError');
done();
});
});
it('sets the remoteDescription', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp}, () => {
expect(pc.remoteDescription.type).to.equal('offer');
expect(pc.remoteDescription.sdp).to.equal(sdp);
done();
});
});
describe('when called with an offer containing a track', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
it('triggers onaddstream', (done) => {
pc.onaddstream = function(event) {
const stream = event.stream;
expect(stream.getTracks().length).to.equal(1);
expect(stream.getTracks()[0].kind).to.equal('audio');
done();
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('emits a addstream event', (done) => {
pc.addEventListener('addstream', function(event) {
const stream = event.stream;
expect(stream.getTracks().length).to.equal(1);
expect(stream.getTracks()[0].kind).to.equal('audio');
done();
});
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('triggers ontrack', (done) => {
pc.ontrack = function(event) {
expect(event.track.kind).to.equal('audio');
expect(event.receiver);
expect(event.streams.length).to.equal(1);
done();
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('emits a track event', (done) => {
pc.addEventListener('track', function(event) {
expect(event.track.kind).to.equal('audio');
expect(event.receiver);
expect(event.streams.length).to.equal(1);
done();
});
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('triggers ontrack and track event before resolving', (done) => {
let clock = sinon.useFakeTimers();
var trackEvent = sinon.stub();
pc.addEventListener('track', trackEvent);
pc.ontrack = sinon.stub();
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
window.setTimeout(() => {
expect(trackEvent).to.have.been.calledWith();
expect(pc.ontrack).to.have.been.calledWith();
clock.restore();
done();
}, 0);
clock.tick(500);
});
});
describe('without a stream (stream id -)', () => {
it('does not trigger onaddstream', (done) => {
let clock = sinon.useFakeTimers();
pc.onaddstream = sinon.stub();
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('stream1', '-')})
.then(() => {
window.setTimeout(() => {
expect(pc.onaddstream).not.to.have.been.calledWith();
clock.restore();
done();
}, 0);
clock.tick(500);
});
});
it('does trigger ontrack with an empty streams set', (done) => {
pc.addEventListener('track', function(event) {
expect(event.track.kind).to.equal('audio');
expect(event.receiver);
expect(event.streams.length).to.equal(0);
done();
});
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('stream1', '-')});
});
});
});
describe('when called with an offer without (explicit) tracks', () => {
const sdp = (SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE)
.replace('a=msid-semantics:WMS *\r\n', '');
it('triggers onaddstream', (done) => {
pc.onaddstream = function(event) {
const stream = event.stream;
expect(stream.getTracks().length).to.equal(1);
expect(stream.getTracks()[0].kind).to.equal('audio');
done();
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('triggers ontrack', (done) => {
pc.ontrack = function(event) {
expect(event.track.kind).to.equal('audio');
expect(event).to.have.property('receiver');
expect(event).to.have.property('transceiver');
expect(event.streams).to.have.lengthOf(1);
done();
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
});
describe('when called with an offer containing multiple streams ' +
'/ tracks', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendonly\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=ssrc:2002 msid:stream2 track2\r\n' +
'a=ssrc:2002 cname:some\r\n';
it('triggers onaddstream twice', (done) => {
let numStreams = 0;
pc.onaddstream = function(event) {
numStreams++;
expect(event.stream.id).to.equal('stream' + numStreams);
if (numStreams === 2) {
done();
}
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
it('triggers ontrack twice', (done) => {
let numTracks = 0;
pc.ontrack = function(event) {
numTracks++;
expect(event.streams[0].id).to.equal('stream' + numTracks);
if (numTracks === 2) {
done();
}
};
pc.setRemoteDescription({type: 'offer', sdp: sdp});
});
});
describe('when called with a bundle offer after adding ' +
'two tracks', () => {
const sdp = SDP_BOILERPLATE +
'a=group:BUNDLE audio1 video1\r\n' +
MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendonly\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=ssrc:2002 msid:stream2 track2\r\n' +
'a=ssrc:2002 cname:some\r\n';
it('disposes the second ice transport', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
// this creates two transceivers with ice transports.
pc.addStream(stream);
// this has bundle so will set usingBundle. But two
// transceivers and their ice/dtls transports exist
// and the second one needs to be disposed.
return pc.setRemoteDescription({type: 'offer', sdp: sdp});
})
.then(() => {
const senders = pc.getSenders();
// the second ice transport should have been disposed.
expect(senders[0].transport.transport).to
.equal(senders[1].transport.transport);
done();
});
});
});
describe('when called with an offer without an a=ssrc line', () => {
const sdp = SDP_BOILERPLATE +
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendonly\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n';
beforeEach(() => {
sinon.spy(window.RTCRtpReceiver.prototype, 'receive');
});
afterEach(() => {
window.RTCRtpReceiver.prototype.receive.restore();
});
it('calls RTCRtpReceiver.recv with encodings set to [{}]', () => {
return pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const receiver = pc.getReceivers()[0];
expect(receiver.receive).to.have.been.calledWith(
sinon.match({encodings: [{}]})
);
});
});
});
// TODO: add a test for recvonly to show it doesn't trigger the callback.
// probably easiest done using a sinon.stub
//
describe('sets the canTrickleIceCandidates property', () => {
it('to true when called with an offer that contains ' +
'a=ice-options:trickle', (done) => {
const sdp = SDP_BOILERPLATE +
'a=ice-options:trickle\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
expect(pc.canTrickleIceCandidates).to.equal(true);
done();
});
});
it('to false when called with an offer that does not contain ' +
'a=ice-options:trickle', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
expect(pc.canTrickleIceCandidates).to.equal(false);
done();
});
});
});
describe('when called with an offer containing candidates', () => {
beforeEach(() => {
sinon.spy(window.RTCIceTransport.prototype, 'addRemoteCandidate');
sinon.spy(window.RTCIceTransport.prototype, 'setRemoteCandidates');
});
afterEach(() => {
window.RTCIceTransport.prototype.addRemoteCandidate.restore();
window.RTCIceTransport.prototype.setRemoteCandidates.restore();
});
const candidateString = 'a=candidate:702786350 1 udp 41819902 ' +
'8.8.8.8 60769 typ host';
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
candidateString + '\r\n';
it('adds the candidates to the ice transport', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
expect(iceTransport.addRemoteCandidate).to.have.been.calledOnce();
done();
});
});
it('interprets end-of-candidates', (done) => {
pc.setRemoteDescription({type: 'offer',
sdp: sdp + 'a=end-of-candidates\r\n'
})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
expect(iceTransport.setRemoteCandidates).to.have.been.calledOnce();
done();
});
});
it('does not add the candidate in a subsequent offer ' +
'again', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
// call SRD again.
return pc.setRemoteDescription({type: 'offer', sdp: sdp});
})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
expect(iceTransport.addRemoteCandidate).to.have.been.calledOnce();
done();
});
});
it('does not add the candidates when they are also supplied ' +
'with addIceCandidate', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
pc.addIceCandidate({sdpMid: 'audio1', sdpMLineIndex: 0,
candidate: candidateString})
.catch(() => {});
expect(iceTransport.addRemoteCandidate).to.have.been.calledOnce();
done();
});
});
});
describe('InvalidStateError is thrown when called with', () => {
it('an answer in signalingState stable', (done) => {
pc.setRemoteDescription({type: 'answer'})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('an offer in signalingState have-local-offer', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
return pc.setRemoteDescription({type: 'offer'});
})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
});
describe('when called with an subsequent offer', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 audiotrack\r\n';
const videoPart =
'm=video 9 UDP/TLS/RTP/SAVPF 102 103\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=rtpmap:103 rtx/90000\r\n' +
'a=fmtp:103 apt=102\r\n' +
'a=ssrc-group:FID 1001 1002\r\n' +
'a=ssrc:1001 msid:stream1 videotrack\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=ssrc:1002 msid:stream1 videotrack\r\n' +
'a=ssrc:1002 cname:some\r\n';
describe('adding a new track', () => {
it('triggers ontrack', (done) => {
pc.onaddstream = sinon.stub();
pc.ontrack = sinon.stub();
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.setRemoteDescription({type: 'offer',
sdp: sdp + videoPart});
})
.then(() => {
window.setTimeout(() => {
expect(pc.onaddstream).to.have.been.calledOnce();
expect(pc.ontrack).to.have.been.calledTwice();
done();
});
clock.tick(500);
});
});
it('fires the stream addtrack event', (done) => {
let remoteStream;
pc.onaddstream = (e) => {
remoteStream = e.stream;
remoteStream.addEventListener('addtrack', (event) => {
expect(event).to.be.an.instanceOf(window.MediaStreamTrackEvent);
expect(event).to.have.property('track');
expect(event.track.id).to.equal('videotrack');
done();
});
};
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
window.setTimeout(() => {
pc.setRemoteDescription({type: 'offer', sdp: sdp + videoPart});
});
clock.tick(500);
});
});
});
describe('removing a track', () => {
it('fires the stream removetrack event', (done) => {
let remoteStream;
pc.onaddstream = (e) => {
remoteStream = e.stream;
remoteStream.addEventListener('removetrack', (event) => {
expect(event).to.be.an.instanceOf(window.MediaStreamTrackEvent);
expect(event).to.have.property('track');
expect(event.track.id).to.equal('videotrack');
done();
});
};
pc.setRemoteDescription({type: 'offer', sdp: sdp + videoPart})
.then(() => {
window.setTimeout(() => {
pc.setRemoteDescription({type: 'offer', sdp:
sdp + videoPart.replace('sendrecv', 'recvonly')});
});
clock.tick(500);
});
});
});
describe('going from rejected to non-rejected', () => {
it('triggers ontrack', (done) => {
pc.onaddstream = sinon.stub();
pc.ontrack = sinon.stub();
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('m=audio 9', 'm=audio 0')})
.then(() => {
return pc.setRemoteDescription({type: 'offer',
sdp: sdp});
})
.then(() => {
window.setTimeout(() => {
expect(pc.onaddstream).to.have.been.calledOnce();
expect(pc.ontrack).to.have.been.calledOnce();
done();
});
clock.tick(500);
});
});
});
});
describe('when rtcp-rsize is', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n';
beforeEach(() => {
sinon.spy(window.RTCRtpReceiver.prototype, 'receive');
});
afterEach(() => {
window.RTCRtpReceiver.prototype.receive.restore();
});
it('set RtpReceiver is called with compound set to false', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const receiver = pc.getReceivers()[0];
expect(receiver.receive).to.have.been.calledWith(
sinon.match({rtcp: sinon.match({compound: false})})
);
done();
});
});
it('not set RtpReceiver is called with compound set to true', (done) => {
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('a=rtcp-rsize\r\n', '')})
.then(() => {
const receiver = pc.getReceivers()[0];
expect(receiver.receive).to.have.been.calledWith(
sinon.match({rtcp: sinon.match({compound: true})})
);
done();
});
});
});
describe('with an ice-lite offer', () => {
beforeEach(() => {
sinon.spy(window.RTCDtlsTransport.prototype, 'start');
sinon.spy(window.RTCIceTransport.prototype, 'start');
});
afterEach(() => {
window.RTCDtlsTransport.prototype.start.restore();
window.RTCIceTransport.prototype.start.restore();
});
const sdp = SDP_BOILERPLATE +
'a=ice-lite\r\n' +
MINIMAL_AUDIO_MLINE;
it('set the ice role to controlling', (done) => {
pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
return pc.setLocalDescription(answer);
})
.then(() => {
const receiver = pc.getReceivers()[0];
const dtlsTransport = receiver.transport;
const iceTransport = dtlsTransport.transport;
expect(iceTransport.start).to.have.been.calledOnce();
expect(iceTransport.start).to.have.been.calledWith(
sinon.match.any,
sinon.match.any,
sinon.match('controlling')
);
done();
});
});
it('sets the dtls role to server', (done) => {
pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
return pc.setLocalDescription(answer);
})
.then(() => {
const receiver = pc.getReceivers()[0];
const dtlsTransport = receiver.transport;
expect(dtlsTransport.start).to.have.been.calledOnce();
expect(dtlsTransport.start).to.have.been.calledWith(
sinon.match({
role: 'server'
})
);
done();
});
});
});
describe('with type=answer', () => {
beforeEach(() => {
sinon.spy(window.RTCIceTransport.prototype, 'setRemoteCandidates');
return pc.createOffer({offerToReceiveAudio: true,
offerToReceiveVideo: true})
.then(offer => pc.setLocalDescription(offer));
});
afterEach(() => {
window.RTCIceTransport.prototype.setRemoteCandidates.restore();
});
it('ignores extra candidates in a bundle answer', (done) => {
const sdp = SDP_BOILERPLATE +
'a=group:BUNDLE audio1 video1\r\n' +
MINIMAL_AUDIO_MLINE +
'a=candidate:702786350 1 udp 41819902 8.8.8.8 60769 typ host\r\n' +
'a=end-of-candidates\r\n' +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1002 cname:some\r\n' +
'a=candidate:702786350 1 udp 41819902 8.8.8.8 60769 typ host\r\n' +
'a=end-of-candidates\r\n';
pc.setRemoteDescription({type: 'answer', sdp})
.then(() => {
const receiver = pc.getReceivers()[0];
const iceTransport = receiver.transport.transport;
expect(iceTransport.setRemoteCandidates).to.have.been.calledOnce();
done();
});
});
});
it('treats bundle-only m-lines as not rejected', (done) => {
const sdp = SDP_BOILERPLATE +
'a=group:BUNDLE audio1 video1\r\n' +
MINIMAL_AUDIO_MLINE +
'a=candidate:702786350 1 udp 41819902 8.8.8.8 60769 typ host\r\n' +
'a=msid:stream1 audiotrack\r\n' +
'a=end-of-candidates\r\n' +
'm=video 0 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=bundle-only\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1002 cname:some\r\n' +
'a=candidate:702786350 1 udp 41819902 8.8.8.8 60769 typ host\r\n' +
'a=msid:stream1 videotrack\r\n' +
'a=end-of-candidates\r\n';
pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
const receivers = pc.getReceivers();
expect(receivers).to.have.length(2);
expect(receivers[1].track.id).to.equal('videotrack');
done();
});
});
});
describe('createOffer', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
if (pc.signalingState !== 'closed') {
pc.close();
}
});
it('returns a promise', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then(() => {
done();
});
});
it('calls the legacy success callback', (done) => {
pc.createOffer((offer) => {
expect(offer.type).to.equal('offer');
done();
}, () => {}, {offerToReceiveAudio: true});
});
it('calls the legacy success callback and resolves with ' +
'no arguments', (done) => {
pc.createOffer((offer) => {})
.then((shouldBeUndefined) => {
expect(shouldBeUndefined).to.equal(undefined);
done();
});
});
it('does not change the signalingState', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then(() => {
expect(pc.signalingState).to.equal('stable');
done();
});
});
it('uses pooled RTCIceGatherer', (done) => {
pc.close();
pc = new RTCPeerConnection({iceCandidatePoolSize: 1});
pc.createOffer({offerToReceiveAudio: true})
.then(() => {
expect(pc._iceGatherers).to.have.length(0);
done();
});
});
it('does not start emitting ICE candidates', (done) => {
let clock = sinon.useFakeTimers();
pc.onicecandidate = sinon.stub();
pc.createOffer({offerToReceiveAudio: true})
.then(() => {
clock.tick(500);
expect(pc.onicecandidate).not.to.have.been.calledWith();
clock.restore();
done();
});
});
it('throws an InvalidStateError when called after close', (done) => {
pc.close();
pc.createOffer({offerToReceiveAudio: true})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('throws an InvalidStateError when called after close ' +
'(callback)', (done) => {
pc.close();
pc.createOffer(undefined, (e) => {
expect(e.name).to.equal('InvalidStateError');
done();
}, {offerToReceiveAudio: true});
});
describe('throws a TypeError when called with legacy constraints', () => {
it('(optional)', () => {
expect(() => pc.createOffer({optional: {OfferToReceiveAudio: true}}))
.to.throw()
.that.has.property('name').that.equals('TypeError');
});
it('(mandatory)', () => {
expect(() => pc.createOffer({mandatory: {OfferToReceiveAudio: true}}))
.to.throw()
.that.has.property('name').that.equals('TypeError');
});
});
describe('when called with offerToReceiveAudio', () => {
it('= true the generated SDP should contain one audio m-line', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
done();
});
});
// probably legacy which was covered by the spec at some point.
it('= 2 the generated SDP should contain two audio m-lines', (done) => {
pc.createOffer({offerToReceiveAudio: 2})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
expect(SDPUtils.getDirection(sections[1])).to.equal('recvonly');
done();
});
});
it('= true the generated SDP should contain one audio m-line', (done) => {
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
done();
});
});
it('= false the generated SDP should not offer to receive ' +
'audio', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer({offerToReceiveAudio: false});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
it('= false and no local track the generated SDP should not ' +
'contain a m-line', (done) => {
// see https://github.com/rtcweb-wg/jsep/issues/832
pc.createOffer({offerToReceiveAudio: false})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections).to.have.length(0);
done();
});
});
});
describe('when called with offerToReceiveVideo', () => {
it('= true the generated SDP should contain one video m-line', (done) => {
pc.createOffer({offerToReceiveVideo: true})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
done();
});
});
// probably legacy which was covered by the spec at some point.
it('= 2 the generated SDP should contain two video m-lines', (done) => {
pc.createOffer({offerToReceiveVideo: 2})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
expect(SDPUtils.getDirection(sections[1])).to.equal('recvonly');
done();
});
});
it('= true the generated SDP should contain one video m-line', (done) => {
pc.createOffer({offerToReceiveVideo: true})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
done();
});
});
it('= false the generated SDP should not offer to receive ' +
'video', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer({offerToReceiveVideo: false});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
it('= false and no local track the generated SDP should not ' +
'contain a m-line', (done) => {
// see https://github.com/rtcweb-wg/jsep/issues/832
pc.createOffer({offerToReceiveVideo: false})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections).to.have.length(0);
done();
});
});
});
describe('when called with offerToReceiveAudio and ' +
'offerToReceiveVideo', () => {
it('the generated SDP should contain two m-lines', (done) => {
pc.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getDirection(sections[1])).to.equal('recvonly');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
done();
});
});
});
describe('when called after adding a stream', () => {
describe('with an audio track', () => {
it('the generated SDP should contain an audio m-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
done();
});
});
});
describe('with an audio track not offering to receive audio', () => {
it('the generated SDP should contain a sendonly audio ' +
'm-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer({offerToReceiveAudio: false});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
});
describe('with an audio track and offering to receive video', () => {
it('the generated SDP should contain a recvonly m-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer({offerToReceiveVideo: true});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
expect(SDPUtils.getDirection(sections[1])).to.equal('recvonly');
done();
});
});
});
describe('with a video track', () => {
it('the generated SDP should contain an video m-line', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getKind(sections[0])).to.equal('video');
done();
});
});
});
describe('with a video track and offerToReceiveAudio', () => {
it('the generated SDP should contain a video and an ' +
'audio m-line', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer({offerToReceiveAudio: true});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('video');
expect(SDPUtils.getKind(sections[1])).to.equal('audio');
done();
});
});
});
describe('with an audio track and a video track', () => {
it('the generated SDP should contain an audio and video ' +
'm-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
done();
});
});
});
describe('with an audio track and two video tracks', () => {
it('the generated SDP should contain an audio and ' +
'video m-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc.addStream(stream);
return navigator.mediaDevices.getUserMedia({video: true});
})
.then((stream) => {
pc.addStream(stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(3);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
expect(SDPUtils.getKind(sections[2])).to.equal('video');
done();
});
});
});
});
describe('when called after addTrack', () => {
describe('with an audio track', () => {
it('the generated SDP should contain a sendrecv ' +
'audio m-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
done();
});
});
});
describe('with an audio track not offering to receive audio', () => {
it('the generated SDP should contain a sendonly audio ' +
'm-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
return pc.createOffer({offerToReceiveAudio: false});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
});
describe('with an audio track and offering to receive video', () => {
it('the generated SDP should contain a sendrecv audio m-line ' +
'and a recvonly video m-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
return pc.createOffer({offerToReceiveVideo: true});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
expect(SDPUtils.getDirection(sections[1])).to.equal('recvonly');
done();
});
});
});
describe('with a video track', () => {
it('the generated SDP should contain an video m-line', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addTrack(stream.getVideoTracks()[0], stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getKind(sections[0])).to.equal('video');
done();
});
});
});
describe('with a video track and offerToReceiveAudio', () => {
it('the generated SDP should contain a video and an ' +
'audio m-line', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addTrack(stream.getVideoTracks()[0], stream);
return pc.createOffer({offerToReceiveAudio: true});
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('video');
expect(SDPUtils.getKind(sections[1])).to.equal('audio');
done();
});
});
});
describe('with an audio track and a video track', () => {
it('the generated SDP should contain an audio and video ' +
'm-line', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
done();
});
});
});
describe('with an audio track and two video tracks', () => {
it('the generated SDP should contain an audio and ' +
'two video m-lines', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
return navigator.mediaDevices.getUserMedia({video: true});
})
.then((stream) => {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(3);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.getKind(sections[1])).to.equal('video');
expect(SDPUtils.getKind(sections[2])).to.equal('video');
done();
});
});
});
describe('with an audio track but no stream', () => {
it('creates an offer with msid stream set to "-"', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
stream.getTracks().forEach((track) => {
pc.addTrack(track);
});
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(1);
const msid = SDPUtils.parseMsid(sections[0]);
expect(msid.stream).to.equal('-');
});
});
});
});
describe('when called subsequently', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('contains the candidates already emitted', (done) => {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState !== 'complete') {
return;
}
pc.createOffer()
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
const candidates = SDPUtils.matchPrefix(sections[0],
'a=candidate:');
const end = SDPUtils.matchPrefix(sections[0],
'a=end-of-candidates');
expect(candidates.length).to.be.above(0);
expect(end.length).to.equal(1);
done();
});
};
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
it('retains the session id', (done) => {
let sessionId;
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
sessionId = SDPUtils.matchPrefix(offer.sdp, 'o=')[0].split(' ')[1];
return pc.createOffer({offerToReceiveAudio: true});
})
.then((offer) => {
let sid = SDPUtils.matchPrefix(offer.sdp, 'o=')[0].split(' ')[1];
expect(sid).to.equal(sessionId);
done();
});
});
it('increments the session version', (done) => {
let version;
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
version = SDPUtils.matchPrefix(offer.sdp, 'o=')[0]
.split(' ')[2] >>> 0;
return pc.createOffer({offerToReceiveAudio: true});
})
.then((offer) => {
let ver = SDPUtils.matchPrefix(offer.sdp, 'o=')[0]
.split(' ')[2] >>> 0;
expect(ver).to.equal(version + 1);
done();
});
});
});
describe('when called after SRD+createAnswer reversing the roles', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
it('retains the MID attribute', () => {
return pc.setRemoteDescription({type: 'offer', sdp})
.then(() => pc.createAnswer())
.then((answer) => pc.setLocalDescription(answer))
.then(() => pc.createOffer())
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(SDPUtils.getMid(sections[0])).to.equal('audio1');
});
});
it('retains the offerer payload types', () => {
return pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace(/111/g, 98)
})
.then(() => pc.createAnswer())
.then((answer) => pc.setLocalDescription(answer))
.then(() => pc.createOffer())
.then((offer) => {
expect(offer.sdp).to.contain('a=rtpmap:98 opus');
expect(offer.sdp).not.to.contain('a=rtpmap:111 opus');
});
});
it('retains the offerer extmap ids', () => {
const extmapUri = 'http://www.webrtc.org/experiments/' +
'rtp-hdrext/abs-send-time';
const videoSdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=extmap:5 ' + extmapUri + '\r\n';
return pc.setRemoteDescription({type: 'offer', sdp: videoSdp})
.then(() => pc.createAnswer())
.then((answer) => pc.setLocalDescription(answer))
.then(() => pc.createOffer())
.then((offer) => {
expect(offer.sdp).to.contain('a=extmap:5 ' + extmapUri + '\r\n');
});
});
});
describe('after replaceTrack', () => {
it('retains the original track id', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
return pc.createOffer();
})
.then((offer) => pc.setLocalDescription(offer))
.then(() => navigator.mediaDevices.getUserMedia({audio: true}))
.then((stream) => {
const sender = pc.getSenders()[0];
return sender.replaceTrack(stream.getAudioTracks()[0]);
})
.then(() => pc.createOffer())
.then((offer) => {
const newMsid = SDPUtils.parseMsid(offer.sdp);
const existingMsid = SDPUtils.parseMsid(pc.localDescription.sdp);
expect(newMsid.track).to.equal(existingMsid.track);
done();
});
});
});
});
describe('createAnswer', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('returns a promise', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then(() => {
done();
});
});
it('calls the legacy success callback', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer((answer) => {
expect(answer.type).to.equal('answer');
done();
}, () => {});
});
});
it('calls the legacy success callback and resolves with ' +
'no arguments', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer((answer) => {});
})
.then((shouldBeUndefined) => {
expect(shouldBeUndefined).to.equal(undefined);
done();
});
});
it('does not change the signaling state', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
expect(pc.signalingState).to.equal('have-remote-offer');
return pc.createAnswer();
})
.then(() => {
expect(pc.signalingState).to.equal('have-remote-offer');
done();
});
});
it('throws an InvalidStateError when called after close', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
pc.close();
return pc.createAnswer();
})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('throws an InvalidStateError when called after close ' +
'(callback)', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
pc.close();
return pc.createAnswer(undefined, (e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
});
it('throws an InvalidStateError when called in the wrong ' +
'signalingstate', (done) => {
pc.createAnswer()
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('uses payload types of offerer', (done) => {
const sdp = SDP_BOILERPLATE +
'm=audio 9 UDP/TLS/RTP/SAVPF 98\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:98 opus/48000/2\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtpmap:98 opus');
done();
});
});
it('uses the extmap ids of the offerer', (done) => {
const extmapUri = 'http://www.webrtc.org/experiments/' +
'rtp-hdrext/abs-send-time';
const sdp = SDP_BOILERPLATE +
MINIMAL_AUDIO_MLINE +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=extmap:5 ' + extmapUri + '\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=extmap:5 ' + extmapUri + '\r\n');
done();
});
});
it('returns the intersection of rtcp feedback', (done) => {
const sdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=rtcp-fb:102 nack\r\n' +
'a=rtcp-fb:102 nack pli\r\n' +
'a=rtcp-fb:102 goog-remb\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtcp-fb:102 nack\r\n');
expect(answer.sdp).to.contain('a=rtcp-fb:102 nack pli\r\n');
expect(answer.sdp).not.to.contain('a=rtcp-fb:102 goog-remb\r\n');
done();
});
});
it('rejects a m-line when there are no compatible codecs', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('opus', 'nosuchcodec')
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
const rejected = SDPUtils.isRejected(sections[0]);
expect(rejected).to.equal(true);
done();
});
});
describe('rejects a legacy datachannel offer', () => {
const sdp = SDP_BOILERPLATE +
'm=application 9 DTLS/SCTP 5000\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:data\r\n' +
'a=sctpmap:5000 webrtc-datachannel 1024\r\n';
it('in setRemoteDescription', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
const rejected = SDPUtils.isRejected(sections[0]);
expect(rejected).to.equal(true);
done();
});
});
it('ignores candidates', () => {
return pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.addIceCandidate({sdpMid: 'data', candidate:
'candidate:702786350 1 udp 41819902 8.8.8.8 60769 typ host'});
});
});
it('ignores end-of-candidates', () => {
return pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => pc.addIceCandidate());
});
});
describe('rejects a new-style datachannel offer', () => {
it('in setRemoteDescription', () => {
const sdp = SDP_BOILERPLATE +
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:data\r\n' +
'a=sctp-port:5000\r\n' +
'a=max-message-size:1073741823\r\n';
return pc.setRemoteDescription({type: 'offer', sdp})
.then(() => pc.createAnswer())
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
const rejected = SDPUtils.isRejected(sections[0]);
expect(rejected).to.equal(true);
});
});
});
// test https://tools.ietf.org/html/draft-ietf-rtcweb-jsep-15#section-5.3.4
describe('direction attribute', () => {
const sdp = SDP_BOILERPLATE +
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
it('responds with a inactive answer to inactive', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp.replace('sendrecv',
'recvonly')})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('inactive');
done();
});
});
describe('with a local track', () => {
it('responds with a sendrecv answer to sendrecv', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.setRemoteDescription({type: 'offer', sdp: sdp});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
done();
});
});
it('responds with a sendonly answer to recvonly', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('sendrecv', 'recvonly')
});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
});
describe('with a local track added after setRemoteDescription', () => {
it('responds with a sendrecv answer to sendrecv', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return navigator.mediaDevices.getUserMedia({audio: true});
})
.then((stream) => {
pc.addStream(stream);
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendrecv');
done();
});
});
it('responds with a sendonly answer to recvonly', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp.replace('sendrecv',
'recvonly')})
.then(() => {
return navigator.mediaDevices.getUserMedia({audio: true});
})
.then((stream) => {
pc.addStream(stream);
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('sendonly');
done();
});
});
});
describe('with no local track', () => {
it('responds with a recvonly answer to sendrecv', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
done();
});
});
it('responds with a inactive answer to recvonly', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp.replace('sendrecv',
'recvonly')})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(SDPUtils.getDirection(sections[0])).to.equal('inactive');
done();
});
});
});
});
describe('after a video offer with RTX', () => {
const sdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102 103\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=rtpmap:103 rtx/90000\r\n' +
'a=fmtp:103 apt=102\r\n';
const remoteRTX = 'a=ssrc-group:FID 1001 1002\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=ssrc:1002 msid:stream1 track1\r\n' +
'a=ssrc:1002 cname:some\r\n';
describe('with no local track', () => {
it('creates an answer with RTX but no FID group', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp + remoteRTX})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtpmap:102 vp8');
expect(answer.sdp).to.contain('a=rtpmap:103 rtx');
expect(answer.sdp).to.contain('a=fmtp:103 apt=102');
expect(answer.sdp).not.to.contain('a=ssrc-group:FID ');
done();
});
});
});
describe('with a local track', () => {
it('creates an answer with RTX', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.setRemoteDescription({type: 'offer',
sdp: sdp + remoteRTX});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtpmap:102 vp8');
expect(answer.sdp).to.contain('a=rtpmap:103 rtx');
expect(answer.sdp).to.contain('a=fmtp:103 apt=102');
expect(answer.sdp).to.contain('a=ssrc-group:FID ');
done();
});
});
});
describe('with no remote track', () => {
it('creates an answer with RTX', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('sendrecv', 'recvonly')});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtpmap:102 vp8');
expect(answer.sdp).to.contain('a=rtpmap:103 rtx');
expect(answer.sdp).to.contain('a=fmtp:103 apt=102');
expect(answer.sdp).to.contain('a=ssrc-group:FID ');
done();
});
});
});
describe('but mismatching video codec', () => {
it('creates an answer without RTX', (done) => {
const modifiedSDP = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 101 102 103\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:101 vp8/90000\r\n' +
'a=rtpmap:102 no-such-codec/90000\r\n' +
'a=rtpmap:103 rtx/90000\r\n' +
'a=fmtp:103 apt=102\r\n';
pc.setRemoteDescription({type: 'offer', sdp: modifiedSDP})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtpmap:101 vp8');
expect(answer.sdp).not.to.contain('a=rtpmap:102 no-such-codec');
expect(answer.sdp).not.to.contain('a=rtpmap:103 rtx');
expect(answer.sdp).not.to.contain('a=fmtp:103 apt=102');
done();
});
});
});
});
describe('after a video offer without RTX', () => {
const sdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
it('there is no ssrc-group in the answer', (done) => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addStream(stream);
return pc.setRemoteDescription({type: 'offer', sdp: sdp});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).not.to.contain('a=ssrc-group:FID ');
done();
});
});
});
describe('after an offer containing a rejected mline', () => {
it('rejects the m-line in the answer', () => {
const sdp = SDP_BOILERPLATE +
MINIMAL_AUDIO_MLINE.replace('m=audio 9', 'm=audio 0');
return pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(1);
expect(SDPUtils.getKind(sections[0])).to.equal('audio');
expect(SDPUtils.isRejected(sections[0])).to.equal(true);
});
});
});
describe('rtcp-rsize is', () => {
const sdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
it('set if the offer contained rtcp-rsize', (done) => {
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).to.contain('a=rtcp-rsize\r\n');
done();
});
});
it('not set if the offer did not contain rtcp-rsize', (done) => {
pc.setRemoteDescription({type: 'offer',
sdp: sdp.replace('a=rtcp-rsize\r\n', '')})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).not.to.contain('a=rtcp-rsize\r\n');
done();
});
});
});
describe('with the remote offering BUNDLE', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
const sdp = SDP_BOILERPLATE +
'a=group:BUNDLE audio1 video1\r\n' +
'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendonly\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'm=video 9 UDP/TLS/RTP/SAVPF 102\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=ssrc:1002 cname:some\r\n';
it('does not send candidates with sdpMLineIndex=1', (done) => {
pc.onicecandidate = sinon.stub();
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(pc.onicecandidate).to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(0)})
}));
expect(pc.onicecandidate).not.to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(1)})
}));
done();
}
};
pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
return pc.setLocalDescription(answer);
})
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
});
describe('session version handling', () => {
it('starts at version 0', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
let ver = SDPUtils.matchPrefix(answer.sdp, 'o=')[0]
.split(' ')[2] >>> 0;
expect(ver).to.equal(0);
done();
});
});
it('subsequent calls increase the session version', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return pc.createAnswer();
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
let ver = SDPUtils.matchPrefix(answer.sdp, 'o=')[0]
.split(' ')[2] >>> 0;
expect(ver).to.equal(1);
done();
});
});
});
describe('with an audio-only offer adding an ' +
'audio/video stream', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
it('does not try to add a video m-line', (done) => {
// https://github.com/webrtc/adapter/issues/638
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
return navigator.mediaDevices.getUserMedia({audio: true,
video: true});
})
.then((stream) => {
pc.addStream(stream);
return pc.createAnswer();
})
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(1);
done();
});
});
});
});
describe('addIceCandidate', () => {
const sdp = SDP_BOILERPLATE +
'a=group:BUNDLE audio1 video1\r\n' +
'm=audio 9 UDP/TLS/RTP/SAVPF 98\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:98 opus/48000/2\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'm=video 9 UDP/TLS/RTP/SAVPF 102 103\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=rtpmap:103 rtx/90000\r\n' +
'a=fmtp:103 apt=102\r\n' +
'a=ssrc-group:FID 1001 1002\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=ssrc:1002 msid:stream1 track1\r\n' +
'a=ssrc:1002 cname:some\r\n';
const candidateString = 'candidate:702786350 1 udp 41819902 8.8.8.8 ' +
'60769 typ host';
const sdpMid = 'audio1';
let pc;
beforeEach((done) => {
pc = new RTCPeerConnection();
pc.setRemoteDescription({type: 'offer', sdp})
.then(done);
});
afterEach(() => {
pc.close();
});
it('returns a promise', (done) => {
pc.addIceCandidate({sdpMid, candidate: candidateString})
.then(done);
});
it('calls the legacy success callback', (done) => {
pc.addIceCandidate({sdpMid, candidate: candidateString}, done, () => {});
});
it('throws a TypeError when called without sdpMid or ' +
'sdpMLineIndex', (done) => {
pc.addIceCandidate({})
.catch((e) => {
expect(e.name).to.equal('TypeError');
done();
});
});
describe('rejects with an OperationError when called with an', () => {
it('invalid sdpMid', (done) => {
pc.addIceCandidate({sdpMid: 'invalid', candidate: candidateString})
.catch((e) => {
expect(e.name).to.equal('OperationError');
done();
});
});
it('invalid sdpMLineIndex', (done) => {
pc.addIceCandidate({sdpMLineIndex: 99, candidate: candidateString})
.catch((e) => {
expect(e.name).to.equal('OperationError');
done();
});
});
});
it('calls the legacy error callback when called with an ' +
'invalid sdpMLineIndex', (done) => {
pc.addIceCandidate({sdpMLineIndex: 99, candidate: candidateString},
() => {},
(e) => {
expect(e.name).to.equal('OperationError');
done();
}
);
});
it('rejects with an InvalidStateError when called before ' +
'setRemoteDescription', (done) => {
pc = new RTCPeerConnection(); // recreate pc.
pc.addIceCandidate({sdpMid, candidate: candidateString})
.catch((e) => {
expect(e.name).to.equal('InvalidStateError');
done();
});
});
it('adds the candidate to the remote description', (done) => {
pc.addIceCandidate({sdpMid, candidate: candidateString})
.then(() => {
const sections = SDPUtils.getMediaSections(pc.remoteDescription.sdp);
expect(SDPUtils.matchPrefix(sections[0],
'a=candidate:')).to.have.length(1);
done();
});
});
it('adds the candidate to the remote description ' +
'with legacy a=candidate syntax', (done) => {
pc.addIceCandidate({sdpMid, candidate: 'a=' + candidateString})
.then(() => {
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=candidate:')).to.have.length(1);
done();
});
});
it('adds end-of-candidates when receiving the null candidate', (done) => {
// add at least one valid candidate.
pc.addIceCandidate({sdpMid, candidate: candidateString});
pc.addIceCandidate()
.then(() => {
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=end-of-candidates')).to.have.length(1);
done();
});
});
it('adds end-of-candidates when receiving the \'\' candidate', (done) => {
// add at least one valid candidate.
pc.addIceCandidate({sdpMid, candidate: candidateString});
pc.addIceCandidate({sdpMid, candidate: ''})
.then(() => {
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=end-of-candidates')).to.have.length(1);
done();
});
});
describe('ignores candidates with', () => {
it('component=2 and does not add them to the sdp', (done) => {
const iceTransport = pc.getReceivers()[0].transport.transport;
sinon.spy(iceTransport, 'addRemoteCandidate');
pc.addIceCandidate({sdpMid, candidate:
candidateString.replace('1 udp', '2 udp')})
.then(() => {
expect(iceTransport.addRemoteCandidate).not.to.have.been.calledWith();
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=candidate:')).to.have.length(0);
done();
});
});
it('non-master mid but does add them to the sdp', (done) => {
const iceTransport = pc.getReceivers()[0].transport.transport;
sinon.spy(iceTransport, 'addRemoteCandidate');
pc.addIceCandidate({sdpMid: 'video1', candidate: candidateString})
.then(() => {
expect(iceTransport.addRemoteCandidate).not.to.have.been.calledWith();
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=candidate:')).to.have.length(1);
done();
});
});
it('port 0 and does not add them to the sdp', (done) => {
const iceTransport = pc.getReceivers()[0].transport.transport;
sinon.spy(iceTransport, 'addRemoteCandidate');
pc.addIceCandidate({sdpMid, candidate:
candidateString.replace('60769', '0').replace('udp', 'tcp')})
.then(() => {
expect(iceTransport.addRemoteCandidate).not.to.have.been.calledWith();
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=candidate:')).to.have.length(0);
done();
});
});
it('port 9 and does not add them to the sdp', (done) => {
const iceTransport = pc.getReceivers()[0].transport.transport;
sinon.spy(iceTransport, 'addRemoteCandidate');
pc.addIceCandidate({sdpMid, candidate:
candidateString.replace('60769', '9').replace('udp', 'tcp')})
.then(() => {
expect(iceTransport.addRemoteCandidate).not.to.have.been.calledWith();
expect(SDPUtils.matchPrefix(pc.remoteDescription.sdp,
'a=candidate:')).to.have.length(0);
done();
});
});
});
});
describe('negotiationneeded', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('fires as an event', (done) => {
const stub = sinon.stub();
pc.addEventListener('negotiationneeded', stub);
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
})
.then(() => {
setTimeout(() => {
expect(stub).to.have.been.calledOnce();
done();
});
});
});
describe('triggers after', () => {
it('addTrack', (done) => {
pc.onnegotiationneeded = sinon.stub();
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
})
.then(() => {
setTimeout(() => {
expect(pc.onnegotiationneeded).to.have.been.calledOnce();
done();
});
});
});
it('addStream', (done) => {
pc.onnegotiationneeded = sinon.stub();
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc.addStream(stream);
})
.then(() => {
setTimeout(() => {
expect(pc.onnegotiationneeded).to.have.been.calledOnce();
done();
});
});
});
});
it('does not trigger when already needing negotiation', (done) => {
pc.onnegotiationneeded = sinon.stub();
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
})
.then(() => {
setTimeout(() => {
expect(pc.onnegotiationneeded).to.have.been.calledOnce();
done();
});
});
});
});
describe('full cycle', () => {
let pc1;
let pc2;
beforeEach(() => {
pc1 = new RTCPeerConnection();
pc2 = new RTCPeerConnection();
});
afterEach(() => {
pc1.close();
pc2.close();
});
it('completes a full createOffer-SLD-SRD-createAnswer-SLD-SRD ' +
'cycle', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc1.addStream(stream);
pc2.addStream(stream);
return pc1.createOffer();
})
.then((offer) => pc1.setLocalDescription(offer))
.then(() => pc2.setRemoteDescription(pc1.localDescription))
.then(() => pc2.createAnswer())
.then((answer) => pc2.setLocalDescription(answer))
.then(() => pc1.setRemoteDescription(pc2.localDescription))
.then(() => {
expect(pc1.signalingState).to.equal('stable');
expect(pc2.signalingState).to.equal('stable');
done();
});
});
});
describe('remote reoffer with role change', () => {
let pc1;
let pc2;
beforeEach(() => {
pc1 = new RTCPeerConnection();
pc2 = new RTCPeerConnection();
});
afterEach(() => {
pc1.close();
pc2.close();
});
it('retains SSRCs', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc1.addStream(stream);
return pc1.createOffer();
})
.then((offer) => pc1.setLocalDescription(offer))
.then(() => pc2.setRemoteDescription(pc1.localDescription))
.then(() => pc2.createAnswer())
.then((answer) => pc2.setLocalDescription(answer))
.then(() => pc1.setRemoteDescription(pc2.localDescription))
.then(() => {
return navigator.mediaDevices.getUserMedia({audio: true, video: true});
})
.then((stream) => {
pc2.addStream(stream);
return pc2.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections.length).to.equal(2);
const audioEncodingParameters = SDPUtils.parseRtpEncodingParameters(
sections[0]);
const videoEncodingParameters = SDPUtils.parseRtpEncodingParameters(
sections[1]);
expect(audioEncodingParameters[0].ssrc).to.equal(2002);
expect(videoEncodingParameters[0].ssrc).to.equal(4004);
expect(videoEncodingParameters[0].rtx.ssrc).to.equal(4005);
done();
});
});
it('sets the right DTLS role in the answer', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc1.addStream(stream);
return pc1.createOffer();
})
.then((offer) => pc1.setLocalDescription(offer))
.then(() => pc2.setRemoteDescription(pc1.localDescription))
.then(() => pc2.createAnswer())
.then((answer) => pc2.setLocalDescription(answer))
.then(() => pc1.setRemoteDescription(pc2.localDescription))
.then(() => {
return navigator.mediaDevices.getUserMedia({audio: true, video: true});
})
.then((stream) => {
pc2.addStream(stream);
return pc2.createOffer();
})
.then((offer) => pc2.setLocalDescription(offer))
.then(() => pc1.setRemoteDescription(pc2.localDescription))
.then(() => pc1.createAnswer())
.then((answer) => {
const sections = SDPUtils.getMediaSections(answer.sdp);
expect(sections.length).to.equal(2);
const setupLine = SDPUtils.matchPrefix(sections[0], 'a=setup:');
expect(setupLine[0]).to.equal('a=setup:passive');
done();
});
});
});
describe('bundlePolicy', () => {
it('creates an offer with a=group:BUNDLE by default', (done) => {
const pc = new RTCPeerConnection();
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
expect(offer.sdp).to.contain('a=group:BUNDLE');
done();
});
});
it('max-compat creates an offer without a=group:BUNDLE', (done) => {
const pc = new RTCPeerConnection({bundlePolicy: 'max-compat'});
pc.createOffer({offerToReceiveAudio: true})
.then((offer) => {
expect(offer.sdp).not.to.contain('a=group:BUNDLE');
done();
});
});
describe('emits candidates with sdpMLineIndex', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('1 and 2 when using max-compat', (done) => {
const pc = new RTCPeerConnection({bundlePolicy: 'max-compat'});
pc.onicecandidate = sinon.stub();
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(pc.onicecandidate).to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(0)})
}));
expect(pc.onicecandidate).to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(1)})
}));
done();
}
};
pc.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
it('1 when using max-bundle', (done) => {
const pc = new RTCPeerConnection({bundlePolicy: 'max-bundle'});
pc.onicecandidate = sinon.stub();
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
expect(pc.onicecandidate).to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(0)})
}));
expect(pc.onicecandidate).not.to.have.been.calledWith(sinon.match({
candidate: sinon.match({sdpMLineIndex: sinon.match(1)})
}));
done();
}
};
pc.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true})
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
window.setTimeout(() => {
clock.tick(500);
});
clock.tick(0);
});
});
});
});
describe('getSenders', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('returns an empty array initially', () => {
expect(pc.getSenders().length).to.equal(0);
});
it('returns a single element after addTrack', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
const track = stream.getTracks()[0];
pc.addTrack(track, stream);
const senders = pc.getSenders();
expect(senders.length).to.equal(1);
expect(senders[0].track).to.equal(track);
done();
});
});
});
describe('getReceivers', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('returns an empty array initially', () => {
expect(pc.getReceivers().length).to.equal(0);
});
it('returns a single element after SRD with a track', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const receivers = pc.getReceivers();
expect(receivers.length).to.equal(1);
expect(receivers[0].track.kind).to.equal('audio');
done();
});
});
});
describe('getLocalStreams', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('returns an empty array initially', () => {
expect(pc.getLocalStreams().length).to.equal(0);
});
describe('returns a single element after', () => {
it('addTrack was called once', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
const track = stream.getTracks()[0];
pc.addTrack(track, stream);
})
.then(() => {
const localStreams = pc.getLocalStreams();
expect(localStreams.length).to.equal(1);
done();
});
});
it('addTrack was called twice with tracks from the ' +
'same stream', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
})
.then(() => {
const localStreams = pc.getLocalStreams();
expect(localStreams.length).to.equal(1);
done();
});
});
it('addStream was called', (done) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc.addStream(stream);
})
.then(() => {
const localStreams = pc.getLocalStreams();
expect(localStreams.length).to.equal(1);
done();
});
});
});
describe('returns two streams after', () => {
it('addTrack was called twice with tracks from two ' +
'streams', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
return navigator.mediaDevices.getUserMedia({video: true});
})
.then((stream) => {
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
})
.then(() => {
const localStreams = pc.getLocalStreams();
expect(localStreams.length).to.equal(2);
done();
});
});
it('addStream was called twice', (done) => {
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
return navigator.mediaDevices.getUserMedia({video: true});
})
.then((stream) => {
pc.addStream(stream);
})
.then(() => {
const localStreams = pc.getLocalStreams();
expect(localStreams.length).to.equal(2);
done();
});
});
});
});
describe('getRemoteStreams', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('returns an empty array initially', () => {
expect(pc.getRemoteStreams().length).to.equal(0);
});
describe('returns a single element after SRD', () => {
it('with a single track', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const remoteStreams = pc.getRemoteStreams();
expect(remoteStreams.length).to.equal(1);
done();
});
});
it('with two tracks in a single stream', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track2\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const remoteStreams = pc.getRemoteStreams();
expect(remoteStreams.length).to.equal(1);
done();
});
});
});
describe('returns two streams after SRD', () => {
it('with two tracks in two streams', (done) => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
MINIMAL_AUDIO_MLINE +
'a=ssrc:1001 msid:stream2 track1\r\n' +
'a=ssrc:1001 cname:some\r\n';
pc.setRemoteDescription({type: 'offer', sdp: sdp})
.then(() => {
const remoteStreams = pc.getRemoteStreams();
expect(remoteStreams.length).to.equal(2);
done();
});
});
});
});
describe('removeTrack', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
if (pc.signalingState !== 'closed') {
pc.close();
}
});
it('throws a TypeError if the argument is not an RTCRtpSender', () => {
const removeTrack = () => {
pc.removeTrack('something');
};
expect(removeTrack).to.throw(/does not implement/)
.that.has.property('name').that.equals('TypeError');
});
it('throws an InvalidAccessError if the sender does not belong ' +
'to the peerconnection', () => {
const removeTrack = () => {
pc.removeTrack(new window.RTCRtpSender());
};
expect(removeTrack).to.throw(/not created by/)
.that.has.property('name').that.equals('InvalidAccessError');
});
it('throws an InvalidStateError if the peerconnection has been ' +
'closed already', () => {
pc.close();
const removeTrack = () => {
pc.removeTrack(new window.RTCRtpSender());
};
expect(removeTrack).to.throw()
.that.has.property('name').that.equals('InvalidStateError');
});
it('makes the m-line recvonly', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
const sender = pc.addTrack(stream.getAudioTracks()[0], stream);
pc.removeTrack(sender);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections).to.have.length(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
});
});
describe('and getLocalStreams', () => {
it('removes local streams when the last sender has been removed', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
const sender = pc.addTrack(stream.getAudioTracks()[0], stream);
pc.removeTrack(sender);
expect(pc.getLocalStreams()).to.have.length(0);
});
});
it('keeps the local stream if there is a transceiver to which the ' +
'stream belongs', () => {
return navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
const sender = pc.addTrack(stream.getVideoTracks()[0], stream);
pc.removeTrack(sender);
expect(pc.getLocalStreams()).to.have.length(1);
});
});
});
describe('legacy removeStream', () => {
it('removes the stream from getLocalStreams', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
pc.removeStream(stream);
expect(pc.getLocalStreams()).to.have.length(0);
});
});
it('makes the m-line recvonly', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addStream(stream);
pc.removeStream(stream);
return pc.createOffer();
})
.then((offer) => {
const sections = SDPUtils.getMediaSections(offer.sdp);
expect(sections).to.have.length(1);
expect(SDPUtils.getDirection(sections[0])).to.equal('recvonly');
});
});
});
});
describe('addTrack', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
describe('throws an exception', () => {
it('if the track has already been added', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
pc.addTrack(stream.getTracks()[0], stream);
const again = () => {
pc.addTrack(stream.getTracks()[0], stream);
};
expect(again).to.throw(/already/)
.that.has.property('name').that.equals('InvalidAccessError');
});
});
it('if the track has already been added via addStream', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
pc.addStream(stream);
const again = () => {
pc.addTrack(stream.getTracks()[0], stream);
};
expect(again).to.throw(/already/)
.that.has.property('name').that.equals('InvalidAccessError');
});
});
it('if addStream is called with a stream containing a track ' +
'already added', () => {
return navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(stream => {
pc.addTrack(stream.getTracks()[0], stream);
const again = () => {
pc.addStream(stream);
};
expect(again).to.throw(/already/)
.that.has.property('name').that.equals('InvalidAccessError');
});
});
it('if the peerconnection has been closed already', () => {
return navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
pc.close();
const afterClose = () => {
pc.addTrack(stream.getTracks()[0], stream);
};
expect(afterClose).to.throw(/closed/)
.that.has.property('name').that.equals('InvalidStateError');
});
});
});
});
describe('getConfiguration', () => {
let pc;
it('fills in default values when no configuration is passed', () => {
// do as jan-ivar says in
// https://github.com/w3c/webrtc-pc/issues/1322#issuecomment-305878881
pc = new RTCPeerConnection();
const config = pc.getConfiguration();
expect(config).to.be.an('Object');
expect(config.bundlePolicy).to.equal('balanced');
expect(config.iceCandidatePoolSize).to.equal(0);
expect(config.iceServers).to.be.an('Array');
expect(config.iceServers.length).equal(0);
expect(config.iceTransportPolicy).to.equal('all');
expect(config.rtcpMuxPolicy).to.equal('require');
});
});
describe('filtering of STUN and TURN servers', () => {
let pc;
it('converts legacy url member to urls', () => {
pc = new RTCPeerConnection({
iceServers: [{url: 'stun:stun.l.google.com'}]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([
{urls: 'stun:stun.l.google.com'}
]);
});
it('filters STUN before r14393', () => {
RTCPeerConnection = shimPeerConnection(window, 14392);
pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com'}]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([]);
});
it('does not filter STUN without protocol after r14393', () => {
pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com'}]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([
{urls: 'stun:stun.l.google.com'}
]);
});
it('does filter STUN with protocol even after r14393', () => {
pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302?transport=udp'}]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([]);
});
it('filters incomplete TURN urls', () => {
pc = new RTCPeerConnection({
iceServers: [
{urls: 'turn:stun.l.google.com'},
{urls: 'turn:stun.l.google.com:19302'}
]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([]);
});
it('filters TURN TCP', () => {
pc = new RTCPeerConnection({
iceServers: [
{urls: 'turn:stun.l.google.com:19302?transport=tcp'}
]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([]);
});
describe('removes all but the first server of a type', () => {
it('in separate entries', () => {
pc = new RTCPeerConnection({
iceServers: [
{urls: 'stun:stun.l.google.com'},
{urls: 'turn:stun.l.google.com:19301?transport=udp'},
{urls: 'turn:stun.l.google.com:19302?transport=udp'}
]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([
{urls: 'stun:stun.l.google.com'},
{urls: 'turn:stun.l.google.com:19301?transport=udp'}
]);
});
it('in urls entries', () => {
pc = new RTCPeerConnection({
iceServers: [
{urls: 'stun:stun.l.google.com'},
{urls: [
'turn:stun.l.google.com:19301?transport=udp',
'turn:stun.l.google.com:19302?transport=udp'
]}
]
});
const config = pc.getConfiguration();
expect(config.iceServers).to.deep.equal([
{urls: 'stun:stun.l.google.com'},
{urls: ['turn:stun.l.google.com:19301?transport=udp']}
]);
});
});
});
describe('getStats', () => {
let pc;
beforeEach((done) => {
pc = new RTCPeerConnection();
navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
pc.addTrack(stream.getAudioTracks()[0], stream);
done();
});
});
afterEach(() => {
pc.close();
});
it('returns a promise', (done) => {
pc.getStats()
.then(() => {
done();
});
});
it('calls the legacy success callback', (done) => {
pc.getStats(null, function() {
done();
});
});
it('hyphenates stats', () => {
return pc.getStats()
.then(stats => {
let hasOutbound = false;
stats.forEach(stat => hasOutbound |= (stat.type === 'outbound-rtp'));
expect(hasOutbound).to.equal(1); // |= changes to 1.
});
});
describe('with a track selector', () => {
it('calls getStats on the sender', () => {
const sender = pc.getSenders()[0];
sinon.spy(sender, 'getStats');
return pc.getStats(sender.track)
.then(() => {
expect(sender.getStats).to.have.been.calledOnce();
});
});
it('calls getStats on the receiver', () => {
const sdp = SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE;
let receiver;
return pc.setRemoteDescription({type: 'offer', sdp})
.then(() => {
receiver = pc.getReceivers()[0];
sinon.spy(receiver, 'getStats');
return pc.getStats(receiver.track);
})
.then(() => {
expect(receiver.getStats).to.have.been.calledOnce();
});
});
it('throws an InvalidAccessError if the track is not assocіated', () => {
const getStats = () => {
pc.getStats(new window.MediaStreamTrack());
};
expect(getStats).to.throw()
.that.has.property('name').that.equals('InvalidAccessError');
});
});
});
describe('RTCIceCandidate contains a port property in', () => {
it('the onicecandidate callback', (done) => {
let hasProperty = false;
const pc = new RTCPeerConnection();
pc.onicecandidate = (e) => {
if (!e.candidate) {
expect(hasProperty).to.equal(true);
done();
} else {
hasProperty = e.candidate.hasOwnProperty('port');
}
};
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer));
});
it('the icecandidate event', (done) => {
let hasProperty = false;
const pc = new RTCPeerConnection();
pc.addEventListener('icecandidate', (e) => {
if (!e.candidate) {
expect(hasProperty).to.equal(true);
done();
} else {
hasProperty = e.candidate.hasOwnProperty('port');
}
});
pc.createOffer({offerToReceiveAudio: true})
.then(offer => pc.setLocalDescription(offer));
});
});
describe('_updateIceConnectionState', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
return pc.createOffer({offerToReceiveAudio: 1});
});
afterEach(() => {
pc.close();
});
it('calls both event and oicenconnectionstatechange', () => {
pc.iceConnectionState = 'weird state';
const stub = sinon.stub();
pc.oniceconnectionstatechange = stub;
pc.addEventListener('iceconnectionstatechange', stub);
pc._updateIceConnectionState();
expect(stub).to.have.been.calledTwice();
expect(pc.iceConnectionState).to.equal('new');
});
describe('emits connectionstatechange when ice is', () => {
['checking', 'connected', 'completed', 'disconnected', 'failed']
.forEach(state => {
it(state, () => {
const transceiver = pc.transceivers[0];
const iceTransport = transceiver.iceTransport;
iceTransport.state = state;
const stub = sinon.stub();
pc.oniceconnectionstatechange = stub;
iceTransport.onicestatechange();
expect(stub).to.have.been.calledOnce();
expect(pc.iceConnectionState).to.equal(state);
});
});
});
});
describe('_updateConnectionState', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
return pc.createOffer({offerToReceiveAudio: 1});
});
afterEach(() => {
pc.close();
});
it('calls both event and onconnectionstatechange', () => {
pc.connectionState = 'weird state';
const stub = sinon.stub();
pc.onconnectionstatechange = stub;
pc.addEventListener('connectionstatechange', stub);
pc._updateConnectionState();
expect(stub).to.have.been.calledTwice();
expect(pc.connectionState).to.equal('new');
});
it('does not emit connectionstatechange when just the ' +
'ice connection changes', () => {
const transceiver = pc.transceivers[0];
const iceTransport = transceiver.iceTransport;
iceTransport.state = 'connected';
const stub = sinon.stub();
pc.onconnectionstatechange = stub;
pc.addEventListener('connectionstatechange', stub);
iceTransport.onicestatechange();
expect(stub).not.to.have.been.calledWith();
});
it('emits connectionstatechange when ice and dtls are connected', () => {
const transceiver = pc.transceivers[0];
const iceTransport = transceiver.iceTransport;
iceTransport.state = 'connected';
const dtlsTransport = transceiver.dtlsTransport;
dtlsTransport.state = 'connected';
const stub = sinon.stub();
pc.onconnectionstatechange = stub;
dtlsTransport.ondtlsstatechange();
expect(stub).to.have.been.calledOnce();
expect(pc.connectionState).to.equal('connected');
});
it('changes the connection state to failed when there ' +
'was a DTLS error', () => {
const transceiver = pc.transceivers[0];
const dtlsTransport = transceiver.dtlsTransport;
const stub = sinon.stub();
pc.onconnectionstatechange = stub;
dtlsTransport.onerror();
expect(stub).to.have.been.calledOnce();
expect(pc.connectionState).to.equal('failed');
});
it('changes the connection state to disconnected when the ICE ' +
'connection disconnects', () => {
pc.connectionState = 'connected';
const transceiver = pc.transceivers[0];
const iceTransport = transceiver.iceTransport;
iceTransport.state = 'disconnected';
const dtlsTransport = transceiver.dtlsTransport;
dtlsTransport.state = 'connected';
const stub = sinon.stub();
pc.onconnectionstatechange = stub;
iceTransport.onicestatechange();
expect(stub).to.have.been.calledOnce();
expect(pc.iceConnectionState).to.equal('disconnected');
});
});
describe('edge pre-rtx behaviour', () => {
let pc;
beforeEach(() => {
RTCPeerConnection = shimPeerConnection(window, 15000); // must be < 15019
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('does not create an offer with RTX', (done) => {
pc.createOffer({offerToReceiveVideo: true})
.then((offer) => {
expect(offer.sdp).not.to.contain(' rtx/90000');
done();
});
});
it('does not answer with RTX', (done) => {
const sdp = SDP_BOILERPLATE +
'm=video 9 UDP/TLS/RTP/SAVPF 102 103\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:' + ICEUFRAG + '\r\n' +
'a=ice-pwd:' + ICEPWD + '\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video1\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:102 vp8/90000\r\n' +
'a=rtpmap:103 rtx/90000\r\n' +
'a=fmtp:103 apt=102\r\n' +
'a=ssrc-group:FID 1001 1002\r\n' +
'a=ssrc:1001 msid:stream1 track1\r\n' +
'a=ssrc:1001 cname:some\r\n' +
'a=ssrc:1002 msid:stream1 track1\r\n' +
'a=ssrc:1002 cname:some\r\n';
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
pc.addTrack(stream.getTracks()[0], stream);
return pc.setRemoteDescription({type: 'offer', sdp});
})
.then(() => {
return pc.createAnswer();
})
.then((answer) => {
expect(answer.sdp).not.to.contain(' rtx/90000');
done();
});
});
});
describe('non-rtx answer to rtx', () => {
let pc;
beforeEach(() => {
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('does not call send() with RTX', () => {
let sender;
return navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
sender = pc.addTrack(stream.getTracks()[0], stream);
sender.send = sinon.stub();
})
.then(() => pc.createOffer())
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
const localMid = SDPUtils.getMid(
SDPUtils.splitSections(pc.localDescription.sdp)[1]);
const candidateString = 'a=candidate:702786350 1 udp 41819902 ' +
'8.8.8.8 60769 typ host';
const sdp = 'v=0\r\n' +
'o=- 0 0 IN IP4 127.0.0.1\r\n' +
's=nortxanswer\r\n' +
't=0 0\r\n' +
'm=video 1 UDP/TLS/RTP/SAVPF 100\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtpmap:100 VP8/90000\r\n' +
'a=rtcp:1 IN IP4 0.0.0.0\r\n' +
'a=rtcp-fb:100 nack\r\n' +
'a=rtcp-fb:100 nack pli\r\n' +
'a=rtcp-fb:100 goog-remb\r\n' +
'a=extmap:1 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n' +
'a=setup:active\r\n' +
'a=mid:' + localMid + '\r\n' +
'a=recvonly\r\n' +
'a=ice-ufrag:S5Zq\r\n' +
'a=ice-pwd:6E1muhzVwnphsbN6uokNU/\r\n' +
'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' +
candidateString + '\r\n' +
'a=end-of-candidates\r\n' +
'a=rtcp-mux\r\n';
return pc.setRemoteDescription({type: 'answer', sdp});
})
.then(() => {
expect(sender.send).to.have.been.calledWith(
sinon.match.has('encodings', [{ssrc: 1001}]));
});
});
});
describe('edge clonestream issue', () => {
let pc;
beforeEach(() => {
RTCPeerConnection = shimPeerConnection(window, 15000); // must be < 15025
pc = new RTCPeerConnection();
});
afterEach(() => {
pc.close();
});
it('clones the stream before addStream', () => {
navigator.mediaDevices.getUserMedia({video: true})
.then((stream) => {
stream.clone = sinon.stub().returns(stream);
pc.addStream(stream);
expect(stream.clone).to.have.been.calledOnce();
});
});
});
});