diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js')
-rw-r--r-- | testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js | 715 |
1 files changed, 715 insertions, 0 deletions
diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js new file mode 100644 index 0000000000..ac435279bd --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js @@ -0,0 +1,715 @@ +'use strict' + +/* + * Helper Methods for testing the following methods in RTCPeerConnection: + * createOffer + * createAnswer + * setLocalDescription + * setRemoteDescription + * + * This file offers the following features: + * SDP similarity comparison + * Generating offer/answer using anonymous peer connection + * Test signalingstatechange event + * Test promise that never resolve + */ + +const audioLineRegex = /\r\nm=audio.+\r\n/g; +const videoLineRegex = /\r\nm=video.+\r\n/g; +const applicationLineRegex = /\r\nm=application.+\r\n/g; + +function countLine(sdp, regex) { + const matches = sdp.match(regex); + if(matches === null) { + return 0; + } else { + return matches.length; + } +} + +function countAudioLine(sdp) { + return countLine(sdp, audioLineRegex); +} + +function countVideoLine(sdp) { + return countLine(sdp, videoLineRegex); +} + +function countApplicationLine(sdp) { + return countLine(sdp, applicationLineRegex); +} + +function similarMediaDescriptions(sdp1, sdp2) { + if(sdp1 === sdp2) { + return true; + } else if( + countAudioLine(sdp1) !== countAudioLine(sdp2) || + countVideoLine(sdp1) !== countVideoLine(sdp2) || + countApplicationLine(sdp1) !== countApplicationLine(sdp2)) + { + return false; + } else { + return true; + } +} + +// Assert that given object is either an +// RTCSessionDescription or RTCSessionDescriptionInit +function assert_is_session_description(sessionDesc) { + if(sessionDesc instanceof RTCSessionDescription) { + return; + } + + assert_not_equals(sessionDesc, undefined, + 'Expect session description to be defined'); + + assert_true(typeof(sessionDesc) === 'object', + 'Expect sessionDescription to be either a RTCSessionDescription or an object'); + + assert_true(typeof(sessionDesc.type) === 'string', + 'Expect sessionDescription.type to be a string'); + + assert_true(typeof(sessionDesc.sdp) === 'string', + 'Expect sessionDescription.sdp to be a string'); +} + + +// We can't do string comparison to the SDP content, +// because RTCPeerConnection may return SDP that is +// slightly modified or reordered from what is given +// to it due to ICE candidate events or serialization. +// Instead, we create SDP with different number of media +// lines, and if the SDP strings are not the same, we +// simply count the media description lines and if they +// are the same, we assume it is the same. +function isSimilarSessionDescription(sessionDesc1, sessionDesc2) { + assert_is_session_description(sessionDesc1); + assert_is_session_description(sessionDesc2); + + if(sessionDesc1.type !== sessionDesc2.type) { + return false; + } else { + return similarMediaDescriptions(sessionDesc1.sdp, sessionDesc2.sdp); + } +} + +function assert_session_desc_similar(sessionDesc1, sessionDesc2) { + assert_true(isSimilarSessionDescription(sessionDesc1, sessionDesc2), + 'Expect both session descriptions to have the same count of media lines'); +} + +function assert_session_desc_not_similar(sessionDesc1, sessionDesc2) { + assert_false(isSimilarSessionDescription(sessionDesc1, sessionDesc2), + 'Expect both session descriptions to have different count of media lines'); +} + +async function generateDataChannelOffer(pc) { + pc.createDataChannel('test'); + const offer = await pc.createOffer(); + assert_equals(countApplicationLine(offer.sdp), 1, 'Expect m=application line to be present in generated SDP'); + return offer; +} + +async function generateAudioReceiveOnlyOffer(pc) +{ + try { + pc.addTransceiver('audio', { direction: 'recvonly' }); + return pc.createOffer(); + } catch(e) { + return pc.createOffer({ offerToReceiveAudio: true }); + } +} + +async function generateVideoReceiveOnlyOffer(pc) +{ + try { + pc.addTransceiver('video', { direction: 'recvonly' }); + return pc.createOffer(); + } catch(e) { + return pc.createOffer({ offerToReceiveVideo: true }); + } +} + +// Helper function to generate answer based on given offer using a freshly +// created RTCPeerConnection object +async function generateAnswer(offer) { + const pc = new RTCPeerConnection(); + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + pc.close(); + return answer; +} + +// Helper function to generate offer using a freshly +// created RTCPeerConnection object +async function generateOffer() { + const pc = new RTCPeerConnection(); + const offer = await pc.createOffer(); + pc.close(); + return offer; +} + +// Run a test function that return a promise that should +// never be resolved. For lack of better options, +// we wait for a time out and pass the test if the +// promise doesn't resolve within that time. +function test_never_resolve(testFunc, testName) { + async_test(t => { + testFunc(t) + .then( + t.step_func(result => { + assert_unreached(`Pending promise should never be resolved. Instead it is fulfilled with: ${result}`); + }), + t.step_func(err => { + assert_unreached(`Pending promise should never be resolved. Instead it is rejected with: ${err}`); + })); + + t.step_timeout(t.step_func_done(), 100) + }, testName); +} + +// Helper function to exchange ice candidates between +// two local peer connections +function exchangeIceCandidates(pc1, pc2) { + // private function + function doExchange(localPc, remotePc) { + localPc.addEventListener('icecandidate', event => { + const { candidate } = event; + + // Guard against already closed peerconnection to + // avoid unrelated exceptions. + if (remotePc.signalingState !== 'closed') { + remotePc.addIceCandidate(candidate); + } + }); + } + + doExchange(pc1, pc2); + doExchange(pc2, pc1); +} + +// Returns a promise that resolves when a |name| event is fired. +function waitUntilEvent(obj, name) { + return new Promise(r => obj.addEventListener(name, r, {once: true})); +} + +// Returns a promise that resolves when the |transport.state| is |state| +// This should work for RTCSctpTransport, RTCDtlsTransport and RTCIceTransport. +async function waitForState(transport, state) { + while (transport.state != state) { + await waitUntilEvent(transport, 'statechange'); + } +} + +// Returns a promise that resolves when |pc.iceConnectionState| is 'connected' +// or 'completed'. +async function listenToIceConnected(pc) { + await waitForIceStateChange(pc, ['connected', 'completed']); +} + +// Returns a promise that resolves when |pc.iceConnectionState| is in one of the +// wanted states. +async function waitForIceStateChange(pc, wantedStates) { + while (!wantedStates.includes(pc.iceConnectionState)) { + await waitUntilEvent(pc, 'iceconnectionstatechange'); + } +} + +// Returns a promise that resolves when |pc.connectionState| is 'connected'. +async function listenToConnected(pc) { + while (pc.connectionState != 'connected') { + await waitUntilEvent(pc, 'connectionstatechange'); + } +} + +// Returns a promise that resolves when |pc.connectionState| is in one of the +// wanted states. +async function waitForConnectionStateChange(pc, wantedStates) { + while (!wantedStates.includes(pc.connectionState)) { + await waitUntilEvent(pc, 'connectionstatechange'); + } +} + +async function waitForIceGatheringState(pc, wantedStates) { + while (!wantedStates.includes(pc.iceGatheringState)) { + await waitUntilEvent(pc, 'icegatheringstatechange'); + } +} + +// Resolves when RTP packets have been received. +async function listenForSSRCs(t, receiver) { + while (true) { + const ssrcs = receiver.getSynchronizationSources(); + if (Array.isArray(ssrcs) && ssrcs.length > 0) { + return ssrcs; + } + await new Promise(r => t.step_timeout(r, 0)); + } +} + +// Helper function to create a pair of connected data channels. +// On success the promise resolves to an array with two data channels. +// It does the heavy lifting of performing signaling handshake, +// ICE candidate exchange, and waiting for data channel at two +// end points to open. Can do both negotiated and non-negotiated setup. +async function createDataChannelPair(t, options, + pc1 = createPeerConnectionWithCleanup(t), + pc2 = createPeerConnectionWithCleanup(t)) { + let pair = [], bothOpen; + try { + if (options.negotiated) { + pair = [pc1, pc2].map(pc => pc.createDataChannel('', options)); + bothOpen = Promise.all(pair.map(dc => new Promise((r, e) => { + dc.onopen = r; + dc.onerror = ({error}) => e(error); + }))); + } else { + pair = [pc1.createDataChannel('', options)]; + bothOpen = Promise.all([ + new Promise((r, e) => { + pair[0].onopen = r; + pair[0].onerror = ({error}) => e(error); + }), + new Promise((r, e) => pc2.ondatachannel = ({channel}) => { + pair[1] = channel; + channel.onopen = r; + channel.onerror = ({error}) => e(error); + }) + ]); + } + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await bothOpen; + return pair; + } finally { + for (const dc of pair) { + dc.onopen = dc.onerror = null; + } + } +} + +// Wait for RTP and RTCP stats to arrive +async function waitForRtpAndRtcpStats(pc) { + // If remote stats are never reported, return after 5 seconds. + const startTime = performance.now(); + while (true) { + const report = await pc.getStats(); + const stats = [...report.values()].filter(({type}) => type.endsWith("bound-rtp")); + // Each RTP and RTCP stat has a reference + // to the matching stat in the other direction + if (stats.length && stats.every(({localId, remoteId}) => localId || remoteId)) { + break; + } + if (performance.now() > startTime + 5000) { + break; + } + } +} + +// Wait for a single message event and return +// a promise that resolve when the event fires +function awaitMessage(channel) { + const once = true; + return new Promise((resolve, reject) => { + channel.addEventListener('message', ({data}) => resolve(data), {once}); + channel.addEventListener('error', reject, {once}); + }); +} + +// Helper to convert a blob to array buffer so that +// we can read the content +async function blobToArrayBuffer(blob) { + const reader = new FileReader(); + reader.readAsArrayBuffer(blob); + return new Promise((resolve, reject) => { + reader.addEventListener('load', () => resolve(reader.result), {once: true}); + reader.addEventListener('error', () => reject(reader.error), {once: true}); + }); +} + +// Assert that two TypedArray or ArrayBuffer objects have the same byte values +function assert_equals_typed_array(array1, array2) { + const [view1, view2] = [array1, array2].map((array) => { + if (array instanceof ArrayBuffer) { + return new DataView(array); + } else { + assert_true(array.buffer instanceof ArrayBuffer, + 'Expect buffer to be instance of ArrayBuffer'); + return new DataView(array.buffer, array.byteOffset, array.byteLength); + } + }); + + assert_equals(view1.byteLength, view2.byteLength, + 'Expect both arrays to be of the same byte length'); + + const byteLength = view1.byteLength; + + for (let i = 0; i < byteLength; ++i) { + assert_equals(view1.getUint8(i), view2.getUint8(i), + `Expect byte at buffer position ${i} to be equal`); + } +} + +// These media tracks will be continually updated with deterministic "noise" in +// order to ensure UAs do not cease transmission in response to apparent +// silence. +// +// > Many codecs and systems are capable of detecting "silence" and changing +// > their behavior in this case by doing things such as not transmitting any +// > media. +// +// Source: https://w3c.github.io/webrtc-pc/#offer-answer-options +const trackFactories = { + // Share a single context between tests to avoid exceeding resource limits + // without requiring explicit destruction. + audioContext: null, + + /** + * Given a set of requested media types, determine if the user agent is + * capable of procedurally generating a suitable media stream. + * + * @param {object} requested + * @param {boolean} [requested.audio] - flag indicating whether the desired + * stream should include an audio track + * @param {boolean} [requested.video] - flag indicating whether the desired + * stream should include a video track + * + * @returns {boolean} + */ + canCreate(requested) { + const supported = { + audio: !!window.AudioContext && !!window.MediaStreamAudioDestinationNode, + video: !!HTMLCanvasElement.prototype.captureStream + }; + + return (!requested.audio || supported.audio) && + (!requested.video || supported.video); + }, + + audio() { + const ctx = trackFactories.audioContext = trackFactories.audioContext || + new AudioContext(); + const oscillator = ctx.createOscillator(); + const dst = oscillator.connect(ctx.createMediaStreamDestination()); + oscillator.start(); + return dst.stream.getAudioTracks()[0]; + }, + + video({width = 640, height = 480, signal} = {}) { + const canvas = Object.assign( + document.createElement("canvas"), {width, height} + ); + const ctx = canvas.getContext('2d'); + const stream = canvas.captureStream(); + + let count = 0; + const interval = setInterval(() => { + ctx.fillStyle = `rgb(${count%255}, ${count*count%255}, ${count%255})`; + count += 1; + ctx.fillRect(0, 0, width, height); + // Add some bouncing boxes in contrast color to add a little more noise. + const contrast = count + 128; + ctx.fillStyle = `rgb(${contrast%255}, ${contrast*contrast%255}, ${contrast%255})`; + const xpos = count % (width - 20); + const ypos = count % (height - 20); + ctx.fillRect(xpos, ypos, xpos + 20, ypos + 20); + const xpos2 = (count + width / 2) % (width - 20); + const ypos2 = (count + height / 2) % (height - 20); + ctx.fillRect(xpos2, ypos2, xpos2 + 20, ypos2 + 20); + // If signal is set (0-255), add a constant-color box of that luminance to + // the video frame at coordinates 20 to 60 in both X and Y direction. + // (big enough to avoid color bleed from surrounding video in some codecs, + // for more stable tests). + if (signal != undefined) { + ctx.fillStyle = `rgb(${signal}, ${signal}, ${signal})`; + ctx.fillRect(20, 20, 40, 40); + } + }, 100); + + if (document.body) { + document.body.appendChild(canvas); + } else { + document.addEventListener('DOMContentLoaded', () => { + document.body.appendChild(canvas); + }, {once: true}); + } + + // Implement track.stop() for performance in some tests on some platforms + const track = stream.getVideoTracks()[0]; + const nativeStop = track.stop; + track.stop = function stop() { + clearInterval(interval); + nativeStop.apply(this); + if (document.body && canvas.parentElement == document.body) { + document.body.removeChild(canvas); + } + }; + return track; + } +}; + +// Get the signal from a video element inserted by createNoiseStream +function getVideoSignal(v) { + if (v.videoWidth < 60 || v.videoHeight < 60) { + throw new Error('getVideoSignal: video too small for test'); + } + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 60; + const context = canvas.getContext('2d'); + context.drawImage(v, 0, 0); + // Extract pixel value at position 40, 40 + const pixel = context.getImageData(40, 40, 1, 1); + // Use luma reconstruction to get back original value according to + // ITU-R rec BT.709 + return (pixel.data[0] * 0.21 + pixel.data[1] * 0.72 + pixel.data[2] * 0.07); +} + +async function detectSignal(t, v, value) { + while (true) { + const signal = getVideoSignal(v).toFixed(); + // allow off-by-two pixel error (observed in some implementations) + if (value - 2 <= signal && signal <= value + 2) { + return; + } + // We would like to wait for each new frame instead here, + // but there seems to be no such callback. + await new Promise(r => t.step_timeout(r, 100)); + } +} + +// Generate a MediaStream bearing the specified tracks. +// +// @param {object} [caps] +// @param {boolean} [caps.audio] - flag indicating whether the generated stream +// should include an audio track +// @param {boolean} [caps.video] - flag indicating whether the generated stream +// should include a video track, or parameters for video +async function getNoiseStream(caps = {}) { + if (!trackFactories.canCreate(caps)) { + return navigator.mediaDevices.getUserMedia(caps); + } + const tracks = []; + + if (caps.audio) { + tracks.push(trackFactories.audio()); + } + + if (caps.video) { + tracks.push(trackFactories.video(caps.video)); + } + + return new MediaStream(tracks); +} + +// Obtain a MediaStreamTrack of kind using procedurally-generated streams (and +// falling back to `getUserMedia` when the user agent cannot generate the +// requested streams). +// Return Promise of pair of track and associated mediaStream. +// Assumes that there is at least one available device +// to generate the track. +function getTrackFromUserMedia(kind) { + return getNoiseStream({ [kind]: true }) + .then(mediaStream => { + const [track] = mediaStream.getTracks(); + return [track, mediaStream]; + }); +} + +// Obtain |count| MediaStreamTracks of type |kind| and MediaStreams. The tracks +// do not belong to any stream and the streams are empty. Returns a Promise +// resolved with a pair of arrays [tracks, streams]. +// Assumes there is at least one available device to generate the tracks and +// streams and that the getUserMedia() calls resolve. +function getUserMediaTracksAndStreams(count, type = 'audio') { + let otherTracksPromise; + if (count > 1) + otherTracksPromise = getUserMediaTracksAndStreams(count - 1, type); + else + otherTracksPromise = Promise.resolve([[], []]); + return otherTracksPromise.then(([tracks, streams]) => { + return getTrackFromUserMedia(type) + .then(([track, stream]) => { + // Remove the default stream-track relationship. + stream.removeTrack(track); + tracks.push(track); + streams.push(stream); + return [tracks, streams]; + }); + }); +} + +// Performs an offer exchange caller -> callee. +async function exchangeOffer(caller, callee) { + await caller.setLocalDescription(await caller.createOffer()); + await callee.setRemoteDescription(caller.localDescription); +} +// Performs an answer exchange caller -> callee. +async function exchangeAnswer(caller, callee) { + // Note that caller's remote description must be set first; if not, + // there's a chance that candidates from callee arrive at caller before + // it has a remote description to apply them to. + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); +} +async function exchangeOfferAnswer(caller, callee) { + await exchangeOffer(caller, callee); + await exchangeAnswer(caller, callee); +} + +// The returned promise is resolved with caller's ontrack event. +async function exchangeAnswerAndListenToOntrack(t, caller, callee) { + const ontrackPromise = addEventListenerPromise(t, caller, 'track'); + await exchangeAnswer(caller, callee); + return ontrackPromise; +} +// The returned promise is resolved with callee's ontrack event. +async function exchangeOfferAndListenToOntrack(t, caller, callee) { + const ontrackPromise = addEventListenerPromise(t, callee, 'track'); + await exchangeOffer(caller, callee); + return ontrackPromise; +} + +// The resolver extends a |promise| that can be resolved or rejected using |resolve| +// or |reject|. +class Resolver extends Promise { + constructor(executor) { + let resolve, reject; + super((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + if (executor) { + return executor(resolve_, reject_); + } + }); + + this._done = false; + this._resolve = resolve; + this._reject = reject; + } + + /** + * Return whether the promise is done (resolved or rejected). + */ + get done() { + return this._done; + } + + /** + * Resolve the promise. + */ + resolve(...args) { + this._done = true; + return this._resolve(...args); + } + + /** + * Reject the promise. + */ + reject(...args) { + this._done = true; + return this._reject(...args); + } +} + +function addEventListenerPromise(t, obj, type, listener) { + if (!listener) { + return waitUntilEvent(obj, type); + } + return new Promise(r => obj.addEventListener(type, + t.step_func(e => r(listener(e))), + {once: true})); +} + +function createPeerConnectionWithCleanup(t) { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc; +} + +async function createTrackAndStreamWithCleanup(t, kind = 'audio') { + let constraints = {}; + constraints[kind] = true; + const stream = await getNoiseStream(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + return [track, stream]; +} + +function findTransceiverForSender(pc, sender) { + const transceivers = pc.getTransceivers(); + for (let i = 0; i < transceivers.length; ++i) { + if (transceivers[i].sender == sender) + return transceivers[i]; + } + return null; +} + +function preferCodec(transceiver, mimeType, sdpFmtpLine) { + const {codecs} = RTCRtpSender.getCapabilities(transceiver.receiver.track.kind); + // sdpFmtpLine is optional, pick the first partial match if not given. + const selectedCodecIndex = codecs.findIndex(c => { + return c.mimeType === mimeType && (c.sdpFmtpLine === sdpFmtpLine || !sdpFmtpLine); + }); + const selectedCodec = codecs[selectedCodecIndex]; + codecs.slice(selectedCodecIndex, 1); + codecs.unshift(selectedCodec); + return transceiver.setCodecPreferences(codecs); +} + +// Contains a set of values and will yell at you if you try to add a value twice. +class UniqueSet extends Set { + constructor(items) { + super(); + if (items !== undefined) { + for (const item of items) { + this.add(item); + } + } + } + + add(value, message) { + if (message === undefined) { + message = `Value '${value}' needs to be unique but it is already in the set`; + } + assert_true(!this.has(value), message); + super.add(value); + } +} + +const iceGatheringStateTransitions = async (pc, ...states) => { + for (const state of states) { + await new Promise((resolve, reject) => { + pc.addEventListener('icegatheringstatechange', () => { + if (pc.iceGatheringState == state) { + resolve(); + } else { + reject(`Unexpected gathering state: ${pc.iceGatheringState}, was expecting ${state}`); + } + }, {once: true}); + }); + } +}; + +const initialOfferAnswerWithIceGatheringStateTransitions = + async (pc1, pc2, offerOptions) => { + await pc1.setLocalDescription( + await pc1.createOffer(offerOptions)); + const pc1Transitions = + iceGatheringStateTransitions(pc1, 'gathering', 'complete'); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + const pc2Transitions = + iceGatheringStateTransitions(pc2, 'gathering', 'complete'); + await pc1.setRemoteDescription(pc2.localDescription); + await pc1Transitions; + await pc2Transitions; + }; + +const expectNoMoreGatheringStateChanges = async (t, pc) => { + pc.onicegatheringstatechange = + t.step_func(() => { + assert_unreached( + 'Should not get an icegatheringstatechange right now!'); + }); +}; |