chromium/chrome/test/data/webrtc/peerconnection.js

/**
 * Copyright 2014 The Chromium Authors
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file.
 */

/**
 * The one and only peer connection in this page.
 * @private
 */
var gPeerConnection = null;

/**
 * This stores ICE candidates generated on this side.
 * @private
 */
var gIceCandidates = [];

/**
 * This stores last ICE gathering state emitted on this side.
 * @private
 */
var gIceGatheringState = 'no-gathering-state';

/**
 * Keeps track of whether we have seen crypto information in the SDP.
 * @private
 */
var gHasSeenCryptoInSdp = 'no-crypto-seen';

/**
 * The default audio codec that should be used when creating an offer.
 * @private
 */
var gDefaultAudioCodec = null;

/**
 * The default video codec that should be used when creating an offer.
 * @private
 */
var gDefaultVideoCodec = null;

/**
 * The default video codec profile that should be used when creating an offer.
 * @private
 */
var gDefaultVideoCodecProfile = null;

/**
 * The default video target bitrate that should be used when creating an offer.
 * @private
 */
var gDefaultVideoTargetBitrate = null;

/**
 * Flag to indicate if HW or SW video codec is preferred.
 * @private
 */
var gDefaultPreferHwVideoCodec = null;

/**
 * Flag to indicate if Opus Dtx should be enabled.
 * @private
 */
var gOpusDtx = false;

/** @private */
var gNegotiationNeededCount = 0;

/** @private */
var gTrackEvents = [];

// Public interface to tests.

/**
 * Creates a peer connection. Must be called before most other public functions
 * in this file. Alternatively, see |preparePeerConnectionWithCertificate|.
 * @param {Object} keygenAlgorithm Unless null, this is an |AlgorithmIdentifier|
 * to be used as parameter to |RTCPeerConnection.generateCertificate|. The
 * resulting certificate will be used by the peer connection. If null, a default
 * certificate is generated by the |RTCPeerConnection| instead.
 * @param {string} peerConnectionConstraints Unless null, this adds peer
 * connection constraints when creating new RTCPeerConnection.
 */
function preparePeerConnection(
    keygenAlgorithm = null, peerConnectionConstraints = null) {
  if (gPeerConnection !== null)
    throw new Error('Creating peer connection, but we already have one.');

  if (keygenAlgorithm === null) {
    gPeerConnection = createPeerConnection_(null, peerConnectionConstraints);
    return logAndReturn('ok-peerconnection-created');
  }
  return RTCPeerConnection.generateCertificate(keygenAlgorithm).then(
      function(certificate) {
        return preparePeerConnectionWithCertificate(certificate,
            peerConnectionConstraints);
      },
      function() {
        throw new Error('Certificate generation failed. keygenAlgorithm: ' +
            JSON.stringify(keygenAlgorithm));
      })
      .catch((err) => 'Test failed: ' + err.stack);
}

/**
 * Creates a peer connection. Must be called before most other public functions
 * in this file. Alternatively, see |preparePeerConnection|.
 * @param {!Object} certificate The |RTCCertificate| that will be used by the
 * peer connection.
 * @param {string} peerConnectionConstraints Unless null, this adds peer
 * connection constraints when creating new RTCPeerConnection.
 */
function preparePeerConnectionWithCertificate(
    certificate, peerConnectionConstraints = null) {
  if (gPeerConnection !== null)
    throw new Error('Creating peer connection, but we already have one.');
  gPeerConnection = createPeerConnection_(
      {iceServers:[], certificates:[certificate]}, peerConnectionConstraints);
  return logAndReturn('ok-peerconnection-created');
}

/**
 * Sets the flag to force Opus Dtx to be used when creating an offer.
 */
function forceOpusDtx() {
  gOpusDtx = true;
  return logAndReturn('ok-forced');
}

/**
 * Sets the default audio codec to be used when creating an offer and returns
 * "ok" to test.
 * @param {string} audioCodec promotes the specified codec to be the default
 *     audio codec, e.g. the first one in the list on the 'm=audio' SDP offer
 *     line. |audioCodec| is the case-sensitive codec name, e.g. 'opus' or
 *     'ISAC'.
 */
