From 8c085f33e02d4bef2334cf8f614c9bd38de67b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 9 Jan 2025 19:22:37 +0100 Subject: [PATCH 1/6] test: Add unit tests for analyzing the sender connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- .../analyzers/PeerConnectionAnalyzer.spec.js | 999 ++++++++++++++++++ 1 file changed, 999 insertions(+) create mode 100644 src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js new file mode 100644 index 00000000000..513017c78ae --- /dev/null +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -0,0 +1,999 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + CONNECTION_QUALITY, + PEER_DIRECTION, + PeerConnectionAnalyzer, +} from './PeerConnectionAnalyzer.js' + +/** + * Helper function to create RTCPeerConnection mocks with just the attributes + * and methods used by PeerConnectionAnalyzer. + */ +function newRTCPeerConnection() { + /** + * RTCPeerConnectionMock constructor. + */ + function RTCPeerConnectionMock() { + this._listeners = [] + this.iceConnectionState = 'new' + this.connectionState = 'new' + this.getStats = jest.fn() + this.addEventListener = jest.fn((type, listener) => { + if (type !== 'iceconnectionstatechange' || type !== 'connectionstatechange') { + return + } + + if (!Object.prototype.hasOwnProperty.call(this._listeners, type)) { + this._listeners[type] = [listener] + } else { + this._listeners[type].push(listener) + } + }) + this.dispatchEvent = (event) => { + let listeners = this._listeners[event.type] + if (!listeners) { + return + } + + listeners = listeners.slice(0) + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i] + listener.apply(listener, event) + } + } + this.removeEventListener = jest.fn((type, listener) => { + if (type !== 'iceconnectionstatechange' || type !== 'connectionstatechange') { + return + } + + const listeners = this._listeners[type] + if (!listeners) { + return + } + + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }) + this._setIceConnectionState = (iceConnectionState) => { + this.iceConnectionState = iceConnectionState + + this.dispatchEvent(new Event('iceconnectionstatechange')) + } + this._setConnectionState = (connectionState) => { + this.connectionState = connectionState + + this.dispatchEvent(new Event('connectionstatechange')) + } + } + return new RTCPeerConnectionMock() +} + +/** + * Helper function to create RTCStatsReport mocks with just the attributes and + * methods used by PeerConnectionAnalyzer. + * + * @param {Array} stats the values of the stats + */ +function newRTCStatsReport(stats) { + /** + * RTCStatsReport constructor. + */ + function RTCStatsReport() { + this.values = () => { + return stats + } + } + return new RTCStatsReport() +} + +describe('PeerConnectionAnalyzer', () => { + + let peerConnectionAnalyzer + let peerConnection + + beforeEach(() => { + jest.useFakeTimers() + + peerConnectionAnalyzer = new PeerConnectionAnalyzer() + + peerConnection = newRTCPeerConnection() + }) + + afterEach(() => { + peerConnectionAnalyzer.setPeerConnection(null) + + jest.clearAllMocks() + }) + + describe('analyze sender connection', () => { + + beforeEach(() => { + peerConnection._setIceConnectionState('connected') + peerConnection._setConnectionState('connected') + }) + + test.each([ + ['good quality', 'audio'], + ['good quality', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['medium quality', 'audio'], + ['medium quality', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 95, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 145, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 185, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 230, timestamp: 14010, packetsLost: 20, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 275, timestamp: 14985, packetsLost: 25, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + } + }) + + test.each([ + ['bad quality', 'audio'], + ['bad quality', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 95, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 145, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 185, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 220, timestamp: 14010, packetsLost: 30, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 255, timestamp: 14985, packetsLost: 45, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + } + }) + + test.each([ + ['very bad quality', 'audio'], + ['very bad quality', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['very bad quality due to low packets', 'audio'], + ['very bad quality due to low packets', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 5, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 10, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 15, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 20, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 25, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 30, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['very bad quality due to high round trip time', 'audio'], + ['very bad quality due to high round trip time', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 1.5 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 1.4 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 1.5 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 1.6 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 1.5 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 1.5 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['no transmitted data due to full packet loss', 'audio'], + ['no transmitted data due to full packet loss', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11950, packetsLost: 100, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 13020, packetsLost: 150, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14010, packetsLost: 200, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14985, packetsLost: 250, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['no transmitted data due to packets not updated', 'audio'], + ['no transmitted data due to packets not updated', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['stats stalled for a second', 'audio'], + ['stats stalled for a second', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['no transmitted data for two seconds', 'audio'], + ['no transmitted data for two seconds', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. But if the packets are not updated three + // times in a row it is assumed that the packets were not + // transmitted. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['good quality degrading to very bad', 'audio'], + ['good quality degrading to very bad', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 340, timestamp: 16010, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 380, timestamp: 17000, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 410, timestamp: 17990, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 435, timestamp: 19005, packetsLost: 65, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(9) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(10) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['very bad quality improving to good', 'audio'], + ['very bad quality improving to good', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 260, timestamp: 16010, packetsLost: 90, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 305, timestamp: 17000, packetsLost: 95, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 355, timestamp: 17990, packetsLost: 95, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 405, timestamp: 19005, packetsLost: 95, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(9) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(10) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + + test('good audio quality, very bad video quality', async () => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + }) + + test('very bad audio quality, good video quality', async () => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + }) + }) +}) From b1aab119563366ce9751c54e3344bf1cb0d6ca81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 23 Jan 2025 06:25:56 +0100 Subject: [PATCH 2/6] test: Add unit tests for missing remote packet count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium does not provide "packetsReceived" in "remote-inbound-rtp" Signed-off-by: Daniel Calviño Sánchez --- .../analyzers/PeerConnectionAnalyzer.spec.js | 1118 ++++++++++++++--- 1 file changed, 944 insertions(+), 174 deletions(-) diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js index 513017c78ae..11e67cc625f 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -169,36 +169,36 @@ describe('PeerConnectionAnalyzer', () => { }) test.each([ - ['medium quality', 'audio'], - ['medium quality', 'video'], + ['good quality, missing remote packet count', 'audio'], + ['good quality, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 95, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 145, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 185, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 230, timestamp: 14010, packetsLost: 20, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 275, timestamp: 14985, packetsLost: 25, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -210,17 +210,17 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) } }) test.each([ - ['bad quality', 'audio'], - ['bad quality', 'video'], + ['medium quality', 'audio'], + ['medium quality', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ @@ -241,14 +241,14 @@ describe('PeerConnectionAnalyzer', () => { ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 220, timestamp: 14010, packetsLost: 30, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 230, timestamp: 14010, packetsLost: 20, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 255, timestamp: 14985, packetsLost: 45, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 275, timestamp: 14985, packetsLost: 25, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -260,45 +260,45 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) } }) test.each([ - ['very bad quality', 'audio'], - ['very bad quality', 'video'], + ['medium quality, missing remote packet count', 'audio'], + ['medium quality, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 20, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 25, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -310,45 +310,45 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) } }) test.each([ - ['very bad quality due to low packets', 'audio'], - ['very bad quality due to low packets', 'video'], + ['bad quality', 'audio'], + ['bad quality', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 5, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 10, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 95, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 15, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 145, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 20, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 185, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 25, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 220, timestamp: 14010, packetsLost: 30, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 30, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 255, timestamp: 14985, packetsLost: 45, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -360,45 +360,45 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) } }) test.each([ - ['very bad quality due to high round trip time', 'audio'], - ['very bad quality due to high round trip time', 'video'], + ['bad quality, missing remote packet count', 'audio'], + ['bad quality, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 1.5 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 1.4 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 1.5 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 1.6 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 15, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 1.5 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 30, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 1.5 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 45, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -410,45 +410,45 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) } }) test.each([ - ['no transmitted data due to full packet loss', 'audio'], - ['no transmitted data due to full packet loss', 'video'], + ['very bad quality', 'audio'], + ['very bad quality', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 45, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11000, packetsLost: 50, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11950, packetsLost: 100, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 13020, packetsLost: 150, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 160, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14010, packetsLost: 200, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 190, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14985, packetsLost: 250, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 225, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -460,180 +460,154 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) } }) test.each([ - ['no transmitted data due to packets not updated', 'audio'], - ['no transmitted data due to packets not updated', 'video'], + ['very bad quality, missing remote packet count', 'audio'], + ['very bad quality, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, - ])) - // When the packets do not increase the analysis is kept on hold - // until more stat reports are received, as it is not possible - // to know if the packets were not transmitted or the stats - // temporarily stalled. - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 16010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) // Force the promises returning the stats to be executed. await null - expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) } }) test.each([ - ['stats stalled for a second', 'audio'], - ['stats stalled for a second', 'video'], + ['very bad quality due to low packets', 'audio'], + ['very bad quality due to low packets', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 5, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 10, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 15, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 20, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 25, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, - ])) - // When the packets do not increase the analysis is kept on hold - // until more stat reports are received, as it is not possible - // to know if the packets were not transmitted or the stats - // temporarily stalled. - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 30, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) // Force the promises returning the stats to be executed. await null - expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) } }) test.each([ - ['no transmitted data for two seconds', 'audio'], - ['no transmitted data for two seconds', 'video'], + ['very bad quality due to low packets, missing remote packet count', 'audio'], + ['very bad quality due to low packets, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, - ])) - // When the packets do not increase the analysis is kept on hold - // until more stat reports are received, as it is not possible - // to know if the packets were not transmitted or the stats - // temporarily stalled. But if the packets are not updated three - // times in a row it is assumed that the packets were not - // transmitted. - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 16010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) // Force the promises returning the stats to be executed. await null - expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) @@ -645,52 +619,36 @@ describe('PeerConnectionAnalyzer', () => { }) test.each([ - ['good quality degrading to very bad', 'audio'], - ['good quality degrading to very bad', 'video'], + ['very bad quality due to high round trip time', 'audio'], + ['very bad quality due to high round trip time', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 1.5 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 1.4 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 1.5 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 1.6 }, ])) .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 1.5 }, ])) // A sixth report is needed for the initial calculation due to // the first stats report being used as the base to calculate // relative values of cumulative stats. .mockResolvedValueOnce(newRTCStatsReport([ { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, - ])) - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 340, timestamp: 16010, packetsLost: 10, roundTripTime: 0.1 }, - ])) - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 380, timestamp: 17000, packetsLost: 20, roundTripTime: 0.1 }, - ])) - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 410, timestamp: 17990, packetsLost: 40, roundTripTime: 0.1 }, - ])) - .mockResolvedValueOnce(newRTCStatsReport([ - { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, - { type: 'remote-inbound-rtp', kind, packetsReceived: 435, timestamp: 19005, packetsLost: 65, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 1.5 }, ])) peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) @@ -702,24 +660,714 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) } + }) - jest.advanceTimersByTime(1000) - // Force the promises returning the stats to be executed. - await null - - expect(peerConnection.getStats).toHaveBeenCalledTimes(7) - - if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) - } else { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + test.each([ + ['very bad quality due to high round trip time, missing remote packet count', 'audio'], + ['very bad quality due to high round trip time, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 1.5 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 1.4 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 1.5 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 1.6 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 1.5 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 1.5 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['no transmitted data due to full packet loss', 'audio'], + ['no transmitted data due to full packet loss', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 11950, packetsLost: 100, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 13020, packetsLost: 150, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14010, packetsLost: 200, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 14985, packetsLost: 250, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['no transmitted data due to full packet loss, missing remote packet count', 'audio'], + ['no transmitted data due to full packet loss, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 100, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 150, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 200, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 250, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['no transmitted data due to packets not updated', 'audio'], + ['no transmitted data due to packets not updated', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['no transmitted data due to packets not updated, missing remote packet count', 'audio'], + ['no transmitted data due to packets not updated, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + } + }) + + test.each([ + ['stats stalled for a second', 'audio'], + ['stats stalled for a second', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['stats stalled for a second, missing remote packet count', 'audio'], + ['stats stalled for a second, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['no transmitted data for two seconds', 'audio'], + ['no transmitted data for two seconds', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. But if the packets are not updated three + // times in a row it is assumed that the packets were not + // transmitted. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['no transmitted data for two seconds, missing remote packet count', 'audio'], + ['no transmitted data for two seconds, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // When the packets do not increase the analysis is kept on hold + // until more stat reports are received, as it is not possible + // to know if the packets were not transmitted or the stats + // temporarily stalled. But if the packets are not updated three + // times in a row it is assumed that the packets were not + // transmitted. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(7000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['good quality degrading to very bad', 'audio'], + ['good quality degrading to very bad', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 340, timestamp: 16010, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 380, timestamp: 17000, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 410, timestamp: 17990, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 435, timestamp: 19005, packetsLost: 65, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(9) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(10) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['good quality degrading to very bad, missing remote packet count', 'audio'], + ['good quality degrading to very bad, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17000, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17990, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, timestamp: 19005, packetsLost: 65, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) } @@ -888,6 +1536,128 @@ describe('PeerConnectionAnalyzer', () => { } }) + test.each([ + ['very bad quality improving to good, missing remote packet count', 'audio'], + ['very bad quality improving to good, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 5, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 10, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 20, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 40, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 60, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 75, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 90, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17000, packetsLost: 95, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17990, packetsLost: 95, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, timestamp: 19005, packetsLost: 95, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(9) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(10) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + } + }) + test('good audio quality, very bad video quality', async () => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ From 8769f8035398bb5453399b2993c463db06c14d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 22 Jan 2025 13:35:57 +0100 Subject: [PATCH 3/6] fix: Fix calculating stats without enough meaningful values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the initial stats reports are added the first value of cumulative stats is used as a base when converting the rest of values to relative. Therefore, that initial value is not meaningful and should not be used in calculations, as otherwise the reported quality would be off. Due to that now no quality is reported until the values of the first report added were shifted. Signed-off-by: Daniel Calviño Sánchez --- .../webrtc/analyzers/AverageStatValue.js | 18 +- .../webrtc/analyzers/AverageStatValue.spec.js | 93 +++- .../analyzers/PeerConnectionAnalyzer.js | 10 +- .../analyzers/PeerConnectionAnalyzer.spec.js | 474 +++++++++++++++++- 4 files changed, 557 insertions(+), 38 deletions(-) diff --git a/src/utils/webrtc/analyzers/AverageStatValue.js b/src/utils/webrtc/analyzers/AverageStatValue.js index b534c914a1d..7563621f5da 100644 --- a/src/utils/webrtc/analyzers/AverageStatValue.js +++ b/src/utils/webrtc/analyzers/AverageStatValue.js @@ -20,7 +20,7 @@ const STAT_VALUE_TYPE = { * * The number of items to keep track of must be set when the AverageStatValue is * created. Once N items have been added adding a new one will discard the - * oldest value. "hasEnoughData()" can be used to check if at least N items have + * oldest value. "hasEnoughData()" can be used to check if enough items have * been added already and the average is reliable. * * An RTCStatsReport value can be cumulative since the creation of the @@ -34,6 +34,10 @@ const STAT_VALUE_TYPE = { * however, that the first value added to a cumulative AverageStatValue after * creating or resetting it will be treated as 0 in the average calculation, * as it will be the base from which the rest of relative values are calculated. + * Therefore, if the values added to an AverageStatValue are relative, + * "hasEnoughData()" will not return true until at least N items were added, + * but if the values are cumulative, it will not return true until at least N+1 + * items were added. * * Besides the weighted average it is possible to "peek" the last value, either * the raw value that was added or the relative one after the conversion (which, @@ -54,15 +58,25 @@ function AverageStatValue(count, type = STAT_VALUE_TYPE.CUMULATIVE, lastValueWei this._rawValues = [] this._relativeValues = [] + + this._hasEnoughData = false } AverageStatValue.prototype = { reset() { this._rawValues = [] this._relativeValues = [] + + this._hasEnoughData = false }, add(value) { + if ((this._type === STAT_VALUE_TYPE.CUMULATIVE && this._rawValues.length === this._count) + || (this._type === STAT_VALUE_TYPE.RELATIVE && this._rawValues.length >= (this._count - 1)) + ) { + this._hasEnoughData = true + } + if (this._rawValues.length === this._count) { this._rawValues.shift() this._relativeValues.shift() @@ -97,7 +111,7 @@ AverageStatValue.prototype = { }, hasEnoughData() { - return this._rawValues.length === this._count + return this._hasEnoughData }, getWeightedAverage() { diff --git a/src/utils/webrtc/analyzers/AverageStatValue.spec.js b/src/utils/webrtc/analyzers/AverageStatValue.spec.js index 1dadf5de40a..1b882d5c790 100644 --- a/src/utils/webrtc/analyzers/AverageStatValue.spec.js +++ b/src/utils/webrtc/analyzers/AverageStatValue.spec.js @@ -25,17 +25,92 @@ describe('AverageStatValue', () => { }) }) - test('returns whether there are enough values for a meaningful calculation', () => { - const testValues = [100, 200, 150, 123, 30, 50, 22, 33] - const stat = new AverageStatValue(3, STAT_VALUE_TYPE.CUMULATIVE, 3) - const stat2 = new AverageStatValue(3, STAT_VALUE_TYPE.RELATIVE, 3) + describe('returns whether there are enough values for a meaningful calculation', () => { + test('after creating', () => { + const testValues = [100, 200, 150, 123, 30, 50, 22, 33] + const stat = new AverageStatValue(3, STAT_VALUE_TYPE.CUMULATIVE, 3) + const stat2 = new AverageStatValue(3, STAT_VALUE_TYPE.RELATIVE, 3) - testValues.forEach((val, index) => { - stat.add(val) - expect(stat.hasEnoughData()).toBe(index >= 2) + testValues.forEach((val, index) => { + stat.add(val) + expect(stat.hasEnoughData()).toBe(index >= 3) - stat2.add(val) - expect(stat2.hasEnoughData()).toBe(index >= 2) + stat2.add(val) + expect(stat2.hasEnoughData()).toBe(index >= 2) + }) + }) + + describe('resetting', () => { + let stat + let stat2 + + const addValues = (values) => { + values.forEach(val => { + stat.add(val) + stat2.add(val) + }) + } + + beforeEach(() => { + stat = new AverageStatValue(3, STAT_VALUE_TYPE.CUMULATIVE, 3) + stat2 = new AverageStatValue(3, STAT_VALUE_TYPE.RELATIVE, 3) + }) + + test('before having enough values', () => { + addValues([100, 200]) + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(false) + + stat.reset() + stat2.reset() + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(false) + + addValues([150, 123]) + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(false) + + addValues([30]) + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(true) + + addValues([50]) + + expect(stat.hasEnoughData()).toBe(true) + expect(stat2.hasEnoughData()).toBe(true) + }) + + test('after having enough values', () => { + addValues([100, 200, 150, 123]) + + expect(stat.hasEnoughData()).toBe(true) + expect(stat2.hasEnoughData()).toBe(true) + + stat.reset() + stat2.reset() + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(false) + + addValues([30, 50]) + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(false) + + addValues([22]) + + expect(stat.hasEnoughData()).toBe(false) + expect(stat2.hasEnoughData()).toBe(true) + + addValues([33]) + + expect(stat.hasEnoughData()).toBe(true) + expect(stat2.hasEnoughData()).toBe(true) + }) }) }) diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js index 619d4500e6b..53b13712509 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js @@ -612,11 +612,19 @@ PeerConnectionAnalyzer.prototype = { }, _calculateConnectionQuality(kind) { + const packets = this._packets[kind] + const packetsLost = this._packetsLost[kind] + const timestamps = this._timestamps[kind] const packetsLostRatio = this._packetsLostRatio[kind] const packetsPerSecond = this._packetsPerSecond[kind] const roundTripTime = this._roundTripTime[kind] - if (!packetsLostRatio.hasEnoughData() || !packetsPerSecond.hasEnoughData()) { + // packetsLostRatio and packetsPerSecond are relative values, but they + // are calculated from cumulative values. Therefore, it is necessary to + // check if the cumulative values that are their source have enough data + // or not, rather than checking if the relative values themselves have + // enough data. + if (!packets.hasEnoughData() || !packetsLost.hasEnoughData() || !timestamps.hasEnoughData()) { return CONNECTION_QUALITY.UNKNOWN } diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js index 11e67cc625f..0ba9fe259e7 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -95,6 +95,8 @@ function newRTCStatsReport(stats) { describe('PeerConnectionAnalyzer', () => { let peerConnectionAnalyzer + let changeConnectionQualityAudioHandler + let changeConnectionQualityVideoHandler let peerConnection beforeEach(() => { @@ -102,6 +104,12 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer = new PeerConnectionAnalyzer() + changeConnectionQualityAudioHandler = jest.fn() + peerConnectionAnalyzer.on('change:connectionQualityAudio', changeConnectionQualityAudioHandler) + + changeConnectionQualityVideoHandler = jest.fn() + peerConnectionAnalyzer.on('change:connectionQualityVideo', changeConnectionQualityVideoHandler) + peerConnection = newRTCPeerConnection() }) @@ -153,7 +161,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -162,9 +181,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) @@ -203,7 +228,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -212,9 +248,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) @@ -253,7 +295,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -262,9 +315,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.MEDIUM) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.MEDIUM) } }) @@ -303,7 +362,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -312,9 +382,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.MEDIUM) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.MEDIUM) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.MEDIUM) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.MEDIUM) } }) @@ -353,7 +429,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -362,9 +449,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.BAD) } }) @@ -403,7 +496,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -412,9 +516,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.BAD) } }) @@ -453,7 +563,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -462,9 +583,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -503,7 +630,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -512,9 +650,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -553,7 +697,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -562,9 +717,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -603,7 +764,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -612,9 +784,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -653,7 +831,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -662,9 +851,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -703,7 +898,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -712,9 +918,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -753,7 +965,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -762,9 +985,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) } }) @@ -803,7 +1032,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -812,9 +1052,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) } }) @@ -861,7 +1107,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -870,9 +1127,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) } }) @@ -919,7 +1182,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -928,9 +1202,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.NO_TRANSMITTED_DATA) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.NO_TRANSMITTED_DATA) } }) @@ -977,7 +1257,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -986,9 +1277,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) @@ -1035,7 +1332,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1044,9 +1352,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) @@ -1095,7 +1409,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1104,9 +1429,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -1155,7 +1486,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(7000) + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1164,9 +1506,15 @@ describe('PeerConnectionAnalyzer', () => { if (kind === 'audio') { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) } }) @@ -1221,7 +1569,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1343,7 +1702,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1465,7 +1835,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1587,7 +1968,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1702,7 +2094,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1710,6 +2113,10 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) }) test('very bad audio quality, good video quality', async () => { @@ -1756,7 +2163,18 @@ describe('PeerConnectionAnalyzer', () => { peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) - jest.advanceTimersByTime(6000) + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) // Force the promises returning the stats to be executed. await null @@ -1764,6 +2182,10 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) }) }) }) From c764fbd153931d7b3fc8a9510d2800f021412bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 23 Jan 2025 11:18:16 +0100 Subject: [PATCH 4/6] fix: Handle timestamps not updated when stats are stalled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the past it was observed that the timestamp and round trip time were updated when the stats stalled (so only the received packets actually stalled). However, nowadays it seems that the timestamp and round trip time can also stall. This caused the stalled timestamp to be computed as 0, and that in turn messed with the rest of calculations (for example, generating a NaN for the number of packets per second) and causing a "very bad quality" to be wrongly reported. To solve that now the timestamps are evenly distributed when they unstall. Signed-off-by: Daniel Calviño Sánchez --- .../analyzers/PeerConnectionAnalyzer.js | 31 +- .../analyzers/PeerConnectionAnalyzer.spec.js | 364 ++++++++++++++++++ 2 files changed, 388 insertions(+), 7 deletions(-) diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js index 53b13712509..6bd537f534f 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js @@ -466,12 +466,13 @@ PeerConnectionAnalyzer.prototype = { * The stats reported by the browser can sometimes stall for a second (or * more, but typically they stall only for a single report). When that * happens the stats are still reported, but with the same number of packets - * as in the previous report (timestamp and round trip time are updated, - * though). In that case the given stats are not added yet to the average - * stats; they are kept on hold until more stats are provided by the browser - * and it can be determined if the previous stats were stalled or not. If - * they were stalled the previous and new stats are distributed, and if they - * were not they are added as is to the average stats. + * as in the previous report (timestamp and round trip time may be updated + * or not, apparently depending on browser version and/or Janus version). In + * that case the given stats are not added yet to the average stats; they + * are kept on hold until more stats are provided by the browser and it can + * be determined if the previous stats were stalled or not. If they were + * stalled the previous and new stats are distributed, and if they were not + * they are added as is to the average stats. * * @param {string} kind the type of the stats ("audio" or "video") * @param {number} packets the cumulative number of packets @@ -536,6 +537,18 @@ PeerConnectionAnalyzer.prototype = { let packetsLostTotal = 0 let timestampsTotal = 0 + // If the first timestamp stalled it is assumed that all of them + // stalled and are thus evenly distributed based on the new timestamp. + if (this._stagedTimestamps[kind][0] === timestampsBase) { + const lastTimestamp = this._stagedTimestamps[kind][this._stagedTimestamps[kind].length - 1] + const timestampsTotalDifference = lastTimestamp - timestampsBase + const timestampsDelta = timestampsTotalDifference / this._stagedTimestamps[kind].length + + for (let i = 0; i < this._stagedTimestamps[kind].length - 1; i++) { + this._stagedTimestamps[kind][i] += timestampsDelta * (i + 1) + } + } + for (let i = 0; i < this._stagedPackets[kind].length; i++) { packetsTotal += (this._stagedPackets[kind][i] - packetsBase) packetsBase = this._stagedPackets[kind][i] @@ -562,7 +575,11 @@ PeerConnectionAnalyzer.prototype = { packetsLostBase = this._stagedPacketsLost[kind][i] // Timestamps and round trip time are not distributed, as those - // values are properly updated even if the stats are stalled. + // values may be properly updated even if the stats are stalled. In + // case they were not timestamps were already evenly distributed + // above, and round trip time can not be distributed, as it is + // already provided in the stats as a relative value rather than a + // cumulative one. } }, diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js index 0ba9fe259e7..a9a1a096945 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -1364,6 +1364,370 @@ describe('PeerConnectionAnalyzer', () => { } }) + describe('remote stats stalled for a second', () => { + test.each([ + ['stats in sync, stall and keep in sync', 'audio'], + ['stats in sync, stall and keep in sync', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 } + ])) + // A sixth report is needed for the initial calculation due + // to the first stats report being used as the base to + // calculate relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 } + ])) + // When the packets do not increase the analysis is kept + // on hold until more stat reports are received, as it + // is not possible to know if the packets were not + // transmitted or the stats temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 } + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['stats out of sync, sync, stall and become out of sync again', 'audio'], + ['stats out of sync, sync, stall and become out of sync again', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 10800, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11600, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 170, timestamp: 12400, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 210, timestamp: 13200, packetsLost: 0, roundTripTime: 0.1 } + ])) + // A sixth report is needed for the initial calculation due + // to the first stats report being used as the base to + // calculate relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 } + ])) + // When the packets do not increase the analysis is kept + // on hold until more stat reports are received, as it + // is not possible to know if the packets were not + // transmitted or the stats temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 } + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(2000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['stats out of sync, sync, stall and stay in sync', 'audio'], + ['stats out of sync, sync, stall and stay in sync', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 90, timestamp: 10800, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 130, timestamp: 11600, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 170, timestamp: 12400, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 210, timestamp: 13200, packetsLost: 0, roundTripTime: 0.1 } + ])) + // A sixth report is needed for the initial calculation due + // to the first stats report being used as the base to + // calculate relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 } + ])) + // When the packets do not increase the analysis is kept + // on hold until more stat reports are received, as it + // is not possible to know if the packets were not + // transmitted or the stats temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 400, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 } + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(2000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(8) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['stats in sync, stall, stay in sync, stall, stay in sync', 'audio'], + ['stats in sync, stall, stay in sync, stall, stay in sync', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 } + ])) + // A sixth report is needed for the initial calculation due + // to the first stats report being used as the base to + // calculate relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 } + ])) + // When the packets do not increase the analysis is kept + // on hold until more stat reports are received, as it + // is not possible to know if the packets were not + // transmitted or the stats temporarily stalled. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 400, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 450, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 450, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 500, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 450, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 } + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 550, timestamp: 20000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 550, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 } + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(4000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(11) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + }) + test.each([ ['no transmitted data for two seconds', 'audio'], ['no transmitted data for two seconds', 'video'], From ca6c903875fcf33356b42268fa07a6d78d49a582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 23 Jan 2025 11:36:16 +0100 Subject: [PATCH 5/6] fix: Do not report very bad quality with a low packets count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the past it was observed that, even for small videos, 10 packets per second was a reasonable threshold to detect connection issues even if there were no lost packets. However, nowadays it seems that it can sometimes trigger a false positive (typically when the background blur is enabled and the video quality is reduced due to being in a call with several participants), so for now the connection problem is no longer reported to the user but just logged. Signed-off-by: Daniel Calviño Sánchez --- .../analyzers/PeerConnectionAnalyzer.js | 7 +- .../analyzers/PeerConnectionAnalyzer.spec.js | 158 ++++++++++++++++-- 2 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js index 6bd537f534f..17dee309872 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js @@ -680,10 +680,13 @@ PeerConnectionAnalyzer.prototype = { // quality to keep a smooth video, albeit on a lower resolution. Thus // with a threshold of 10 packets issues can be detected too for videos, // although only once they can not be further downscaled. + // Despite all of the above it has been observed that less than 10 + // packets are sometimes sent without any connection problem (for + // example, when the background is blurred and the video quality is + // reduced due to being in a call with several participants), so for now + // it is only logged but not reported. if (packetsPerSecond.getWeightedAverage() < 10) { this._logStats(kind, 'Low packets per second: ' + packetsPerSecond.getWeightedAverage()) - - return CONNECTION_QUALITY.VERY_BAD } if (packetsLostRatioWeightedAverage > 0.3) { diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js index a9a1a096945..6c8a6e32e03 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -663,8 +663,142 @@ describe('PeerConnectionAnalyzer', () => { }) test.each([ - ['very bad quality due to low packets', 'audio'], - ['very bad quality due to low packets', 'video'], + ['very bad quality with low packets and packet loss', 'audio'], + ['very bad quality with low packets and packet loss', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 3, timestamp: 10000, packetsLost: 2, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 6, timestamp: 11000, packetsLost: 4, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 9, timestamp: 11950, packetsLost: 6, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 12, timestamp: 13020, packetsLost: 8, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 15, timestamp: 14010, packetsLost: 10, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 18, timestamp: 14985, packetsLost: 12, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['good quality even with low packets if no packet loss, missing remote packet count', 'audio'], + ['good quality even with low packets if no packet loss, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 5, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 2, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 10, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 4, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 15, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 6, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 20, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 8, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 25, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 10, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 30, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 12, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + } + }) + + test.each([ + ['good quality even with low packets if no packet loss', 'audio'], + ['good quality even with low packets if no packet loss', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ @@ -715,23 +849,23 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) - expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) - expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) test.each([ - ['very bad quality due to low packets, missing remote packet count', 'audio'], - ['very bad quality due to low packets, missing remote packet count', 'video'], + ['good quality even with low packets if no packet loss, missing remote packet count', 'audio'], + ['good quality even with low packets if no packet loss, missing remote packet count', 'video'], ])('%s, %s', async (name, kind) => { peerConnection.getStats .mockResolvedValueOnce(newRTCStatsReport([ @@ -782,17 +916,17 @@ describe('PeerConnectionAnalyzer', () => { expect(peerConnection.getStats).toHaveBeenCalledTimes(6) if (kind === 'audio') { - expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) - expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) } else { expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) - expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.VERY_BAD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) - expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.VERY_BAD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) } }) From cc2a32fda4179af8c1237d92a75b3dd39ba4abf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Tue, 28 Jan 2025 03:08:12 +0100 Subject: [PATCH 6/6] fix: Fix quality report when packet count regresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Due to a bug in Firefox and/or Janus, when using simulcast video the remote report for one of the streams (typically the lowest quality one) may start with garbage values. This causes the received packet count to be reported the maximum integer value minus the packet lost count (which is usually around a few thousands). When newer stats arrive the received packets start to increase from that extremely high value, and eventually it overflows, causing the received packet count to go back from ~4294967295 to ~0. In other cases it was seen that the received packet count can regress a few values, although it is not clear when or why (this was much rarer and not reproducible, unlike the scenario described above). To prevent the regressed value from distorting the analysis due to the packet count being < 0, and as in both cases once the packet count regressed the received packet count in all following stat reports increase from the regressed value, now the stats are reset when a lower packet count is found. Signed-off-by: Daniel Calviño Sánchez --- .../analyzers/PeerConnectionAnalyzer.js | 13 + .../analyzers/PeerConnectionAnalyzer.spec.js | 675 ++++++++++++++++++ 2 files changed, 688 insertions(+) diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js index 17dee309872..d0695a5d55b 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js @@ -396,6 +396,19 @@ PeerConnectionAnalyzer.prototype = { packetsLost[kind] = this._packetsLost[kind].getLastRawValue() } + // In some (also strange) cases a newer stat may report a lower + // value than a previous one (it happens sometimes with garbage + // remote reports in simulcast video that cause the values to + // overflow, although it was also seen with a small value regression + // when enabling video). If that happens the stats are reset to + // prevent distorting the analysis with negative packet counts; note + // that in this case the previous value is not kept because it is + // not just an isolated wrong value, all the following stats + // increase from the regressed value. + if (packets[kind] >= 0 && packets[kind] < this._packets[kind].getLastRawValue()) { + this._resetStats(kind) + } + this._addStats(kind, packets[kind], packetsLost[kind], timestamp[kind], roundTripTime[kind]) } }, diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js index 6c8a6e32e03..d69844eaee8 100644 --- a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js +++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.spec.js @@ -2016,6 +2016,681 @@ describe('PeerConnectionAnalyzer', () => { } }) + test.each([ + ['regressing packet count at the beginning', 'audio'], + ['regressing packet count at the beginning', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 1500, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 1500, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // If the packet count changes to a lower value the stats are + // reset. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['regressing packet count at the beginning, missing remote packet count', 'audio'], + ['regressing packet count at the beginning, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 1500, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // If the packet count changes to a lower value the stats are + // reset. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 350, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(6000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['regressing packet count', 'audio'], + ['regressing packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // If the packet count changes to a lower value the stats are + // reset. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 50, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 100, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 150, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 200, timestamp: 19005, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 20000 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 250, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 21010 }, + { type: 'remote-inbound-rtp', kind, packetsReceived: 300, timestamp: 21010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + } + + jest.advanceTimersByTime(4000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(11) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(12) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(3) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(3, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(3) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(3, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test.each([ + ['regressing packet count, missing remote packet count', 'audio'], + ['regressing packet count, missing remote packet count', 'video'], + ])('%s, %s', async (name, kind) => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // If the packet count changes to a lower value the stats are + // reset. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 50, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 100, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 150, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 200, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind, timestamp: 19005, packetsLost: 0, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 250, timestamp: 20000 }, + { type: 'remote-inbound-rtp', kind, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind, packetsSent: 300, timestamp: 21010 }, + { type: 'remote-inbound-rtp', kind, timestamp: 21010, packetsLost: 0, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + } + + jest.advanceTimersByTime(4000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(11) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + } + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(12) + + if (kind === 'audio') { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(3) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenNthCalledWith(3, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + } else { + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(3) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(3, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + } + }) + + test('regressing packet count, overflowing remote packets in simulcast video', async () => { + peerConnection.getStats + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 50, timestamp: 10000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 50, timestamp: 10000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 30, timestamp: 10000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 10, timestamp: 10000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 50, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 30, timestamp: 10000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967245, timestamp: 10000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 100, timestamp: 11000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 100, timestamp: 11000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 60, timestamp: 11000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 20, timestamp: 11000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 100, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 60, timestamp: 11000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967255, timestamp: 11000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 150, timestamp: 11950 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 150, timestamp: 11950 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 90, timestamp: 11950 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 30, timestamp: 11950 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 150, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 90, timestamp: 11950, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967265, timestamp: 11950, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 200, timestamp: 13020 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 200, timestamp: 13020 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 120, timestamp: 13020 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 40, timestamp: 13020 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 200, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 120, timestamp: 13020, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967275, timestamp: 13020, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 250, timestamp: 14010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 250, timestamp: 14010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 150, timestamp: 14010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 50, timestamp: 14010 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 250, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 150, timestamp: 14010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967285, timestamp: 14010, packetsLost: 50, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 300, timestamp: 14985 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 300, timestamp: 14985 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 180, timestamp: 14985 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 60, timestamp: 14985 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 300, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 180, timestamp: 14985, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 4294967295, timestamp: 14985, packetsLost: 50, roundTripTime: 0.1 }, + ])) + // If the packet count changes to a lower value the stats are + // reset. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 350, timestamp: 16010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 350, timestamp: 16010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 210, timestamp: 16010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 70, timestamp: 16010 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 350, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 210, timestamp: 16010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 10, timestamp: 16010, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 400, timestamp: 17000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 400, timestamp: 17000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 240, timestamp: 17000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 80, timestamp: 17000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 400, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 400, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 240, timestamp: 17000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 20, timestamp: 17000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 450, timestamp: 17990 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 450, timestamp: 17990 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 270, timestamp: 17990 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 90, timestamp: 17990 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 450, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 450, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 270, timestamp: 17990, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 30, timestamp: 17990, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 500, timestamp: 19005 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 500, timestamp: 19005 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 300, timestamp: 19005 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 100, timestamp: 19005 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 500, timestamp: 19005, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 500, timestamp: 19005, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 300, timestamp: 19005, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 40, timestamp: 19005, packetsLost: 50, roundTripTime: 0.1 }, + ])) + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 550, timestamp: 20000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 550, timestamp: 20000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 330, timestamp: 20000 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 110, timestamp: 20000 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 550, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 550, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 330, timestamp: 20000, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 50, timestamp: 20000, packetsLost: 50, roundTripTime: 0.1 }, + ])) + // A sixth report is needed for the initial calculation due to + // the first stats report being used as the base to calculate + // relative values of cumulative stats. + .mockResolvedValueOnce(newRTCStatsReport([ + { type: 'outbound-rtp', kind: 'audio', packetsSent: 600, timestamp: 21010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 600, timestamp: 21010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 360, timestamp: 21010 }, + { type: 'outbound-rtp', kind: 'video', packetsSent: 120, timestamp: 21010 }, + { type: 'remote-inbound-rtp', kind: 'audio', packetsReceived: 600, timestamp: 21010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 600, timestamp: 21010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 360, timestamp: 21010, packetsLost: 0, roundTripTime: 0.1 }, + { type: 'remote-inbound-rtp', kind: 'video', packetsReceived: 60, timestamp: 21010, packetsLost: 50, roundTripTime: 0.1 }, + ])) + + peerConnectionAnalyzer.setPeerConnection(peerConnection, PEER_DIRECTION.SENDER) + + jest.advanceTimersByTime(5000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(5) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(0) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(0) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(6) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(7) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + + jest.advanceTimersByTime(4000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(11) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(2) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + + jest.advanceTimersByTime(1000) + // Force the promises returning the stats to be executed. + await null + + expect(peerConnection.getStats).toHaveBeenCalledTimes(12) + + expect(peerConnectionAnalyzer.getConnectionQualityAudio()).toBe(CONNECTION_QUALITY.GOOD) + expect(peerConnectionAnalyzer.getConnectionQualityVideo()).toBe(CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledTimes(1) + expect(changeConnectionQualityAudioHandler).toHaveBeenCalledWith(peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenCalledTimes(3) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(1, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(2, peerConnectionAnalyzer, CONNECTION_QUALITY.UNKNOWN) + expect(changeConnectionQualityVideoHandler).toHaveBeenNthCalledWith(3, peerConnectionAnalyzer, CONNECTION_QUALITY.GOOD) + }) + test.each([ ['good quality degrading to very bad', 'audio'], ['good quality degrading to very bad', 'video'],