'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!'); }); }; async function queueAWebrtcTask() { const pc = new RTCPeerConnection(); pc.addTransceiver('audio'); await new Promise(r => pc.onnegotiationneeded = r); }