function setDefaultAudioCodec(audioCodec) {
  gDefaultAudioCodec = audioCodec;
  return logAndReturn('ok');
}

/**
 * Sets the default video codec to be used when creating an offer and returns
 * "ok" to test.
 * @param {string} videoCodec promotes the specified codec to be the default
 *     video codec, e.g. the first one in the list on the 'm=video' SDP offer
 *     line. |videoCodec| is the case-sensitive codec name, e.g. 'VP8' or
 *     'H264'.
 * @param {string} profile promotes the specified codec profile.
 * @param {bool} preferHwVideoCodec specifies what codec to use from the
 *     'm=video' line when there are multiple codecs with the name |videoCodec|.
 *     If true, it will return the last codec with that name, and if false, it
 *     will return the first codec with that name.
 */
function setDefaultVideoCodec(videoCodec, preferHwVideoCodec, profile) {
  gDefaultVideoCodec = videoCodec;
  gDefaultPreferHwVideoCodec = preferHwVideoCodec;
  gDefaultVideoCodecProfile = profile;
  return logAndReturn('ok');
}

/**
 * Sets the default video target bitrate to be used when creating an offer and
 * returns "ok" to test.
 * @param {int} modifies "b=AS:" line with the given value.
 */
function setDefaultVideoTargetBitrate(bitrate) {
  gDefaultVideoTargetBitrate = bitrate;
  return logAndReturn('ok');
}

/**
 * Creates a data channel with the specified label.
 * Returns 'ok-created' to test.
 */
function createDataChannel(label) {
  peerConnection_().createDataChannel(label);
  return logAndReturn('ok-created');
}

/**
 * Asks this page to create a local offer.
 *
 * Returns a string on the format ok-(JSON encoded session description).
 *
 * @param {!Object} constraints Any createOffer constraints.
 */
function createLocalOffer(constraints) {
  return new Promise((resolve, reject) => {
    peerConnection_().createOffer(
      resolve, reject, constraints);
    }).then(function(localOffer) {
        success('createOffer');

        setLocalDescription(peerConnection, localOffer);
        if (gDefaultAudioCodec !== null) {
          localOffer.sdp = setSdpDefaultAudioCodec(localOffer.sdp,
                                                   gDefaultAudioCodec);
        }
        if (gDefaultVideoCodec !== null) {
          localOffer.sdp = setSdpDefaultVideoCodec(
              localOffer.sdp, gDefaultVideoCodec, gDefaultPreferHwVideoCodec,
              gDefaultVideoCodecProfile);
        }
        if (gOpusDtx) {
          localOffer.sdp = setOpusDtxEnabled(localOffer.sdp);
        }
        if (gDefaultVideoTargetBitrate !== null) {
          localOffer.sdp = setSdpVideoTargetBitrate(localOffer.sdp,
                                                    gDefaultVideoTargetBitrate);
        }
        return logAndReturn('ok-' + JSON.stringify(localOffer));
      },
      function(error) { return new MethodError('createOffer', error, false); });
}

/**
 * Asks this page to accept an offer and generate an answer.
 *
 * Returns a string on the format ok-(JSON encoded session description).
 *
 * @param {!string} sessionDescJson A JSON-encoded session description of type
 *     'offer'.
 * @param {!Object} constraints Any createAnswer constraints.
 */
function receiveOfferFromPeer(sessionDescJson, constraints) {
  offer = parseJson_(sessionDescJson);
  if (!offer.type)
    throw new Error('Got invalid session description from peer: '
      + sessionDescJson);
  if (offer.type != 'offer')
    throw new Error('Expected to receive offer from peer, got '
      + offer.type);

  var sessionDescription = new RTCSessionDescription(offer);
  return Promise.all([
    new Promise((resolve, reject) => {
      peerConnection_().setRemoteDescription(
          sessionDescription,
          resolve, reject);
      }).then(
        function() { success('setRemoteDescription'); },
        function(error) {
          throw new MethodError('setRemoteDescription', error);
        }),

    new Promise((resolve, reject) => {
      peerConnection_().createAnswer(resolve, reject, constraints);
    }).then(
      function(answer) {
        success('createAnswer');
        setLocalDescription(peerConnection, answer);
        if (gOpusDtx) {
          answer.sdp = setOpusDtxEnabled(answer.sdp);
        }
        return logAndReturn('ok-' + JSON.stringify(answer));
      },
      function(error) { throw new MethodError('createAnswer', error); }),
  ]).then(([_, result]) => result);
}

