pc.js (76084B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const LOOPBACK_ADDR = "127.0.0."; 8 9 const iceStateTransitions = { 10 new: ["checking", "closed"], //Note: 'failed' might need to added here 11 // even though it is not in the standard 12 checking: ["new", "connected", "failed", "closed"], //Note: do we need to 13 // allow 'completed' in 14 // here as well? 15 connected: ["new", "checking", "completed", "disconnected", "closed"], 16 completed: ["new", "checking", "disconnected", "closed"], 17 disconnected: ["new", "connected", "completed", "failed", "closed"], 18 failed: ["new", "disconnected", "closed"], 19 closed: [], 20 }; 21 22 const signalingStateTransitions = { 23 stable: ["have-local-offer", "have-remote-offer", "closed"], 24 "have-local-offer": [ 25 "have-remote-pranswer", 26 "stable", 27 "closed", 28 "have-local-offer", 29 ], 30 "have-remote-pranswer": ["stable", "closed", "have-remote-pranswer"], 31 "have-remote-offer": [ 32 "have-local-pranswer", 33 "stable", 34 "closed", 35 "have-remote-offer", 36 ], 37 "have-local-pranswer": ["stable", "closed", "have-local-pranswer"], 38 closed: [], 39 }; 40 41 var makeDefaultCommands = () => { 42 return [].concat( 43 commandsPeerConnectionInitial, 44 commandsGetUserMedia, 45 commandsPeerConnectionOfferAnswer 46 ); 47 }; 48 49 /** 50 * This class handles tests for peer connections. 51 * 52 * @class 53 * @param {object} [options={}] 54 * Optional options for the peer connection test 55 * @param {object} [options.commands=commandsPeerConnection] 56 * Commands to run for the test 57 * @param {bool} [options.is_local=true] 58 * true if this test should run the tests for the "local" side. 59 * @param {bool} [options.is_remote=true] 60 * true if this test should run the tests for the "remote" side. 61 * @param {object} [options.config_local=undefined] 62 * Configuration for the local peer connection instance 63 * @param {object} [options.config_remote=undefined] 64 * Configuration for the remote peer connection instance. If not defined 65 * the configuration from the local instance will be used 66 */ 67 function PeerConnectionTest(options) { 68 // If no options are specified make it an empty object 69 options = options || {}; 70 options.commands = options.commands || makeDefaultCommands(); 71 options.is_local = "is_local" in options ? options.is_local : true; 72 options.is_remote = "is_remote" in options ? options.is_remote : true; 73 74 options.h264 = "h264" in options ? options.h264 : false; 75 options.av1 = "av1" in options ? options.av1 : false; 76 options.bundle = "bundle" in options ? options.bundle : true; 77 options.rtcpmux = "rtcpmux" in options ? options.rtcpmux : true; 78 options.opus = "opus" in options ? options.opus : true; 79 options.ssrc = "ssrc" in options ? options.ssrc : true; 80 81 options.config_local = options.config_local || {}; 82 options.config_remote = options.config_remote || {}; 83 84 if (!options.bundle) { 85 // Make sure neither end tries to use bundle-only! 86 options.config_local.bundlePolicy = "max-compat"; 87 options.config_remote.bundlePolicy = "max-compat"; 88 } 89 90 if (iceServersArray.length) { 91 if (!options.turn_disabled_local && !options.config_local.iceServers) { 92 options.config_local.iceServers = iceServersArray; 93 } 94 if (!options.turn_disabled_remote && !options.config_remote.iceServers) { 95 options.config_remote.iceServers = iceServersArray; 96 } 97 } else if (typeof turnServers !== "undefined") { 98 if (!options.turn_disabled_local && turnServers.local) { 99 if (!options.config_local.hasOwnProperty("iceServers")) { 100 options.config_local.iceServers = turnServers.local.iceServers; 101 } 102 } 103 if (!options.turn_disabled_remote && turnServers.remote) { 104 if (!options.config_remote.hasOwnProperty("iceServers")) { 105 options.config_remote.iceServers = turnServers.remote.iceServers; 106 } 107 } 108 } 109 110 if (options.is_local) { 111 this.pcLocal = new PeerConnectionWrapper("pcLocal", options.config_local); 112 } else { 113 this.pcLocal = null; 114 } 115 116 if (options.is_remote) { 117 this.pcRemote = new PeerConnectionWrapper( 118 "pcRemote", 119 options.config_remote || options.config_local 120 ); 121 } else { 122 this.pcRemote = null; 123 } 124 125 // Create command chain instance and assign default commands 126 this.chain = new CommandChain(this, options.commands); 127 128 this.testOptions = options; 129 } 130 131 /** TODO: consider removing this dependency on timeouts */ 132 function timerGuard(p, time, message) { 133 return Promise.race([ 134 p, 135 wait(time).then(() => { 136 throw new Error("timeout after " + time / 1000 + "s: " + message); 137 }), 138 ]); 139 } 140 141 /** 142 * Closes the peer connection if it is active 143 */ 144 PeerConnectionTest.prototype.closePC = function () { 145 info("Closing peer connections"); 146 147 var closeIt = pc => { 148 if (!pc || pc.signalingState === "closed") { 149 return Promise.resolve(); 150 } 151 152 var promise = Promise.all([ 153 Promise.all( 154 pc._pc 155 .getReceivers() 156 .filter(receiver => receiver.track.readyState == "live") 157 .map(receiver => { 158 info( 159 "Waiting for track " + 160 receiver.track.id + 161 " (" + 162 receiver.track.kind + 163 ") to end." 164 ); 165 return haveEvent(receiver.track, "ended", wait(50000)).then( 166 event => { 167 is( 168 event.target, 169 receiver.track, 170 "Event target should be the correct track" 171 ); 172 info(pc + " ended fired for track " + receiver.track.id); 173 }, 174 e => 175 e 176 ? Promise.reject(e) 177 : ok( 178 false, 179 "ended never fired for track " + receiver.track.id 180 ) 181 ); 182 }) 183 ), 184 ]); 185 pc.close(); 186 return promise; 187 }; 188 189 return timerGuard( 190 Promise.all([closeIt(this.pcLocal), closeIt(this.pcRemote)]), 191 60000, 192 "failed to close peer connection" 193 ); 194 }; 195 196 /** 197 * Close the open data channels, followed by the underlying peer connection 198 */ 199 PeerConnectionTest.prototype.close = function () { 200 var allChannels = (this.pcLocal || this.pcRemote).dataChannels; 201 return timerGuard( 202 Promise.all(allChannels.map((channel, i) => this.closeDataChannels(i))), 203 120000, 204 "failed to close data channels" 205 ).then(() => this.closePC()); 206 }; 207 208 /** 209 * Close the specified data channels 210 * 211 * @param {number} index 212 * Index of the data channels to close on both sides 213 */ 214 PeerConnectionTest.prototype.closeDataChannels = function (index) { 215 info("closeDataChannels called with index: " + index); 216 var localChannel = null; 217 if (this.pcLocal) { 218 localChannel = this.pcLocal.dataChannels[index]; 219 } 220 var remoteChannel = null; 221 if (this.pcRemote) { 222 remoteChannel = this.pcRemote.dataChannels[index]; 223 } 224 225 // We need to setup all the close listeners before calling close 226 var setupClosePromise = channel => { 227 if (!channel) { 228 return Promise.resolve(); 229 } 230 return new Promise(resolve => { 231 channel.onclose = () => { 232 is( 233 channel.readyState, 234 "closed", 235 name + " channel " + index + " closed" 236 ); 237 resolve(); 238 }; 239 }); 240 }; 241 242 // make sure to setup close listeners before triggering any actions 243 var allClosed = Promise.all([ 244 setupClosePromise(localChannel), 245 setupClosePromise(remoteChannel), 246 ]); 247 var complete = timerGuard( 248 allClosed, 249 120000, 250 "failed to close data channel pair" 251 ); 252 253 // triggering close on one side should suffice 254 if (remoteChannel) { 255 remoteChannel.close(); 256 } else if (localChannel) { 257 localChannel.close(); 258 } 259 260 return complete; 261 }; 262 263 /** 264 * Send data (message or blob) to the other peer 265 * 266 * @param {string | Blob} data 267 * Data to send to the other peer. For Blobs the MIME type will be lost. 268 * @param {object} [options={ }] 269 * Options to specify the data channels to be used 270 * @param {DataChannelWrapper} [options.sourceChannel=pcLocal.dataChannels[length - 1]] 271 * Data channel to use for sending the message 272 * @param {DataChannelWrapper} [options.targetChannel=pcRemote.dataChannels[length - 1]] 273 * Data channel to use for receiving the message 274 */ 275 PeerConnectionTest.prototype.send = async function (data, options) { 276 options = options || {}; 277 const source = 278 options.sourceChannel || 279 this.pcLocal.dataChannels[this.pcLocal.dataChannels.length - 1]; 280 const target = 281 options.targetChannel || 282 this.pcRemote.dataChannels[this.pcRemote.dataChannels.length - 1]; 283 source.bufferedAmountLowThreshold = options.bufferedAmountLowThreshold || 0; 284 285 const getSizeInBytes = d => { 286 if (d instanceof Blob) { 287 return d.size; 288 } else if (d instanceof ArrayBuffer) { 289 return d.byteLength; 290 } else if (d instanceof String || typeof d === "string") { 291 return new TextEncoder().encode(d).length; 292 } else { 293 ok(false); 294 throw new Error("Could not get size"); 295 } 296 }; 297 298 const expectedSizeInBytes = getSizeInBytes(data); 299 const bufferedAmount = source.bufferedAmount; 300 301 source.send(data); 302 is( 303 source.bufferedAmount, 304 expectedSizeInBytes + bufferedAmount, 305 `Buffered amount should be ${expectedSizeInBytes}` 306 ); 307 308 const resultReceived = new Promise(resolve => { 309 // Register event handler for the target channel 310 target.onmessage = e => { 311 is( 312 getSizeInBytes(e.data), 313 expectedSizeInBytes, 314 `Expected to receive the same number of bytes as we sent (${expectedSizeInBytes})` 315 ); 316 resolve({ channel: target, data: e.data }); 317 }; 318 }); 319 320 await new Promise(resolve => (source.onbufferedamountlow = resolve)); 321 322 return resultReceived; 323 }; 324 325 /** 326 * Create a data channel 327 * 328 * @param {Dict} options 329 * Options for the data channel (see nsIPeerConnection) 330 */ 331 PeerConnectionTest.prototype.createDataChannel = function (options) { 332 var remotePromise; 333 if (!options.negotiated) { 334 this.pcRemote.expectDataChannel("pcRemote expected data channel"); 335 remotePromise = this.pcRemote.nextDataChannel; 336 } 337 338 // Create the datachannel 339 var localChannel = this.pcLocal.createDataChannel(options); 340 var localPromise = localChannel.opened; 341 342 if (options.negotiated) { 343 remotePromise = localPromise.then(localChannel => { 344 // externally negotiated - we need to open from both ends 345 options.id = options.id || channel.id; // allow for no id on options 346 var remoteChannel = this.pcRemote.createDataChannel(options); 347 return remoteChannel.opened; 348 }); 349 } 350 351 // pcRemote.observedNegotiationNeeded might be undefined if 352 // !options.negotiated, which means we just wait on pcLocal 353 return Promise.all([ 354 this.pcLocal.observedNegotiationNeeded, 355 this.pcRemote.observedNegotiationNeeded, 356 ]).then(() => { 357 return Promise.all([localPromise, remotePromise]).then(result => { 358 return { local: result[0], remote: result[1] }; 359 }); 360 }); 361 }; 362 363 /** 364 * Creates an answer for the specified peer connection instance 365 * and automatically handles the failure case. 366 * 367 * @param {PeerConnectionWrapper} peer 368 * The peer connection wrapper to run the command on 369 */ 370 PeerConnectionTest.prototype.createAnswer = function (peer) { 371 return peer.createAnswer().then(answer => { 372 // make a copy so this does not get updated with ICE candidates 373 this.originalAnswer = JSON.parse(JSON.stringify(answer)); 374 return answer; 375 }); 376 }; 377 378 /** 379 * Creates an offer for the specified peer connection instance 380 * and automatically handles the failure case. 381 * 382 * @param {PeerConnectionWrapper} peer 383 * The peer connection wrapper to run the command on 384 */ 385 PeerConnectionTest.prototype.createOffer = function (peer) { 386 return peer.createOffer().then(offer => { 387 // make a copy so this does not get updated with ICE candidates 388 this.originalOffer = JSON.parse(JSON.stringify(offer)); 389 return offer; 390 }); 391 }; 392 393 /** 394 * Sets the local description for the specified peer connection instance 395 * and automatically handles the failure case. 396 * 397 * @param {PeerConnectionWrapper} peer 398 The peer connection wrapper to run the command on 399 * @param {RTCSessionDescriptionInit} desc 400 * Session description for the local description request 401 */ 402 PeerConnectionTest.prototype.setLocalDescription = function ( 403 peer, 404 desc, 405 stateExpected 406 ) { 407 var eventFired = new Promise(resolve => { 408 peer.onsignalingstatechange = e => { 409 info(peer + ": 'signalingstatechange' event received"); 410 var state = e.target.signalingState; 411 if (stateExpected === state) { 412 peer.setLocalDescStableEventDate = new Date(); 413 resolve(); 414 } else { 415 ok( 416 false, 417 "This event has either already fired or there has been a " + 418 "mismatch between event received " + 419 state + 420 " and event expected " + 421 stateExpected 422 ); 423 } 424 }; 425 }); 426 427 var stateChanged = peer.setLocalDescription(desc).then(() => { 428 peer.setLocalDescDate = new Date(); 429 }); 430 431 peer.endOfTrickleSdp = peer.endOfTrickleIce 432 .then(() => { 433 return peer._pc.localDescription; 434 }) 435 .catch(e => ok(false, "Sending EOC message failed: " + e)); 436 437 return Promise.all([eventFired, stateChanged]); 438 }; 439 440 /** 441 * Sets the media constraints for both peer connection instances. 442 * 443 * @param {object} constraintsLocal 444 * Media constrains for the local peer connection instance 445 * @param constraintsRemote 446 */ 447 PeerConnectionTest.prototype.setMediaConstraints = function ( 448 constraintsLocal, 449 constraintsRemote 450 ) { 451 if (this.pcLocal) { 452 this.pcLocal.constraints = constraintsLocal; 453 } 454 if (this.pcRemote) { 455 this.pcRemote.constraints = constraintsRemote; 456 } 457 }; 458 459 /** 460 * Sets the media options used on a createOffer call in the test. 461 * 462 * @param {object} options the media constraints to use on createOffer 463 */ 464 PeerConnectionTest.prototype.setOfferOptions = function (options) { 465 if (this.pcLocal) { 466 this.pcLocal.offerOptions = options; 467 } 468 }; 469 470 /** 471 * Sets the remote description for the specified peer connection instance 472 * and automatically handles the failure case. 473 * 474 * @param {PeerConnectionWrapper} peer 475 The peer connection wrapper to run the command on 476 * @param {RTCSessionDescriptionInit} desc 477 * Session description for the remote description request 478 */ 479 PeerConnectionTest.prototype.setRemoteDescription = function ( 480 peer, 481 desc, 482 stateExpected 483 ) { 484 var eventFired = new Promise(resolve => { 485 peer.onsignalingstatechange = e => { 486 info(peer + ": 'signalingstatechange' event received"); 487 var state = e.target.signalingState; 488 if (stateExpected === state) { 489 peer.setRemoteDescStableEventDate = new Date(); 490 resolve(); 491 } else { 492 ok( 493 false, 494 "This event has either already fired or there has been a " + 495 "mismatch between event received " + 496 state + 497 " and event expected " + 498 stateExpected 499 ); 500 } 501 }; 502 }); 503 504 var stateChanged = peer.setRemoteDescription(desc).then(() => { 505 peer.setRemoteDescDate = new Date(); 506 peer.checkMediaTracks(); 507 }); 508 509 return Promise.all([eventFired, stateChanged]); 510 }; 511 512 /** 513 * Adds and removes steps to/from the execution chain based on the configured 514 * testOptions. 515 */ 516 PeerConnectionTest.prototype.updateChainSteps = function () { 517 if (this.testOptions.h264) { 518 this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ 519 PC_LOCAL_REMOVE_ALL_BUT_H264_FROM_OFFER, 520 ]); 521 } 522 if (this.testOptions.av1) { 523 this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ 524 PC_LOCAL_REMOVE_ALL_BUT_AV1_FROM_OFFER, 525 ]); 526 } 527 if (!this.testOptions.bundle) { 528 this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ 529 PC_LOCAL_REMOVE_BUNDLE_FROM_OFFER, 530 ]); 531 } 532 if (!this.testOptions.rtcpmux) { 533 this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ 534 PC_LOCAL_REMOVE_RTCPMUX_FROM_OFFER, 535 ]); 536 } 537 if (!this.testOptions.ssrc) { 538 this.chain.insertAfterEach("PC_LOCAL_CREATE_OFFER", [ 539 PC_LOCAL_REMOVE_SSRC_FROM_OFFER, 540 ]); 541 this.chain.insertAfterEach("PC_REMOTE_CREATE_ANSWER", [ 542 PC_REMOTE_REMOVE_SSRC_FROM_ANSWER, 543 ]); 544 } 545 if (!this.testOptions.is_local) { 546 this.chain.filterOut(/^PC_LOCAL/); 547 } 548 if (!this.testOptions.is_remote) { 549 this.chain.filterOut(/^PC_REMOTE/); 550 } 551 }; 552 553 /** 554 * Start running the tests as assigned to the command chain. 555 */ 556 PeerConnectionTest.prototype.run = async function () { 557 /* We have to modify the chain here to allow tests which modify the default 558 * test chain instantiating a PeerConnectionTest() */ 559 this.updateChainSteps(); 560 try { 561 await this.chain.execute(); 562 await this.close(); 563 } catch (e) { 564 const stack = 565 typeof e.stack === "string" 566 ? ` ${e.stack.split("\n").join(" ... ")}` 567 : ""; 568 ok(false, `Error in test execution: ${e} (${stack})`); 569 } 570 }; 571 572 /** 573 * Routes ice candidates from one PCW to the other PCW 574 */ 575 PeerConnectionTest.prototype.iceCandidateHandler = function ( 576 caller, 577 candidate 578 ) { 579 info("Received: " + JSON.stringify(candidate) + " from " + caller); 580 581 var target = null; 582 if (caller.includes("pcLocal")) { 583 if (this.pcRemote) { 584 target = this.pcRemote; 585 } 586 } else if (caller.includes("pcRemote")) { 587 if (this.pcLocal) { 588 target = this.pcLocal; 589 } 590 } else { 591 ok(false, "received event from unknown caller: " + caller); 592 return; 593 } 594 595 if (target) { 596 target.storeOrAddIceCandidate(candidate); 597 } else { 598 info("sending ice candidate to signaling server"); 599 send_message({ type: "ice_candidate", ice_candidate: candidate }); 600 } 601 }; 602 603 /** 604 * Installs a polling function for the socket.io client to read 605 * all messages from the chat room into a message queue. 606 */ 607 PeerConnectionTest.prototype.setupSignalingClient = function () { 608 this.signalingMessageQueue = []; 609 this.signalingCallbacks = {}; 610 this.signalingLoopRun = true; 611 612 var queueMessage = message => { 613 info("Received signaling message: " + JSON.stringify(message)); 614 var fired = false; 615 Object.keys(this.signalingCallbacks).forEach(name => { 616 if (name === message.type) { 617 info("Invoking callback for message type: " + name); 618 this.signalingCallbacks[name](message); 619 fired = true; 620 } 621 }); 622 if (!fired) { 623 this.signalingMessageQueue.push(message); 624 info( 625 "signalingMessageQueue.length: " + this.signalingMessageQueue.length 626 ); 627 } 628 if (this.signalingLoopRun) { 629 wait_for_message().then(queueMessage); 630 } else { 631 info("Exiting signaling message event loop"); 632 } 633 }; 634 wait_for_message().then(queueMessage); 635 }; 636 637 /** 638 * Sets a flag to stop reading further messages from the chat room. 639 */ 640 PeerConnectionTest.prototype.signalingMessagesFinished = function () { 641 this.signalingLoopRun = false; 642 }; 643 644 /** 645 * Register a callback function to deliver messages from the chat room 646 * directly instead of storing them in the message queue. 647 * 648 * @param {string} messageType 649 * For which message types should the callback get invoked. 650 * 651 * @param {function} onMessage 652 * The function which gets invoked if a message of the messageType 653 * has been received from the chat room. 654 */ 655 PeerConnectionTest.prototype.registerSignalingCallback = function ( 656 messageType, 657 onMessage 658 ) { 659 this.signalingCallbacks[messageType] = onMessage; 660 }; 661 662 /** 663 * Searches the message queue for the first message of a given type 664 * and invokes the given callback function, or registers the callback 665 * function for future messages if the queue contains no such message. 666 * 667 * @param {string} messageType 668 * The type of message to search and register for. 669 */ 670 PeerConnectionTest.prototype.getSignalingMessage = function (messageType) { 671 var i = this.signalingMessageQueue.findIndex(m => m.type === messageType); 672 if (i >= 0) { 673 info( 674 "invoking callback on message " + 675 i + 676 " from message queue, for message type:" + 677 messageType 678 ); 679 return Promise.resolve(this.signalingMessageQueue.splice(i, 1)[0]); 680 } 681 return new Promise(resolve => 682 this.registerSignalingCallback(messageType, resolve) 683 ); 684 }; 685 686 /** 687 * This class acts as a wrapper around a DataChannel instance. 688 * 689 * @param dataChannel 690 * @param peerConnectionWrapper 691 * @class 692 */ 693 function DataChannelWrapper(dataChannel, peerConnectionWrapper) { 694 this._channel = dataChannel; 695 this._pc = peerConnectionWrapper; 696 697 info("Creating " + this); 698 699 /** 700 * Setup appropriate callbacks 701 */ 702 createOneShotEventWrapper(this, this._channel, "close"); 703 createOneShotEventWrapper(this, this._channel, "error"); 704 createOneShotEventWrapper(this, this._channel, "message"); 705 createOneShotEventWrapper(this, this._channel, "bufferedamountlow"); 706 707 this.opened = timerGuard( 708 new Promise(resolve => { 709 this._channel.onopen = () => { 710 this._channel.onopen = unexpectedEvent(this, "onopen"); 711 is(this.readyState, "open", "data channel is 'open' after 'onopen'"); 712 resolve(this); 713 }; 714 }), 715 180000, 716 "channel didn't open in time" 717 ); 718 } 719 720 DataChannelWrapper.prototype = { 721 /** 722 * Returns the binary type of the channel 723 * 724 * @returns {string} The binary type 725 */ 726 get binaryType() { 727 return this._channel.binaryType; 728 }, 729 730 /** 731 * Sets the binary type of the channel 732 * 733 * @param {string} type 734 * The new binary type of the channel 735 */ 736 set binaryType(type) { 737 this._channel.binaryType = type; 738 }, 739 740 /** 741 * Returns the label of the underlying data channel 742 * 743 * @returns {string} The label 744 */ 745 get label() { 746 return this._channel.label; 747 }, 748 749 /** 750 * Returns the protocol of the underlying data channel 751 * 752 * @returns {string} The protocol 753 */ 754 get protocol() { 755 return this._channel.protocol; 756 }, 757 758 /** 759 * Returns the id of the underlying data channel 760 * 761 * @returns {number} The stream id 762 */ 763 get id() { 764 return this._channel.id; 765 }, 766 767 /** 768 * Returns the ordered attribute of the data channel 769 * 770 * @returns {bool} The ordered attribute 771 */ 772 get ordered() { 773 return this._channel.ordered; 774 }, 775 776 /** 777 * Returns the maxPacketLifeTime attribute of the data channel 778 * 779 * @returns {number} The maxPacketLifeTime attribute 780 */ 781 get maxPacketLifeTime() { 782 return this._channel.maxPacketLifeTime; 783 }, 784 785 /** 786 * Returns the maxRetransmits attribute of the data channel 787 * 788 * @returns {number} The maxRetransmits attribute 789 */ 790 get maxRetransmits() { 791 return this._channel.maxRetransmits; 792 }, 793 794 /** 795 * Returns the readyState bit of the data channel 796 * 797 * @returns {string} The state of the channel 798 */ 799 get readyState() { 800 return this._channel.readyState; 801 }, 802 803 get bufferedAmount() { 804 return this._channel.bufferedAmount; 805 }, 806 807 /** 808 * Sets the bufferlowthreshold of the channel 809 * 810 * @param {integer} amoutn 811 * The new threshold for the chanel 812 */ 813 set bufferedAmountLowThreshold(amount) { 814 this._channel.bufferedAmountLowThreshold = amount; 815 }, 816 817 /** 818 * Close the data channel 819 */ 820 close() { 821 info(this + ": Closing channel"); 822 this._channel.close(); 823 }, 824 825 /** 826 * Send data through the data channel 827 * 828 * @param {string | object} data 829 * Data which has to be sent through the data channel 830 */ 831 send(data) { 832 info(this + ": Sending data '" + data + "'"); 833 this._channel.send(data); 834 }, 835 836 /** 837 * Returns the string representation of the class 838 * 839 * @returns {string} The string representation 840 */ 841 toString() { 842 return ( 843 "DataChannelWrapper (" + this._pc.label + "_" + this._channel.label + ")" 844 ); 845 }, 846 }; 847 848 /** 849 * This class acts as a wrapper around a PeerConnection instance. 850 * 851 * @class 852 * @param {string} label 853 * Description for the peer connection instance 854 * @param {object} configuration 855 * Configuration for the peer connection instance 856 */ 857 function PeerConnectionWrapper(label, configuration) { 858 this.configuration = configuration; 859 if (configuration && configuration.label_suffix) { 860 label = label + "_" + configuration.label_suffix; 861 } 862 this.label = label; 863 864 this.constraints = []; 865 this.offerOptions = {}; 866 867 this.dataChannels = []; 868 869 this._local_ice_candidates = []; 870 this._remote_ice_candidates = []; 871 this.localRequiresTrickleIce = false; 872 this.remoteRequiresTrickleIce = false; 873 this.localMediaElements = []; 874 this.remoteMediaElements = []; 875 this.audioElementsOnly = false; 876 877 this._sendStreams = []; 878 879 this.expectedLocalTrackInfo = []; 880 this.remoteStreamsByTrackId = new Map(); 881 882 this.disableRtpCountChecking = false; 883 884 this.iceConnectedResolve; 885 this.iceConnectedReject; 886 this.iceConnected = new Promise((resolve, reject) => { 887 this.iceConnectedResolve = resolve; 888 this.iceConnectedReject = reject; 889 }); 890 this.iceCheckingRestartExpected = false; 891 this.iceCheckingIceRollbackExpected = false; 892 893 info("Creating " + this); 894 this._pc = new RTCPeerConnection(this.configuration); 895 896 /** 897 * Setup callback handlers 898 */ 899 // This allows test to register their own callbacks for ICE connection state changes 900 this.ice_connection_callbacks = {}; 901 902 this._pc.oniceconnectionstatechange = e => { 903 isnot( 904 typeof this._pc.iceConnectionState, 905 "undefined", 906 "iceConnectionState should not be undefined" 907 ); 908 var iceState = this._pc.iceConnectionState; 909 info( 910 this + ": oniceconnectionstatechange fired, new state is: " + iceState 911 ); 912 Object.keys(this.ice_connection_callbacks).forEach(name => { 913 this.ice_connection_callbacks[name](); 914 }); 915 if (iceState === "connected") { 916 this.iceConnectedResolve(); 917 } else if (iceState === "failed") { 918 this.iceConnectedReject(new Error("ICE failed")); 919 } 920 }; 921 922 this._pc.onicegatheringstatechange = e => { 923 isnot( 924 typeof this._pc.iceGatheringState, 925 "undefined", 926 "iceGetheringState should not be undefined" 927 ); 928 var gatheringState = this._pc.iceGatheringState; 929 info( 930 this + 931 ": onicegatheringstatechange fired, new state is: " + 932 gatheringState 933 ); 934 }; 935 936 createOneShotEventWrapper(this, this._pc, "datachannel"); 937 this._pc.addEventListener("datachannel", e => { 938 var wrapper = new DataChannelWrapper(e.channel, this); 939 this.dataChannels.push(wrapper); 940 }); 941 942 createOneShotEventWrapper(this, this._pc, "signalingstatechange"); 943 createOneShotEventWrapper(this, this._pc, "negotiationneeded"); 944 } 945 946 PeerConnectionWrapper.prototype = { 947 /** 948 * Returns the senders 949 * 950 * @returns {sequence<RTCRtpSender>} the senders 951 */ 952 getSenders() { 953 return this._pc.getSenders(); 954 }, 955 956 /** 957 * Returns the getters 958 * 959 * @returns {sequence<RTCRtpReceiver>} the receivers 960 */ 961 getReceivers() { 962 return this._pc.getReceivers(); 963 }, 964 965 /** 966 * Returns the local description. 967 * 968 * @returns {object} The local description 969 */ 970 get localDescription() { 971 return this._pc.localDescription; 972 }, 973 974 /** 975 * Returns the remote description. 976 * 977 * @returns {object} The remote description 978 */ 979 get remoteDescription() { 980 return this._pc.remoteDescription; 981 }, 982 983 /** 984 * Returns the signaling state. 985 * 986 * @returns {object} The local description 987 */ 988 get signalingState() { 989 return this._pc.signalingState; 990 }, 991 /** 992 * Returns the ICE connection state. 993 * 994 * @returns {object} The local description 995 */ 996 get iceConnectionState() { 997 return this._pc.iceConnectionState; 998 }, 999 1000 setIdentityProvider(provider, options) { 1001 this._pc.setIdentityProvider(provider, options); 1002 }, 1003 1004 elementPrefix: direction => { 1005 return [this.label, direction].join("_"); 1006 }, 1007 1008 getMediaElementForTrack(track, direction) { 1009 var prefix = this.elementPrefix(direction); 1010 return getMediaElementForTrack(track, prefix); 1011 }, 1012 1013 createMediaElementForTrack(track, direction) { 1014 var prefix = this.elementPrefix(direction); 1015 return createMediaElementForTrack(track, prefix); 1016 }, 1017 1018 ensureMediaElement(track, direction) { 1019 var prefix = this.elementPrefix(direction); 1020 var element = this.getMediaElementForTrack(track, direction); 1021 if (!element) { 1022 element = this.createMediaElementForTrack(track, direction); 1023 if (direction == "local") { 1024 this.localMediaElements.push(element); 1025 } else if (direction == "remote") { 1026 this.remoteMediaElements.push(element); 1027 } 1028 } 1029 1030 // We do this regardless, because sometimes we end up with a new stream with 1031 // an old id (ie; the rollback tests cause the same stream to be added 1032 // twice) 1033 element.srcObject = new MediaStream([track]); 1034 element.play(); 1035 }, 1036 1037 addSendStream(stream) { 1038 // The PeerConnection will not necessarily know about this stream 1039 // automatically, because replaceTrack is not told about any streams the 1040 // new track might be associated with. Only content really knows. 1041 this._sendStreams.push(stream); 1042 }, 1043 1044 getStreamForSendTrack(track) { 1045 return this._sendStreams.find(str => str.getTrackById(track.id)); 1046 }, 1047 1048 getStreamForRecvTrack(track) { 1049 return this._pc.getRemoteStreams().find(s => !!s.getTrackById(track.id)); 1050 }, 1051 1052 /** 1053 * Attaches a local track to this RTCPeerConnection using 1054 * RTCPeerConnection.addTrack(). 1055 * 1056 * Also creates a media element playing a MediaStream containing all 1057 * tracks that have been added to `stream` using `attachLocalTrack()`. 1058 * 1059 * @param {MediaStreamTrack} track 1060 * MediaStreamTrack to handle 1061 * @param {MediaStream} stream 1062 * MediaStream to use as container for `track` on remote side 1063 */ 1064 attachLocalTrack(track, stream) { 1065 info("Got a local " + track.kind + " track"); 1066 1067 this.expectNegotiationNeeded(); 1068 var sender = this._pc.addTrack(track, stream); 1069 is(sender.track, track, "addTrack returns sender"); 1070 is( 1071 this._pc.getSenders().pop(), 1072 sender, 1073 "Sender should be the last element in getSenders()" 1074 ); 1075 1076 ok(track.id, "track has id"); 1077 ok(track.kind, "track has kind"); 1078 ok(stream.id, "stream has id"); 1079 this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id }); 1080 this.addSendStream(stream); 1081 1082 // This will create one media element per track, which might not be how 1083 // we set up things with the RTCPeerConnection. It's the only way 1084 // we can ensure all sent tracks are flowing however. 1085 this.ensureMediaElement(track, "local"); 1086 1087 return this.observedNegotiationNeeded; 1088 }, 1089 1090 /** 1091 * Callback when we get local media. Also an appropriate HTML media element 1092 * will be created and added to the content node. 1093 * 1094 * @param {MediaStream} stream 1095 * Media stream to handle 1096 */ 1097 attachLocalStream(stream, useAddTransceiver) { 1098 info("Got local media stream: (" + stream.id + ")"); 1099 1100 this.expectNegotiationNeeded(); 1101 if (useAddTransceiver) { 1102 info("Using addTransceiver (on PC)."); 1103 stream.getTracks().forEach(track => { 1104 var transceiver = this._pc.addTransceiver(track, { streams: [stream] }); 1105 is(transceiver.sender.track, track, "addTransceiver returns sender"); 1106 }); 1107 } 1108 // In order to test both the addStream and addTrack APIs, we do half one 1109 // way, half the other, at random. 1110 else if (Math.random() < 0.5) { 1111 info("Using addStream."); 1112 this._pc.addStream(stream); 1113 ok( 1114 this._pc 1115 .getSenders() 1116 .find(sender => sender.track == stream.getTracks()[0]), 1117 "addStream returns sender" 1118 ); 1119 } else { 1120 info("Using addTrack (on PC)."); 1121 stream.getTracks().forEach(track => { 1122 var sender = this._pc.addTrack(track, stream); 1123 is(sender.track, track, "addTrack returns sender"); 1124 }); 1125 } 1126 1127 this.addSendStream(stream); 1128 1129 stream.getTracks().forEach(track => { 1130 ok(track.id, "track has id"); 1131 ok(track.kind, "track has kind"); 1132 const sender = this._pc.getSenders().find(s => s.track == track); 1133 ok(sender, "track has a sender"); 1134 this.expectedLocalTrackInfo.push({ track, sender, streamId: stream.id }); 1135 this.ensureMediaElement(track, "local"); 1136 }); 1137 1138 return this.observedNegotiationNeeded; 1139 }, 1140 1141 removeSender(index) { 1142 var sender = this._pc.getSenders()[index]; 1143 this.expectedLocalTrackInfo = this.expectedLocalTrackInfo.filter( 1144 i => i.sender != sender 1145 ); 1146 this.expectNegotiationNeeded(); 1147 this._pc.removeTrack(sender); 1148 return this.observedNegotiationNeeded; 1149 }, 1150 1151 senderReplaceTrack(sender, withTrack, stream) { 1152 const info = this.expectedLocalTrackInfo.find(i => i.sender == sender); 1153 if (!info) { 1154 return undefined; // replaceTrack on a null track, probably 1155 } 1156 info.track = withTrack; 1157 this.addSendStream(stream); 1158 this.ensureMediaElement(withTrack, "local"); 1159 return sender.replaceTrack(withTrack); 1160 }, 1161 1162 async getUserMedia(constraints) { 1163 SpecialPowers.wrap(document).notifyUserGestureActivation(); 1164 var stream = await getUserMedia(constraints); 1165 if (constraints.audio) { 1166 stream.getAudioTracks().forEach(track => { 1167 info( 1168 this + 1169 " gUM local stream " + 1170 stream.id + 1171 " with audio track " + 1172 track.id 1173 ); 1174 }); 1175 } 1176 if (constraints.video) { 1177 stream.getVideoTracks().forEach(track => { 1178 info( 1179 this + 1180 " gUM local stream " + 1181 stream.id + 1182 " with video track " + 1183 track.id 1184 ); 1185 }); 1186 } 1187 return stream; 1188 }, 1189 1190 /** 1191 * Requests all the media streams as specified in the constrains property. 1192 * 1193 * @param {Array} constraintsList 1194 * Array of constraints for GUM calls 1195 */ 1196 getAllUserMedia(constraintsList) { 1197 if (constraintsList.length === 0) { 1198 info("Skipping GUM: no UserMedia requested"); 1199 return Promise.resolve(); 1200 } 1201 1202 info("Get " + constraintsList.length + " local streams"); 1203 return Promise.all( 1204 constraintsList.map(constraints => this.getUserMedia(constraints)) 1205 ); 1206 }, 1207 1208 async getAllUserMediaAndAddStreams(constraintsList) { 1209 var streams = await this.getAllUserMedia(constraintsList); 1210 if (!streams) { 1211 return undefined; 1212 } 1213 return Promise.all(streams.map(stream => this.attachLocalStream(stream))); 1214 }, 1215 1216 async getAllUserMediaAndAddTransceivers(constraintsList) { 1217 var streams = await this.getAllUserMedia(constraintsList); 1218 if (!streams) { 1219 return undefined; 1220 } 1221 return Promise.all( 1222 streams.map(stream => this.attachLocalStream(stream, true)) 1223 ); 1224 }, 1225 1226 /** 1227 * Create a new data channel instance. Also creates a promise called 1228 * `this.nextDataChannel` that resolves when the next data channel arrives. 1229 */ 1230 expectDataChannel(message) { 1231 this.nextDataChannel = new Promise(resolve => { 1232 this.ondatachannel = e => { 1233 ok(e.channel, message); 1234 is( 1235 e.channel.readyState, 1236 "open", 1237 "data channel in 'open' after 'ondatachannel'" 1238 ); 1239 resolve(e.channel); 1240 }; 1241 }); 1242 }, 1243 1244 /** 1245 * Create a new data channel instance 1246 * 1247 * @param {object} options 1248 * Options which get forwarded to nsIPeerConnection.createDataChannel 1249 * @returns {DataChannelWrapper} The created data channel 1250 */ 1251 createDataChannel(options) { 1252 var label = "channel_" + this.dataChannels.length; 1253 info(this + ": Create data channel '" + label); 1254 1255 if (!this.dataChannels.length) { 1256 this.expectNegotiationNeeded(); 1257 } 1258 var channel = this._pc.createDataChannel(label, options); 1259 is(channel.readyState, "connecting", "initial readyState is 'connecting'"); 1260 var wrapper = new DataChannelWrapper(channel, this); 1261 this.dataChannels.push(wrapper); 1262 return wrapper; 1263 }, 1264 1265 /** 1266 * Creates an offer and automatically handles the failure case. 1267 */ 1268 createOffer() { 1269 return this._pc.createOffer(this.offerOptions).then(offer => { 1270 info("Got offer: " + JSON.stringify(offer)); 1271 // note: this might get updated through ICE gathering 1272 this._latest_offer = offer; 1273 return offer; 1274 }); 1275 }, 1276 1277 /** 1278 * Creates an answer and automatically handles the failure case. 1279 */ 1280 createAnswer() { 1281 return this._pc.createAnswer().then(answer => { 1282 info(this + ": Got answer: " + JSON.stringify(answer)); 1283 this._last_answer = answer; 1284 return answer; 1285 }); 1286 }, 1287 1288 /** 1289 * Sets the local description and automatically handles the failure case. 1290 * 1291 * @param {object} desc 1292 * RTCSessionDescriptionInit for the local description request 1293 */ 1294 setLocalDescription(desc) { 1295 this.observedNegotiationNeeded = undefined; 1296 return this._pc.setLocalDescription(desc).then(() => { 1297 info(this + ": Successfully set the local description"); 1298 }); 1299 }, 1300 1301 /** 1302 * Tries to set the local description and expect failure. Automatically 1303 * causes the test case to fail if the call succeeds. 1304 * 1305 * @param {object} desc 1306 * RTCSessionDescriptionInit for the local description request 1307 * @returns {Promise} 1308 * A promise that resolves to the expected error 1309 */ 1310 setLocalDescriptionAndFail(desc) { 1311 return this._pc 1312 .setLocalDescription(desc) 1313 .then( 1314 generateErrorCallback("setLocalDescription should have failed."), 1315 err => { 1316 info(this + ": As expected, failed to set the local description"); 1317 return err; 1318 } 1319 ); 1320 }, 1321 1322 /** 1323 * Sets the remote description and automatically handles the failure case. 1324 * 1325 * @param {object} desc 1326 * RTCSessionDescriptionInit for the remote description request 1327 */ 1328 setRemoteDescription(desc) { 1329 this.observedNegotiationNeeded = undefined; 1330 // This has to be done before calling sRD, otherwise a candidate in flight 1331 // could end up in the PC's operations queue before sRD resolves. 1332 if (desc.type == "rollback") { 1333 this.holdIceCandidates = new Promise( 1334 r => (this.releaseIceCandidates = r) 1335 ); 1336 } 1337 return this._pc.setRemoteDescription(desc).then(() => { 1338 info(this + ": Successfully set remote description"); 1339 if (desc.type != "rollback") { 1340 this.releaseIceCandidates(); 1341 } 1342 }); 1343 }, 1344 1345 /** 1346 * Tries to set the remote description and expect failure. Automatically 1347 * causes the test case to fail if the call succeeds. 1348 * 1349 * @param {object} desc 1350 * RTCSessionDescriptionInit for the remote description request 1351 * @returns {Promise} 1352 * a promise that resolve to the returned error 1353 */ 1354 setRemoteDescriptionAndFail(desc) { 1355 return this._pc 1356 .setRemoteDescription(desc) 1357 .then( 1358 generateErrorCallback("setRemoteDescription should have failed."), 1359 err => { 1360 info(this + ": As expected, failed to set the remote description"); 1361 return err; 1362 } 1363 ); 1364 }, 1365 1366 /** 1367 * Registers a callback for the signaling state change and 1368 * appends the new state to an array for logging it later. 1369 */ 1370 logSignalingState() { 1371 this.signalingStateLog = [this._pc.signalingState]; 1372 this._pc.addEventListener("signalingstatechange", e => { 1373 var newstate = this._pc.signalingState; 1374 var oldstate = this.signalingStateLog[this.signalingStateLog.length - 1]; 1375 if (Object.keys(signalingStateTransitions).includes(oldstate)) { 1376 ok( 1377 signalingStateTransitions[oldstate].includes(newstate), 1378 this + 1379 ": legal signaling state transition from " + 1380 oldstate + 1381 " to " + 1382 newstate 1383 ); 1384 } else { 1385 ok( 1386 false, 1387 this + 1388 ": old signaling state " + 1389 oldstate + 1390 " missing in signaling transition array" 1391 ); 1392 } 1393 this.signalingStateLog.push(newstate); 1394 }); 1395 }, 1396 1397 allExpectedTracksAreObserved(expected, observed) { 1398 return Object.keys(expected).every(trackId => observed[trackId]); 1399 }, 1400 1401 setupStreamEventHandlers(stream) { 1402 const myTrackIds = new Set(stream.getTracks().map(t => t.id)); 1403 1404 stream.addEventListener("addtrack", ({ track }) => { 1405 ok( 1406 !myTrackIds.has(track.id), 1407 "Duplicate addtrack callback: " + 1408 `stream id=${stream.id} track id=${track.id}` 1409 ); 1410 myTrackIds.add(track.id); 1411 // addtrack events happen before track events, so the track callback hasn't 1412 // heard about this yet. 1413 let streams = this.remoteStreamsByTrackId.get(track.id); 1414 ok( 1415 !streams || !streams.has(stream.id), 1416 `In addtrack for stream id=${stream.id}` + 1417 `there should not have been a track event for track id=${track.id} ` + 1418 " containing this stream yet." 1419 ); 1420 ok( 1421 stream.getTracks().includes(track), 1422 "In addtrack, stream id=" + 1423 `${stream.id} should already contain track id=${track.id}` 1424 ); 1425 }); 1426 1427 stream.addEventListener("removetrack", ({ track }) => { 1428 ok( 1429 myTrackIds.has(track.id), 1430 "Duplicate removetrack callback: " + 1431 `stream id=${stream.id} track id=${track.id}` 1432 ); 1433 myTrackIds.delete(track.id); 1434 // Also remove the association from remoteStreamsByTrackId 1435 const streams = this.remoteStreamsByTrackId.get(track.id); 1436 ok( 1437 streams, 1438 `In removetrack for stream id=${stream.id}, track id=` + 1439 `${track.id} should have had a track callback for the stream.` 1440 ); 1441 streams.delete(stream.id); 1442 ok( 1443 !stream.getTracks().includes(track), 1444 "In removetrack, stream id=" + 1445 `${stream.id} should not contain track id=${track.id}` 1446 ); 1447 }); 1448 }, 1449 1450 setupTrackEventHandler() { 1451 this._pc.addEventListener("track", ({ track, streams }) => { 1452 info(`${this}: 'ontrack' event fired for ${track.id}`); 1453 ok( 1454 this._pc.getReceivers().some(r => r.track == track), 1455 `Found track ${track.id}` 1456 ); 1457 1458 let gratuitousEvent = true; 1459 let streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id); 1460 if (!streamsContainingTrack) { 1461 gratuitousEvent = false; // Told us about a new track 1462 this.remoteStreamsByTrackId.set(track.id, new Set()); 1463 streamsContainingTrack = this.remoteStreamsByTrackId.get(track.id); 1464 } 1465 1466 for (const stream of streams) { 1467 ok( 1468 stream.getTracks().includes(track), 1469 `In track event, track id=${track.id}` + 1470 ` should already be in stream id=${stream.id}` 1471 ); 1472 1473 if (!streamsContainingTrack.has(stream.id)) { 1474 gratuitousEvent = false; // Told us about a new stream 1475 streamsContainingTrack.add(stream.id); 1476 this.setupStreamEventHandlers(stream); 1477 } 1478 } 1479 1480 ok(!gratuitousEvent, "track event told us something new"); 1481 1482 // So far, we've verified consistency between the current state of the 1483 // streams, addtrack/removetrack events on the streams, and track events 1484 // on the peerconnection. We have also verified that we have not gotten 1485 // any gratuitous events. We have not done anything to verify that the 1486 // current state of affairs matches what we were expecting it to. 1487 1488 this.ensureMediaElement(track, "remote"); 1489 }); 1490 }, 1491 1492 /** 1493 * Either adds a given ICE candidate right away or stores it to be added 1494 * later, depending on the state of the PeerConnection. 1495 * 1496 * @param {object} candidate 1497 * The RTCIceCandidate to be added or stored 1498 */ 1499 storeOrAddIceCandidate(candidate) { 1500 this._remote_ice_candidates.push(candidate); 1501 if (this.signalingState === "closed") { 1502 info("Received ICE candidate for closed PeerConnection - discarding"); 1503 return; 1504 } 1505 this.holdIceCandidates 1506 .then(() => { 1507 info(this + ": adding ICE candidate " + JSON.stringify(candidate)); 1508 return this._pc.addIceCandidate(candidate); 1509 }) 1510 .then(() => ok(true, this + " successfully added an ICE candidate")) 1511 .catch(e => 1512 // The onicecandidate callback runs independent of the test steps 1513 // and therefore errors thrown from in there don't get caught by the 1514 // race of the Promises around our test steps. 1515 // Note: as long as we are queuing ICE candidates until the success 1516 // of sRD() this should never ever happen. 1517 ok(false, this + " adding ICE candidate failed with: " + e.message) 1518 ); 1519 }, 1520 1521 /** 1522 * Registers a callback for the ICE connection state change and 1523 * appends the new state to an array for logging it later. 1524 */ 1525 logIceConnectionState() { 1526 this.iceConnectionLog = [this._pc.iceConnectionState]; 1527 this.ice_connection_callbacks.logIceStatus = () => { 1528 var newstate = this._pc.iceConnectionState; 1529 var oldstate = this.iceConnectionLog[this.iceConnectionLog.length - 1]; 1530 if (Object.keys(iceStateTransitions).includes(oldstate)) { 1531 if (this.iceCheckingRestartExpected) { 1532 is( 1533 newstate, 1534 "checking", 1535 "iceconnectionstate event '" + 1536 newstate + 1537 "' matches expected state 'checking'" 1538 ); 1539 this.iceCheckingRestartExpected = false; 1540 } else if (this.iceCheckingIceRollbackExpected) { 1541 is( 1542 newstate, 1543 "connected", 1544 "iceconnectionstate event '" + 1545 newstate + 1546 "' matches expected state 'connected'" 1547 ); 1548 this.iceCheckingIceRollbackExpected = false; 1549 } else { 1550 ok( 1551 iceStateTransitions[oldstate].includes(newstate), 1552 this + 1553 ": legal ICE state transition from " + 1554 oldstate + 1555 " to " + 1556 newstate 1557 ); 1558 } 1559 } else { 1560 ok( 1561 false, 1562 this + 1563 ": old ICE state " + 1564 oldstate + 1565 " missing in ICE transition array" 1566 ); 1567 } 1568 this.iceConnectionLog.push(newstate); 1569 }; 1570 }, 1571 1572 /** 1573 * Resets the ICE connected Promise and allows ICE connection state monitoring 1574 * to go backwards to 'checking'. 1575 */ 1576 expectIceChecking() { 1577 this.iceCheckingRestartExpected = true; 1578 this.iceConnected = new Promise((resolve, reject) => { 1579 this.iceConnectedResolve = resolve; 1580 this.iceConnectedReject = reject; 1581 }); 1582 }, 1583 1584 /** 1585 * Waits for ICE to either connect or fail. 1586 * 1587 * @returns {Promise} 1588 * resolves when connected, rejects on failure 1589 */ 1590 waitForIceConnected() { 1591 return this.iceConnected; 1592 }, 1593 1594 /** 1595 * Setup a onicecandidate handler 1596 * 1597 * @param {object} test 1598 * A PeerConnectionTest object to which the ice candidates gets 1599 * forwarded. 1600 */ 1601 setupIceCandidateHandler(test, candidateHandler) { 1602 candidateHandler = candidateHandler || test.iceCandidateHandler.bind(test); 1603 1604 var resolveEndOfTrickle; 1605 this.endOfTrickleIce = new Promise(r => (resolveEndOfTrickle = r)); 1606 this.holdIceCandidates = new Promise(r => (this.releaseIceCandidates = r)); 1607 this._new_local_ice_candidates = []; 1608 1609 this._pc.onicecandidate = anEvent => { 1610 if (!anEvent.candidate) { 1611 this._pc.onicecandidate = () => 1612 ok( 1613 false, 1614 this.label + " received ICE candidate after end of trickle" 1615 ); 1616 info(this.label + ": received end of trickle ICE event"); 1617 ok( 1618 this._pc.iceGatheringState === "complete", 1619 "ICE gathering state has reached complete" 1620 ); 1621 resolveEndOfTrickle(this.label); 1622 return; 1623 } 1624 1625 info( 1626 this.label + ": iceCandidate = " + JSON.stringify(anEvent.candidate) 1627 ); 1628 ok(anEvent.candidate.sdpMid.length, "SDP mid not empty"); 1629 ok( 1630 anEvent.candidate.usernameFragment.length, 1631 "usernameFragment not empty" 1632 ); 1633 1634 ok( 1635 typeof anEvent.candidate.sdpMLineIndex === "number", 1636 "SDP MLine Index needs to exist" 1637 ); 1638 this._local_ice_candidates.push(anEvent.candidate); 1639 this._new_local_ice_candidates.push(anEvent.candidate); 1640 candidateHandler(this.label, anEvent.candidate); 1641 }; 1642 }, 1643 1644 checkLocalMediaTracks() { 1645 info( 1646 `${this}: Checking local tracks ${JSON.stringify( 1647 this.expectedLocalTrackInfo 1648 )}` 1649 ); 1650 const sendersWithTrack = this._pc.getSenders().filter(({ track }) => track); 1651 is( 1652 sendersWithTrack.length, 1653 this.expectedLocalTrackInfo.length, 1654 "The number of senders with a track should be equal to the number of " + 1655 "expected local tracks." 1656 ); 1657 1658 // expectedLocalTrackInfo is in the same order that the tracks were added, and 1659 // so should the output of getSenders. 1660 this.expectedLocalTrackInfo.forEach((info, i) => { 1661 const sender = sendersWithTrack[i]; 1662 is(sender, info.sender, `Sender ${i} should match`); 1663 is(sender.track, info.track, `Track ${i} should match`); 1664 }); 1665 }, 1666 1667 /** 1668 * Checks that we are getting the media tracks we expect. 1669 */ 1670 checkMediaTracks() { 1671 this.checkLocalMediaTracks(); 1672 }, 1673 1674 checkLocalMsids() { 1675 const sdp = this.localDescription.sdp; 1676 const msections = sdputils.getMSections(sdp); 1677 const expectedStreamIdCounts = new Map(); 1678 for (const { track, sender, streamId } of this.expectedLocalTrackInfo) { 1679 const transceiver = this._pc 1680 .getTransceivers() 1681 .find(t => t.sender == sender); 1682 ok(transceiver, "There should be a transceiver for each sender"); 1683 if (transceiver.mid) { 1684 const midFinder = new RegExp(`^a=mid:${transceiver.mid}$`, "m"); 1685 const msection = msections.find(m => m.match(midFinder)); 1686 ok( 1687 msection, 1688 `There should be a media section for mid = ${transceiver.mid}` 1689 ); 1690 ok( 1691 msection.startsWith(`m=${track.kind}`), 1692 `Media section should be of type ${track.kind}` 1693 ); 1694 const msidFinder = new RegExp(`^a=msid:${streamId} \\S+$`, "m"); 1695 ok( 1696 msection.match(msidFinder), 1697 `Should find a=msid:${streamId} in media section` + 1698 " (with any track id for now)" 1699 ); 1700 const count = expectedStreamIdCounts.get(streamId) || 0; 1701 expectedStreamIdCounts.set(streamId, count + 1); 1702 } 1703 } 1704 1705 // Check for any unexpected msids. 1706 const allMsids = sdp.match(new RegExp("^a=msid:\\S+", "mg")); 1707 if (!allMsids) { 1708 return; 1709 } 1710 const allStreamIds = allMsids.map(msidAttr => 1711 msidAttr.replace("a=msid:", "") 1712 ); 1713 allStreamIds.forEach(id => { 1714 const count = expectedStreamIdCounts.get(id); 1715 ok(count, `Unexpected stream id ${id} found in local description.`); 1716 if (count) { 1717 expectedStreamIdCounts.set(id, count - 1); 1718 } 1719 }); 1720 }, 1721 1722 /** 1723 * Check that media flow is present for the given media element by checking 1724 * that it reaches ready state HAVE_ENOUGH_DATA and progresses time further 1725 * than the start of the check. 1726 * 1727 * This ensures, that the stream being played is producing 1728 * data and, in case it contains a video track, that at least one video frame 1729 * has been displayed. 1730 * 1731 * @param {HTMLMediaElement} track 1732 * The media element to check 1733 * @returns {Promise} 1734 * A promise that resolves when media data is flowing. 1735 */ 1736 waitForMediaElementFlow(element) { 1737 info("Checking data flow for element: " + element.id); 1738 is( 1739 element.ended, 1740 !element.srcObject.active, 1741 "Element ended should be the inverse of the MediaStream's active state" 1742 ); 1743 if (element.ended) { 1744 is( 1745 element.readyState, 1746 element.HAVE_CURRENT_DATA, 1747 "Element " + element.id + " is ended and should have had data" 1748 ); 1749 return Promise.resolve(); 1750 } 1751 1752 const haveEnoughData = ( 1753 element.readyState == element.HAVE_ENOUGH_DATA 1754 ? Promise.resolve() 1755 : haveEvent( 1756 element, 1757 "canplay", 1758 wait(60000, new Error("Timeout for element " + element.id)) 1759 ) 1760 ).then(_ => info("Element " + element.id + " has enough data.")); 1761 1762 const startTime = element.currentTime; 1763 // eslint-disable-next-line promise/valid-params 1764 const timeProgressed = timeout( 1765 listenUntil(element, "timeupdate", _ => element.currentTime > startTime), 1766 60000, 1767 "Element " + element.id + " should progress currentTime" 1768 ).then(); 1769 1770 return Promise.all([haveEnoughData, timeProgressed]); 1771 }, 1772 1773 /** 1774 * Wait for RTP packet flow for the given MediaStreamTrack. 1775 * 1776 * @param {object} track 1777 * A MediaStreamTrack to wait for data flow on. 1778 * @returns {Promise} 1779 * Returns a promise which yields a StatsReport object with RTP stats. 1780 */ 1781 async _waitForRtpFlow(target, rtpType) { 1782 const { track } = target; 1783 info(`_waitForRtpFlow(${track.id}, ${rtpType})`); 1784 const packets = `packets${rtpType == "outbound-rtp" ? "Sent" : "Received"}`; 1785 1786 const retryInterval = 500; // Time between stats checks 1787 const timeout = 30000; // Timeout in ms 1788 const retries = timeout / retryInterval; 1789 1790 for (let i = 0; i < retries; i++) { 1791 info(`Checking ${rtpType} for ${track.kind} track ${track.id} try ${i}`); 1792 for (const rtp of (await target.getStats()).values()) { 1793 if (rtp.type != rtpType) { 1794 continue; 1795 } 1796 if (rtp.kind != track.kind) { 1797 continue; 1798 } 1799 1800 const numPackets = rtp[packets]; 1801 info(`Track ${track.id} has ${numPackets} ${packets}.`); 1802 if (!numPackets) { 1803 continue; 1804 } 1805 1806 ok(true, `RTP flowing for ${track.kind} track ${track.id}`); 1807 return; 1808 } 1809 await wait(retryInterval); 1810 } 1811 throw new Error( 1812 `Checking stats for track ${track.id} timed out after ${timeout} ms` 1813 ); 1814 }, 1815 1816 /** 1817 * Wait for inbound RTP packet flow for the given MediaStreamTrack. 1818 * 1819 * @param {object} receiver 1820 * An RTCRtpReceiver to wait for data flow on. 1821 * @returns {Promise} 1822 * Returns a promise that resolves once data is flowing. 1823 */ 1824 async waitForInboundRtpFlow(receiver) { 1825 return this._waitForRtpFlow(receiver, "inbound-rtp"); 1826 }, 1827 1828 /** 1829 * Wait for outbound RTP packet flow for the given MediaStreamTrack. 1830 * 1831 * @param {object} sender 1832 * An RTCRtpSender to wait for data flow on. 1833 * @returns {Promise} 1834 * Returns a promise that resolves once data is flowing. 1835 */ 1836 async waitForOutboundRtpFlow(sender) { 1837 return this._waitForRtpFlow(sender, "outbound-rtp"); 1838 }, 1839 1840 getExpectedActiveReceivers() { 1841 return this._pc 1842 .getTransceivers() 1843 .filter( 1844 t => 1845 !t.stopped && 1846 t.currentDirection && 1847 t.currentDirection != "inactive" && 1848 t.currentDirection != "sendonly" 1849 ) 1850 .filter(({ receiver }) => receiver.track) 1851 .map(({ mid, currentDirection, receiver }) => { 1852 info( 1853 `Found transceiver that should be receiving RTP: mid=${mid}` + 1854 ` currentDirection=${currentDirection}` + 1855 ` kind=${receiver.track.kind} track-id=${receiver.track.id}` 1856 ); 1857 return receiver; 1858 }); 1859 }, 1860 1861 getExpectedSenders() { 1862 return this._pc.getSenders().filter(({ track }) => track); 1863 }, 1864 1865 /** 1866 * Wait for presence of video flow on all media elements and rtp flow on 1867 * all sending and receiving track involved in this test. 1868 * 1869 * @returns {Promise} 1870 * A promise that resolves when media flows for all elements and tracks 1871 */ 1872 waitForMediaFlow() { 1873 const receivers = this.getExpectedActiveReceivers(); 1874 return Promise.all([ 1875 ...this.localMediaElements.map(el => this.waitForMediaElementFlow(el)), 1876 ...this.remoteMediaElements 1877 .filter(({ srcObject }) => 1878 receivers.some(({ track }) => 1879 srcObject.getTracks().some(t => t == track) 1880 ) 1881 ) 1882 .map(el => this.waitForMediaElementFlow(el)), 1883 ...receivers.map(receiver => this.waitForInboundRtpFlow(receiver)), 1884 ...this.getExpectedSenders().map(sender => 1885 this.waitForOutboundRtpFlow(sender) 1886 ), 1887 ]); 1888 }, 1889 1890 /** 1891 * Check that correct audio (typically a flat tone) is flowing to this 1892 * PeerConnection for each transceiver that should be receiving. Uses 1893 * WebAudio AnalyserNodes to compare input and output audio data in the 1894 * frequency domain. 1895 * 1896 * @param {object} from 1897 * A PeerConnectionWrapper whose audio RTPSender we use as source for 1898 * the audio flow check. 1899 * @returns {Promise} 1900 * A promise that resolves when we're receiving the tone/s from |from|. 1901 */ 1902 async checkReceivingToneFrom( 1903 audiocontext, 1904 from, 1905 cancel = wait(60000, new Error("Tone not detected")) 1906 ) { 1907 let localTransceivers = this._pc 1908 .getTransceivers() 1909 .filter(t => t.mid) 1910 .filter(t => t.receiver.track.kind == "audio") 1911 .sort((t1, t2) => t1.mid < t2.mid); 1912 let remoteTransceivers = from._pc 1913 .getTransceivers() 1914 .filter(t => t.mid) 1915 .filter(t => t.receiver.track.kind == "audio") 1916 .sort((t1, t2) => t1.mid < t2.mid); 1917 1918 is( 1919 localTransceivers.length, 1920 remoteTransceivers.length, 1921 "Same number of associated audio transceivers on remote and local." 1922 ); 1923 1924 for (let i = 0; i < localTransceivers.length; i++) { 1925 is( 1926 localTransceivers[i].mid, 1927 remoteTransceivers[i].mid, 1928 "Transceivers at index " + i + " have the same mid." 1929 ); 1930 1931 if (!remoteTransceivers[i].sender.track) { 1932 continue; 1933 } 1934 1935 if ( 1936 remoteTransceivers[i].currentDirection == "recvonly" || 1937 remoteTransceivers[i].currentDirection == "inactive" 1938 ) { 1939 continue; 1940 } 1941 1942 let sendTrack = remoteTransceivers[i].sender.track; 1943 let inputElem = from.getMediaElementForTrack(sendTrack, "local"); 1944 ok( 1945 inputElem, 1946 "Remote wrapper should have a media element for track id " + 1947 sendTrack.id 1948 ); 1949 let inputAudioStream = from.getStreamForSendTrack(sendTrack); 1950 ok( 1951 inputAudioStream, 1952 "Remote wrapper should have a stream for track id " + sendTrack.id 1953 ); 1954 let inputAnalyser = new AudioStreamAnalyser( 1955 audiocontext, 1956 inputAudioStream 1957 ); 1958 1959 let recvTrack = localTransceivers[i].receiver.track; 1960 let outputAudioStream = this.getStreamForRecvTrack(recvTrack); 1961 ok( 1962 outputAudioStream, 1963 "Local wrapper should have a stream for track id " + recvTrack.id 1964 ); 1965 let outputAnalyser = new AudioStreamAnalyser( 1966 audiocontext, 1967 outputAudioStream 1968 ); 1969 1970 let error = null; 1971 cancel.then(e => (error = e)); 1972 1973 let indexOfMax = data => 1974 data.reduce((max, val, i) => (val >= data[max] ? i : max), 0); 1975 1976 await outputAnalyser.waitForAnalysisSuccess(() => { 1977 if (error) { 1978 throw error; 1979 } 1980 1981 let inputData = inputAnalyser.getByteFrequencyData(); 1982 let outputData = outputAnalyser.getByteFrequencyData(); 1983 1984 let inputMax = indexOfMax(inputData); 1985 let outputMax = indexOfMax(outputData); 1986 info( 1987 `Comparing maxima; input[${inputMax}] = ${inputData[inputMax]},` + 1988 ` output[${outputMax}] = ${outputData[outputMax]}` 1989 ); 1990 if (!inputData[inputMax] || !outputData[outputMax]) { 1991 return false; 1992 } 1993 1994 // When the input and output maxima are within reasonable distance (2% of 1995 // total length, which means ~10 for length 512) from each other, we can 1996 // be sure that the input tone has made it through the peer connection. 1997 info(`input data length: ${inputData.length}`); 1998 return Math.abs(inputMax - outputMax) < inputData.length * 0.02; 1999 }); 2000 } 2001 }, 2002 2003 /** 2004 * Check that stats are present by checking for known stats. 2005 */ 2006 async getStats(selector) { 2007 const stats = await this._pc.getStats(selector); 2008 const dict = {}; 2009 for (const [k, v] of stats.entries()) { 2010 dict[k] = v; 2011 } 2012 info(`${this}: Got stats: ${JSON.stringify(dict)}`); 2013 return stats; 2014 }, 2015 2016 /** 2017 * Checks that we are getting the media streams we expect. 2018 * 2019 * @param {object} stats 2020 * The stats to check from this PeerConnectionWrapper 2021 */ 2022 checkStats(stats) { 2023 const isRemote = ({ type }) => 2024 ["remote-outbound-rtp", "remote-inbound-rtp"].includes(type); 2025 var counters = {}; 2026 for (let [key, res] of stats) { 2027 info("Checking stats for " + key + " : " + res); 2028 // validate stats 2029 ok(res.id == key, "Coherent stats id"); 2030 const now = performance.timeOrigin + performance.now(); 2031 const minimum = performance.timeOrigin; 2032 const type = isRemote(res) ? "rtcp" : "rtp"; 2033 ok( 2034 res.timestamp >= minimum, 2035 `Valid ${type} timestamp ${res.timestamp} >= ${minimum} ( 2036 ${res.timestamp - minimum} ms)` 2037 ); 2038 ok( 2039 res.timestamp <= now, 2040 `Valid ${type} timestamp ${res.timestamp} <= ${now} ( 2041 ${res.timestamp - now} ms)` 2042 ); 2043 if (isRemote(res)) { 2044 continue; 2045 } 2046 counters[res.type] = (counters[res.type] || 0) + 1; 2047 2048 switch (res.type) { 2049 case "inbound-rtp": 2050 case "outbound-rtp": 2051 { 2052 // Inbound tracks won't have an ssrc if RTP is not flowing. 2053 // (eg; negotiated inactive) 2054 ok( 2055 res.ssrc || res.type == "inbound-rtp", 2056 "Outbound RTP stats has an ssrc." 2057 ); 2058 2059 if (res.ssrc) { 2060 // ssrc is a 32 bit number returned as an unsigned long 2061 ok(!/[^0-9]/.test(`${res.ssrc}`), "SSRC is numeric"); 2062 ok(parseInt(res.ssrc) < Math.pow(2, 32), "SSRC is within limits"); 2063 } 2064 2065 if (res.type == "outbound-rtp") { 2066 ok(res.packetsSent !== undefined, "Rtp packetsSent"); 2067 // We assume minimum payload to be 1 byte (guess from RFC 3550) 2068 ok(res.bytesSent >= res.packetsSent, "Rtp bytesSent"); 2069 } else { 2070 ok(res.packetsReceived !== undefined, "Rtp packetsReceived"); 2071 ok(res.bytesReceived >= res.packetsReceived, "Rtp bytesReceived"); 2072 } 2073 if (res.remoteId) { 2074 var rem = stats.get(res.remoteId); 2075 ok(isRemote(rem), "Remote is rtcp"); 2076 ok(rem.localId == res.id, "Remote backlink match"); 2077 if (res.type == "outbound-rtp") { 2078 ok(rem.type == "remote-inbound-rtp", "Rtcp is inbound"); 2079 if (rem.packetsLost) { 2080 ok( 2081 rem.packetsLost >= 0, 2082 "Rtcp packetsLost " + rem.packetsLost + " >= 0" 2083 ); 2084 ok( 2085 rem.packetsLost < 1000, 2086 "Rtcp packetsLost " + rem.packetsLost + " < 1000" 2087 ); 2088 } 2089 if (!this.disableRtpCountChecking) { 2090 // no guarantee which one is newer! 2091 // Note: this must change when we add a timestamp field to remote RTCP reports 2092 // and make rem.timestamp be the reception time 2093 if (res.timestamp < rem.timestamp) { 2094 info( 2095 "REVERSED timestamps: rec:" + 2096 rem.packetsReceived + 2097 " time:" + 2098 rem.timestamp + 2099 " sent:" + 2100 res.packetsSent + 2101 " time:" + 2102 res.timestamp 2103 ); 2104 } 2105 } 2106 if (rem.jitter) { 2107 ok(rem.jitter >= 0, "Rtcp jitter " + rem.jitter + " >= 0"); 2108 ok(rem.jitter < 5, "Rtcp jitter " + rem.jitter + " < 5 sec"); 2109 } 2110 if (rem.roundTripTime) { 2111 ok( 2112 rem.roundTripTime >= 0, 2113 "Rtcp rtt " + rem.roundTripTime + " >= 0" 2114 ); 2115 ok( 2116 rem.roundTripTime < 60, 2117 "Rtcp rtt " + rem.roundTripTime + " < 1 min" 2118 ); 2119 } 2120 } else { 2121 ok(rem.type == "remote-outbound-rtp", "Rtcp is outbound"); 2122 ok(rem.packetsSent !== undefined, "Rtcp packetsSent"); 2123 ok(rem.bytesSent !== undefined, "Rtcp bytesSent"); 2124 } 2125 ok(rem.ssrc == res.ssrc, "Remote ssrc match"); 2126 } else { 2127 info("No rtcp info received yet"); 2128 } 2129 } 2130 break; 2131 } 2132 } 2133 2134 var nin = this._pc.getTransceivers().filter(t => { 2135 return ( 2136 !t.stopped && 2137 t.currentDirection != "inactive" && 2138 t.currentDirection != "sendonly" 2139 ); 2140 }).length; 2141 const nout = Object.keys(this.expectedLocalTrackInfo).length; 2142 var ndata = this.dataChannels.length; 2143 2144 // TODO(Bug 957145): Restore stronger inbound-rtp test once Bug 948249 is fixed 2145 //is((counters["inbound-rtp"] || 0), nin, "Have " + nin + " inbound-rtp stat(s)"); 2146 ok( 2147 (counters["inbound-rtp"] || 0) >= nin, 2148 "Have at least " + nin + " inbound-rtp stat(s) *" 2149 ); 2150 2151 ok( 2152 (counters["outbound-rtp"] || 0) >= nout, 2153 "Have at least" + nout + " outbound-rtp stat(s)" 2154 ); 2155 2156 var numLocalCandidates = counters["local-candidate"] || 0; 2157 var numRemoteCandidates = counters["remote-candidate"] || 0; 2158 // If there are no tracks, there will be no stats either. 2159 if (nin + nout + ndata > 0) { 2160 ok(numLocalCandidates, "Have local-candidate stat(s)"); 2161 ok(numRemoteCandidates, "Have remote-candidate stat(s)"); 2162 } else { 2163 is(numLocalCandidates, 0, "Have no local-candidate stats"); 2164 is(numRemoteCandidates, 0, "Have no remote-candidate stats"); 2165 } 2166 }, 2167 2168 /** 2169 * Compares the Ice server configured for this PeerConnectionWrapper 2170 * with the ICE candidates received in the RTCP stats. 2171 * 2172 * @param {object} stats 2173 * The stats to be verified for relayed vs. direct connection. 2174 */ 2175 checkStatsIceConnectionType(stats, expectedLocalCandidateType) { 2176 let lId; 2177 let rId; 2178 for (let stat of stats.values()) { 2179 if (stat.type == "candidate-pair" && stat.selected) { 2180 lId = stat.localCandidateId; 2181 rId = stat.remoteCandidateId; 2182 break; 2183 } 2184 } 2185 isnot( 2186 lId, 2187 undefined, 2188 "Got local candidate ID " + lId + " for selected pair" 2189 ); 2190 isnot( 2191 rId, 2192 undefined, 2193 "Got remote candidate ID " + rId + " for selected pair" 2194 ); 2195 let lCand = stats.get(lId); 2196 let rCand = stats.get(rId); 2197 if (!lCand || !rCand) { 2198 ok( 2199 false, 2200 "failed to find candidatepair IDs or stats for local: " + 2201 lId + 2202 " remote: " + 2203 rId 2204 ); 2205 return; 2206 } 2207 2208 info( 2209 "checkStatsIceConnectionType verifying: local=" + 2210 JSON.stringify(lCand) + 2211 " remote=" + 2212 JSON.stringify(rCand) 2213 ); 2214 expectedLocalCandidateType = expectedLocalCandidateType || "host"; 2215 var candidateType = lCand.candidateType; 2216 if (lCand.relayProtocol === "tcp" && candidateType === "relay") { 2217 candidateType = "relay-tcp"; 2218 } 2219 2220 if (lCand.relayProtocol === "tls" && candidateType === "relay") { 2221 candidateType = "relay-tls"; 2222 } 2223 2224 if (expectedLocalCandidateType === "srflx" && candidateType === "prflx") { 2225 // Be forgiving of prflx when expecting srflx, since that can happen due 2226 // to timing. 2227 candidateType = "srflx"; 2228 } 2229 2230 is( 2231 candidateType, 2232 expectedLocalCandidateType, 2233 "Local candidate type is what we expected for selected pair" 2234 ); 2235 }, 2236 2237 /** 2238 * Compares amount of established ICE connection according to ICE candidate 2239 * pairs in the stats reporting with the expected amount of connection based 2240 * on the constraints. 2241 * 2242 * @param {object} stats 2243 * The stats to check for ICE candidate pairs 2244 * @param {object} testOptions 2245 * The test options object from the PeerConnectionTest 2246 */ 2247 checkStatsIceConnections(stats, testOptions) { 2248 var numIceConnections = 0; 2249 stats.forEach(stat => { 2250 if (stat.type === "candidate-pair" && stat.selected) { 2251 numIceConnections += 1; 2252 } 2253 }); 2254 info("ICE connections according to stats: " + numIceConnections); 2255 isnot( 2256 numIceConnections, 2257 0, 2258 "Number of ICE connections according to stats is not zero" 2259 ); 2260 if (testOptions.bundle) { 2261 if (testOptions.rtcpmux) { 2262 is(numIceConnections, 1, "stats reports exactly 1 ICE connection"); 2263 } else { 2264 ok( 2265 numIceConnections >= 1, 2266 `stats ICE connections should be at least 1` 2267 ); 2268 ok(numIceConnections <= 2, `stats ICE connections should be at most 2`); 2269 } 2270 } else { 2271 var numAudioTransceivers = this._pc 2272 .getTransceivers() 2273 .filter(transceiver => { 2274 return ( 2275 !transceiver.stopped && transceiver.receiver.track.kind == "audio" 2276 ); 2277 }).length; 2278 2279 var numVideoTransceivers = this._pc 2280 .getTransceivers() 2281 .filter(transceiver => { 2282 return ( 2283 !transceiver.stopped && transceiver.receiver.track.kind == "video" 2284 ); 2285 }).length; 2286 2287 var numExpectedTransports = numAudioTransceivers + numVideoTransceivers; 2288 2289 if (this.dataChannels.length) { 2290 ++numExpectedTransports; 2291 } 2292 2293 info( 2294 "expected audio + video + data transports: " + numExpectedTransports 2295 ); 2296 if (!testOptions.rtcpmux) { 2297 // Without rtcp mux, the expected number of transports doubles, but 2298 // there's no good way to check whether the rtcp transports are ready, 2299 // since there's no way to expose the state of those extra transports. 2300 ok( 2301 numIceConnections >= numExpectedTransports, 2302 `stats ICE connections should be at least ${numExpectedTransports}` 2303 ); 2304 ok( 2305 numIceConnections <= numExpectedTransports * 2, 2306 `stats ICE connections should be at most ${numExpectedTransports * 2}` 2307 ); 2308 } else { 2309 is( 2310 numIceConnections, 2311 numExpectedTransports, 2312 "stats ICE connections matches expected A/V transports" 2313 ); 2314 } 2315 } 2316 }, 2317 2318 expectNegotiationNeeded() { 2319 if (!this.observedNegotiationNeeded) { 2320 this.observedNegotiationNeeded = new Promise(resolve => { 2321 this.onnegotiationneeded = resolve; 2322 }); 2323 } 2324 }, 2325 2326 /** 2327 * Property-matching function for finding a certain stat in passed-in stats 2328 * 2329 * @param {object} stats 2330 * The stats to check from this PeerConnectionWrapper 2331 * @param {object} props 2332 * The properties to look for 2333 * @returns {boolean} Whether an entry containing all match-props was found. 2334 */ 2335 hasStat(stats, props) { 2336 for (let res of stats.values()) { 2337 var match = true; 2338 for (let prop in props) { 2339 if (res[prop] !== props[prop]) { 2340 match = false; 2341 break; 2342 } 2343 } 2344 if (match) { 2345 return true; 2346 } 2347 } 2348 return false; 2349 }, 2350 2351 /** 2352 * Closes the connection 2353 */ 2354 close() { 2355 this._pc.close(); 2356 this.localMediaElements.forEach(e => e.pause()); 2357 info(this + ": Closed connection."); 2358 }, 2359 2360 /** 2361 * Returns the string representation of the class 2362 * 2363 * @returns {string} The string representation 2364 */ 2365 toString() { 2366 return "PeerConnectionWrapper (" + this.label + ")"; 2367 }, 2368 }; 2369 2370 // haxx to prevent SimpleTest from failing at window.onload 2371 function addLoadEvent() {} 2372 2373 function loadScript(...scripts) { 2374 return Promise.all( 2375 scripts.map(script => { 2376 var el = document.createElement("script"); 2377 if (typeof scriptRelativePath === "string" && script.charAt(0) !== "/") { 2378 script = scriptRelativePath + script; 2379 } 2380 el.src = script; 2381 document.head.appendChild(el); 2382 return new Promise(r => { 2383 el.onload = r; 2384 el.onerror = r; 2385 }); 2386 }) 2387 ); 2388 } 2389 2390 // Ensure SimpleTest.js is loaded before other scripts. 2391 /* import-globals-from /testing/mochitest/tests/SimpleTest/SimpleTest.js */ 2392 /* import-globals-from head.js */ 2393 /* import-globals-from templates.js */ 2394 /* import-globals-from turnConfig.js */ 2395 /* import-globals-from dataChannel.js */ 2396 /* import-globals-from network.js */ 2397 /* import-globals-from sdpUtils.js */ 2398 2399 var scriptsReady = loadScript("/tests/SimpleTest/SimpleTest.js").then(() => { 2400 return loadScript( 2401 "head.js", 2402 "templates.js", 2403 "turnConfig.js", 2404 "dataChannel.js", 2405 "network.js", 2406 "sdpUtils.js" 2407 ); 2408 }); 2409 2410 function createHTML(options) { 2411 return scriptsReady.then(() => realCreateHTML(options)); 2412 } 2413 2414 var iceServerWebsocket; 2415 var iceServersArray = []; 2416 2417 var addTurnsSelfsignedCerts = () => { 2418 var gUrl = SimpleTest.getTestFileURL("addTurnsSelfsignedCert.js"); 2419 var gScript = SpecialPowers.loadChromeScript(gUrl); 2420 var certs = []; 2421 // If the ICE server is running TURNS, and includes a "cert" attribute in 2422 // its JSON, we set up an override that will forgive things like 2423 // self-signed for it. 2424 iceServersArray.forEach(iceServer => { 2425 if (iceServer.hasOwnProperty("cert")) { 2426 iceServer.urls.forEach(url => { 2427 if (url.startsWith("turns:")) { 2428 // Assumes no port or params! 2429 certs.push({ cert: iceServer.cert, hostname: url.substr(6) }); 2430 } 2431 }); 2432 } 2433 }); 2434 2435 return new Promise((resolve, reject) => { 2436 gScript.addMessageListener("certs-added", () => { 2437 resolve(); 2438 }); 2439 2440 gScript.sendAsyncMessage("add-turns-certs", certs); 2441 }); 2442 }; 2443 2444 var setupIceServerConfig = useIceServer => { 2445 // We disable ICE support for HTTP proxy when using a TURN server, because 2446 // mochitest uses a fake HTTP proxy to serve content, which will eat our STUN 2447 // packets for TURN TCP. 2448 var enableHttpProxy = enable => 2449 SpecialPowers.pushPrefEnv({ 2450 set: [["media.peerconnection.disable_http_proxy", !enable]], 2451 }); 2452 2453 var spawnIceServer = () => 2454 new Promise((resolve, reject) => { 2455 iceServerWebsocket = new WebSocket("ws://localhost:8191/"); 2456 iceServerWebsocket.onopen = event => { 2457 info("websocket/process bridge open, starting ICE Server..."); 2458 iceServerWebsocket.send("iceserver"); 2459 }; 2460 2461 iceServerWebsocket.onmessage = event => { 2462 // The first message will contain the iceServers configuration, subsequent 2463 // messages are just logging. 2464 info("ICE Server: " + event.data); 2465 resolve(event.data); 2466 }; 2467 2468 iceServerWebsocket.onerror = () => { 2469 reject("ICE Server error: Is the ICE server websocket up?"); 2470 }; 2471 2472 iceServerWebsocket.onclose = () => { 2473 info("ICE Server websocket closed"); 2474 reject("ICE Server gone before getting configuration"); 2475 }; 2476 }); 2477 2478 if (!useIceServer) { 2479 info("Skipping ICE Server for this test"); 2480 return enableHttpProxy(true); 2481 } 2482 2483 return enableHttpProxy(false) 2484 .then(spawnIceServer) 2485 .then(iceServersStr => { 2486 iceServersArray = JSON.parse(iceServersStr); 2487 }) 2488 .then(addTurnsSelfsignedCerts); 2489 }; 2490 2491 async function runNetworkTest(testFunction, fixtureOptions = {}) { 2492 let { AppConstants } = SpecialPowers.ChromeUtils.importESModule( 2493 "resource://gre/modules/AppConstants.sys.mjs" 2494 ); 2495 2496 await scriptsReady; 2497 await runTestWhenReady(async options => { 2498 await startNetworkAndTest(); 2499 await setupIceServerConfig(fixtureOptions.useIceServer); 2500 await testFunction(options); 2501 await networkTestFinished(); 2502 }); 2503 }