|  | <!doctype html> | 
|  | <title>Test RTCPeerConnection.prototype.addIceCandidate</title> | 
|  | <script src="/resources/testharness.js"></script> | 
|  | <script src="/resources/testharnessreport.js"></script> | 
|  | <script> | 
|  | 'use strict'; | 
|  |  | 
|  | // Test is based on the following editor draft: | 
|  | // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.htm | 
|  |  | 
|  | /* | 
|  | 4.3.2. Interface Definition | 
|  | interface RTCPeerConnection : EventTarget { | 
|  | ... | 
|  | Promise<void> addIceCandidate((RTCIceCandidateInit or RTCIceCandidate) candidate); | 
|  | }; | 
|  |  | 
|  | interface RTCIceCandidate { | 
|  | readonly attribute DOMString candidate; | 
|  | readonly attribute DOMString? sdpMid; | 
|  | readonly attribute unsigned short? sdpMLineIndex; | 
|  | readonly attribute DOMString? ufrag; | 
|  | ... | 
|  | }; | 
|  |  | 
|  | dictionary RTCIceCandidateInit { | 
|  | DOMString candidate = ""; | 
|  | DOMString? sdpMid = null; | 
|  | unsigned short? sdpMLineIndex = null; | 
|  | DOMString ufrag; | 
|  | }; | 
|  | */ | 
|  |  | 
|  | // SDP copied from JSEP Example 7.1 | 
|  | // It contains two media streams with different ufrags | 
|  | // to test if candidate is added to the correct stream | 
|  | const sdp = `v=0 | 
|  | o=- 4962303333179871722 1 IN IP4 0.0.0.0 | 
|  | s=- | 
|  | t=0 0 | 
|  | a=ice-options:trickle | 
|  | a=group:BUNDLE a1 v1 | 
|  | a=group:LS a1 v1 | 
|  | m=audio 10100 UDP/TLS/RTP/SAVPF 96 0 8 97 98 | 
|  | c=IN IP4 203.0.113.100 | 
|  | a=mid:a1 | 
|  | a=sendrecv | 
|  | a=rtpmap:96 opus/48000/2 | 
|  | a=rtpmap:0 PCMU/8000 | 
|  | a=rtpmap:8 PCMA/8000 | 
|  | a=rtpmap:97 telephone-event/8000 | 
|  | a=rtpmap:98 telephone-event/48000 | 
|  | a=maxptime:120 | 
|  | a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid | 
|  | a=extmap:2 urn:ietf:params:rtp-hdrext:ssrc-audio-level | 
|  | a=msid:47017fee-b6c1-4162-929c-a25110252400 f83006c5-a0ff-4e0a-9ed9-d3e6747be7d9 | 
|  | a=ice-ufrag:ETEn | 
|  | a=ice-pwd:OtSK0WpNtpUjkY4+86js7ZQl | 
|  | a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 | 
|  | a=setup:actpass | 
|  | a=dtls-id:1 | 
|  | a=rtcp:10101 IN IP4 203.0.113.100 | 
|  | a=rtcp-mux | 
|  | a=rtcp-rsize | 
|  | m=video 10102 UDP/TLS/RTP/SAVPF 100 101 | 
|  | c=IN IP4 203.0.113.100 | 
|  | a=mid:v1 | 
|  | a=sendrecv | 
|  | a=rtpmap:100 VP8/90000 | 
|  | a=rtpmap:101 rtx/90000 | 
|  | a=fmtp:101 apt=100 | 
|  | a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid | 
|  | a=rtcp-fb:100 ccm fir | 
|  | a=rtcp-fb:100 nack | 
|  | a=rtcp-fb:100 nack pli | 
|  | a=msid:47017fee-b6c1-4162-929c-a25110252400 f30bdb4a-5db8-49b5-bcdc-e0c9a23172e0 | 
|  | a=ice-ufrag:BGKk | 
|  | a=ice-pwd:mqyWsAjvtKwTGnvhPztQ9mIf | 
|  | a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 | 
|  | a=setup:actpass | 
|  | a=dtls-id:1 | 
|  | a=rtcp:10103 IN IP4 203.0.113.100 | 
|  | a=rtcp-mux | 
|  | a=rtcp-rsize | 
|  | `; | 
|  |  | 
|  | const sessionDesc = { type: 'offer', sdp }; | 
|  |  | 
|  | // valid candidate attributes | 
|  | const sdpMid = 'a1'; | 
|  | const sdpMLineIndex = 0; | 
|  | const ufrag = 'ETEn'; | 
|  |  | 
|  | const sdpMid2 = 'v1'; | 
|  | const sdpMLineIndex2 = 1; | 
|  | const ufrag2 = 'BGKk'; | 
|  |  | 
|  | const mediaLine1 = 'm=audio'; | 
|  | const mediaLine2 = 'm=video'; | 
|  |  | 
|  | const candidateStr1 = 'candidate:1 1 udp 2113929471 203.0.113.100 10100 typ host'; | 
|  | const candidateStr2 = 'candidate:1 2 udp 2113929470 203.0.113.100 10101 typ host'; | 
|  | const invalidCandidateStr = '(Invalid) candidate \r\n string'; | 
|  |  | 
|  | const candidateLine1 = `a=${candidateStr1}`; | 
|  | const candidateLine2 = `a=${candidateStr2}`; | 
|  | const endOfCandidateLine = 'a=end-of-candidates'; | 
|  |  | 
|  | // Copied from MDN | 
|  | function escapeRegExp(string) { | 
|  | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | 
|  | } | 
|  |  | 
|  | // Check that a candidate line is found after the first media line | 
|  | // but before the second, i.e. it belongs to the first media stream | 
|  | function assert_candidate_line_between(sdp, beforeMediaLine, candidateLine, afterMediaLine) { | 
|  | const line1 = escapeRegExp(beforeMediaLine); | 
|  | const line2 = escapeRegExp(candidateLine); | 
|  | const line3 = escapeRegExp(afterMediaLine); | 
|  |  | 
|  | const regex = new RegExp(`${line1}[^]+${line2}[^]+${line3}`); | 
|  |  | 
|  | assert_true(regex.test(sdp), | 
|  | `Expect candidate line to be found between media lines ${beforeMediaLine} and ${afterMediaLine}`); | 
|  | } | 
|  |  | 
|  | // Check that a candidate line is found after the second media line | 
|  | // i.e. it belongs to the second media stream | 
|  | function assert_candidate_line_after(sdp, beforeMediaLine, candidateLine) { | 
|  | const line1 = escapeRegExp(beforeMediaLine); | 
|  | const line2 = escapeRegExp(candidateLine); | 
|  |  | 
|  | const regex = new RegExp(`${line1}[^]+${line2}`); | 
|  |  | 
|  | assert_true(regex.test(sdp), | 
|  | `Expect candidate line to be found after media line ${beforeMediaLine}`); | 
|  | } | 
|  |  | 
|  | // Reject because WebIDL for addIceCandidate does not allow null argument | 
|  | // null can be accidentally passed from onicecandidate event handler | 
|  | // when null is used to indicate end of candidate | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate(null))); | 
|  | }, 'Add null candidate should reject with TypeError'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4. Return the result of enqueuing the following steps: | 
|  | 1. If remoteDescription is null return a promise rejected with a | 
|  | newly created InvalidStateError. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return promise_rejects(t, 'InvalidStateError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | })); | 
|  | }, 'Add ICE candidate before setting remote description should reject with InvalidStateError'); | 
|  |  | 
|  | /* | 
|  | Success cases | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | })); | 
|  | }, 'Add ICE candidate after setting remote description should succeed'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate(new RTCIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | }))); | 
|  | }, 'Add ICE candidate with RTCIceCandidate should succeed'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ sdpMid })); | 
|  | }, 'Add candidate with only valid sdpMid should succeed'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ sdpMLineIndex })); | 
|  | }, 'Add candidate with only valid sdpMLineIndex should succeed'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.6.2. If candidate is applied successfully, the user agent MUST queue | 
|  | a task that runs the following steps: | 
|  | 2. Let remoteDescription be connection's pendingRemoteDescription | 
|  | if not null, otherwise connection's currentRemoteDescription. | 
|  | 3. Add candidate to remoteDescription. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_between(pc.remoteDescription.sdp, | 
|  | mediaLine1, candidateLine1, mediaLine2); | 
|  | }); | 
|  | }, 'addIceCandidate with first sdpMid and sdpMLineIndex add candidate to first media stream'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr2, | 
|  | sdpMid: sdpMid2, | 
|  | sdpMLineIndex: sdpMLineIndex2, | 
|  | ufrag: ufrag2 | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_after(pc.remoteDescription.sdp, | 
|  | mediaLine2, candidateLine2); | 
|  | }); | 
|  | }, 'addIceCandidate with second sdpMid and sdpMLineIndex should add candidate to second media stream'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, | 
|  | ufrag: null | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_between(pc.remoteDescription.sdp, | 
|  | mediaLine1, candidateLine1, mediaLine2); | 
|  | }); | 
|  | }, 'Add candidate for first media stream with null ufrag should add candidate to first media stream'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | })) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr2, | 
|  | sdpMid: sdpMid2, | 
|  | sdpMLineIndex: sdpMLineIndex2, | 
|  | ufrag: ufrag2 | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_between(pc.remoteDescription.sdp, | 
|  | mediaLine1, candidateLine1, mediaLine2); | 
|  |  | 
|  | assert_candidate_line_after(pc.remoteDescription.sdp, | 
|  | mediaLine2, candidateLine2); | 
|  | }); | 
|  | }, 'Adding multiple candidates should add candidates to their corresponding media stream'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.6. If candidate.candidate is an empty string, process candidate as an | 
|  | end-of-candidates indication for the corresponding media description | 
|  | and ICE candidate generation. | 
|  | 2. If candidate is applied successfully, the user agent MUST queue | 
|  | a task that runs the following steps: | 
|  | 2. Let remoteDescription be connection's pendingRemoteDescription | 
|  | if not null, otherwise connection's currentRemoteDescription. | 
|  | 3. Add candidate to remoteDescription. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | })) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: '', | 
|  | sdpMid, sdpMLineIndex, | 
|  | ufrag | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_between(pc.remoteDescription.sdp, | 
|  | mediaLine1, candidateLine1, mediaLine2); | 
|  |  | 
|  | assert_candidate_line_between(pc.remoteDescription.sdp, | 
|  | mediaLine1, endOfCandidateLine, mediaLine2); | 
|  | }); | 
|  | }, 'Add with empty candidate string (end of candidate) should succeed'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 3. If both sdpMid and sdpMLineIndex are null, return a promise rejected | 
|  | with a newly created TypeError. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid: null, | 
|  | sdpMLineIndex: null | 
|  | }))); | 
|  | }, 'Add candidate with both sdpMid and sdpMLineIndex manually set to null should reject with TypeError'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1 | 
|  | }))); | 
|  | }, 'Add candidate with only valid candidate string should reject with TypeError'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate({ | 
|  | candidate: invalidCandidateStr, | 
|  | sdpMid: null, | 
|  | sdpMLineIndex: null | 
|  | }))); | 
|  | }, 'Add candidate with invalid candidate string and both sdpMid and sdpMLineIndex null should reject with TypeError'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate({}))); | 
|  | }, 'Add candidate with empty dict should reject with TypeError'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, new TypeError(), | 
|  | pc.addIceCandidate({ | 
|  | candidate: '', | 
|  | sdpMid: null, | 
|  | sdpMLineIndex: null, | 
|  | ufrag: undefined | 
|  | }))); | 
|  | }, 'Add candidate with manually filled default values should reject with TypeError'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.3. If candidate.sdpMid is not null, run the following steps: | 
|  | 1. If candidate.sdpMid is not equal to the mid of any media | 
|  | description in remoteDescription , reject p with a newly | 
|  | created OperationError and abort these steps. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, 'OperationError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid: 'invalid', sdpMLineIndex, ufrag | 
|  | }))); | 
|  | }, 'Add candidate with invalid sdpMid should reject with OperationError'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.4. Else, if candidate.sdpMLineIndex is not null, run the following | 
|  | steps: | 
|  | 1. If candidate.sdpMLineIndex is equal to or larger than the | 
|  | number of media descriptions in remoteDescription , reject p | 
|  | with a newly created OperationError and abort these steps. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, 'OperationError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMLineIndex: 2, | 
|  | ufrag | 
|  | }))); | 
|  | }, 'Add candidate with invalid sdpMLineIndex should reject with OperationError'); | 
|  |  | 
|  | // There is an "Else" for the statement: | 
|  | // "Else, if candidate.sdpMLineIndex is not null, ..." | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, | 
|  | sdpMLineIndex: 2, | 
|  | ufrag | 
|  | })); | 
|  | }, 'Invalid sdpMLineIndex should be ignored if valid sdpMid is provided'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => pc.addIceCandidate({ | 
|  | candidate: candidateStr2, | 
|  | sdpMid: sdpMid2, | 
|  | sdpMLineIndex: sdpMLineIndex2, | 
|  | ufrag: null | 
|  | })) | 
|  | .then(() => { | 
|  | assert_candidate_line_after(pc.remoteDescription.sdp, | 
|  | mediaLine2, candidateLine2); | 
|  | }); | 
|  | }, 'Add candidate for media stream 2 with null ufrag should succeed'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.5. If candidate.ufrag is neither undefined nor null, and is not equal | 
|  | to any ufrag present in the corresponding media description of an | 
|  | applied remote description, reject p with a newly created | 
|  | OperationError and abort these steps. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, 'OperationError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr1, | 
|  | sdpMid, sdpMLineIndex, | 
|  | ufrag: 'invalid' | 
|  | }))); | 
|  | }, 'Add candidate with invalid ufrag should reject with OperationError'); | 
|  |  | 
|  | /* | 
|  | 4.3.2. addIceCandidate | 
|  | 4.6.1. If candidate could not be successfully added the user agent MUST | 
|  | queue a task that runs the following steps: | 
|  | 2. Reject p with a DOMException object whose name attribute has | 
|  | the value OperationError and abort these steps. | 
|  | */ | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, 'OperationError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: invalidCandidateStr, | 
|  | sdpMid, sdpMLineIndex, ufrag | 
|  | }))); | 
|  | }, 'Add candidate with invalid candidate string should reject with OperationError'); | 
|  |  | 
|  | promise_test(t => { | 
|  | const pc = new RTCPeerConnection(); | 
|  |  | 
|  | return pc.setRemoteDescription(sessionDesc) | 
|  | .then(() => | 
|  | promise_rejects(t, 'OperationError', | 
|  | pc.addIceCandidate({ | 
|  | candidate: candidateStr2, | 
|  | sdpMid: sdpMid2, | 
|  | sdpMLineIndex: sdpMLineIndex2, | 
|  | ufrag: ufrag | 
|  | }))); | 
|  | }, 'Add candidate with sdpMid belonging to different ufrag should reject with OperationError'); | 
|  |  | 
|  | /* | 
|  | TODO | 
|  | 4.3.2. addIceCandidate | 
|  | 4.6. In parallel, add the ICE candidate candidate as described in [JSEP] | 
|  | (section 4.1.17.). Use candidate.ufrag to identify the ICE generation; | 
|  |  | 
|  | If the ufrag is null, process the candidate for the most recent ICE | 
|  | generation. | 
|  |  | 
|  | - Call with candidate string containing partial malformed syntax, i.e. malformed IP. | 
|  | Some browsers may ignore the syntax error and add it to the SDP regardless. | 
|  |  | 
|  | Non-Testable | 
|  | 4.3.2. addIceCandidate | 
|  | 4.6. (The steps are non-testable because the abort step in enqueue operation | 
|  | steps in before they can reach here): | 
|  | 1. If candidate could not be successfully added the user agent MUST | 
|  | queue a task that runs the following steps: | 
|  | 1. If connection's [[isClosed]] slot is true, then abort | 
|  | these steps. | 
|  |  | 
|  | 2. If candidate is applied successfully, the user agent MUST queue | 
|  | a task that runs the following steps: | 
|  | 1. If connection's [[isClosed]] slot is true, then abort these steps. | 
|  |  | 
|  | Issues | 
|  | w3c/webrtc-pc#1213 | 
|  | addIceCandidate end of candidates woes | 
|  |  | 
|  | w3c/webrtc-pc#1216 | 
|  | Clarify addIceCandidate behavior when adding candidate after end of candidate | 
|  |  | 
|  | w3c/webrtc-pc#1227 | 
|  | addIceCandidate may add ice candidate to the wrong remote description | 
|  |  | 
|  | w3c/webrtc-pc#1345 | 
|  | Make promise rejection/enqueing consistent | 
|  |  | 
|  | Coverage Report | 
|  | Total: 23 | 
|  | Tested: 19 | 
|  | Not Tested: 2 | 
|  | Non-Testable: 2 | 
|  | */ | 
|  | </script> |