/**
 * Verifies that the codec previously set using setDefault[Audio/Video]Codec()
 * is the default audio/video codec, e.g. the first one in the list on the
 * 'm=audio'/'m=video' SDP answer line. If this is not the case, a new
 * |MethodError| is thrown. If no codec was previously set using
 * setDefault[Audio/Video]Codec(), this function will return
 * 'ok-no-defaults-set'.
 *
 * @param {!string} sessionDescJson A JSON-encoded session description.
 */
function verifyDefaultCodecs(sessionDescJson) {
  let sessionDesc = parseJson_(sessionDescJson);
  if (!sessionDesc.type) {
    throw new MethodError('verifyDefaultCodecs',
             'Invalid session description: ' + sessionDescJson);
  }
  if (gDefaultAudioCodec !== null && gDefaultVideoCodec !== null) {
    return logAndReturn('ok-no-defaults-set');
  }
  if (gDefaultAudioCodec !== null) {
    let defaultAudioCodec = getSdpDefaultAudioCodec(sessionDesc.sdp);
    if (defaultAudioCodec === null) {
      throw new MethodError('verifyDefaultCodecs',
               'Could not determine default audio codec.');
    }
    if (gDefaultAudioCodec !== defaultAudioCodec) {
      throw new MethodError('verifyDefaultCodecs',
               'Expected default audio codec ' + gDefaultAudioCodec +
               ', got ' + defaultAudioCodec + '.');
    }
  }
  if (gDefaultVideoCodec !== null) {
    let defaultVideoCodec = getSdpDefaultVideoCodec(sessionDesc.sdp);
    if (defaultVideoCodec === null) {
      throw new MethodError('verifyDefaultCodecs',
               'Could not determine default video codec.');
    }
    if (gDefaultVideoCodec !== defaultVideoCodec) {
      throw new MethodError('verifyDefaultCodecs',
               'Expected default video codec ' + gDefaultVideoCodec +
               ', got ' + defaultVideoCodec + '.');
    }
  }
  return logAndReturn('ok-verified');
}

/**
 * Verifies that the peer connection's local description contains one of
 * |certificate|'s fingerprints.
 *
 * Returns 'ok-verified' on success.
 */
function verifyLocalDescriptionContainsCertificate(certificate) {
  let localDescription = peerConnection_().localDescription;
  if (localDescription == null)
    throw new Error('localDescription is null.');
  for (let i = 0; i < certificate.getFingerprints().length; ++i) {
    let fingerprintSdp = 'a=fingerprint:' +
        certificate.getFingerprints()[i].algorithm + ' ' +
        certificate.getFingerprints()[i].value.toUpperCase();
    if (localDescription.sdp.includes(fingerprintSdp)) {
      return logAndReturn('ok-verified');
    }
  }
  if (!localDescription.sdp.includes('a=fingerprint'))
    throw new Error('localDescription does not contain any fingerprints.');
  throw new Error('Certificate fingerprint not found in localDescription.');
}

/**
 * Asks this page to accept an answer generated by the peer in response to a
 * previous offer by this page
 *
 * Returns a string ok-accepted-answer on success.
 *
 * @param {!string} sessionDescJson A JSON-encoded session description of type
 *     'answer'.
 */
function receiveAnswerFromPeer(sessionDescJson) {
  answer = parseJson_(sessionDescJson);
  if (!answer.type)
    throw new Error('Got invalid session description from peer: '
      + sessionDescJson);
  if (answer.type != 'answer')
    throw new Error('Expected to receive answer from peer, got ' + answer.type);

  var sessionDescription = new RTCSessionDescription(answer);
  return new Promise((resolve, reject) => {
    peerConnection_().setRemoteDescription(
        sessionDescription, resolve, reject);
  }).then(
      function() {
        success('setRemoteDescription');
        return logAndReturn('ok-accepted-answer');
      },
      function(error) {
        throw new MethodError('setRemoteDescription', error);
      });
}

