/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const statsExpectedByType = { "inbound-rtp": { expected: [ "trackIdentifier", "id", "timestamp", "type", "ssrc", "mediaType", "kind", "codecId", "packetsReceived", "packetsLost", "packetsDiscarded", "bytesReceived", "jitter", "lastPacketReceivedTimestamp", "headerBytesReceived", // Always missing from libwebrtc stats // "estimatedPlayoutTimestamp", "jitterBufferDelay", "jitterBufferEmittedCount", ], optional: ["remoteId", "nackCount", "qpSum"], localVideoOnly: [ "firCount", "pliCount", "framesDecoded", "framesDropped", "discardedPackets", "framesPerSecond", "frameWidth", "frameHeight", "framesReceived", "totalDecodeTime", "totalInterFrameDelay", "totalProcessingDelay", "totalSquaredInterFrameDelay", ], localAudioOnly: [ "totalSamplesReceived", // libwebrtc doesn't seem to do FEC for video "fecPacketsReceived", "fecPacketsDiscarded", "concealedSamples", "silentConcealedSamples", "concealmentEvents", "insertedSamplesForDeceleration", "removedSamplesForAcceleration", "audioLevel", "totalAudioEnergy", "totalSamplesDuration", ], unimplemented: [ "mediaTrackId", "transportId", "associateStatsId", "sliCount", "packetsRepaired", "fractionLost", "burstPacketsLost", "burstLossCount", "burstDiscardCount", "gapDiscardRate", "gapLossRate", ], deprecated: ["mozRtt", "isRemote"], }, "outbound-rtp": { expected: [ "id", "timestamp", "type", "ssrc", "mediaType", "kind", "codecId", "packetsSent", "bytesSent", "remoteId", "headerBytesSent", "retransmittedPacketsSent", "retransmittedBytesSent", ], optional: ["nackCount", "qpSum"], localAudioOnly: [], localVideoOnly: [ "framesEncoded", "firCount", "pliCount", "frameWidth", "frameHeight", "framesSent", "hugeFramesSent", "totalEncodeTime", "totalEncodedBytesTarget", ], unimplemented: ["mediaTrackId", "transportId", "sliCount", "targetBitrate"], deprecated: ["isRemote"], }, "remote-inbound-rtp": { expected: [ "id", "timestamp", "type", "ssrc", "mediaType", "kind", "codecId", "packetsLost", "jitter", "localId", "totalRoundTripTime", "fractionLost", "roundTripTimeMeasurements", ], optional: ["roundTripTime", "nackCount", "packetsReceived"], unimplemented: [ "mediaTrackId", "transportId", "packetsDiscarded", "associateStatsId", "sliCount", "packetsRepaired", "burstPacketsLost", "burstLossCount", "burstDiscardCount", "gapDiscardRate", "gapLossRate", ], deprecated: ["mozRtt", "isRemote"], }, "remote-outbound-rtp": { expected: [ "id", "timestamp", "type", "ssrc", "mediaType", "kind", "codecId", "packetsSent", "bytesSent", "localId", "remoteTimestamp", ], optional: ["nackCount"], unimplemented: ["mediaTrackId", "transportId", "sliCount", "targetBitrate"], deprecated: ["isRemote"], }, "media-source": { expected: ["id", "timestamp", "type", "trackIdentifier", "kind"], unimplemented: [ "audioLevel", "totalAudioEnergy", "totalSamplesDuration", "echoReturnLoss", "echoReturnLossEnhancement", "droppedSamplesDuration", "droppedSamplesEvents", "totalCaptureDelay", "totalSamplesCaptured", "width", "height", "frames", "framesPerSecond", ], optional: [], deprecated: [], }, csrc: { skip: true }, codec: { expected: [ "timestamp", "type", "id", "payloadType", "transportId", "mimeType", "clockRate", "sdpFmtpLine", ], optional: ["codecType", "channels"], unimplemented: [], deprecated: [], }, "peer-connection": { skip: true }, "data-channel": { skip: true }, track: { skip: true }, transport: { skip: true }, "candidate-pair": { expected: [ "id", "timestamp", "type", "transportId", "localCandidateId", "remoteCandidateId", "state", "priority", "nominated", "writable", "readable", "bytesSent", "bytesReceived", "lastPacketSentTimestamp", "lastPacketReceivedTimestamp", ], optional: ["selected"], unimplemented: [ "totalRoundTripTime", "currentRoundTripTime", "availableOutgoingBitrate", "availableIncomingBitrate", "requestsReceived", "requestsSent", "responsesReceived", "responsesSent", "retransmissionsReceived", "retransmissionsSent", "consentRequestsSent", ], deprecated: [], }, "local-candidate": { expected: [ "id", "timestamp", "type", "address", "protocol", "port", "candidateType", "priority", ], optional: ["relayProtocol", "proxied"], unimplemented: ["networkType", "url", "transportId"], deprecated: [ "candidateId", "portNumber", "ipAddress", "componentId", "mozLocalTransport", "transport", ], }, "remote-candidate": { expected: [ "id", "timestamp", "type", "address", "protocol", "port", "candidateType", "priority", ], optional: ["relayProtocol", "proxied"], unimplemented: ["networkType", "url", "transportId"], deprecated: [ "candidateId", "portNumber", "ipAddress", "componentId", "mozLocalTransport", "transport", ], }, certificate: { skip: true }, }; ["in", "out"].forEach(pre => { let s = statsExpectedByType[pre + "bound-rtp"]; s.optional = [...s.optional, ...s.localVideoOnly, ...s.localAudioOnly]; }); // // Checks that the fields in a report conform to the expectations in // statExpectedByType // function checkExpectedFields(report) { report.forEach(stat => { let expectations = statsExpectedByType[stat.type]; ok(expectations, "Stats type " + stat.type + " was expected"); // If the type is not expected or if it is flagged for skipping continue to // the next if (!expectations || expectations.skip) { return; } // Check that all required fields exist expectations.expected.forEach(field => { ok( field in stat, "Expected stat field " + stat.type + "." + field + " exists" ); }); // Check that each field is either expected or optional let allowed = [...expectations.expected, ...expectations.optional]; Object.keys(stat).forEach(field => { ok( allowed.includes(field), "Stat field " + stat.type + "." + field + ` is allowed. ${JSON.stringify(stat)}` ); }); // // Ensure that unimplemented fields are not implemented // note: if a field is implemented it should be moved to expected or // optional. // expectations.unimplemented.forEach(field => { ok( !Object.keys(stat).includes(field), "Unimplemented field " + stat.type + "." + field + " does not exist." ); }); // // Ensure that all deprecated fields are not present // expectations.deprecated.forEach(field => { ok( !Object.keys(stat).includes(field), "Deprecated field " + stat.type + "." + field + " does not exist." ); }); }); } function pedanticChecks(report) { // Check that report is only-maplike [...report.keys()].forEach(key => is( report[key], undefined, `Report is not dictionary like, it lacks a property for key ${key}` ) ); report.forEach((statObj, mapKey) => { info(`"${mapKey} = ${JSON.stringify(statObj, null, 2)}`); }); // eslint-disable-next-line complexity report.forEach((statObj, mapKey) => { let tested = {}; // Record what fields get tested. // To access a field foo without marking it as tested use stat.inner.foo let stat = new Proxy(statObj, { get(stat, key) { if (key == "inner") { return stat; } tested[key] = true; return stat[key]; }, }); let expectations = statsExpectedByType[stat.type]; if (expectations.skip) { return; } // All stats share the following attributes inherited from RTCStats is(stat.id, mapKey, stat.type + ".id is the same as the report key."); // timestamp ok(stat.timestamp >= 0, stat.type + ".timestamp is not less than 0"); // If the timebase for the timestamp is not properly set the timestamp // will appear relative to the year 1970; Bug 1495446 const date = new Date(stat.timestamp); ok( date.getFullYear() > 1970, `${stat.type}.timestamp is relative to current time, date=${date}` ); // // RTCStreamStats attributes with common behavior // // inbound-rtp, outbound-rtp, remote-inbound-rtp, remote-outbound-rtp // inherit from RTCStreamStats if ( [ "inbound-rtp", "outbound-rtp", "remote-inbound-rtp", "remote-outbound-rtp", ].includes(stat.type) ) { const isRemote = stat.type.startsWith("remote-"); // // Common RTCStreamStats fields // // SSRC ok(stat.ssrc, stat.type + ".ssrc has a value"); // kind ok( ["audio", "video"].includes(stat.kind), stat.type + ".kind is 'audio' or 'video'" ); // mediaType, renamed to kind but remains for backward compability. ok( ["audio", "video"].includes(stat.mediaType), stat.type + ".mediaType is 'audio' or 'video'" ); ok(stat.kind == stat.mediaType, "kind equals legacy mediaType"); // codecId ok(stat.codecId, `${stat.type}.codecId has a value`); ok(report.has(stat.codecId), `codecId ${stat.codecId} exists in report`); is( report.get(stat.codecId).type, "codec", `codecId ${stat.codecId} in report is codec type` ); is( report.get(stat.codecId).mimeType.slice(0, 5), stat.kind, `codecId ${stat.codecId} in report is for a mimeType of the same ` + `media type as the referencing rtp stream stat` ); if (isRemote) { // local id if (stat.localId) { ok( report.has(stat.localId), `localId ${stat.localId} exists in report.` ); is( report.get(stat.localId).ssrc, stat.ssrc, "remote ssrc and local ssrc match." ); is( report.get(stat.localId).remoteId, stat.id, "local object has remote object as it's own remote object." ); } } else { // remote id if (stat.remoteId) { ok( report.has(stat.remoteId), `remoteId ${stat.remoteId} exists in report.` ); is( report.get(stat.remoteId).ssrc, stat.ssrc, "remote ssrc and local ssrc match." ); is( report.get(stat.remoteId).localId, stat.id, "remote object has local object as it's own local object." ); } } // nackCount if (stat.nackCount) { ok( stat.nackCount >= 0, `${stat.type}.nackCount is sane (${stat.kind}).` ); } if (!isRemote && stat.inner.kind == "video") { // firCount ok( stat.firCount >= 0 && stat.firCount < 100, `${stat.type}.firCount is a sane number for a short ` + `${stat.kind} test. value=${stat.firCount}` ); // pliCount ok( stat.pliCount >= 0 && stat.pliCount < 200, `${stat.type}.pliCount is a sane number for a short ` + `${stat.kind} test. value=${stat.pliCount}` ); // qpSum if (stat.qpSum !== undefined) { ok( stat.qpSum > 0, `${stat.type}.qpSum is at least 0 ` + `${stat.kind} test. value=${stat.qpSum}` ); } } else { is( stat.qpSum, undefined, `${stat.type}.qpSum does not exist when stat.kind != video` ); } } if (stat.type == "inbound-rtp") { // // Required fields // // trackIdentifier is(typeof stat.trackIdentifier, "string"); isnot(stat.trackIdentifier, ""); // packetsReceived ok( stat.packetsReceived >= 0 && stat.packetsReceived < 10 ** 5, `${stat.type}.packetsReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsReceived}` ); // packetsDiscarded ok( stat.packetsDiscarded >= 0 && stat.packetsDiscarded < 100, `${stat.type}.packetsDiscarded is sane number for a short test. ` + `value=${stat.packetsDiscarded}` ); // bytesReceived ok( stat.bytesReceived >= 0 && stat.bytesReceived < 10 ** 9, // Not a magic number, just a guess `${stat.type}.bytesReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.bytesReceived}` ); // packetsLost ok( stat.packetsLost < 100, `${stat.type}.packetsLost is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsLost}` ); // This should be much lower for audio, TODO: Bug 1330575 let expectedJitter = stat.kind == "video" ? 0.5 : 1; // jitter ok( stat.jitter < expectedJitter, `${stat.type}.jitter is sane number for a ${stat.kind} ` + `local only test. value=${stat.jitter}` ); // lastPacketReceivedTimestamp ok( stat.lastPacketReceivedTimestamp !== undefined, `${stat.type}.lastPacketReceivedTimestamp has a value` ); // headerBytesReceived ok( stat.headerBytesReceived >= 0 && stat.headerBytesReceived < 50000, `${stat.type}.headerBytesReceived is sane for a short test. ` + `value=${stat.headerBytesReceived}` ); // Always missing from libwebrtc stats // estimatedPlayoutTimestamp // ok( // stat.estimatedPlayoutTimestamp !== undefined, // `${stat.type}.estimatedPlayoutTimestamp has a value` // ); // jitterBufferEmittedCount let expectedJitterBufferEmmitedCount = stat.kind == "video" ? 7 : 1000; ok( stat.jitterBufferEmittedCount > expectedJitterBufferEmmitedCount, `${stat.type}.jitterBufferEmittedCount is a sane number for a short ` + `${stat.kind} test. value=${stat.jitterBufferEmittedCount}` ); // jitterBufferDelay let avgJitterBufferDelay = stat.jitterBufferDelay / stat.jitterBufferEmittedCount; ok( avgJitterBufferDelay > 0.01 && avgJitterBufferDelay < 10, `${stat.type}.jitterBufferDelay is a sane number for a short ` + `${stat.kind} test. value=${stat.jitterBufferDelay}/${stat.jitterBufferEmittedCount}=${avgJitterBufferDelay}` ); // // Optional fields // // // Local audio only stats // if (stat.inner.kind != "audio") { expectations.localAudioOnly.forEach(field => { ok( stat[field] === undefined, `${stat.type} does not have field ${field}` + ` when kind is not 'audio'` ); }); } else { expectations.localAudioOnly.forEach(field => { ok( stat.inner[field] !== undefined, stat.type + " has field " + field + " when kind is video" ); }); // totalSamplesReceived ok( stat.totalSamplesReceived > 1000, `${stat.type}.totalSamplesReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.totalSamplesReceived}` ); // fecPacketsReceived ok( stat.fecPacketsReceived >= 0 && stat.fecPacketsReceived < 10 ** 5, `${stat.type}.fecPacketsReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.fecPacketsReceived}` ); // fecPacketsDiscarded ok( stat.fecPacketsDiscarded >= 0 && stat.fecPacketsDiscarded < 100, `${stat.type}.fecPacketsDiscarded is sane number for a short test. ` + `value=${stat.fecPacketsDiscarded}` ); // concealedSamples ok( stat.concealedSamples >= 0 && stat.concealedSamples <= stat.totalSamplesReceived, `${stat.type}.concealedSamples is a sane number for a short ` + `${stat.kind} test. value=${stat.concealedSamples}` ); // silentConcealedSamples ok( stat.silentConcealedSamples >= 0 && stat.silentConcealedSamples <= stat.concealedSamples, `${stat.type}.silentConcealedSamples is a sane number for a short ` + `${stat.kind} test. value=${stat.silentConcealedSamples}` ); // concealmentEvents ok( stat.concealmentEvents >= 0 && stat.concealmentEvents <= stat.packetsReceived, `${stat.type}.concealmentEvents is a sane number for a short ` + `${stat.kind} test. value=${stat.concealmentEvents}` ); // insertedSamplesForDeceleration ok( stat.insertedSamplesForDeceleration >= 0 && stat.insertedSamplesForDeceleration <= stat.totalSamplesReceived, `${stat.type}.insertedSamplesForDeceleration is a sane number for a short ` + `${stat.kind} test. value=${stat.insertedSamplesForDeceleration}` ); // removedSamplesForAcceleration ok( stat.removedSamplesForAcceleration >= 0 && stat.removedSamplesForAcceleration <= stat.totalSamplesReceived, `${stat.type}.removedSamplesForAcceleration is a sane number for a short ` + `${stat.kind} test. value=${stat.removedSamplesForAcceleration}` ); // audioLevel ok( stat.audioLevel >= 0 && stat.audioLevel <= 128, `${stat.type}.bytesReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.audioLevel}` ); // totalAudioEnergy ok( stat.totalAudioEnergy >= 0 && stat.totalAudioEnergy <= 128, `${stat.type}.totalAudioEnergy is a sane number for a short ` + `${stat.kind} test. value=${stat.totalAudioEnergy}` ); // totalSamplesDuration ok( stat.totalSamplesDuration >= 0 && stat.totalSamplesDuration <= 300, `${stat.type}.totalSamplesDuration is a sane number for a short ` + `${stat.kind} test. value=${stat.totalSamplesDuration}` ); } // // Local video only stats // if (stat.inner.kind != "video") { expectations.localVideoOnly.forEach(field => { ok( stat[field] === undefined, `${stat.type} does not have field ${field}` + ` when kind is not 'video'` ); }); } else { expectations.localVideoOnly.forEach(field => { ok( stat.inner[field] !== undefined, stat.type + " has field " + field + " when kind is video" ); }); // discardedPackets ok( stat.discardedPackets < 100, `${stat.type}.discardedPackets is a sane number for a short test. ` + `value=${stat.discardedPackets}` ); // framesPerSecond ok( stat.framesPerSecond > 0 && stat.framesPerSecond < 70, `${stat.type}.framesPerSecond is a sane number for a short ` + `${stat.kind} test. value=${stat.framesPerSecond}` ); // framesDecoded ok( stat.framesDecoded > 0 && stat.framesDecoded < 1000000, `${stat.type}.framesDecoded is a sane number for a short ` + `${stat.kind} test. value=${stat.framesDecoded}` ); // framesDropped ok( stat.framesDropped >= 0 && stat.framesDropped < 100, `${stat.type}.framesDropped is a sane number for a short ` + `${stat.kind} test. value=${stat.framesDropped}` ); // frameWidth ok( stat.frameWidth > 0 && stat.frameWidth < 100000, `${stat.type}.frameWidth is a sane number for a short ` + `${stat.kind} test. value=${stat.framesSent}` ); // frameHeight ok( stat.frameHeight > 0 && stat.frameHeight < 100000, `${stat.type}.frameHeight is a sane number for a short ` + `${stat.kind} test. value=${stat.frameHeight}` ); // totalDecodeTime ok( stat.totalDecodeTime >= 0 && stat.totalDecodeTime < 300, `${stat.type}.totalDecodeTime is sane for a short test. ` + `value=${stat.totalDecodeTime}` ); // totalProcessingDelay ok( stat.totalProcessingDelay < 100, `${stat.type}.totalProcessingDelay is sane number for a short test ` + `local only test. value=${stat.totalProcessingDelay}` ); // totalInterFrameDelay ok( stat.totalInterFrameDelay >= 0 && stat.totalInterFrameDelay < 100, `${stat.type}.totalInterFrameDelay is sane for a short test. ` + `value=${stat.totalInterFrameDelay}` ); // totalSquaredInterFrameDelay ok( stat.totalSquaredInterFrameDelay >= 0 && stat.totalSquaredInterFrameDelay < 100, `${stat.type}.totalSquaredInterFrameDelay is sane for a short test. ` + `value=${stat.totalSquaredInterFrameDelay}` ); // framesReceived ok( stat.framesReceived >= 0 && stat.framesReceived < 100000, `${stat.type}.framesReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.framesReceived}` ); } } else if (stat.type == "remote-inbound-rtp") { // roundTripTime ok( stat.roundTripTime >= 0, `${stat.type}.roundTripTime is sane with` + `value of: ${stat.roundTripTime} (${stat.kind})` ); // // Required fields // // packetsLost ok( stat.packetsLost < 100, `${stat.type}.packetsLost is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsLost}` ); // jitter ok( stat.jitter >= 0, `${stat.type}.jitter is sane number (${stat.kind}). ` + `value=${stat.jitter}` ); // // Optional fields // // packetsReceived if (stat.packetsReceived) { ok( stat.packetsReceived >= 0 && stat.packetsReceived < 10 ** 5, `${stat.type}.packetsReceived is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsReceived}` ); } // totalRoundTripTime ok( stat.totalRoundTripTime < 50000, `${stat.type}.totalRoundTripTime is a sane number for a short ` + `${stat.kind} test. value=${stat.totalRoundTripTime}` ); // fractionLost ok( stat.fractionLost < 0.2, `${stat.type}.fractionLost is a sane number for a short ` + `${stat.kind} test. value=${stat.fractionLost}` ); // roundTripTimeMeasurements ok( stat.roundTripTimeMeasurements >= 1 && stat.roundTripTimeMeasurements < 500, `${stat.type}.roundTripTimeMeasurements is a sane number for a short ` + `${stat.kind} test. value=${stat.roundTripTimeMeasurements}` ); } else if (stat.type == "outbound-rtp") { // // Required fields // // packetsSent ok( stat.packetsSent > 0 && stat.packetsSent < 10000, `${stat.type}.packetsSent is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsSent}` ); // bytesSent const audio1Min = 16000 * 60; // 128kbps const video1Min = 250000 * 60; // 2Mbps ok( stat.bytesSent > 0 && stat.bytesSent < (stat.kind == "video" ? video1Min : audio1Min), `${stat.type}.bytesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.bytesSent}` ); // headerBytesSent ok( stat.headerBytesSent > 0 && stat.headerBytesSent < (stat.kind == "video" ? video1Min : audio1Min), `${stat.type}.headerBytesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.headerBytesSent}` ); // retransmittedPacketsSent ok( stat.retransmittedPacketsSent >= 0 && stat.retransmittedPacketsSent < (stat.kind == "video" ? video1Min : audio1Min), `${stat.type}.retransmittedPacketsSent is a sane number for a short ` + `${stat.kind} test. value=${stat.retransmittedPacketsSent}` ); // retransmittedBytesSent ok( stat.retransmittedBytesSent >= 0 && stat.retransmittedBytesSent < (stat.kind == "video" ? video1Min : audio1Min), `${stat.type}.retransmittedBytesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.retransmittedBytesSent}` ); // // Optional fields // // qpSum // This is supported for all of our vpx codecs (on the encode side, see // bug 1519590) const mimeType = report.get(stat.codecId).mimeType; if (mimeType.includes("VP")) { ok( stat.qpSum >= 0, `${stat.type}.qpSum is a sane number (${stat.kind}) ` + `for ${report.get(stat.codecId).mimeType}. value=${stat.qpSum}` ); } else if (mimeType.includes("H264")) { // OpenH264 encoder records QP so we check for either condition. if (!stat.qpSum && !("qpSum" in stat)) { ok( !stat.qpSum && !("qpSum" in stat), `${stat.type}.qpSum absent for ${report.get(stat.codecId).mimeType}` ); } else { ok( stat.qpSum >= 0, `${stat.type}.qpSum is a sane number (${stat.kind}) ` + `for ${report.get(stat.codecId).mimeType}. value=${stat.qpSum}` ); } } else { ok( !stat.qpSum && !("qpSum" in stat), `${stat.type}.qpSum absent for ${report.get(stat.codecId).mimeType}` ); } // // Local video only stats // if (stat.inner.kind != "video") { expectations.localVideoOnly.forEach(field => { ok( stat[field] === undefined, `${stat.type} does not have field ` + `${field} when kind is not 'video'` ); }); } else { expectations.localVideoOnly.forEach(field => { ok( stat.inner[field] !== undefined, `${stat.type} has field ` + `${field} when kind is video and isRemote is false` ); }); // framesEncoded ok( stat.framesEncoded >= 0 && stat.framesEncoded < 100000, `${stat.type}.framesEncoded is a sane number for a short ` + `${stat.kind} test. value=${stat.framesEncoded}` ); // frameWidth ok( stat.frameWidth >= 0 && stat.frameWidth < 100000, `${stat.type}.frameWidth is a sane number for a short ` + `${stat.kind} test. value=${stat.frameWidth}` ); // frameHeight ok( stat.frameHeight >= 0 && stat.frameHeight < 100000, `${stat.type}.frameHeight is a sane number for a short ` + `${stat.kind} test. value=${stat.frameHeight}` ); // framesSent ok( stat.framesSent >= 0 && stat.framesSent < 100000, `${stat.type}.framesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.framesSent}` ); // hugeFramesSent ok( stat.hugeFramesSent >= 0 && stat.hugeFramesSent < 100000, `${stat.type}.hugeFramesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.hugeFramesSent}` ); // totalEncodeTime ok( stat.totalEncodeTime >= 0, `${stat.type}.totalEncodeTime is a sane number for a short ` + `${stat.kind} test. value=${stat.totalEncodeTime}` ); // totalEncodedBytesTarget ok( stat.totalEncodedBytesTarget > 1000, `${stat.type}.totalEncodedBytesTarget is a sane number for a short ` + `${stat.kind} test. value=${stat.totalEncodedBytesTarget}` ); } } else if (stat.type == "remote-outbound-rtp") { // // Required fields // // packetsSent ok( stat.packetsSent > 0 && stat.packetsSent < 10000, `${stat.type}.packetsSent is a sane number for a short ` + `${stat.kind} test. value=${stat.packetsSent}` ); // bytesSent const audio1Min = 16000 * 60; // 128kbps const video1Min = 250000 * 60; // 2Mbps ok( stat.bytesSent > 0 && stat.bytesSent < (stat.kind == "video" ? video1Min : audio1Min), `${stat.type}.bytesSent is a sane number for a short ` + `${stat.kind} test. value=${stat.bytesSent}` ); ok( stat.remoteTimestamp !== undefined, `${stat.type}.remoteTimestamp ` + `is not undefined (${stat.kind})` ); const ageSeconds = (stat.timestamp - stat.remoteTimestamp) / 1000; // remoteTimestamp is exact (so it can be mapped to a packet), whereas // timestamp has reduced precision. It is possible that // remoteTimestamp occurs a millisecond into the future from // timestamp. We also subtract half a millisecond when reducing // precision on libwebrtc timestamps, to counteract the potential // rounding up that libwebrtc may do since it tends to round its // internal timestamps to whole milliseconds. In the worst case // remoteTimestamp may therefore occur 2 milliseconds ahead of // timestamp. ok( ageSeconds >= -0.002 && ageSeconds < 30, `${stat.type}.remoteTimestamp is on the same timeline as ` + `${stat.type}.timestamp, and no older than 30 seconds. ` + `difference=${ageSeconds}s` ); } else if (stat.type == "media-source") { // trackIdentifier is(typeof stat.trackIdentifier, "string"); isnot(stat.trackIdentifier, ""); // kind is(typeof stat.kind, "string"); ok(stat.kind == "audio" || stat.kind == "video"); } else if (stat.type == "codec") { // // Required fields // // mimeType & payloadType switch (stat.mimeType) { case "audio/opus": is(stat.payloadType, 109, "codec.payloadType for opus"); break; case "video/VP8": is(stat.payloadType, 120, "codec.payloadType for VP8"); break; case "video/VP9": is(stat.payloadType, 121, "codec.payloadType for VP9"); break; case "video/H264": ok( stat.payloadType == 97 || stat.payloadType == 126, `codec.payloadType for H264 was ${stat.payloadType}, exp. 97 or 126` ); break; default: ok( false, `Unexpected codec.mimeType ${stat.mimeType} for payloadType ` + `${stat.payloadType}` ); break; } // transportId // (no transport stats yet) ok(stat.transportId, "codec.transportId is set"); // clockRate if (stat.mimeType.startsWith("audio")) { is(stat.clockRate, 48000, "codec.clockRate for audio/opus"); } else if (stat.mimeType.startsWith("video")) { is(stat.clockRate, 90000, "codec.clockRate for video"); } // sdpFmtpLine // (not technically mandated by spec, but expected here) ok(stat.sdpFmtpLine, "codec.sdpFmtpLine is set"); const opusParams = [ "maxplaybackrate", "maxaveragebitrate", "usedtx", "stereo", "useinbandfec", "cbr", "ptime", "minptime", "maxptime", ]; const vpxParams = ["max-fs", "max-fr"]; const h264Params = [ "packetization-mode", "level-asymmetry-allowed", "profile-level-id", "max-fs", "max-cpb", "max-dpb", "max-br", "max-mbps", ]; for (const param of stat.sdpFmtpLine.split(";")) { const [key, value] = param.split("="); if (stat.payloadType == 109) { ok( opusParams.includes(key), `codec.sdpFmtpLine param ${key}=${value} for opus` ); } else if (stat.payloadType == 120 || stat.payloadType == 121) { ok( vpxParams.includes(key), `codec.sdpFmtpLine param ${key}=${value} for VPx` ); } else if (stat.payloadType == 97 || stat.payloadType == 126) { ok( h264Params.includes(key), `codec.sdpFmtpLine param ${key}=${value} for H264` ); if (key == "packetization-mode") { if (stat.payloadType == 97) { is(value, "0", "codec.sdpFmtpLine: H264 (97) packetization-mode"); } else if (stat.payloadType == 126) { is( value, "1", "codec.sdpFmtpLine: H264 (126) packetization-mode" ); } } if (key == "profile-level-id") { is(value, "42e01f", "codec.sdpFmtpLine: H264 profile-level-id"); } } } // // Optional fields // // codecType ok( !Object.keys(stat).includes("codecType") || stat.codecType == "encode" || stat.codecType == "decode", "codec.codecType (${codec.codecType}) is an expected value or absent" ); let numRecvStreams = 0; let numSendStreams = 0; const counts = { "inbound-rtp": 0, "outbound-rtp": 0, "remote-inbound-rtp": 0, "remote-outbound-rtp": 0, }; const [kind] = stat.mimeType.split("/"); report.forEach(other => { if (other.type == "inbound-rtp" && other.kind == kind) { numRecvStreams += 1; } else if (other.type == "outbound-rtp" && other.kind == kind) { numSendStreams += 1; } if (other.codecId == stat.id) { counts[other.type] += 1; } }); const expectedCounts = { encode: { "inbound-rtp": 0, "outbound-rtp": numSendStreams, "remote-inbound-rtp": numSendStreams, "remote-outbound-rtp": 0, }, decode: { "inbound-rtp": numRecvStreams, "outbound-rtp": 0, "remote-inbound-rtp": 0, "remote-outbound-rtp": numRecvStreams, }, absent: { "inbound-rtp": numRecvStreams, "outbound-rtp": numSendStreams, "remote-inbound-rtp": numSendStreams, "remote-outbound-rtp": numRecvStreams, }, }; // Note that the logic above assumes at most one sender and at most one // receiver was used to generate this stats report. If more senders or // receivers are present, they'd be referring to not only this codec stat, // skewing `numSendStreams` and `numRecvStreams` above. // This could be fixed when we support `senderId` and `receiverId` in // RTCOutboundRtpStreamStats and RTCInboundRtpStreamStats respectively. for (const [key, value] of Object.entries(counts)) { is( value, expectedCounts[stat.codecType || "absent"][key], `codec.codecType ${stat.codecType || "absent"} ref from ${key} stat` ); } // channels if (stat.mimeType.startsWith("audio")) { ok(stat.channels, "codec.channels should exist for audio"); if (stat.channels) { if (stat.sdpFmtpLine.includes("stereo=1")) { is(stat.channels, 2, "codec.channels for stereo audio"); } else { is(stat.channels, 1, "codec.channels for mono audio"); } } } else { ok(!stat.channels, "codec.channels should not exist for video"); } } else if (stat.type == "candidate-pair") { info("candidate-pair is: " + JSON.stringify(stat)); // // Required fields // // transportId ok( stat.transportId, `${stat.type}.transportId has a value. value=` + `${stat.transportId} (${stat.kind})` ); // localCandidateId ok( stat.localCandidateId, `${stat.type}.localCandidateId has a value. value=` + `${stat.localCandidateId} (${stat.kind})` ); // remoteCandidateId ok( stat.remoteCandidateId, `${stat.type}.remoteCandidateId has a value. value=` + `${stat.remoteCandidateId} (${stat.kind})` ); // priority ok( stat.priority, `${stat.type}.priority has a value. value=` + `${stat.priority} (${stat.kind})` ); // readable ok( stat.readable, `${stat.type}.readable is true. value=${stat.readable} ` + `(${stat.kind})` ); // writable ok( stat.writable, `${stat.type}.writable is true. value=${stat.writable} ` + `(${stat.kind})` ); // state if ( stat.state == "succeeded" && stat.selected !== undefined && stat.selected ) { info("candidate-pair state is succeeded and selected is true"); // nominated ok( stat.nominated, `${stat.type}.nominated is true. value=${stat.nominated} ` + `(${stat.kind})` ); // bytesSent ok( stat.bytesSent > 1000, `${stat.type}.bytesSent is a sane number (>1,000) for a short ` + `${stat.kind} test. value=${stat.bytesSent}` ); // bytesReceived ok( stat.bytesReceived > 500, `${stat.type}.bytesReceived is a sane number (>500) for a short ` + `${stat.kind} test. value=${stat.bytesReceived}` ); // lastPacketSentTimestamp ok( stat.lastPacketSentTimestamp, `${stat.type}.lastPacketSentTimestamp has a value. value=` + `${stat.lastPacketSentTimestamp} (${stat.kind})` ); // lastPacketReceivedTimestamp ok( stat.lastPacketReceivedTimestamp, `${stat.type}.lastPacketReceivedTimestamp has a value. value=` + `${stat.lastPacketReceivedTimestamp} (${stat.kind})` ); } else { info("candidate-pair is _not_ both state == succeeded and selected"); // nominated ok( stat.nominated !== undefined, `${stat.type}.nominated exists. value=${stat.nominated} ` + `(${stat.kind})` ); ok( stat.bytesSent !== undefined, `${stat.type}.bytesSent exists. value=${stat.bytesSent} ` + `(${stat.kind})` ); ok( stat.bytesReceived !== undefined, `${stat.type}.bytesReceived exists. value=${stat.bytesReceived} ` + `(${stat.kind})` ); ok( stat.lastPacketSentTimestamp !== undefined, `${stat.type}.lastPacketSentTimestamp exists. value=` + `${stat.lastPacketSentTimestamp} (${stat.kind})` ); ok( stat.lastPacketReceivedTimestamp !== undefined, `${stat.type}.lastPacketReceivedTimestamp exists. value=` + `${stat.lastPacketReceivedTimestamp} (${stat.kind})` ); } // // Optional fields // // selected ok( stat.selected === undefined || (stat.state == "succeeded" && stat.selected) || !stat.selected, `${stat.type}.selected is undefined, true when state is succeeded, ` + `or false. value=${stat.selected} (${stat.kind})` ); } else if ( stat.type == "local-candidate" || stat.type == "remote-candidate" ) { info(`candidate is ${JSON.stringify(stat)}`); // address ok( stat.address, `${stat.type} has address. value=${stat.address} ` + `(${stat.kind})` ); // protocol ok( stat.protocol, `${stat.type} has protocol. value=${stat.protocol} ` + `(${stat.kind})` ); // port ok( stat.port >= 0, `${stat.type} has port >= 0. value=${stat.port} ` + `(${stat.kind})` ); ok( stat.port <= 65535, `${stat.type} has port <= 65535. value=${stat.port} ` + `(${stat.kind})` ); // candidateType ok( stat.candidateType, `${stat.type} has candidateType. value=${stat.candidateType} ` + `(${stat.kind})` ); // priority ok( stat.priority > 0 && stat.priority < 2 ** 32 - 1, `${stat.type} has priority between 1 and 2^32 - 1 inc. ` + `value=${stat.priority} (${stat.kind})` ); // relayProtocol if (stat.type == "local-candidate" && stat.candidateType == "relay") { ok( stat.relayProtocol, `relay ${stat.type} has relayProtocol. value=${stat.relayProtocol} ` + `(${stat.kind})` ); } else { is( stat.relayProtocol, undefined, `relayProtocol is undefined for candidates that are not relay and ` + `local. value=${stat.relayProtocol} (${stat.kind})` ); } // proxied if (stat.proxied) { ok( stat.proxied == "proxied" || stat.proxied == "non-proxied", `${stat.type} has proxied. value=${stat.proxied} (${stat.kind})` ); } } // // Ensure everything was tested // [...expectations.expected, ...expectations.optional].forEach(field => { ok( Object.keys(tested).includes(field), `${stat.type}.${field} was tested.` ); }); }); } function dumpStats(stats) { const dict = {}; for (const [k, v] of stats.entries()) { dict[k] = v; } info(`Got stats: ${JSON.stringify(dict)}`); } async function waitForSyncedRtcp(pc) { // Ensures that RTCP is present let ensureSyncedRtcp = async () => { let report = await pc.getStats(); for (const v of report.values()) { if (v.type.endsWith("bound-rtp") && !(v.remoteId || v.localId)) { info(`${v.id} is missing remoteId or localId: ${JSON.stringify(v)}`); return null; } if (v.type == "remote-inbound-rtp" && v.roundTripTime === undefined) { info(`${v.id} is missing roundTripTime: ${JSON.stringify(v)}`); return null; } } return report; }; // Returns true if there is proof in aStats of rtcp flow for all remote stats // objects, compared to baseStats. const hasAllRtcpUpdated = (baseStats, stats) => { let hasRtcpStats = false; for (const v of stats.values()) { if (v.type == "remote-outbound-rtp") { hasRtcpStats = true; if (!v.remoteTimestamp) { // `remoteTimestamp` is 0 or not present. return false; } if (v.remoteTimestamp <= baseStats.get(v.id)?.remoteTimestamp) { // `remoteTimestamp` has not advanced further than the base stats, // i.e., no new sender report has been received. return false; } } else if (v.type == "remote-inbound-rtp") { hasRtcpStats = true; // The ideal thing here would be to check `reportsReceived`, but it's // not yet implemented. if (!v.packetsReceived) { // `packetsReceived` is 0 or not present. return false; } if (v.packetsReceived <= baseStats.get(v.id)?.packetsReceived) { // `packetsReceived` has not advanced further than the base stats, // i.e., no new receiver report has been received. return false; } } } return hasRtcpStats; }; let attempts = 0; const baseStats = await pc.getStats(); // Time-units are MS const waitPeriod = 100; const maxTime = 20000; for (let totalTime = maxTime; totalTime > 0; totalTime -= waitPeriod) { try { let syncedStats = await ensureSyncedRtcp(); if (syncedStats && hasAllRtcpUpdated(baseStats, syncedStats)) { dumpStats(syncedStats); return syncedStats; } } catch (e) { info(e); info(e.stack); throw e; } attempts += 1; info(`waitForSyncedRtcp: no sync on attempt ${attempts}, retrying.`); await wait(waitPeriod); } throw Error( "Waiting for synced RTCP timed out after at least " + maxTime + "ms" ); } function checkSenderStats(senderStats, streamCount) { const outboundRtpReports = []; const remoteInboundRtpReports = []; for (const v of senderStats.values()) { if (v.type == "outbound-rtp") { outboundRtpReports.push(v); } else if (v.type == "remote-inbound-rtp") { remoteInboundRtpReports.push(v); } } is( outboundRtpReports.length, streamCount, `Sender with ${streamCount} simulcast streams has ${streamCount} outbound-rtp reports` ); is( remoteInboundRtpReports.length, streamCount, `Sender with ${streamCount} simulcast streams has ${streamCount} remote-inbound-rtp reports` ); for (const outboundRtpReport of outboundRtpReports) { is( outboundRtpReports.filter(r => r.ssrc == outboundRtpReport.ssrc).length, 1, "Simulcast send track SSRCs are distinct" ); const remoteReports = remoteInboundRtpReports.filter( r => r.id == outboundRtpReport.remoteId ); is( remoteReports.length, 1, "Simulcast send tracks have exactly one remote counterpart" ); const remoteInboundRtpReport = remoteReports[0]; is( outboundRtpReport.ssrc, remoteInboundRtpReport.ssrc, "SSRC matches for outbound-rtp and remote-inbound-rtp" ); } } function PC_LOCAL_TEST_LOCAL_STATS(test) { return waitForSyncedRtcp(test.pcLocal._pc).then(stats => { checkExpectedFields(stats); pedanticChecks(stats); return Promise.all([ test.pcLocal._pc.getSenders().map(async s => { checkSenderStats( await s.getStats(), Math.max(1, s.getParameters()?.encodings?.length ?? 0) ); }), ]); }); } function PC_REMOTE_TEST_REMOTE_STATS(test) { return waitForSyncedRtcp(test.pcRemote._pc).then(stats => { checkExpectedFields(stats); pedanticChecks(stats); return Promise.all([ test.pcRemote._pc.getSenders().map(async s => { checkSenderStats( await s.getStats(), s.track ? Math.max(1, s.getParameters()?.encodings?.length ?? 0) : 0 ); }), ]); }); }