summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js')
-rw-r--r--testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js715
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!');
+ });
+};