/**
 * Adds the local stream to the peer connection. You will have to re-negotiate
 * the call for this to take effect in the call.
 */
function addLocalStream() {
  addLocalStreamToPeerConnection(peerConnection_());
  return logAndReturn('ok-added');
}

/**
 * Loads a file with WebAudio and connects it to the peer connection.
 *
 * The loadAudioAndAddToPeerConnection will return ok-added to the test when
 * the sound is loaded and added to the peer connection. The sound will start
 * playing when you call playAudioFile.
 *
 * @param url URL pointing to the file to play. You can assume that you can
 *     serve files from the repository's file system. For instance, to serve a
 *     file from chrome/test/data/pyauto_private/webrtc/file.wav, pass in a path
 *     relative to this directory (e.g. ../pyauto_private/webrtc/file.wav).
 */
function addAudioFile(url) {
  return loadAudioAndAddToPeerConnection(url, peerConnection_());
}

/**
 * Must be called after addAudioFile.
 */
function playAudioFile() {
  playPreviouslyLoadedAudioFile(peerConnection_());
  return logAndReturn('ok-playing');
}

/**
 * Hangs up a started call. Returns ok-call-hung-up on success.
 */
function hangUp() {
  peerConnection_().close();
  gPeerConnection = null;
  return logAndReturn('ok-call-hung-up');
}

/**
 * Retrieves all ICE candidates generated on this side. Must be called after
 * ICE candidate generation is triggered (for instance by running a call
 * negotiation). This function will wait if necessary if we're not done
 * generating ICE candidates on this side.
 *
 * Returns a JSON-encoded array of RTCIceCandidate instances to the test.
 */
async function getAllIceCandidates() {
  while (peerConnection_().iceGatheringState != 'complete') {
    console.log('Still ICE gathering - waiting...');
    await new Promise(resolve => {
      setTimeout(resolve, 100);
    });
  }

  return logAndReturn(JSON.stringify(gIceCandidates));
}

/**
 * Receives ICE candidates from the peer.
 *
 * Returns ok-received-candidates to the test on success.
 *
 * @param iceCandidatesJson a JSON-encoded array of RTCIceCandidate instances.
 */
function receiveIceCandidates(iceCandidatesJson) {
  var iceCandidates = parseJson_(iceCandidatesJson);
  if (!iceCandidates.length)
    throw new Error('Received invalid ICE candidate list from peer: ' +
        iceCandidatesJson);

  return new Promise((resolve, reject) => {
    for (const iceCandidate of iceCandidates) {
      if (!iceCandidate.candidate)
        throw new Error('Received invalid ICE candidate from peer: ' +
            iceCandidatesJson);

      peerConnection_().addIceCandidate(new RTCIceCandidate(iceCandidate,
        function() { success('addIceCandidate'); },
        function(error) { reject(new MethodError('addIceCandidate', error)); }
      ));
    }
    resolve(logAndReturn('ok-received-candidates'));
  });
}

/**
 * Sets the mute state of the selected media element.
 *
 * Returns ok-muted on success.
 *
 * @param elementId The id of the element to mute.
 * @param muted The mute state to set.
 */
function setMediaElementMuted(elementId, muted) {
  var element = document.getElementById(elementId);
  if (!element)
    throw new Error('Cannot mute ' + elementId + '; does not exist.');
  element.muted = muted;
  return logAndReturn('ok-muted');
}

/**
 * Returns
 */
function hasSeenCryptoInSdp() {
  return logAndReturn(gHasSeenCryptoInSdp);
}

/**
 * Measures the performance of the legacy (callback-based)
 * |RTCPeerConnection.getStats| and returns the time it took in milliseconds as
 * a double (DOMHighResTimeStamp, accurate to one thousandth of a millisecond).
 *
 * Returns "ok-" followed by a double.
 */
