diff --git a/CHANGELOG.md b/CHANGELOG.md index c9aa0b8a..f61ec19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +2.4.0 (In Progress) +=================== + +Changes +------- + +- Updated the description of [Device.updateToken](https://twilio.github.io/twilio-voice.js/classes/voice.device.html#updatetoken) API. It is recommended to call this API after [Device.tokenWillExpireEvent](https://twilio.github.io/twilio-voice.js/classes/voice.device.html#tokenwillexpireevent) is emitted, and before or after a call to prevent a potential ~1s audio loss during the update process. + +- Updated stats reporting to stop using deprecated `RTCIceCandidateStats` - `ip` and `deleted`. + +Bug Fixes +--------- + +- Fixed an [issue](https://github.com/twilio/twilio-voice.js/issues/100) where a `TypeError` is thrown after rejecting a call then invoking `updateToken`. + +- Fixed an issue (https://github.com/twilio/twilio-voice.js/issues/87, https://github.com/twilio/twilio-voice.js/issues/145) where the `PeerConnection` object is not properly disposed. + +- Fixed an [issue](https://github.com/twilio/twilio-voice.js/issues/14) where `device.audio.disconnect`, `device.audio.incoming` and `device.audio.outgoing` do not have the correct type definitions. + +- Fixed an [issue](https://github.com/twilio/twilio-voice.js/issues/126) where the internal `deviceinfochange` event is being emitted indefinitely, causing high cpu usage. + 2.3.2 (February 27, 2023) =================== diff --git a/lib/twilio/audiohelper.ts b/lib/twilio/audiohelper.ts index 843c9abf..502abb99 100644 --- a/lib/twilio/audiohelper.ts +++ b/lib/twilio/audiohelper.ts @@ -7,7 +7,7 @@ import Device from './device'; import { InvalidArgumentError, NotSupportedError } from './errors'; import Log from './log'; import OutputDeviceCollection from './outputdevicecollection'; -import * as defaultMediaDevices from './shims/mediadevices'; +import * as getMediaDevicesInstance from './shims/mediadevices'; import { average, difference, isFirefox } from './util'; const MediaDeviceInfoShim = require('./shims/mediadeviceinfo'); @@ -90,6 +90,15 @@ class AudioHelper extends EventEmitter { */ private _audioContext?: AudioContext; + /** + * Whether each sound is enabled. + */ + private _enabledSounds: Record = { + [Device.SoundName.Disconnect]: true, + [Device.SoundName.Incoming]: true, + [Device.SoundName.Outgoing]: true, + }; + /** * The `getUserMedia()` function to use. */ @@ -163,7 +172,7 @@ class AudioHelper extends EventEmitter { }, options); this._getUserMedia = getUserMedia; - this._mediaDevices = options.mediaDevices || defaultMediaDevices; + this._mediaDevices = options.mediaDevices || getMediaDevicesInstance(); this._onActiveInputChanged = onActiveInputChanged; const isAudioContextSupported: boolean = !!(options.AudioContext || options.audioContext); @@ -172,10 +181,6 @@ class AudioHelper extends EventEmitter { this.isOutputSelectionSupported = isEnumerationSupported && isSetSinkSupported; this.isVolumeSupported = isAudioContextSupported; - if (options.enabledSounds) { - this._addEnabledSounds(options.enabledSounds); - } - if (this.isVolumeSupported) { this._audioContext = options.audioContext || options.AudioContext && new options.AudioContext(); if (this._audioContext) { @@ -287,6 +292,36 @@ class AudioHelper extends EventEmitter { } } + /** + * Enable or disable the disconnect sound. + * @param doEnable Passing `true` will enable the sound and `false` will disable the sound. + * Not passing this parameter will not alter the enable-status of the sound. + * @returns The enable-status of the sound. + */ + disconnect(doEnable?: boolean): boolean { + return this._maybeEnableSound(Device.SoundName.Disconnect, doEnable); + } + + /** + * Enable or disable the incoming sound. + * @param doEnable Passing `true` will enable the sound and `false` will disable the sound. + * Not passing this parameter will not alter the enable-status of the sound. + * @returns The enable-status of the sound. + */ + incoming(doEnable?: boolean): boolean { + return this._maybeEnableSound(Device.SoundName.Incoming, doEnable); + } + + /** + * Enable or disable the outgoing sound. + * @param doEnable Passing `true` will enable the sound and `false` will disable the sound. + * Not passing this parameter will not alter the enable-status of the sound. + * @returns The enable-status of the sound. + */ + outgoing(doEnable?: boolean): boolean { + return this._maybeEnableSound(Device.SoundName.Outgoing, doEnable); + } + /** * Set the MediaTrackConstraints to be applied on every getUserMedia call for new input * device audio. Any deviceId specified here will be ignored. Instead, device IDs should @@ -343,27 +378,6 @@ class AudioHelper extends EventEmitter { }); } - /** - * Merge the passed enabledSounds into {@link AudioHelper}. Currently used to merge the deprecated - * Device.sounds object onto the new {@link AudioHelper} interface. Mutates - * by reference, sharing state between {@link Device} and {@link AudioHelper}. - * @param enabledSounds - The initial sound settings to merge. - * @private - */ - private _addEnabledSounds(enabledSounds: { [name: string]: boolean }) { - function setValue(key: Device.ToggleableSound, value: boolean) { - if (typeof value !== 'undefined') { - enabledSounds[key] = value; - } - - return enabledSounds[key]; - } - - Object.keys(enabledSounds).forEach(key => { - (this as any)[key] = setValue.bind(null, key); - }); - } - /** * Get the index of an un-labeled Device. * @param mediaDeviceInfo @@ -407,6 +421,19 @@ class AudioHelper extends EventEmitter { }); } + /** + * Set whether the sound is enabled or not + * @param soundName + * @param doEnable + * @returns Whether the sound is enabled or not + */ + private _maybeEnableSound(soundName: Device.ToggleableSound, doEnable?: boolean): boolean { + if (typeof doEnable !== 'undefined') { + this._enabledSounds[soundName] = doEnable; + } + return this._enabledSounds[soundName]; + } + /** * Remove an input device from inputs * @param lostDevice @@ -669,13 +696,6 @@ namespace AudioHelper { */ audioContext?: AudioContext; - /** - * A Record of sounds. This is modified by reference, and is used to - * maintain backward-compatibility. This should be removed or refactored in 2.0. - * TODO: Remove / refactor in 2.0. (CLIENT-5302) - */ - enabledSounds?: Record; - /** * A custom MediaDevices instance to use. */ diff --git a/lib/twilio/call.ts b/lib/twilio/call.ts index 3c637999..13d9cbbf 100644 --- a/lib/twilio/call.ts +++ b/lib/twilio/call.ts @@ -174,6 +174,11 @@ class Call extends EventEmitter { */ private _isCancelled: boolean = false; + /** + * Whether the call has been rejected + */ + private _isRejected: boolean = false; + /** * Whether or not the browser uses unified-plan SDP by default. */ @@ -515,7 +520,8 @@ class Call extends EventEmitter { if (this._options.shouldPlayDisconnect && this._options.shouldPlayDisconnect() // Don't play disconnect sound if this was from a cancel event. i.e. the call // was ignored or hung up even before it was answered. - && !this._isCancelled) { + // Similarly, don't play disconnect sound if the call was rejected. + && !this._isCancelled && !this._isRejected) { this._soundcache.get(Device.SoundName.Disconnect).play(); } @@ -523,7 +529,7 @@ class Call extends EventEmitter { monitor.disable(); this._publishMetrics(); - if (!this._isCancelled) { + if (!this._isCancelled && !this._isRejected) { // tslint:disable no-console this.emit('disconnect', this); } @@ -630,13 +636,13 @@ class Call extends EventEmitter { if (this._direction === Call.CallDirection.Incoming) { this._isAnswered = true; - this._pstream.on('answer', this._onAnswer.bind(this)); + this._pstream.on('answer', this._onAnswer); this._mediaHandler.answerIncomingCall(this.parameters.CallSid, this._options.offerSdp, rtcConstraints, rtcConfiguration, onAnswer); } else { const params = Array.from(this.customParameters.entries()).map(pair => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1])}`).join('&'); - this._pstream.on('answer', this._onAnswer.bind(this)); + this._pstream.on('answer', this._onAnswer); this._mediaHandler.makeOutgoingCall(this._pstream.token, params, this.outboundConnectionId, rtcConstraints, rtcConfiguration, onAnswer); } @@ -784,11 +790,14 @@ class Call extends EventEmitter { return; } + this._isRejected = true; this._pstream.reject(this.parameters.CallSid); - this._status = Call.State.Closed; - this.emit('reject'); this._mediaHandler.reject(this.parameters.CallSid); this._publisher.info('connection', 'rejected-by-local', null, this); + this._cleanupEventListeners(); + this._mediaHandler.close(); + this._status = Call.State.Closed; + this.emit('reject'); } /** diff --git a/lib/twilio/device.ts b/lib/twilio/device.ts index 96dbf8c1..37d4dc74 100644 --- a/lib/twilio/device.ts +++ b/lib/twilio/device.ts @@ -354,15 +354,6 @@ class Device extends EventEmitter { */ private _edge: string | null = null; - /** - * Whether each sound is enabled. - */ - private _enabledSounds: Record = { - [Device.SoundName.Disconnect]: true, - [Device.SoundName.Incoming]: true, - [Device.SoundName.Outgoing]: true, - }; - /** * The name of the home region the {@link Device} is connected to. */ @@ -812,6 +803,8 @@ class Device extends EventEmitter { /** * Update the token used by this {@link Device} to connect to Twilio. + * It is recommended to call this API after [[Device.tokenWillExpireEvent]] is emitted, + * and before or after a call to prevent a potential ~1s audio loss during the update process. * @param token */ updateToken(token: string) { @@ -976,7 +969,7 @@ class Device extends EventEmitter { maxAverageBitrate: this._options.maxAverageBitrate, preflight: this._options.preflight, rtcConstraints: this._options.rtcConstraints, - shouldPlayDisconnect: () => this._enabledSounds.disconnect, + shouldPlayDisconnect: () => this.audio?.disconnect(), twimlParams, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }, options); @@ -1001,7 +994,7 @@ class Device extends EventEmitter { this._audio._maybeStartPollingVolume(); } - if (call.direction === Call.CallDirection.Outgoing && this._enabledSounds.outgoing) { + if (call.direction === Call.CallDirection.Outgoing && this.audio?.outgoing()) { this._soundcache.get(Device.SoundName.Outgoing).play(); } @@ -1211,7 +1204,7 @@ class Device extends EventEmitter { this._publishNetworkChange(); }); - const play = (this._enabledSounds.incoming && !wasBusy) + const play = (this.audio?.incoming() && !wasBusy) ? () => this._soundcache.get(Device.SoundName.Incoming).play() : () => Promise.resolve(); @@ -1318,10 +1311,7 @@ class Device extends EventEmitter { this._updateSinkIds, this._updateInputStream, getUserMedia, - { - audioContext: Device.audioContext, - enabledSounds: this._enabledSounds, - }, + { audioContext: Device.audioContext }, ); this._audio.on('deviceChange', (lostActiveDevices: MediaDeviceInfo[]) => { diff --git a/lib/twilio/rtc/icecandidate.ts b/lib/twilio/rtc/icecandidate.ts index c9fefdf5..dc32885b 100644 --- a/lib/twilio/rtc/icecandidate.ts +++ b/lib/twilio/rtc/icecandidate.ts @@ -10,6 +10,7 @@ */ interface RTCIceCandidatePayload { candidate_type: string; + // Deprecated by newer browsers. Will likely not show on most recent versions of browsers. deleted: boolean; ip: string; is_remote: boolean; diff --git a/lib/twilio/rtc/stats.js b/lib/twilio/rtc/stats.js index f3942e1d..547f0584 100644 --- a/lib/twilio/rtc/stats.js +++ b/lib/twilio/rtc/stats.js @@ -204,8 +204,9 @@ function createRTCSample(statsReport) { } Object.assign(sample, { - localAddress: localCandidate && localCandidate.ip, - remoteAddress: remoteCandidate && remoteCandidate.ip, + // ip is deprecated. use address first then ip if on older versions of browser + localAddress: localCandidate && (localCandidate.address || localCandidate.ip), + remoteAddress: remoteCandidate && (remoteCandidate.address || remoteCandidate.ip), }); return sample; diff --git a/lib/twilio/shims/mediadevices.js b/lib/twilio/shims/mediadevices.js index a5271588..ecf61b56 100644 --- a/lib/twilio/shims/mediadevices.js +++ b/lib/twilio/shims/mediadevices.js @@ -2,7 +2,7 @@ const EventTarget = require('./eventtarget'); const POLL_INTERVAL_MS = 500; -const nativeMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices; +let nativeMediaDevices = null; /** * Make a custom MediaDevices object, and proxy through existing functionality. If @@ -40,7 +40,7 @@ class MediaDevicesShim extends EventTarget { if (typeof nativeMediaDevices.enumerateDevices === 'function') { nativeMediaDevices.enumerateDevices().then(devices => { - devices.sort(sortDevicesById).forEach([].push, knownDevices); + devices.sort(sortDevicesById).forEach(d => knownDevices.push(d)); }); } @@ -49,6 +49,7 @@ class MediaDevicesShim extends EventTarget { return; } + // TODO: Remove polling in the next major release. this._pollInterval = this._pollInterval || setInterval(sampleDevices.bind(null, this), POLL_INTERVAL_MS); }.bind(this)); @@ -62,22 +63,38 @@ class MediaDevicesShim extends EventTarget { } } -if (nativeMediaDevices && typeof nativeMediaDevices.enumerateDevices === 'function') { - MediaDevicesShim.prototype.enumerateDevices = function enumerateDevices() { +MediaDevicesShim.prototype.enumerateDevices = function enumerateDevices() { + if (nativeMediaDevices && typeof nativeMediaDevices.enumerateDevices === 'function') { return nativeMediaDevices.enumerateDevices(...arguments); - }; -} + } + return null; +}; MediaDevicesShim.prototype.getUserMedia = function getUserMedia() { return nativeMediaDevices.getUserMedia(...arguments); }; function deviceInfosHaveChanged(newDevices, oldDevices) { - const oldLabels = oldDevices.reduce((map, device) => map.set(device.deviceId, device.label || null), new Map()); + newDevices = newDevices.filter(d => d.kind === 'audioinput' || d.kind === 'audiooutput'); + oldDevices = oldDevices.filter(d => d.kind === 'audioinput' || d.kind === 'audiooutput'); + + // On certain browsers, we cannot use deviceId as a key for comparison. + // It's missing along with the device label if the customer has not granted permission. + // The following checks whether some old devices have empty labels and if they are now available. + // This means, the user has granted permissions and the device info have changed. + if (oldDevices.some(d => !d.deviceId) && + newDevices.some(d => !!d.deviceId)) { + return true; + } + + // Use both deviceId and "kind" to create a unique key + // since deviceId is not unique across different kinds of devices. + const oldLabels = oldDevices.reduce((map, device) => + map.set(`${device.deviceId}-${device.kind}`, device.label), new Map()); - return newDevices.some(newDevice => { - const oldLabel = oldLabels.get(newDevice.deviceId); - return typeof oldLabel !== 'undefined' && oldLabel !== newDevice.label; + return newDevices.some(device => { + const oldLabel = oldLabels.get(`${device.deviceId}-${device.kind}`); + return typeof oldLabel !== 'undefined' && oldLabel !== device.label; }); } @@ -164,6 +181,7 @@ function sortDevicesById(a, b) { return a.deviceId < b.deviceId; } -module.exports = (function shimMediaDevices() { +module.exports = () => { + nativeMediaDevices = typeof navigator !== 'undefined' ? navigator.mediaDevices : null; return nativeMediaDevices ? new MediaDevicesShim() : null; -})(); +}; diff --git a/package-lock.json b/package-lock.json index 90a29be4..750ce5d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@twilio/voice-sdk", - "version": "2.3.2-dev", + "version": "2.3.3-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@twilio/voice-sdk", - "version": "2.3.2-dev", + "version": "2.3.3-dev", "license": "Apache-2.0", "dependencies": { "@twilio/audioplayer": "1.0.6", diff --git a/package.json b/package.json index 6135e2eb..feeaff92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@twilio/voice-sdk", - "version": "2.3.3-dev", + "version": "2.4.0-dev", "description": "Twilio's JavaScript Voice SDK", "main": "./es5/twilio.js", "types": "./es5/twilio.d.ts", @@ -20,7 +20,7 @@ "url": "git@github.com:twilio/twilio-voice.js.git" }, "scripts": { - "build": "npm-run-all clean build:constants build:errors docs:ts build:es5 build:ts build:dist build:dist-min", + "build": "npm-run-all clean build:constants build:errors docs:ts build:es5 build:ts build:dist build:dist-min test:typecheck", "build:errors": "node ./scripts/errors.js", "build:es5": "rimraf ./es5 && babel lib -d es5", "build:dev": "ENV=dev npm run build", @@ -55,6 +55,7 @@ "test:integration": "karma start $PWD/karma.conf.ts", "test:network": "node ./scripts/karma.js $PWD/karma.network.conf.ts", "test:selenium": "mocha tests/browser/index.js", + "test:typecheck": "./node_modules/typescript/bin/tsc tests/typecheck/index.ts --noEmit", "test:unit": "nyc mocha -r ts-node/register ./tests/index.ts", "test:webpack": "cd ./tests/webpack && npm install && npm test" }, diff --git a/tests/audiohelper.js b/tests/audiohelper.js index 4c27f3de..54699418 100644 --- a/tests/audiohelper.js +++ b/tests/audiohelper.js @@ -297,7 +297,33 @@ describe('AudioHelper', () => { }); }); - describe('setAudioConstraints', () => { + ['disconnect', 'incoming', 'outgoing'].forEach(soundName => { + describe(`.${soundName}`, () => { + let testFn; + + beforeEach(() => { + testFn = audio[soundName].bind(audio); + }); + + it('should return true as default', () => { + assert.strictEqual(testFn(), true); + }); + + it('should return false after setting to false', () => { + assert.strictEqual(testFn(false), false); + assert.strictEqual(testFn(), false); + }); + + it('should return true after setting to true', () => { + assert.strictEqual(testFn(false), false); + assert.strictEqual(testFn(), false); + assert.strictEqual(testFn(true), true); + assert.strictEqual(testFn(), true); + }); + }); + }); + + describe('.setAudioConstraints', () => { context('when no input device is active', () => { it('should set .audioConstraints', () => { audio.setAudioConstraints({ foo: 'bar' }); @@ -328,7 +354,7 @@ describe('AudioHelper', () => { }); }); - describe('unsetAudioConstraints', () => { + describe('.unsetAudioConstraints', () => { beforeEach(() => { audio.setAudioConstraints({ foo: 'bar' }); }); diff --git a/tests/index.ts b/tests/index.ts index 97713513..486714c9 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -96,3 +96,5 @@ require('./unit/error'); require('./unit/log'); require('./unit/regions'); require('./unit/uuid'); + +require('./shims/mediadevices'); diff --git a/tests/payloads/rtcstatsreport-edge.json b/tests/payloads/rtcstatsreport-edge.json index d89bda8d..b7317601 100644 --- a/tests/payloads/rtcstatsreport-edge.json +++ b/tests/payloads/rtcstatsreport-edge.json @@ -46,8 +46,7 @@ "port": 52513, "protocol": "udp", "candidateType": "prflx", - "priority": 1853824767, - "deleted": false + "priority": 1853824767 }, { "id": "RTCIceCandidate_yB1+bJsu", @@ -59,8 +58,7 @@ "port": 11270, "protocol": "udp", "candidateType": "host", - "priority": 2130714367, - "deleted": false + "priority": 2130714367 }, { "id": "RTCInboundRTPAudioStream_774468856", diff --git a/tests/payloads/rtcstatsreport-with-transport.json b/tests/payloads/rtcstatsreport-with-transport.json index d9015e8c..ebbcfd11 100644 --- a/tests/payloads/rtcstatsreport-with-transport.json +++ b/tests/payloads/rtcstatsreport-with-transport.json @@ -284,8 +284,7 @@ "port": 53585, "protocol": "udp", "candidateType": "srflx", - "priority": 1686052607, - "deleted": false + "priority": 1686052607 }, { "id": "RTCIceCandidate_Di/J4tEz", @@ -297,8 +296,7 @@ "port": 19282, "protocol": "udp", "candidateType": "host", - "priority": 2130706431, - "deleted": false + "priority": 2130706431 }, { "id": "RTCIceCandidate_UqrtRLJZ", @@ -312,8 +310,7 @@ "protocol": "udp", "relayProtocol": "udp", "candidateType": "relay", - "priority": 41754879, - "deleted": false + "priority": 41754879 }, { "id": "RTCIceCandidate_e3FLKFoG", @@ -326,8 +323,7 @@ "port": 52497, "protocol": "udp", "candidateType": "srflx", - "priority": 1685921535, - "deleted": false + "priority": 1685921535 }, { "id": "RTCIceCandidate_oYROhau8", @@ -341,8 +337,7 @@ "protocol": "udp", "relayProtocol": "udp", "candidateType": "relay", - "priority": 41885951, - "deleted": false + "priority": 41885951 }, { "id": "RTCIceCandidate_qQRsdncV", @@ -355,8 +350,7 @@ "port": 50951, "protocol": "udp", "candidateType": "host", - "priority": 2122194687, - "deleted": false + "priority": 2122194687 }, { "id": "RTCInboundRTPAudioStream_976775258", diff --git a/tests/payloads/rtcstatsreport.json b/tests/payloads/rtcstatsreport.json index 959a108e..a16ec800 100644 --- a/tests/payloads/rtcstatsreport.json +++ b/tests/payloads/rtcstatsreport.json @@ -162,12 +162,11 @@ "type": "local-candidate", "transportId": "RTCTransport_audio_1", "isRemote": false, - "ip": "107.20.226.156", + "address": "107.20.226.156", "port": 52513, "protocol": "udp", "candidateType": "prflx", - "priority": 1853824767, - "deleted": false + "priority": 1853824767 }, { "id": "RTCIceCandidate_yB1+bJsu", @@ -179,8 +178,7 @@ "port": 11270, "protocol": "udp", "candidateType": "host", - "priority": 2130714367, - "deleted": false + "priority": 2130714367 }, { "id": "RTCInboundRTPAudioStream_774468856", diff --git a/tests/shims/mediadevices.js b/tests/shims/mediadevices.js new file mode 100644 index 00000000..61c85616 --- /dev/null +++ b/tests/shims/mediadevices.js @@ -0,0 +1,279 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const getMediaDevicesInstance = require('../../lib/twilio/shims/mediadevices'); + +describe('MediaDevicesShim', () => { + const userMediaStream = 'USER-STREAM'; + + let clock; + let globalMediaDevices; + let getDevices; + let mediaDevices; + let mediaDeviceList; + let nativeMediaDevices; + + const sampleDevices = async () => { + await clock.tickAsync(500); + }; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + + mediaDeviceList = [ + { deviceId: 'id1', kind: 'audioinput', label: 'label1' }, + { deviceId: 'id2', kind: 'audiooutput', label: 'label2' }, + { deviceId: 'id3', kind: 'videoinput', label: 'label3' }, + { deviceId: 'id4', kind: 'videooutput', label: 'label4' }, + ]; + + // Always return a deep copy + getDevices = () => new Promise(res => res(mediaDeviceList.map(d => ({ ...d })))); + + nativeMediaDevices = { + enumerateDevices: sinon.stub().callsFake(getDevices), + getUserMedia: sinon.stub().returns(Promise.resolve(userMediaStream)), + }; + + globalMediaDevices = global.navigator.mediaDevices; + global.navigator.mediaDevices = nativeMediaDevices; + + mediaDevices = getMediaDevicesInstance(); + }); + + afterEach(() => { + global.navigator.mediaDevices = globalMediaDevices; + clock.restore(); + }); + + describe('.enumerateDevices()', () => { + it('should call native enumerateDevices properly', async () => { + sinon.assert.calledOnce(nativeMediaDevices.enumerateDevices); + const devices = await mediaDevices.enumerateDevices(); + sinon.assert.calledTwice(nativeMediaDevices.enumerateDevices); + assert.deepStrictEqual(devices, mediaDeviceList); + }); + + it('should return null if the browser does not support enumerateDevices', async () => { + nativeMediaDevices.enumerateDevices = null; + const devices = await mediaDevices.enumerateDevices(); + assert.deepStrictEqual(devices, null); + }); + }); + + describe('.getUserMedia()', () => { + it('should call native getUserMedia properly', async () => { + const stream = await mediaDevices.getUserMedia({ foo: 'foo' }); + sinon.assert.calledOnce(nativeMediaDevices.getUserMedia); + sinon.assert.calledWithExactly(nativeMediaDevices.getUserMedia, { foo: 'foo' }) + assert.strictEqual(stream, userMediaStream); + }); + }); + + describe('#devicechange', () => { + let callback; + + beforeEach(async () => { + callback = sinon.stub(); + mediaDevices.addEventListener('devicechange', callback); + await sampleDevices(); + }); + + it('should stop polling after removing listeners', async () => { + const existingCallCount = nativeMediaDevices.enumerateDevices.callCount; + mediaDevices.removeEventListener('devicechange', callback); + sinon.assert.callCount(nativeMediaDevices.enumerateDevices, existingCallCount); + await sampleDevices(); + sinon.assert.callCount(nativeMediaDevices.enumerateDevices, existingCallCount); + }); + + it('should not emit the first time', async () => { + sinon.assert.notCalled(callback); + }); + + it('should emit once if a new device is added', async () => { + mediaDeviceList.push({ deviceId: 'id5', kind: 'audioinput', label: 'label5' }); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + it('should emit once if a device is removed', async () => { + mediaDeviceList.pop(); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + it('should emit once if a device is removed and a new device is added', async () => { + mediaDeviceList.pop(); + mediaDeviceList.push({ deviceId: 'id5', kind: 'audioinput', label: 'label5' }); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + describe('when native event is supported', () => { + const setup = async () => { + nativeMediaDevices.ondevicechange = null; + mediaDevices = getMediaDevicesInstance(); + callback = sinon.stub(); + mediaDevices.addEventListener('devicechange', callback); + await sampleDevices(); + }; + + beforeEach(async () => await setup()); + + it('should not emit manually', async () => { + mediaDeviceList.pop(); + await sampleDevices(); + sinon.assert.notCalled(callback); + }); + + it('should reemit if addEventListener is not supported', async () => { + await sampleDevices(); + nativeMediaDevices.ondevicechange({ type: 'devicechange' }); + sinon.assert.calledOnce(callback); + }); + + it('should reemit if addEventListener is supported', async () => { + nativeMediaDevices.addEventListener = (eventName, dispatchEvent) => { + nativeMediaDevices[`_emit${eventName}`] = () => dispatchEvent({ type: eventName }); + }; + await setup(); + await sampleDevices(); + nativeMediaDevices._emitdevicechange(); + sinon.assert.calledOnce(callback); + }); + }); + }); + + describe('#deviceinfochange', () => { + let callback; + + const setup = async () => { + callback = sinon.stub(); + mediaDevices = getMediaDevicesInstance(); + mediaDevices.addEventListener('deviceinfochange', callback); + await sampleDevices(); + }; + + beforeEach(async () => { + mediaDeviceList.forEach(d => d.label = ''); + await setup(); + }); + + it('should stop polling after removing listeners', async () => { + const existingCallCount = nativeMediaDevices.enumerateDevices.callCount; + mediaDevices.removeEventListener('deviceinfochange', callback); + sinon.assert.callCount(nativeMediaDevices.enumerateDevices, existingCallCount); + await sampleDevices(); + sinon.assert.callCount(nativeMediaDevices.enumerateDevices, existingCallCount); + }); + + it('should not emit the first time', async () => { + sinon.assert.notCalled(callback); + }); + + it('should emit once when device labels become available', async () => { + mediaDeviceList.forEach((d, i) => d.label = `label${i}`); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + it('should emit once when only audioinput or audiooutput device labels become available', async () => { + mediaDeviceList.forEach((d, i) => { + if (d.kind === 'audioinput' || d.kind === 'audiooutput') { + d.label = `label${i}`; + } + }); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + it('should emit once when there are duplicate ids across different types of devices', async () => { + mediaDeviceList.forEach((d, i) => { + d.deviceId = 'foo'; + d.label = ''; + }); + await setup(); + mediaDeviceList.forEach((d, i) => { + d.deviceId = 'foo'; + d.label = `label${i}`; + }); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + it('should not emit when device labels become available for a videoinput or videooutput device', async () => { + mediaDeviceList.forEach((d, i) => { + if (d.kind === 'videoinput' || d.kind === 'videooutput') { + d.label = `label${i}`; + } + }); + await sampleDevices(); + sinon.assert.notCalled(callback); + await sampleDevices(); + sinon.assert.notCalled(callback); + }); + + it('should emit once when ids are all empty initially', async () => { + mediaDeviceList.forEach((d, i) => { + d.deviceId = ''; + d.label = ''; + }); + await setup(); + mediaDeviceList.forEach((d, i) => { + d.deviceId = `id${i}`; + d.label = `label${i}`; + }); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + await sampleDevices(); + sinon.assert.calledOnce(callback); + }); + + describe('when native event is supported', () => { + const setupForNative = async () => { + nativeMediaDevices.ondeviceinfochange = null; + await setup(); + }; + + beforeEach(async () => await setupForNative()); + + it('should not emit manually', async () => { + mediaDeviceList.forEach((d, i) => d.label = `label${i}`); + await sampleDevices(); + sinon.assert.notCalled(callback); + }); + + it('should reemit if addEventListener is not supported', async () => { + await sampleDevices(); + nativeMediaDevices.ondeviceinfochange({ type: 'deviceinfochange' }); + sinon.assert.calledOnce(callback); + }); + + it('should reemit if addEventListener is supported', async () => { + nativeMediaDevices.addEventListener = (eventName, dispatchEvent) => { + nativeMediaDevices[`_emit${eventName}`] = () => dispatchEvent({ type: eventName }); + }; + await setupForNative(); + await sampleDevices(); + nativeMediaDevices._emitdeviceinfochange(); + sinon.assert.calledOnce(callback); + }); + }); + }); +}); diff --git a/tests/spec/rtcicecandidates-with-transport.json b/tests/spec/rtcicecandidates-with-transport.json index b7d046af..eb883d26 100644 --- a/tests/spec/rtcicecandidates-with-transport.json +++ b/tests/spec/rtcicecandidates-with-transport.json @@ -11,8 +11,7 @@ "port": 53585, "protocol": "udp", "candidateType": "srflx", - "priority": 1686052607, - "deleted": false + "priority": 1686052607 }, { "id": "RTCIceCandidate_UqrtRLJZ", @@ -26,8 +25,7 @@ "protocol": "udp", "relayProtocol": "udp", "candidateType": "relay", - "priority": 41754879, - "deleted": false + "priority": 41754879 }, { "id": "RTCIceCandidate_e3FLKFoG", @@ -40,8 +38,7 @@ "port": 52497, "protocol": "udp", "candidateType": "srflx", - "priority": 1685921535, - "deleted": false + "priority": 1685921535 }, { "id": "RTCIceCandidate_oYROhau8", @@ -55,8 +52,7 @@ "protocol": "udp", "relayProtocol": "udp", "candidateType": "relay", - "priority": 41885951, - "deleted": false + "priority": 41885951 }, { "id": "RTCIceCandidate_qQRsdncV", @@ -69,8 +65,7 @@ "port": 50951, "protocol": "udp", "candidateType": "host", - "priority": 2122194687, - "deleted": false + "priority": 2122194687 }, { "id": "RTCIceCandidate_Di/J4tEz", @@ -82,8 +77,7 @@ "port": 19282, "protocol": "udp", "candidateType": "host", - "priority": 2130706431, - "deleted": false + "priority": 2130706431 } ], "selectedIceCandidatePairStats": { @@ -98,8 +92,7 @@ "port": 53585, "protocol": "udp", "candidateType": "srflx", - "priority": 1686052607, - "deleted": false + "priority": 1686052607 }, "remoteCandidate": { "id": "RTCIceCandidate_Di/J4tEz", @@ -111,8 +104,7 @@ "port": 19282, "protocol": "udp", "candidateType": "host", - "priority": 2130706431, - "deleted": false + "priority": 2130706431 } } } diff --git a/tests/typecheck/index.ts b/tests/typecheck/index.ts new file mode 100644 index 00000000..74f3d1a0 --- /dev/null +++ b/tests/typecheck/index.ts @@ -0,0 +1,15 @@ +import { Device } from '../../'; + +(async () => { + const device: Device = new Device('foo', {}); + + await device.register(); + + const call = await device.connect({ + params: { To: 'foo' } + }); + + device.audio?.disconnect(false); + device.audio?.incoming(false); + device.audio?.outgoing(false); +}); diff --git a/tests/unit/call.ts b/tests/unit/call.ts index 3e5b3793..823a7811 100644 --- a/tests/unit/call.ts +++ b/tests/unit/call.ts @@ -676,8 +676,13 @@ describe('Call', function() { }); [ + 'ack', 'answer', + 'cancel', + 'connected', + 'error', 'hangup', + 'message', 'ringing', 'transportClose', ].forEach((eventName: string) => { @@ -901,6 +906,11 @@ describe('Call', function() { sinon.assert.calledOnce(mediaHandler.reject); }); + it('should call mediaHandler.close', () => { + conn.reject(); + sinon.assert.calledOnce(mediaHandler.close); + }); + it('should emit cancel', (done) => { conn.on('reject', () => done()); conn.reject(); @@ -915,6 +925,40 @@ describe('Call', function() { conn.reject(); assert.equal(conn.status(), 'closed'); }); + + it('should not emit a disconnect event', () => { + const callback = sinon.stub(); + conn['_mediaHandler'].close = () => mediaHandler.onclose(); + conn.on('disconnect', callback); + conn.reject(); + clock.tick(10); + sinon.assert.notCalled(callback); + }); + + it('should not play disconnect sound', () => { + conn['_mediaHandler'].close = () => mediaHandler.onclose(); + conn.reject(); + clock.tick(10); + sinon.assert.notCalled(soundcache.get(Device.SoundName.Disconnect).play); + }); + + [ + 'ack', + 'answer', + 'cancel', + 'connected', + 'error', + 'hangup', + 'message', + 'ringing', + 'transportClose', + ].forEach((eventName: string) => { + it(`should call pstream.removeListener on ${eventName}`, () => { + conn.reject(); + clock.tick(10); + assert.equal(pstream.listenerCount(eventName), 0); + }); + }); }); [ @@ -938,6 +982,11 @@ describe('Call', function() { sinon.assert.notCalled(mediaHandler.reject); }); + it('should not call mediaHandler.close', () => { + conn.reject(); + sinon.assert.notCalled(mediaHandler.close); + }); + it('should not emit reject', () => { conn.on('reject', () => { throw new Error('Should not have emitted reject'); }); conn.reject(); @@ -1657,45 +1706,72 @@ describe('Call', function() { describe('pstream.answer event', () => { let pStreamAnswerPayload: any; - beforeEach(async () => { + beforeEach(() => { pStreamAnswerPayload = { edge: 'foobar-edge', reconnect: 'foobar-reconnect-token', }; - conn = new Call(config, options); - conn.accept(); - await clock.tickAsync(0); }); - it('should set the call to "answered"', () => { - pstream.emit('answer', pStreamAnswerPayload); - assert(conn['_isAnswered']); - }); + describe('for incoming calls', () => { + beforeEach(async () => { + // With a CallSid, this becomes an incoming call + const callParameters = { CallSid: 'CA1234' }; + conn = new Call(config, Object.assign(options, { callParameters })); + conn.accept(); + await clock.tickAsync(0); + }); - it('should save the reconnect token', () => { - pstream.emit('answer', pStreamAnswerPayload); - assert.equal(conn['_signalingReconnectToken'], pStreamAnswerPayload.reconnect); + it('should remove event handler after disconnect for an incoming call', () => { + pstream.emit('answer', pStreamAnswerPayload); + conn.disconnect(); + assert.strictEqual(pstream.listenerCount('answer'), 0); + }); }); - describe('if raised multiple times', () => { - it('should save the reconnect token multiple times', () => { - pstream.emit('answer', pStreamAnswerPayload); - assert.equal(conn['_signalingReconnectToken'], pStreamAnswerPayload.reconnect); + describe('for outgoing calls', () => { + beforeEach(async () => { + conn = new Call(config, options); + conn.accept(); + await clock.tickAsync(0); + }); - pStreamAnswerPayload.reconnect = 'biffbazz-reconnect-token'; + it('should set the call to "answered"', () => { + pstream.emit('answer', pStreamAnswerPayload); + assert(conn['_isAnswered']); + }); + it('should save the reconnect token', () => { pstream.emit('answer', pStreamAnswerPayload); assert.equal(conn['_signalingReconnectToken'], pStreamAnswerPayload.reconnect); }); - it('should not invoke "call._maybeTransitionToOpen" more than once', () => { - const spy = conn['_maybeTransitionToOpen'] = sinon.spy(conn['_maybeTransitionToOpen']); - + it('should remove event handler after disconnect for an outgoing call', () => { pstream.emit('answer', pStreamAnswerPayload); - sinon.assert.calledOnce(spy); + conn.disconnect(); + assert.strictEqual(pstream.listenerCount('answer'), 0); + }); - pstream.emit('answer', pStreamAnswerPayload); - sinon.assert.calledOnce(spy); + describe('if raised multiple times', () => { + it('should save the reconnect token multiple times', () => { + pstream.emit('answer', pStreamAnswerPayload); + assert.equal(conn['_signalingReconnectToken'], pStreamAnswerPayload.reconnect); + + pStreamAnswerPayload.reconnect = 'biffbazz-reconnect-token'; + + pstream.emit('answer', pStreamAnswerPayload); + assert.equal(conn['_signalingReconnectToken'], pStreamAnswerPayload.reconnect); + }); + + it('should not invoke "call._maybeTransitionToOpen" more than once', () => { + const spy = conn['_maybeTransitionToOpen'] = sinon.spy(conn['_maybeTransitionToOpen']); + + pstream.emit('answer', pStreamAnswerPayload); + sinon.assert.calledOnce(spy); + + pstream.emit('answer', pStreamAnswerPayload); + sinon.assert.calledOnce(spy); + }); }); }); }); diff --git a/tests/unit/device.ts b/tests/unit/device.ts index 528b1f08..2bf3a9bc 100644 --- a/tests/unit/device.ts +++ b/tests/unit/device.ts @@ -26,6 +26,7 @@ describe('Device', function() { let clock: SinonFakeTimers; let connectOptions: Record | undefined; let device: Device; + let enabledSounds: Record; let pstream: any; let publisher: any; let stub: SinonStubbedInstance; @@ -40,7 +41,11 @@ describe('Device', function() { const AudioHelper = (_updateSinkIds: Function, _updateInputStream: Function) => { updateInputStream = _updateInputStream; updateSinkIds = _updateSinkIds; - return audioHelper = createEmitterStub(require('../../lib/twilio/audiohelper').default); + const audioHelper = createEmitterStub(require('../../lib/twilio/audiohelper').default); + audioHelper.disconnect = () => enabledSounds[Device.SoundName.Disconnect]; + audioHelper.incoming = () => enabledSounds[Device.SoundName.Incoming]; + audioHelper.outgoing = () => enabledSounds[Device.SoundName.Outgoing]; + return audioHelper; }; const Call = (_?: any, _connectOptions?: Record) => { connectOptions = _connectOptions; @@ -63,6 +68,11 @@ describe('Device', function() { }); beforeEach(() => { + enabledSounds = { + [Device.SoundName.Disconnect]: true, + [Device.SoundName.Incoming]: true, + [Device.SoundName.Outgoing]: true, + }; pstream = null; publisher = null; clock = sinon.useFakeTimers(Date.now()); @@ -227,6 +237,16 @@ describe('Device', function() { activeCall.emit('accept'); sinon.assert.calledOnce(spy.play); }); + + it('should not play outgoing sound after accepted if disabled', async () => { + enabledSounds[Device.SoundName.Outgoing] = false; + const spy: any = { play: sinon.spy() }; + device['_soundcache'].set(Device.SoundName.Outgoing, spy); + await device.connect(); + activeCall._direction = 'OUTGOING'; + activeCall.emit('accept'); + sinon.assert.notCalled(spy.play); + }); }); describe('.destroy()', () => { @@ -670,7 +690,7 @@ describe('Device', function() { }); }); - it('should play the incoming sound', async () => { + it('should play the incoming sound if enabled', async () => { const spy = { play: sinon.spy() }; device['_soundcache'].set(Device.SoundName.Incoming, spy); pstream.emit('invite', { callsid: 'foo', sdp: 'bar' }); @@ -678,6 +698,15 @@ describe('Device', function() { sinon.assert.calledOnce(spy.play); }); + it('should not play the incoming sound if disabled', async () => { + enabledSounds[Device.SoundName.Incoming] = false; + const spy = { play: sinon.spy() }; + device['_soundcache'].set(Device.SoundName.Incoming, spy); + pstream.emit('invite', { callsid: 'foo', sdp: 'bar' }); + await clock.tickAsync(0); + sinon.assert.notCalled(spy.play); + }); + context('when allowIncomingWhileBusy is true', () => { beforeEach(async () => { device = new Device(token, { ...setupOptions, allowIncomingWhileBusy: true });