diff --git a/.circleci/config.yml b/.circleci/config.yml index 88569c0d..317f8edc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -78,12 +78,12 @@ commands: - store_artifacts: path: ./dist prefix: ./dist - unit-tests: + build-checks: steps: - build - run: - name: Running unit tests - command: npm run test:unit + name: Running build checks + command: npm run test:typecheck && npm run lint && npm run test:unit - store_artifacts: path: coverage destination: coverage @@ -105,9 +105,9 @@ commands: # Jobs ### jobs: - run-unit-tests: + run-build-checks: executor: docker-with-browser - steps: [unit-tests] + steps: [build-checks] run-network-tests: parameters: bver: @@ -174,10 +174,10 @@ workflows: - equal: [true, <>] - equal: [false, <>] jobs: - - run-unit-tests: + - run-build-checks: context: - vblocks-js - name: Unit Tests + name: Build Checks - run-integration-tests: name: Integration Tests <> <> context: @@ -200,10 +200,10 @@ workflows: release-workflow: when: <> jobs: - - run-unit-tests: + - run-build-checks: context: - vblocks-js - name: Unit Tests + name: Build Checks - run-integration-tests: context: - dockerhub-pulls @@ -230,7 +230,7 @@ workflows: name: Create Release Dry Run dryRun: true requires: - - Unit Tests + - Build Checks # NOTE(mhuynh): Temporarily allow release without these tests passing # # Chrome integration tests # - Integration Tests chrome beta diff --git a/CHANGELOG.md b/CHANGELOG.md index 192a007e..cb471fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +2.5.0 (May 9, 2023) +=================== + +New Features +------------ + +### WebRTC API Overrides (Beta) + +The SDK now allows you to override WebRTC APIs using the following options and events. If your environment supports WebRTC redirection, such as [Citrix HDX](https://www.citrix.com/solutions/vdi-and-daas/hdx/what-is-hdx.html)'s WebRTC [redirection technologies](https://www.citrix.com/blogs/2019/01/15/hdx-a-webrtc-manifesto/), your application can use this new *beta* feature for improved audio quality in those environments. + +- [Device.Options.enumerateDevices](https://twilio.github.io/twilio-voice.js/interfaces/voice.device.options.html#enumeratedevices) +- [Device.Options.getUserMedia](https://twilio.github.io/twilio-voice.js/interfaces/voice.device.options.html#getusermedia) +- [Device.Options.RTCPeerConnection](https://twilio.github.io/twilio-voice.js/interfaces/voice.device.options.html#rtcpeerconnection) +- [call.on('audio', handler(remoteAudio))](https://twilio.github.io/twilio-voice.js/classes/voice.call.html#audioevent) + 2.4.0 (April 6, 2023) =================== diff --git a/lib/twilio/audiohelper.ts b/lib/twilio/audiohelper.ts index 502abb99..f64b3886 100644 --- a/lib/twilio/audiohelper.ts +++ b/lib/twilio/audiohelper.ts @@ -99,6 +99,11 @@ class AudioHelper extends EventEmitter { [Device.SoundName.Outgoing]: true, }; + /** + * The enumerateDevices method to use + */ + private _enumerateDevices: any; + /** * The `getUserMedia()` function to use. */ @@ -174,9 +179,13 @@ class AudioHelper extends EventEmitter { this._getUserMedia = getUserMedia; this._mediaDevices = options.mediaDevices || getMediaDevicesInstance(); this._onActiveInputChanged = onActiveInputChanged; + this._enumerateDevices = typeof options.enumerateDevices === 'function' + ? options.enumerateDevices + : this._mediaDevices && this._mediaDevices.enumerateDevices; const isAudioContextSupported: boolean = !!(options.AudioContext || options.audioContext); - const isEnumerationSupported: boolean = !!(this._mediaDevices && this._mediaDevices.enumerateDevices); + const isEnumerationSupported: boolean = !!this._enumerateDevices; + const isSetSinkSupported: boolean = typeof options.setSinkId === 'function'; this.isOutputSelectionSupported = isEnumerationSupported && isSetSinkSupported; this.isVolumeSupported = isAudioContextSupported; @@ -282,7 +291,7 @@ class AudioHelper extends EventEmitter { * @private */ _unbind(): void { - if (!this._mediaDevices) { + if (!this._mediaDevices || !this._enumerateDevices) { throw new NotSupportedError('Enumeration is not supported'); } @@ -400,7 +409,7 @@ class AudioHelper extends EventEmitter { * Initialize output device enumeration. */ private _initializeEnumeration(): void { - if (!this._mediaDevices) { + if (!this._mediaDevices || !this._enumerateDevices) { throw new NotSupportedError('Enumeration is not supported'); } @@ -526,11 +535,11 @@ class AudioHelper extends EventEmitter { * Update the available input and output devices */ private _updateAvailableDevices = (): Promise => { - if (!this._mediaDevices) { + if (!this._mediaDevices || !this._enumerateDevices) { return Promise.reject('Enumeration not supported'); } - return this._mediaDevices.enumerateDevices().then((devices: MediaDeviceInfo[]) => { + return this._enumerateDevices().then((devices: MediaDeviceInfo[]) => { this._updateDevices(devices.filter((d: MediaDeviceInfo) => d.kind === 'audiooutput'), this.availableOutputDevices, this._removeLostOutput); @@ -618,8 +627,13 @@ class AudioHelper extends EventEmitter { this._inputVolumeSource.disconnect(); } - this._inputVolumeSource = this._audioContext.createMediaStreamSource(this._inputStream); - this._inputVolumeSource.connect(this._inputVolumeAnalyser); + try { + this._inputVolumeSource = this._audioContext.createMediaStreamSource(this._inputStream); + this._inputVolumeSource.connect(this._inputVolumeAnalyser); + } catch (ex) { + this._log.warn('Unable to update volume source', ex); + delete this._inputVolumeSource; + } } /** @@ -696,6 +710,11 @@ namespace AudioHelper { */ audioContext?: AudioContext; + /** + * Overrides the native MediaDevices.enumerateDevices API. + */ + enumerateDevices?: any; + /** * A custom MediaDevices instance to use. */ diff --git a/lib/twilio/call.ts b/lib/twilio/call.ts index 13d9cbbf..b375a459 100644 --- a/lib/twilio/call.ts +++ b/lib/twilio/call.ts @@ -378,6 +378,7 @@ class Call extends EventEmitter { this._mediaHandler = new (this._options.MediaHandler) (config.audioHelper, config.pstream, config.getUserMedia, { + RTCPeerConnection: this._options.RTCPeerConnection, codecPreferences: this._options.codecPreferences, dscp: this._options.dscp, forceAggressiveIceNomination: this._options.forceAggressiveIceNomination, @@ -395,6 +396,11 @@ class Call extends EventEmitter { this._latestOutputVolume = outputVolume; }); + this._mediaHandler.onaudio = (remoteAudio: typeof Audio) => { + this._log.info('Remote audio created'); + this.emit('audio', remoteAudio); + }; + this._mediaHandler.onvolume = (inputVolume: number, outputVolume: number, internalInputVolume: number, internalOutputVolume: number) => { // (rrowland) These values mock the 0 -> 32767 format used by legacy getStats. We should look into @@ -1510,6 +1516,14 @@ namespace Call { */ declare function acceptEvent(call: Call): void; + /** + * Emitted after the HTMLAudioElement for the remote audio is created. + * @param remoteAudio - The HTMLAudioElement. + * @example `call.on('audio', handler(remoteAudio))` + * @event + */ + declare function audioEvent(remoteAudio: HTMLAudioElement): void; + /** * Emitted when the {@link Call} is canceled. * @example `call.on('cancel', () => { })` @@ -1875,6 +1889,11 @@ namespace Call { */ rtcConstraints?: MediaStreamConstraints; + /** + * The RTCPeerConnection passed to {@link Device} on setup. + */ + RTCPeerConnection?: any; + /** * Whether the disconnect sound should be played. */ diff --git a/lib/twilio/device.ts b/lib/twilio/device.ts index 37d4dc74..8122f3fb 100644 --- a/lib/twilio/device.ts +++ b/lib/twilio/device.ts @@ -940,7 +940,7 @@ class Device extends EventEmitter { const config: Call.Config = { audioHelper: this._audio, - getUserMedia, + getUserMedia: this._options.getUserMedia || getUserMedia, isUnifiedPlanDefault: Device._isUnifiedPlanDefault, onIgnore: (): void => { this._soundcache.get(Device.SoundName.Incoming).stop(); @@ -952,6 +952,7 @@ class Device extends EventEmitter { options = Object.assign({ MediaStream: this._options.MediaStream || rtc.PeerConnection, + RTCPeerConnection: this._options.RTCPeerConnection, beforeAccept: (currentCall: Call) => { if (!this._activeCall || this._activeCall === currentCall) { return; @@ -969,7 +970,7 @@ class Device extends EventEmitter { maxAverageBitrate: this._options.maxAverageBitrate, preflight: this._options.preflight, rtcConstraints: this._options.rtcConstraints, - shouldPlayDisconnect: () => this.audio?.disconnect(), + shouldPlayDisconnect: () => this._audio?.disconnect(), twimlParams, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }, options); @@ -986,6 +987,12 @@ class Device extends EventEmitter { const call = new (this._options.Call || Call)(config, options); + this._publisher.info('settings', 'init', { + RTCPeerConnection: !!this._options.RTCPeerConnection, + enumerateDevices: !!this._options.enumerateDevices, + getUserMedia: !!this._options.getUserMedia, + }, call); + call.once('accept', () => { this._stream.updatePreferredURI(this._preferredURI); this._removeCall(call); @@ -994,7 +1001,7 @@ class Device extends EventEmitter { this._audio._maybeStartPollingVolume(); } - if (call.direction === Call.CallDirection.Outgoing && this.audio?.outgoing()) { + if (call.direction === Call.CallDirection.Outgoing && this._audio?.outgoing()) { this._soundcache.get(Device.SoundName.Outgoing).play(); } @@ -1204,7 +1211,7 @@ class Device extends EventEmitter { this._publishNetworkChange(); }); - const play = (this.audio?.incoming() && !wasBusy) + const play = (this._audio?.incoming() && !wasBusy) ? () => this._soundcache.get(Device.SoundName.Incoming).play() : () => Promise.resolve(); @@ -1310,8 +1317,11 @@ class Device extends EventEmitter { this._audio = new (this._options.AudioHelper || AudioHelper)( this._updateSinkIds, this._updateInputStream, - getUserMedia, - { audioContext: Device.audioContext }, + this._options.getUserMedia || getUserMedia, + { + audioContext: Device.audioContext, + enumerateDevices: this._options.enumerateDevices, + }, ); this._audio.on('deviceChange', (lostActiveDevices: MediaDeviceInfo[]) => { @@ -1687,12 +1697,22 @@ namespace Device { */ edge?: string[] | string; + /** + * Overrides the native MediaDevices.enumerateDevices API. + */ + enumerateDevices?: any; + /** * Experimental feature. * Whether to use ICE Aggressive nomination. */ forceAggressiveIceNomination?: boolean; + /** + * Overrides the native MediaDevices.getUserMedia API. + */ + getUserMedia?: any; + /** * Log level. */ @@ -1724,6 +1744,46 @@ namespace Device { */ maxCallSignalingTimeoutMs?: number; + /** + * Overrides the native RTCPeerConnection class. + * + * By default, the SDK will use the `unified-plan` SDP format if the browser supports it. + * Unexpected behavior may happen if the `RTCPeerConnection` parameter uses an SDP format + * that is different than what the SDK uses. + * + * For example, if the browser supports `unified-plan` and the `RTCPeerConnection` + * parameter uses `plan-b` by default, the SDK will use `unified-plan` + * which will cause conflicts with the usage of the `RTCPeerConnection`. + * + * In order to avoid this issue, you need to explicitly set the SDP format that you want + * the SDK to use with the `RTCPeerConnection` via [[Device.ConnectOptions.rtcConfiguration]] for outgoing calls. + * Or [[Call.AcceptOptions.rtcConfiguration]] for incoming calls. + * + * See the example below. Assuming the `RTCPeerConnection` you provided uses `plan-b` by default, the following + * code sets the SDP format to `unified-plan` instead. + * + * ```ts + * // Outgoing calls + * const call = await device.connect({ + * rtcConfiguration: { + * sdpSemantics: 'unified-plan' + * } + * // Other options + * }); + * + * // Incoming calls + * device.on('incoming', call => { + * call.accept({ + * rtcConfiguration: { + * sdpSemantics: 'unified-plan' + * } + * // Other options + * }); + * }); + * ``` + */ + RTCPeerConnection?: any; + /** * A mapping of custom sound URLs by sound name. */ diff --git a/lib/twilio/log.ts b/lib/twilio/log.ts index 8be43587..ad25b0be 100644 --- a/lib/twilio/log.ts +++ b/lib/twilio/log.ts @@ -54,7 +54,13 @@ class Log { * @param [options] - Optional settings */ constructor(options?: LogOptions) { - this._log = (options && options.LogLevelModule ? options.LogLevelModule : LogLevelModule).getLogger(PACKAGE_NAME); + try { + this._log = (options && options.LogLevelModule ? options.LogLevelModule : LogLevelModule).getLogger(PACKAGE_NAME); + } catch { + // tslint:disable-next-line + console.warn('Cannot create custom logger'); + this._log = console as any; + } } /** @@ -93,7 +99,12 @@ class Log { * Set a default log level to disable all logging below the given level */ setDefaultLevel(level: LogLevelModule.LogLevelDesc): void { - this._log.setDefaultLevel(level); + if (this._log.setDefaultLevel) { + this._log.setDefaultLevel(level); + } else { + // tslint:disable-next-line + console.warn('Logger cannot setDefaultLevel'); + } } /** diff --git a/lib/twilio/rtc/peerconnection.js b/lib/twilio/rtc/peerconnection.js index f0fd8e30..3028a836 100644 --- a/lib/twilio/rtc/peerconnection.js +++ b/lib/twilio/rtc/peerconnection.js @@ -33,6 +33,7 @@ function PeerConnection(audioHelper, pstream, getUserMedia, options) { } function noop() { } + this.onaudio = noop; this.onopen = noop; this.onerror = noop; this.onclose = noop; @@ -228,9 +229,14 @@ PeerConnection.prototype._updateInputStreamSource = function(stream) { this._inputStreamSource.disconnect(); } - this._inputStreamSource = this._audioContext.createMediaStreamSource(stream); - this._inputStreamSource.connect(this._inputAnalyser); - this._inputStreamSource.connect(this._inputAnalyser2); + try { + this._inputStreamSource = this._audioContext.createMediaStreamSource(stream); + this._inputStreamSource.connect(this._inputAnalyser); + this._inputStreamSource.connect(this._inputAnalyser2); + } catch (ex) { + this._log.warn('Unable to update input MediaStreamSource', ex); + this._inputStreamSource = null; + } }; /** @@ -243,9 +249,14 @@ PeerConnection.prototype._updateOutputStreamSource = function(stream) { this._outputStreamSource.disconnect(); } - this._outputStreamSource = this._audioContext.createMediaStreamSource(stream); - this._outputStreamSource.connect(this._outputAnalyser); - this._outputStreamSource.connect(this._outputAnalyser2); + try { + this._outputStreamSource = this._audioContext.createMediaStreamSource(stream); + this._outputStreamSource.connect(this._outputAnalyser); + this._outputStreamSource.connect(this._outputAnalyser2); + } catch (ex) { + this._log.warn('Unable to update output MediaStreamSource', ex); + this._outputStreamSource = null; + } }; /** @@ -467,15 +478,20 @@ PeerConnection.prototype._updateAudioOutputs = function updateAudioOutputs() { }; PeerConnection.prototype._createAudio = function createAudio(arr) { - return new Audio(arr); + const audio = new Audio(arr); + this.onaudio(audio); + return audio; }; PeerConnection.prototype._createAudioOutput = function createAudioOutput(id) { - const dest = this._audioContext.createMediaStreamDestination(); - this._mediaStreamSource.connect(dest); + let dest = null; + if (this._mediaStreamSource) { + dest = this._audioContext.createMediaStreamDestination(); + this._mediaStreamSource.connect(dest); + } const audio = this._createAudio(); - setAudioSource(audio, dest.stream); + setAudioSource(audio, dest && dest.stream ? dest.stream : this.pcStream); const self = this; return audio.setSinkId(id).then(() => audio.play()).then(() => { @@ -577,7 +593,12 @@ PeerConnection.prototype._onAddTrack = function onAddTrack(pc, stream) { audio }); - pc._mediaStreamSource = pc._audioContext.createMediaStreamSource(stream); + try { + pc._mediaStreamSource = pc._audioContext.createMediaStreamSource(stream); + } catch (ex) { + this._log.warn('Unable to create a MediaStreamSource from onAddTrack', ex); + this._mediaStreamSource = null; + } pc.pcStream = stream; pc._updateAudioOutputs(); @@ -630,7 +651,7 @@ PeerConnection.prototype._setEncodingParameters = function(enableDscp) { PeerConnection.prototype._setupPeerConnection = function(rtcConstraints, rtcConfiguration) { const self = this; - const version = new (this.options.rtcpcFactory || RTCPC)(); + const version = new (this.options.rtcpcFactory || RTCPC)({ RTCPeerConnection: this.options.RTCPeerConnection }); version.create(rtcConstraints, rtcConfiguration); addStream(version.pc, this.stream); @@ -690,10 +711,21 @@ PeerConnection.prototype._setupChannel = function() { }; // Chrome 72+ - pc.onconnectionstatechange = () => { - this._log.info(`pc.connectionState is "${pc.connectionState}"`); - this.onpcconnectionstatechange(pc.connectionState); - this._onMediaConnectionStateChange(pc.connectionState); + pc.onconnectionstatechange = event => { + let state = pc.connectionState; + if (!state && event && event.target) { + // VDI environment + const targetPc = event.target; + state = targetPc.connectionState || targetPc.connectionState_; + this._log.info(`pc.connectionState not detected. Using target PC. State=${state}`); + } + if (!state) { + this._log.warn(`onconnectionstatechange detected but state is "${state}"`); + } else { + this._log.info(`pc.connectionState is "${state}"`); + } + this.onpcconnectionstatechange(state); + this._onMediaConnectionStateChange(state); }; pc.onicecandidate = event => { diff --git a/lib/twilio/rtc/rtcpc.js b/lib/twilio/rtc/rtcpc.js index 431f47b5..d9fe1483 100644 --- a/lib/twilio/rtc/rtcpc.js +++ b/lib/twilio/rtc/rtcpc.js @@ -4,13 +4,15 @@ const Log = require('../log').default; const { setCodecPreferences, setMaxAverageBitrate } = require('./sdp'); const util = require('../util'); -function RTCPC() { +function RTCPC(options) { if (typeof window === 'undefined') { this.log.info('No RTCPeerConnection implementation available. The window object was not found.'); return; } - if (util.isLegacyEdge()) { + if (options && options.RTCPeerConnection) { + this.RTCPeerConnection = options.RTCPeerConnection; + } else if (util.isLegacyEdge()) { this.RTCPeerConnection = new RTCPeerConnectionShim(typeof window !== 'undefined' ? window : global); } else if (typeof window.RTCPeerConnection === 'function') { this.RTCPeerConnection = window.RTCPeerConnection; @@ -157,12 +159,21 @@ RTCPC.test = () => { return false; }; -function promisify(fn, ctx, areCallbacksFirst) { +function promisify(fn, ctx, areCallbacksFirst, checkRval) { return function() { const args = Array.prototype.slice.call(arguments); return new Promise(resolve => { - resolve(fn.apply(ctx, args)); + const returnValue = fn.apply(ctx, args); + if (!checkRval) { + resolve(returnValue); + return; + } + if (typeof returnValue === 'object' && typeof returnValue.then === 'function') { + resolve(returnValue); + } else { + throw new Error(); + } }).catch(() => new Promise((resolve, reject) => { fn.apply(ctx, areCallbacksFirst ? [resolve, reject].concat(args) @@ -172,11 +183,11 @@ function promisify(fn, ctx, areCallbacksFirst) { } function promisifyCreate(fn, ctx) { - return promisify(fn, ctx, true); + return promisify(fn, ctx, true, true); } function promisifySet(fn, ctx) { - return promisify(fn, ctx, false); + return promisify(fn, ctx, false, false); } module.exports = RTCPC; diff --git a/lib/twilio/rtc/stats.js b/lib/twilio/rtc/stats.js index 547f0584..c477955c 100644 --- a/lib/twilio/rtc/stats.js +++ b/lib/twilio/rtc/stats.js @@ -5,6 +5,19 @@ const MockRTCStatsReport = require('./mockrtcstatsreport'); const ERROR_PEER_CONNECTION_NULL = 'PeerConnection is null'; const ERROR_WEB_RTC_UNSUPPORTED = 'WebRTC statistics are unsupported'; +/** + * Helper function to find a specific stat from a report. + * Some environment provide the stats report as a map (regular browsers) + * but some provide stats report as an array (citrix vdi) + * @private + */ +function findStatById(report, id) { + if (typeof report.get === 'function') { + return report.get(id); + } + return report.find(s => s.id === id); +} + /** * Generate WebRTC statistics report for the given {@link PeerConnection} * @param {PeerConnection} peerConnection - Target connection. @@ -151,7 +164,7 @@ function createRTCSample(statsReport) { // currently coming in on remote-outbound-rtp, so I'm leaving this outside the switch until // the appropriate place to look is cleared up. if (stats.remoteId) { - const remote = statsReport.get(stats.remoteId); + const remote = findStatById(statsReport, stats.remoteId); if (remote && remote.roundTripTime) { sample.rtt = remote.roundTripTime * 1000; } @@ -172,7 +185,7 @@ function createRTCSample(statsReport) { sample.bytesSent = stats.bytesSent; if (stats.codecId) { - const codec = statsReport.get(stats.codecId); + const codec = findStatById(statsReport, stats.codecId); sample.codecName = codec ? codec.mimeType && codec.mimeType.match(/(.*\/)?(.*)/)[2] : stats.codecId; @@ -189,14 +202,14 @@ function createRTCSample(statsReport) { sample.timestamp = fallbackTimestamp; } - const activeTransport = statsReport.get(activeTransportId); + const activeTransport = findStatById(statsReport, activeTransportId); if (!activeTransport) { return sample; } - const selectedCandidatePair = statsReport.get(activeTransport.selectedCandidatePairId); + const selectedCandidatePair = findStatById(statsReport, activeTransport.selectedCandidatePairId); if (!selectedCandidatePair) { return sample; } - const localCandidate = statsReport.get(selectedCandidatePair.localCandidateId); - const remoteCandidate = statsReport.get(selectedCandidatePair.remoteCandidateId); + const localCandidate = findStatById(statsReport, selectedCandidatePair.localCandidateId); + const remoteCandidate = findStatById(statsReport, selectedCandidatePair.remoteCandidateId); if (!sample.rtt) { sample.rtt = selectedCandidatePair && diff --git a/package-lock.json b/package-lock.json index d706d759..eeef94c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@twilio/voice-sdk", - "version": "2.4.0-dev", + "version": "2.5.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@twilio/voice-sdk", - "version": "2.4.0-dev", + "version": "2.5.0-dev", "license": "Apache-2.0", "dependencies": { "@twilio/audioplayer": "1.0.6", diff --git a/package.json b/package.json index c1146bfa..fbd812cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@twilio/voice-sdk", - "version": "2.4.1-dev", + "version": "2.5.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 test:typecheck", + "build": "npm-run-all clean build:constants build:errors docs:ts build:es5 build:ts build:dist build:dist-min", "build:errors": "node ./scripts/errors.js", "build:es5": "rimraf ./es5 && babel lib -d es5", "build:dev": "ENV=dev npm run build", diff --git a/tests/unit/device.ts b/tests/unit/device.ts index 2bf3a9bc..56d38451 100644 --- a/tests/unit/device.ts +++ b/tests/unit/device.ts @@ -1042,7 +1042,7 @@ describe('Device', function() { }); it('should publish a speaker-devices-set event', () => { - sinon.assert.calledOnce(publisher.info); + sinon.assert.calledTwice(publisher.info); sinon.assert.calledWith(publisher.info, 'audio', 'speaker-devices-set', { audio_device_ids: sinkIds }); }); @@ -1085,7 +1085,7 @@ describe('Device', function() { }); it('should publish a ringtone-devices-set event', () => { - sinon.assert.calledOnce(publisher.info); + sinon.assert.calledTwice(publisher.info); sinon.assert.calledWith(publisher.info, 'audio', 'ringtone-devices-set', { audio_device_ids: sinkIds }); });