| <!doctype html> | |
| <meta charset=utf-8> | |
| <meta name="variant" content="?interop-2026"> | |
| <meta name="variant" content="?rest"> | |
| <meta name="timeout" content="long"> | |
| <title>RTCPeerConnection.prototype.getStats</title> | |
| <script src="/resources/testharness.js"></script> | |
| <script src="/resources/testharnessreport.js"></script> | |
| <script src="RTCPeerConnection-helper.js"></script> | |
| <script> | |
| 'use strict'; | |
| const rest = !new URLSearchParams(location.search).has("interop-2026"); | |
| const interop2026 = !new URLSearchParams(location.search).has("rest"); | |
| if (rest) { | |
| // Test is based on the following editor draft: | |
| // webrtc-pc 20171130 | |
| // webrtc-stats 20171122 | |
| // The following helper function is called from RTCPeerConnection-helper.js | |
| // getTrackFromUserMedia | |
| // The following helper function is called from RTCPeerConnection-helper.js | |
| // exchangeIceCandidates | |
| // exchangeOfferAnswer | |
| /* | |
| 8.2. getStats | |
| 1. Let selectorArg be the method's first argument. | |
| 2. Let connection be the RTCPeerConnection object on which the method was invoked. | |
| 3. If selectorArg is null, let selector be null. | |
| 4. If selectorArg is a MediaStreamTrack let selector be an RTCRtpSender | |
| or RTCRtpReceiver on connection which track member matches selectorArg. | |
| If no such sender or receiver exists, or if more than one sender or | |
| receiver fit this criteria, return a promise rejected with a newly | |
| created InvalidAccessError. | |
| 5. Let p be a new promise. | |
| 6. Run the following steps in parallel: | |
| 1. Gather the stats indicated by selector according to the stats selection algorithm. | |
| 2. Resolve p with the resulting RTCStatsReport object, containing the gathered stats. | |
| */ | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| return pc.getStats(); | |
| }, 'getStats() with no argument should succeed'); | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| return pc.getStats(null); | |
| }, 'getStats(null) should succeed'); | |
| } | |
| if (interop2026) { | |
| /* | |
| 8.2. getStats | |
| 4. If selectorArg is a MediaStreamTrack let selector be an RTCRtpSender | |
| or RTCRtpReceiver on connection which track member matches selectorArg. | |
| If no such sender or receiver exists, or if more than one sender or | |
| receiver fit this criteria, return a promise rejected with a newly | |
| created InvalidAccessError. | |
| */ | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| return getTrackFromUserMedia('audio') | |
| .then(([track, mediaStream]) => { | |
| return promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(track)); | |
| }); | |
| }, 'getStats() with track not added to connection should reject with InvalidAccessError'); | |
| } | |
| if (rest) { | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| return getTrackFromUserMedia('audio') | |
| .then(([track, mediaStream]) => { | |
| pc.addTrack(track, mediaStream); | |
| return pc.getStats(track); | |
| }); | |
| }, 'getStats() with track added via addTrack should succeed'); | |
| promise_test(async t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| const stream = await getNoiseStream({audio: true}); | |
| t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); | |
| const [track] = stream.getTracks(); | |
| pc.addTransceiver(track); | |
| return pc.getStats(track); | |
| }, 'getStats() with track added via addTransceiver should succeed'); | |
| } | |
| if (interop2026) { | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| const transceiver1 = pc.addTransceiver('audio'); | |
| // Create another transceiver that resends what | |
| // is being received, kind of like echo | |
| const transceiver2 = pc.addTransceiver(transceiver1.receiver.track); | |
| assert_equals(transceiver1.receiver.track, transceiver2.sender.track); | |
| return promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(transceiver1.receiver.track)); | |
| }, 'getStats() with track associated with both sender and receiver should reject with InvalidAccessError'); | |
| /* | |
| 8.5. The stats selection algorithm | |
| 2. If selector is null, gather stats for the whole connection, add them to result, | |
| return result, and abort these steps. | |
| */ | |
| promise_test(t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| return pc.getStats() | |
| .then(statsReport => { | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'peer-connection')); | |
| }); | |
| }, 'getStats() with no argument should return stats report containing peer-connection stats on an empty PC'); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(sendtrack, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await Promise.all([ | |
| exchangeOfferAnswer(pc, pc2), | |
| new Promise(r => pc2.ontrack = e => e.track.onunmute = r) | |
| ]); | |
| const statsReport = await pc.getStats(); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'peer-connection')); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'outbound-rtp')); | |
| }, 'getStats() track with stream returns peer-connection and outbound-rtp stats'); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(sendtrack); | |
| exchangeIceCandidates(pc, pc2); | |
| await Promise.all([ | |
| exchangeOfferAnswer(pc, pc2), | |
| new Promise(r => pc2.ontrack = e => e.track.onunmute = r) | |
| ]); | |
| const statsReport = await pc.getStats(); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'peer-connection')); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'outbound-rtp')); | |
| }, 'getStats() track without stream returns peer-connection and outbound-rtp stats'); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(sendtrack, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await Promise.all([ | |
| exchangeOfferAnswer(pc, pc2), | |
| new Promise(r => pc2.ontrack = e => e.track.onunmute = r) | |
| ]); | |
| const statsReport = await pc.getStats(); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'outbound-rtp')); | |
| }, 'getStats() audio contains outbound-rtp stats'); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| const [sendtrack, mediaStream] = await getTrackFromUserMedia('video'); | |
| pc.addTrack(sendtrack, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await Promise.all([ | |
| exchangeOfferAnswer(pc, pc2), | |
| new Promise(r => pc2.ontrack = e => e.track.onunmute = r) | |
| ]); | |
| const statsReport = await pc.getStats(); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'outbound-rtp')); | |
| }, 'getStats() video contains outbound-rtp stats'); | |
| /* | |
| 8.5. The stats selection algorithm | |
| 3. If selector is an RTCRtpSender, gather stats for and add the following objects | |
| to result: | |
| - All RTCOutboundRtpStreamStats objects corresponding to selector. | |
| - All stats objects referenced directly or indirectly by the RTCOutboundRtpStreamStats | |
| objects added. | |
| */ | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| let [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(sendtrack, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await Promise.all([ | |
| exchangeOfferAnswer(pc, pc2), | |
| new Promise(r => pc2.ontrack = e => e.track.onunmute = r) | |
| ]); | |
| const statsReport = await pc.getStats(sendtrack); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'outbound-rtp')); | |
| }, `getStats() on track associated with RTCRtpSender should return stats report containing outbound-rtp stats`); | |
| /* | |
| 8.5. The stats selection algorithm | |
| 4. If selector is an RTCRtpReceiver, gather stats for and add the following objects | |
| to result: | |
| - All RTCInboundRtpStreamStats objects corresponding to selector. | |
| - All stats objects referenced directly or indirectly by the RTCInboundRtpStreamStats | |
| added. | |
| */ | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| let [track, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(track, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await exchangeOfferAnswer(pc, pc2); | |
| // Wait for unmute if the track is not already unmuted. | |
| // According to spec, it should be muted when being created, but this | |
| // is not what this test is testing, so allow it to be unmuted. | |
| if (pc2.getReceivers()[0].track.muted) { | |
| await new Promise(resolve => { | |
| pc2.getReceivers()[0].track.addEventListener('unmute', resolve); | |
| }); | |
| } | |
| const statsReport = await pc2.getStats(pc2.getReceivers()[0].track); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'inbound-rtp')); | |
| }, `getStats() on track associated with RTCRtpReceiver should return stats report containing inbound-rtp stats`); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| let [track, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTrack(track, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| await exchangeOfferAnswer(pc, pc2); | |
| // Wait for unmute if the track is not already unmuted. | |
| // According to spec, it should be muted when being created, but this | |
| // is not what this test is testing, so allow it to be unmuted. | |
| if (pc2.getReceivers()[0].track.muted) { | |
| await new Promise(resolve => { | |
| pc2.getReceivers()[0].track.addEventListener('unmute', resolve); | |
| }); | |
| } | |
| const statsReport = await pc2.getStats(pc2.getReceivers()[0].track); | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'inbound-rtp')); | |
| }, `getStats() audio contains inbound-rtp stats`); | |
| promise_test(async t => { | |
| const pc = createPeerConnectionWithCleanup(t); | |
| const pc2 = createPeerConnectionWithCleanup(t); | |
| function check_no_candidate_pair_stats(statsReport) { | |
| const candidatePairStats = [...(statsReport).values()].filter(({type}) => type === 'candidate-pair'); | |
| assert_greater_than_equal(candidatePairStats.length, 0); | |
| } | |
| function check_candidate_pair_stats(statsReport, elapsed_time_ms) { | |
| assert_true(!![...statsReport.values()].find(({type}) => type === 'candidate-pair')); | |
| const candidatePairStats = [...(statsReport).values()].filter(({type}) => type === 'candidate-pair'); | |
| assert_greater_than_equal(candidatePairStats.length, 1); | |
| for (let pairStats of candidatePairStats) { | |
| assert_not_equals(pairStats.responsesReceived, null, "responsesReceived should not be null"); | |
| assert_not_equals(pairStats.totalRoundTripTime, null, "totalRoundTripTime should not be null"); | |
| assert_not_equals(pairStats.currentRoundTripTime, null, "currentRoundTripTime should not be null"); | |
| assert_greater_than_equal(pairStats.responsesReceived, 0); | |
| assert_greater_than_equal(pairStats.totalRoundTripTime, 0); | |
| assert_greater_than_equal(pairStats.currentRoundTripTime, 0); | |
| if (pairStats.responsesReceived < 2) { | |
| assert_equals(pairStats.totalRoundTripTime, pairStats.currentRoundTripTime); | |
| } else { | |
| assert_greater_than_equal(pairStats.totalRoundTripTime, pairStats.currentRoundTripTime); | |
| } | |
| assert_less_than_equal(pairStats.totalRoundTripTime*1000, elapsed_time_ms) | |
| } | |
| } | |
| let [track, mediaStream] = await getTrackFromUserMedia('video'); | |
| pc.addTrack(track, mediaStream); | |
| exchangeIceCandidates(pc, pc2); | |
| assert_equals(pc2.getReceivers().length, 0); | |
| await exchangeOfferAnswer(pc, pc2); | |
| assert_equals(pc.getSenders().length, 1); | |
| assert_equals(pc2.getReceivers().length, 1); | |
| const t0 = performance.now(); | |
| check_no_candidate_pair_stats(await pc.getStats(pc.getSenders()[0].track)); | |
| // Wait for unmute if the track is not already unmuted. | |
| // According to spec, it should be muted when being created, but this | |
| // is not what this test is testing, so allow it to be unmuted. | |
| if (pc2.getReceivers()[0].track.muted) { | |
| await new Promise(resolve => { | |
| pc2.getReceivers()[0].track.addEventListener('unmute', resolve); | |
| }); | |
| } | |
| // wait a bit longer for a few consent messages | |
| await new Promise(r => t.step_timeout(r, 8000)); | |
| const t1 = performance.now(); | |
| check_candidate_pair_stats(await pc.getStats(pc.getSenders()[0].track), t1-t0); | |
| }, `getStats() audio contains candidate-pair stats`); | |
| promise_test(async t => { | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| const [track, mediaStream] = await getTrackFromUserMedia('audio'); | |
| pc.addTransceiver(track); | |
| pc.addTransceiver(track); | |
| await promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(track)); | |
| }, `getStats(track) should not work if multiple senders have the same track`); | |
| } | |
| if (rest) { | |
| promise_test(async t => { | |
| const kMinimumTimeElapsedBetweenGetStatsCallsMs = 500; | |
| const pc = new RTCPeerConnection(); | |
| t.add_cleanup(() => pc.close()); | |
| const t0 = Math.floor(performance.now()); | |
| const t0Stats = [...(await pc.getStats()).values()].find(({type}) => type === 'peer-connection'); | |
| await new Promise( | |
| r => t.step_timeout(r, kMinimumTimeElapsedBetweenGetStatsCallsMs)); | |
| const t1Stats = [...(await pc.getStats()).values()].find(({type}) => type === 'peer-connection'); | |
| const t1 = Math.ceil(performance.now()); | |
| const maximumTimeElapsedBetweenGetStatsCallsMs = t1 - t0; | |
| const deltaTimestampMs = t1Stats.timestamp - t0Stats.timestamp; | |
| // The delta must be at least the time we waited between calls. | |
| assert_greater_than_equal(deltaTimestampMs, | |
| kMinimumTimeElapsedBetweenGetStatsCallsMs); | |
| // The delta must be at most the time elapsed before the first getStats() | |
| // call and after the second getStats() call. | |
| assert_less_than_equal(deltaTimestampMs, | |
| maximumTimeElapsedBetweenGetStatsCallsMs); | |
| }, `RTCStats.timestamp increases with time passing`); | |
| } | |
| if (interop2026) { | |
| promise_test(async t => { | |
| const pc1 = new RTCPeerConnection(); | |
| pc1.close(); | |
| await pc1.getStats(); | |
| }, 'getStats succeeds on a closed peerconnection'); | |
| } | |
| </script> |