function measureGetStatsCallbackPerformance() {
  let t0 = performance.now();
  return new Promise(resolve => {
    peerConnection_().getStats(resolve);
  }).then(
    function(response) {
      let t1 = performance.now();
      return logAndReturn('ok-' + (t1 - t0));
    });
}

/**
 * Returns the last iceGatheringState emitted from icegatheringstatechange.
 */
function getLastGatheringState() {
  return logAndReturn(gIceGatheringState);
}

/**
 * Returns "ok-negotiation-count-is-" followed by the number of times
 * onnegotiationneeded has fired. This will include any currently queued
 * negotiationneeded events.
 */
function getNegotiationNeededCount() {
  return new Promise(resolve => {
    window.setTimeout(resolve, 0);
  }).then(function() {
    return logAndReturn('ok-negotiation-count-is-' + gNegotiationNeededCount);
  });
}

/**
 * Gets the track and stream IDs of each "ontrack" event that has been fired on
 * the peer connection in chronological order.
 *
 * Returns "ok-" followed by a series of space-separated
 * "RTCTrackEvent <track id> <stream ids>".
 */
function getTrackEvents() {
  let result = '';
  for (const event of gTrackEvents) {
    if (event.receiver.track != event.track)
      throw new Error('RTCTrackEvent\'s track does not match its receiver\'s.');
    let eventString = 'RTCTrackEvent ' + event.track.id;
    event.streams.forEach(function(stream) {
      eventString += ' ' + stream.id;
    });
    if (result.length)
      result += ' ';
    result += eventString;
  }
  return logAndReturn('ok-' + result);
}

// Internals.

/** @private */
function createPeerConnection_(rtcConfig, peerConnectionConstraints) {
  try {
    peerConnection =
        new RTCPeerConnection(rtcConfig, peerConnectionConstraints);
  } catch (exception) {
    throw new Error('Failed to create peer connection: ' + exception);
  }
  peerConnection.onaddstream = addStreamCallback_;
  peerConnection.onremovestream = removeStreamCallback_;
  peerConnection.onicecandidate = iceCallback_;
  peerConnection.onicegatheringstatechange = iceGatheringCallback_;
  peerConnection.onnegotiationneeded = negotiationNeededCallback_;
  peerConnection.ontrack = onTrackCallback_;
  return peerConnection;
}

/** @private */
function peerConnection_() {
  if (gPeerConnection == null)
    throw new Error('Trying to use peer connection, but none was created.');
  return gPeerConnection;
}

/** @private */
function iceCallback_(event) {
  if (event.candidate)
    gIceCandidates.push(event.candidate);
}

/** @private */
function iceGatheringCallback_() {
  gIceGatheringState = peerConnection.iceGatheringState;
}

/** @private */
function negotiationNeededCallback_() {
  ++gNegotiationNeededCount;
}

/** @private */
function onTrackCallback_(event) {
  gTrackEvents.push(event);
}

/** @private */
function setLocalDescription(peerConnection, sessionDescription) {
  if (sessionDescription.sdp.search('a=crypto') != -1 ||
      sessionDescription.sdp.search('a=fingerprint') != -1)
    gHasSeenCryptoInSdp = 'crypto-seen';

  peerConnection.setLocalDescription(
    sessionDescription,
    function() { success('setLocalDescription'); },
    function(error) { new MethodError('setLocalDescription', error); });
}

/** @private */
function addStreamCallback_(event) {
  debug('Receiving remote stream...');
  var videoTag = document.getElementById('remote-view');
  videoTag.srcObject = event.stream;
}

/** @private */
function removeStreamCallback_(event) {
  debug('Call ended.');
  document.getElementById('remote-view').src = '';
}

/**
 * Parses JSON-encoded session descriptions and ICE candidates.
 * @private
 */
function parseJson_(json) {
  // Escape since the \r\n in the SDP tend to get unescaped.
  jsonWithEscapedLineBreaks = json.replace(/\r\n/g, '\\r\\n');
  try {
    return JSON.parse(jsonWithEscapedLineBreaks);
  } catch (exception) {
    throw new Error('Failed to parse JSON: ' + jsonWithEscapedLineBreaks +
      ', got ' + exception);
  }
}