Files
2025-11-30 08:35:03 +02:00

3878 lines
129 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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();
});
});
});
});