diff --git a/CHANGELOG.md b/CHANGELOG.md index b01fbde1a..d06c06492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - [docs] Added changelog in PR [#1230](https://github.com/Microsoft/BotFramework-Emulator/pull/1230) -- [style] 💅 integrate prettier and eslint ([#1240](https://github.com/Microsoft/BotFramework-Emulator/pull/1240)) +- [style] 💅 Integrated prettier and eslint in PR [#1240](https://github.com/Microsoft/BotFramework-Emulator/pull/1240) +- [feat] Added app-wide instrumentation in PR [#1251](https://github.com/Microsoft/BotFramework-Emulator/pull/1251) diff --git a/package-lock.json b/package-lock.json index 76739f57d..daee7b7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2827,6 +2827,16 @@ "default-require-extensions": "^1.0.0" } }, + "applicationinsights": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-1.0.8.tgz", + "integrity": "sha512-KzOOGdphOS/lXWMFZe5440LUdFbrLpMvh2SaRxn7BmiI550KAoSb2gIhiq6kJZ9Ir3AxRRztjhzif+e5P5IXIg==", + "requires": { + "diagnostic-channel": "0.2.0", + "diagnostic-channel-publishers": "0.2.1", + "zone.js": "0.7.6" + } + }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -6882,6 +6892,19 @@ "wrappy": "1" } }, + "diagnostic-channel": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz", + "integrity": "sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc=", + "requires": { + "semver": "^5.3.0" + } + }, + "diagnostic-channel-publishers": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.2.1.tgz", + "integrity": "sha1-ji1geottef6IC1SLxYzGvrKIxPM=" + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -23001,6 +23024,11 @@ "requires": { "fd-slicer": "~1.0.1" } + }, + "zone.js": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.7.6.tgz", + "integrity": "sha1-+7w50+AmHQmG8boGMG6zrrDSIAk=" } } } diff --git a/packages/app/client/src/commands/botCommands.spec.ts b/packages/app/client/src/commands/botCommands.spec.ts index 7cf05d88b..6a239890d 100644 --- a/packages/app/client/src/commands/botCommands.spec.ts +++ b/packages/app/client/src/commands/botCommands.spec.ts @@ -87,12 +87,25 @@ describe('The bot commands', () => { }); it('should make the appropriate calls to switch bots', () => { + const remoteCallArgs = []; + CommandServiceImpl.remoteCall = async (...args: any[]) => { + remoteCallArgs.push(args); + return true; + }; const spy = jest.spyOn(ActiveBotHelper, 'confirmAndSwitchBots'); const { handler } = registry.getCommand( SharedConstants.Commands.Bot.Switch ); handler({}); expect(spy).toHaveBeenCalledWith({}); + expect(remoteCallArgs[0][0]).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(remoteCallArgs[0][1]).toBe('bot_open'); + expect(remoteCallArgs[0][2]).toEqual({ + method: 'bots_list', + numOfServices: undefined, + }); }); it('should make the appropriate calls to close a bot', () => { diff --git a/packages/app/client/src/commands/botCommands.ts b/packages/app/client/src/commands/botCommands.ts index 9233839c9..e62b51fa5 100644 --- a/packages/app/client/src/commands/botCommands.ts +++ b/packages/app/client/src/commands/botCommands.ts @@ -60,8 +60,17 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { // Switches the current active bot commandRegistry.registerCommand( Commands.Bot.Switch, - (bot: BotConfigWithPath | string) => - ActiveBotHelper.confirmAndSwitchBots(bot) + (bot: BotConfigWithPath | string) => { + let numOfServices; + if (typeof bot !== 'string') { + numOfServices = bot.services && bot.services.length; + } + CommandServiceImpl.remoteCall(Commands.Telemetry.TrackEvent, 'bot_open', { + method: 'bots_list', + numOfServices, + }).catch(_e => void 0); + return ActiveBotHelper.confirmAndSwitchBots(bot); + } ); // --------------------------------------------------------------------------- diff --git a/packages/app/client/src/commands/emulatorCommands.spec.ts b/packages/app/client/src/commands/emulatorCommands.spec.ts index 1d518391d..b824564b1 100644 --- a/packages/app/client/src/commands/emulatorCommands.spec.ts +++ b/packages/app/client/src/commands/emulatorCommands.spec.ts @@ -127,7 +127,9 @@ describe('The emulator commands', () => { const remoteCallSpy = jest .spyOn(CommandServiceImpl, 'remoteCall') .mockResolvedValue('transcript.transcript'); - const callSpy = jest.spyOn(CommandServiceImpl, 'call'); + const callSpy = jest + .spyOn(CommandServiceImpl, 'call') + .mockResolvedValue(null); await handler(); @@ -140,6 +142,11 @@ describe('The emulator commands', () => { title: 'Open transcript file', } ); + expect(remoteCallSpy).toHaveBeenCalledWith( + SharedConstants.Commands.Telemetry.TrackEvent, + 'transcriptFile_open', + { method: 'file_menu' } + ); expect(callSpy).toHaveBeenCalledWith( 'transcript:open', diff --git a/packages/app/client/src/commands/emulatorCommands.ts b/packages/app/client/src/commands/emulatorCommands.ts index 221860fa5..38df04271 100644 --- a/packages/app/client/src/commands/emulatorCommands.ts +++ b/packages/app/client/src/commands/emulatorCommands.ts @@ -35,6 +35,7 @@ import { newNotification, SharedConstants } from '@bfemulator/app-shared'; import { Activity, CommandRegistryImpl, + isLocalHostUrl, uniqueId, } from '@bfemulator/sdk-shared'; import { IEndpointService } from 'botframework-config/lib/schema'; @@ -49,7 +50,10 @@ import { CommandServiceImpl } from '../platform/commands/commandServiceImpl'; /** Registers emulator (actual conversation emulation logic) commands */ export function registerCommands(commandRegistry: CommandRegistryImpl) { - const { Emulator } = SharedConstants.Commands; + const { + Emulator, + Telemetry: { TrackEvent }, + } = SharedConstants.Commands; // --------------------------------------------------------------------------- // Open a new emulator tabbed document @@ -79,6 +83,12 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { ); } + if (!isLocalHostUrl(endpoint.endpoint)) { + CommandServiceImpl.remoteCall(TrackEvent, 'livechat_openRemote').catch( + _e => void 0 + ); + } + store.dispatch( EditorActions.open({ contentType: Constants.CONTENT_TYPE_LIVE_CHAT, @@ -140,6 +150,9 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { dialogOptions ); await CommandServiceImpl.call(Emulator.OpenTranscript, filename); + CommandServiceImpl.remoteCall(TrackEvent, 'transcriptFile_open', { + method: 'file_menu', + }).catch(_e => void 0); } catch (e) { const errMsg = `Error while opening transcript file: ${e}`; const notification = newNotification(errMsg); diff --git a/packages/app/client/src/commands/uiCommands.spec.ts b/packages/app/client/src/commands/uiCommands.spec.ts index 0fbb1dd42..e2b0bdbbf 100644 --- a/packages/app/client/src/commands/uiCommands.spec.ts +++ b/packages/app/client/src/commands/uiCommands.spec.ts @@ -57,8 +57,10 @@ import { OpenBotDialogContainer, SecretPromptDialogContainer, } from '../ui/dialogs'; +import { CommandServiceImpl } from '../platform/commands/commandServiceImpl'; import { registerCommands } from './uiCommands'; + jest.mock('../ui/dialogs', () => ({ AzureLoginPromptDialogContainer: class {}, AzureLoginSuccessDialogContainer: class {}, @@ -153,10 +155,16 @@ describe('the uiCommands', () => { }); it('should set the proper href on the theme tag when the SwitchTheme command is dispatched', () => { + const remoteCallSpy = jest.spyOn(CommandServiceImpl, 'remoteCall'); const link = document.createElement('link'); link.id = 'themeVars'; document.querySelector('head').appendChild(link); registry.getCommand(Commands.SwitchTheme).handler('light', './light.css'); expect(link.href).toBe('http://localhost/light.css'); + expect(remoteCallSpy).toHaveBeenCalledWith( + SharedConstants.Commands.Telemetry.TrackEvent, + 'app_chooseTheme', + { themeName: 'light' } + ); }); }); diff --git a/packages/app/client/src/commands/uiCommands.ts b/packages/app/client/src/commands/uiCommands.ts index 6f215d207..784f903fe 100644 --- a/packages/app/client/src/commands/uiCommands.ts +++ b/packages/app/client/src/commands/uiCommands.ts @@ -51,6 +51,7 @@ import { switchTheme } from '../data/action/themeActions'; import { showWelcomePage } from '../data/editorHelpers'; import { AzureAuthState } from '../data/reducer/azureAuthReducer'; import { store } from '../data/store'; +import { CommandServiceImpl } from '../platform/commands/commandServiceImpl'; import { AzureLoginFailedDialogContainer, AzureLoginPromptDialogContainer, @@ -67,7 +68,7 @@ import { /** Register UI commands (toggling UI) */ export function registerCommands(commandRegistry: CommandRegistry) { - const { UI } = SharedConstants.Commands; + const { UI, Telemetry } = SharedConstants.Commands; // --------------------------------------------------------------------------- // Shows the welcome page @@ -136,6 +137,9 @@ export function registerCommands(commandRegistry: CommandRegistry) { link => link.href ); // href is fully qualified store.dispatch(switchTheme(themeName, themeComponents)); + CommandServiceImpl.remoteCall(Telemetry.TrackEvent, 'app_chooseTheme', { + themeName, + }).catch(_e => void 0); } ); diff --git a/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts b/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts index 756805842..d1aac49e5 100644 --- a/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts +++ b/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts @@ -182,6 +182,10 @@ describe('The azureAuthSaga', () => { ct++; } expect(ct).toBe(5); + expect(remoteCallSpy).toHaveBeenCalledWith( + SharedConstants.Commands.Telemetry.TrackEvent, + 'signIn_failure' + ); }); it('should contain 6 steps when the Azure login dialog prompt is confirmed and auth succeeds', async () => { @@ -257,6 +261,10 @@ describe('The azureAuthSaga', () => { expect(store.getState().azureAuth.access_token).toBe( 'a valid access_token' ); + expect(remoteCallSpy).toHaveBeenCalledWith( + SharedConstants.Commands.Telemetry.TrackEvent, + 'signIn_success' + ); }); }); }); diff --git a/packages/app/client/src/data/sagas/azureAuthSaga.ts b/packages/app/client/src/data/sagas/azureAuthSaga.ts index c93c8d088..6cbfcac79 100644 --- a/packages/app/client/src/data/sagas/azureAuthSaga.ts +++ b/packages/app/client/src/data/sagas/azureAuthSaga.ts @@ -66,6 +66,7 @@ export function* getArmToken( RetrieveArmToken, PersistAzureLoginChanged, } = SharedConstants.Commands.Azure; + const { TrackEvent } = SharedConstants.Commands.Telemetry; azureAuth = yield call( CommandServiceImpl.remoteCall.bind(CommandServiceImpl), RetrieveArmToken @@ -80,8 +81,14 @@ export function* getArmToken( PersistAzureLoginChanged, persistLogin ); + CommandServiceImpl.remoteCall(TrackEvent, 'signIn_success').catch( + _e => void 0 + ); } else { yield DialogService.showDialog(action.payload.loginFailedDialog); + CommandServiceImpl.remoteCall(TrackEvent, 'signIn_failure').catch( + _e => void 0 + ); } yield put(azureArmTokenDataChanged(azureAuth.access_token)); return azureAuth; diff --git a/packages/app/client/src/data/sagas/resourceSagas.spec.ts b/packages/app/client/src/data/sagas/resourceSagas.spec.ts index 49f6b61b4..cb38bd66d 100644 --- a/packages/app/client/src/data/sagas/resourceSagas.spec.ts +++ b/packages/app/client/src/data/sagas/resourceSagas.spec.ts @@ -241,7 +241,7 @@ describe('The ResourceSagas', () => { }); }); - describe(',when opening the resource in the Emulator', () => { + describe('when opening the resource in the Emulator', () => { let mockResource; beforeEach(() => { mockResource = BotConfigWithPathImpl.serviceFromJSON({ @@ -259,6 +259,12 @@ describe('The ResourceSagas', () => { args: ['the/file/path/chat.chat', true], }, ]); + expect(mockRemoteCommandsCalled).toEqual([ + { + commandName: SharedConstants.Commands.Telemetry.TrackEvent, + args: ['chatFile_open'], + }, + ]); }); it('should open a transcript file', async () => { @@ -270,6 +276,12 @@ describe('The ResourceSagas', () => { args: ['the/file/path/transcript.transcript'], }, ]); + expect(mockRemoteCommandsCalled).toEqual([ + { + commandName: SharedConstants.Commands.Telemetry.TrackEvent, + args: ['transcriptFile_open', { method: 'resources_pane' }], + }, + ]); }); }); diff --git a/packages/app/client/src/data/sagas/resourcesSagas.ts b/packages/app/client/src/data/sagas/resourcesSagas.ts index 2dd0bbb6c..5d1150124 100644 --- a/packages/app/client/src/data/sagas/resourcesSagas.ts +++ b/packages/app/client/src/data/sagas/resourcesSagas.ts @@ -129,11 +129,18 @@ function* doOpenResource( action: ResourcesAction ): IterableIterator { const { OpenChatFile, OpenTranscript } = SharedConstants.Commands.Emulator; + const { TrackEvent } = SharedConstants.Commands.Telemetry; const { path } = action.payload; if (isChatFile(path)) { yield CommandServiceImpl.call(OpenChatFile, path, true); + CommandServiceImpl.remoteCall(TrackEvent, 'chatFile_open').catch( + _e => void 0 + ); } else if (isTranscriptFile(path)) { yield CommandServiceImpl.call(OpenTranscript, path); + CommandServiceImpl.remoteCall(TrackEvent, 'transcriptFile_open', { + method: 'resources_pane', + }).catch(_e => void 0); } // unknown types just fall into the abyss } diff --git a/packages/app/client/src/hyperlinkHandler.ts b/packages/app/client/src/hyperlinkHandler.ts index 201f28f97..5d36fc249 100644 --- a/packages/app/client/src/hyperlinkHandler.ts +++ b/packages/app/client/src/hyperlinkHandler.ts @@ -41,6 +41,7 @@ const Electron = (window as any).require('electron'); const { shell } = Electron; export function navigate(url: string) { + const { TrackEvent } = SharedConstants.Commands.Telemetry; try { const parsed = URL.parse(url) || { protocol: '' }; if ((parsed.protocol || '').startsWith('oauth:')) { @@ -48,9 +49,15 @@ export function navigate(url: string) { } else if (parsed.protocol.startsWith('oauthlink:')) { navigateOAuthUrl(url.substring(12)); } else { + CommandServiceImpl.remoteCall(TrackEvent, 'app_openLink', { url }).catch( + _e => void 0 + ); shell.openExternal(url, { activate: true }); } } catch (e) { + CommandServiceImpl.remoteCall(TrackEvent, 'app_openLink', { url }).catch( + _e => void 0 + ); shell.openExternal(url, { activate: true }); } } diff --git a/packages/app/client/src/inspector-preload.js b/packages/app/client/src/inspector-preload.js index 0dcc42258..bfeeb7e03 100644 --- a/packages/app/client/src/inspector-preload.js +++ b/packages/app/client/src/inspector-preload.js @@ -105,6 +105,10 @@ window.host = { } }, + trackEvent: function(name, properties) { + ipcRenderer.sendToHost('track-event', name, properties); + }, + dispatch: function(event, ...args) { this.handlers[event].forEach(handler => handler(...args)); }, diff --git a/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx b/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx index f399ebf8e..c01975c27 100644 --- a/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx +++ b/packages/app/client/src/ui/editor/appSettingsEditor/appSettingsEditor.tsx @@ -73,6 +73,7 @@ interface AppSettingsEditorState { const defaultAppSettings: FrameworkSettings = { autoUpdate: true, bypassNgrokLocalhost: true, + collectUsageData: true, locale: '', localhost: '', ngrokPath: '', @@ -183,9 +184,10 @@ export class AppSettingsEditor extends React.Component< Sign-in Application Updates + Data Collection + + + Learn more. + @@ -259,16 +280,19 @@ export class AppSettingsEditor extends React.Component< ); } - private onChangeAutoInstallUpdates = (): void => { - this.setUncommittedState({ - autoUpdate: !this.state.uncommitted.autoUpdate, - }); - }; - - private onChangeUsePrereleases = (): void => { - this.setUncommittedState({ - usePrereleases: !this.state.uncommitted.usePrereleases, - }); + private onChangeCheckBox = (event: ChangeEvent) => { + const { target } = event; + const settingsProperty = target.getAttribute('name'); + const uncommittedState = Object.create( + {}, + { + [settingsProperty]: { + get: () => !this.state.uncommitted[settingsProperty], + enumerable: true, // important since rest spread is used. + }, + } + ); + this.setUncommittedState(uncommittedState); }; private setUncommittedState(patch: any) { @@ -322,6 +346,7 @@ export class AppSettingsEditor extends React.Component< locale: uncommitted.locale.trim(), usePrereleases: uncommitted.usePrereleases, autoUpdate: uncommitted.autoUpdate, + collectUsageData: uncommitted.collectUsageData, }; CommandServiceImpl.remoteCall(Commands.Settings.SaveAppSettings, settings) @@ -333,24 +358,6 @@ export class AppSettingsEditor extends React.Component< }); }; - private onChangeAuthTokenVersion = (): void => { - this.setUncommittedState({ - use10Tokens: !this.state.uncommitted.use10Tokens, - }); - }; - - private onChangeUseValidationToken = (): void => { - this.setUncommittedState({ - useCodeValidation: !this.state.uncommitted.useCodeValidation, - }); - }; - - private onChangeNgrokBypass = (): void => { - this.setUncommittedState({ - bypassNgrokLocalhost: !this.state.uncommitted.bypassNgrokLocalhost, - }); - }; - private onInputChange = (event: ChangeEvent): void => { const { value } = event.target; const { prop } = event.target.dataset; diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx new file mode 100644 index 000000000..10d153e95 --- /dev/null +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -0,0 +1,443 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import { mount, shallow } from 'enzyme'; +import { SharedConstants } from '@bfemulator/app-shared'; +import base64Url from 'base64url'; + +import { disable, enable } from '../../../data/action/presentationActions'; +import { + clearLog, + newConversation, + setInspectorObjects, + updateChat, +} from '../../../data/action/chatActions'; +import { updateDocument } from '../../../data/action/editorActions'; + +import { + Emulator, + EmulatorComponent, + RestartConversationOptions, +} from './emulator'; + +const { encode } = base64Url; + +let mockCallsMade, mockRemoteCallsMade; +const mockSharedConstants = SharedConstants; +jest.mock('../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + call: (commandName, ...args) => { + mockCallsMade.push({ commandName, args }); + return Promise.resolve(); + }, + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + if (commandName === mockSharedConstants.Commands.Emulator.NewTranscript) { + return Promise.resolve({ conversationId: 'someConvoId' }); + } + if ( + commandName === + mockSharedConstants.Commands.Emulator.FeedTranscriptFromDisk + ) { + return Promise.resolve({ meta: 'some file info' }); + } + return Promise.resolve(); + }, + }, +})); +jest.mock('./chatPanel/chatPanel', () => { + return jest.fn(() =>
); +}); +jest.mock('./logPanel/logPanel', () => { + return jest.fn(() =>
); +}); +jest.mock('./playbackBar/playbackBar', () => { + return jest.fn(() =>
); +}); +jest.mock('./emulator.scss', () => ({})); +jest.mock('./parts', () => { + return jest.fn(() =>
); +}); +jest.mock('./toolbar/toolbar', () => { + return jest.fn(() =>
); +}); +jest.mock('@bfemulator/sdk-shared', () => ({ + uniqueId: () => 'someUniqueId', + uniqueIdv4: () => 'newUserId', +})); + +jest.mock('botframework-webchat', () => ({ + createDirectLine: (...args) => ({ args }), +})); + +describe('', () => { + let wrapper; + let node; + let instance; + let mockDispatch; + let mockStoreState; + + beforeEach(() => { + mockCallsMade = []; + mockRemoteCallsMade = []; + mockStoreState = { + chat: { + chats: { + doc1: { + conversationId: 'convo1', + documentId: 'doc1', + endpointId: 'endpoint1', + }, + }, + }, + editor: { + activeEditor: 'primary', + editors: { + primary: { + activeDocumentId: 'doc1', + }, + }, + }, + presentation: { enabled: true }, + }; + const mockStore = createStore((_state, _action) => mockStoreState); + mockDispatch = jest.spyOn(mockStore, 'dispatch'); + wrapper = mount( + + + + ); + node = wrapper.find(EmulatorComponent); + instance = node.instance(); + }); + + it('should render properly', () => { + expect(instance).not.toBe(true); + }); + + it('should determine when to start a new conversation', () => { + expect(instance.shouldStartNewConversation()).toBe(true); + mockStoreState.chat.chats.doc1.directLine = { conversationId: 'convo2' }; + expect(instance.shouldStartNewConversation()).toBe(true); + mockStoreState.chat.chats.doc1.directLine = { conversationId: 'convo1' }; + expect(instance.shouldStartNewConversation()).toBe(false); + }); + + it('should render the presentation view', () => { + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + const presentationView = instance.renderPresentationView(); + + expect(presentationView).not.toBeNull(); + }); + + it('should render the default view', () => { + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + const defaultView = instance.renderDefaultView(); + + expect(defaultView).not.toBeNull(); + }); + + it('should get the veritcal splitter sizes', () => { + mockStoreState.chat.chats.doc1.ui = { + verticalSplitter: { + 0: { + percentage: '55', + }, + }, + }; + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + const verticalSplitterSizes = instance.getVerticalSplitterSizes(); + + expect(verticalSplitterSizes[0]).toBe('55'); + }); + + it('should get the veritcal splitter sizes', () => { + mockStoreState.chat.chats.doc1.ui = { + horizontalSplitter: { + 0: { + percentage: '46', + }, + }, + }; + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + const horizontalSplitterSizes = instance.getHorizontalSplitterSizes(); + + expect(horizontalSplitterSizes[0]).toBe('46'); + }); + + it('should restart the conversation on Ctrl/Cmd + Shift + R', () => { + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + const mockOnStartOverClick = jest.fn(() => null); + instance.onStartOverClick = mockOnStartOverClick; + let mockGetModifierState = jest.fn(modifier => { + if (modifier === 'Control') { + return true; + } else if (modifier === 'Shift') { + return true; + } + return true; + }); + const mockEvent = { + getModifierState: mockGetModifierState, + key: 'R', + }; + instance.keyboardEventListener(mockEvent); + + expect(mockOnStartOverClick).toHaveBeenCalledTimes(1); + + mockGetModifierState = jest.fn(modifier => { + if (modifier === 'Control') { + return false; + } else if (modifier === 'Shift') { + return true; + } else { + return true; // Cmd / Meta + } + }); + instance.keyboardEventListener(mockEvent); + + expect(mockOnStartOverClick).toHaveBeenCalledTimes(2); + }); + + it('should enable presentation mode', () => { + instance.onPresentationClick(true); + + expect(mockDispatch).toHaveBeenCalledWith(enable()); + }); + + it('should disable presentation mode', () => { + instance.onPresentationClick(false); + + expect(mockDispatch).toHaveBeenCalledWith(disable()); + }); + + it('should export a transcript', () => { + mockStoreState.chat.chats.doc1.directLine = { + conversationId: 'convo1', + }; + wrapper = shallow( + null)} + newConversation={jest.fn(() => null)} + mode={'transcript'} + document={mockStoreState.chat.chats.doc1} + /> + ); + instance = wrapper.instance(); + instance.onExportClick(); + + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Emulator.SaveTranscriptToFile + ); + expect(mockRemoteCallsMade[0].args).toEqual(['convo1']); + }); + + it('should start over a conversation with a new user id', async () => { + await instance.onStartOverClick(); + + expect(mockDispatch).toHaveBeenCalledWith(clearLog('doc1')); + expect(mockDispatch).toHaveBeenCalledWith(setInspectorObjects('doc1', [])); + expect(mockDispatch).toHaveBeenCalledWith( + updateChat('doc1', { userId: 'newUserId' }) + ); + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual([ + 'conversation_restart', + { userId: 'new' }, + ]); + expect(mockRemoteCallsMade[1].commandName).toBe( + SharedConstants.Commands.Emulator.SetCurrentUser + ); + expect(mockRemoteCallsMade[1].args).toEqual(['newUserId']); + }); + + it('should start over a conversation with the same user id', async () => { + const mockStartNewConversation = jest.fn(() => null); + instance.startNewConversation = mockStartNewConversation; + + await instance.onStartOverClick(RestartConversationOptions.SameUserId); + + expect(mockDispatch).toHaveBeenCalledWith(clearLog('doc1')); + expect(mockDispatch).toHaveBeenCalledWith(setInspectorObjects('doc1', [])); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual([ + 'conversation_restart', + { userId: 'same' }, + ]); + expect(mockStartNewConversation).toHaveBeenCalledTimes(1); + }); + + it('should init a conversation', () => { + const mockProps = { + documentId: 'doc1', + url: 'someUrl', + }; + const mockOptions = { conversationId: 'convo1' }; + const encodedOptions = encode(JSON.stringify(mockOptions)); + instance.initConversation(mockProps, mockOptions, {}, {}); + + expect(mockDispatch).toHaveBeenCalledWith( + newConversation('doc1', { + conversationId: 'convo1', + directLine: { + args: [ + { + secret: encodedOptions, + domain: 'someUrl/v3/directline', + webSocket: false, + }, + ], + }, + selectedActivity$: {}, + subscription: {}, + }) + ); + }); + + it('should start a new conversation from transcript in memory', async () => { + const mockInitConversation = jest.fn(() => null); + instance.initConversation = mockInitConversation; + const mockProps = { + document: { + activities: [], + botId: 'someBotId', + inMemory: true, + userId: 'someUserId', + }, + mode: 'transcript', + }; + + await instance.startNewConversation(mockProps); + + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Emulator.NewTranscript + ); + expect(mockRemoteCallsMade[0].args).toEqual(['someUniqueId|transcript']); + expect(mockRemoteCallsMade[1].commandName).toBe( + SharedConstants.Commands.Emulator.FeedTranscriptFromMemory + ); + expect(mockRemoteCallsMade[1].args).toEqual([ + 'someConvoId', + 'someBotId', + 'someUserId', + [], + ]); + }); + + it('should start a new conversation from transcript on disk', async () => { + const mockInitConversation = jest.fn(() => null); + instance.initConversation = mockInitConversation; + const mockProps = { + document: { + activities: [], + botId: 'someBotId', + documentId: 'someDocId', + inMemory: false, + userId: 'someUserId', + }, + documentId: 'someDocId', + mode: 'transcript', + }; + + await instance.startNewConversation(mockProps); + + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Emulator.NewTranscript + ); + expect(mockRemoteCallsMade[0].args).toEqual(['someUniqueId|transcript']); + expect(mockRemoteCallsMade[1].commandName).toBe( + SharedConstants.Commands.Emulator.FeedTranscriptFromDisk + ); + expect(mockRemoteCallsMade[1].args).toEqual([ + 'someConvoId', + 'someBotId', + 'someUserId', + 'someDocId', + ]); + expect(mockDispatch).toHaveBeenCalledWith( + updateDocument('someDocId', { meta: 'some file info' }) + ); + }); +}); diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index d26639439..837973600 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -63,7 +63,7 @@ import { ToolBar } from './toolbar/toolbar'; const { encode } = base64Url; -const RestartConversationOptions = { +export const RestartConversationOptions = { NewUserId: 'Restart with new user ID', SameUserId: 'Restart with same user ID', }; @@ -85,6 +85,7 @@ interface EmulatorProps { newConversation?: (documentId: string, options: any) => void; presentationModeEnabled?: boolean; setInspectorObjects?: (documentId: string, objects: any) => void; + trackEvent?: (name: string, properties?: { [key: string]: any }) => void; updateChat?: (documentId: string, updatedValues: any) => void; updateDocument?: ( documentId: string, @@ -93,7 +94,7 @@ interface EmulatorProps { url?: string; } -class EmulatorComponent extends React.Component { +export class EmulatorComponent extends React.Component { private readonly onVerticalSizeChange = debounce(sizes => { this.props.document.ui = { ...this.props.document.ui, @@ -227,7 +228,7 @@ class EmulatorComponent extends React.Component { props.document.documentId ); - this.props.updateDocument(this.props.documentId, fileInfo); + this.props.updateDocument(props.documentId, fileInfo); } catch (err) { throw new Error( `Error while feeding transcript on disk to conversation: ${err}` @@ -381,6 +382,9 @@ class EmulatorComponent extends React.Component { switch (option) { case NewUserId: { + this.props.trackEvent('conversation_restart', { + userId: 'new', + }); const newUserId = uniqueIdv4(); // set new user as current on emulator facilities side await CommandServiceImpl.remoteCall( @@ -392,6 +396,9 @@ class EmulatorComponent extends React.Component { } case SameUserId: + this.props.trackEvent('conversation_restart', { + userId: 'same', + }); this.startNewConversation(); break; @@ -451,6 +458,12 @@ const mapDispatchToProps = (dispatch): EmulatorProps => ({ dispatch(updateDocument(documentId, updatedValues)), createErrorNotification: (notification: Notification) => dispatch(beginAdd(notification)), + trackEvent: (name: string, properties?: { [key: string]: any }) => + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + name, + properties + ).catch(_e => void 0), }); export const Emulator = connect( diff --git a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.spec.tsx index 5f8b930c7..5c05527e9 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.spec.tsx @@ -31,6 +31,7 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { logEntry, LogLevel, textItem } from '@bfemulator/sdk-shared'; +import { SharedConstants } from '@bfemulator/app-shared'; import { mount } from 'enzyme'; import * as React from 'react'; import { Provider } from 'react-redux'; @@ -63,6 +64,16 @@ jest.mock('../../../../../data/store', () => ({ }, })); +let mockRemoteCallsMade; +jest.mock('../../../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + return Promise.resolve(); + }, + }, +})); + const mockState = { bot: { description: '', @@ -267,6 +278,7 @@ describe('The Inspector component', () => { mockStore.dispatch(switchTheme('light', ['vars.css', 'light.css'])); mockStore.dispatch(loadBotInfos([mockState.bot])); mockStore.dispatch(setActiveBot(mockState.bot as any)); + mockRemoteCallsMade = []; parent = mount( @@ -383,5 +395,26 @@ describe('The Inspector component', () => { logEntry(textItem(LogLevel.Info, text)) ); }); + + it('"track-event"', () => { + event.channel = 'track-event'; + event.args[0] = 'someEvent'; + event.args[1] = { some: 'data' }; + instance.ipcMessageEventHandler(event); + + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0]).toEqual({ + commandName: SharedConstants.Commands.Telemetry.TrackEvent, + args: ['someEvent', { some: 'data' }], + }); + + event.args[1] = undefined; + instance.ipcMessageEventHandler(event); + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[1]).toEqual({ + commandName: SharedConstants.Commands.Telemetry.TrackEvent, + args: ['someEvent', {}], + }); + }); }); }); diff --git a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.tsx b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.tsx index ce862c729..80a434966 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspector.tsx @@ -30,6 +30,7 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + // Cheating here and pulling in a module from node. Can be easily replaced if we ever move the emulator to the web. import { LogLevel } from '@bfemulator/sdk-shared'; import { logEntry, textItem } from '@bfemulator/sdk-shared'; @@ -74,6 +75,7 @@ interface InspectorProps { themeInfo: { themeName: string; themeComponents: string[] }; activeBot?: IBotConfiguration; botHash?: string; + trackEvent?: (name: string, properties?: { [key: string]: any }) => void; } interface InspectorState { @@ -417,6 +419,14 @@ export class Inspector extends React.Component { break; } + // record telemetry from extension + case 'track-event': { + const eventName = event.args[0]; + const eventProperties = event.args[1] || {}; + this.props.trackEvent(eventName, eventProperties); + break; + } + default: // eslint-disable-next-line no-console console.warn( diff --git a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspectorContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspectorContainer.ts index 3207f8d30..3af1399e8 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/inspector/inspectorContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/inspector/inspectorContainer.ts @@ -30,9 +30,12 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + import { connect } from 'react-redux'; +import { SharedConstants } from '@bfemulator/app-shared'; import { RootState } from '../../../../../data/store'; +import { CommandServiceImpl } from '../../../../../platform/commands/commandServiceImpl'; import { Inspector } from './inspector'; @@ -50,7 +53,19 @@ const mapStateToProps = (state: RootState, ownProps: any) => { }; }; +const mapDispatchToProps = _dispatch => { + return { + trackEvent: (name: string, properties?: { [key: string]: any }) => { + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + name, + properties + ).catch(_e => void 0); + }, + }; +}; + export const InspectorContainer = connect( mapStateToProps, - null + mapDispatchToProps )(Inspector); diff --git a/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.spec.tsx index 70a87cf75..42fae9b12 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.spec.tsx @@ -34,27 +34,76 @@ import { LogLevel, textItem } from '@bfemulator/sdk-shared'; import { mount, ReactWrapper } from 'enzyme'; import * as React from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import { SharedConstants } from '@bfemulator/app-shared'; + +import { setInspectorObjects } from '../../../../../data/action/chatActions'; import { LogEntry, LogEntryProps, number2, timestamp } from './logEntry'; +import { LogEntry as LogEntryContainer } from './logEntryContainer'; + +jest.mock('../../../../dialogs', () => ({ + BotCreationDialog: () => ({}), +})); jest.mock('./log.scss', () => ({})); +let mockRemoteCallsMade; +let mockCallsMade; +jest.mock('../../../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + call: (commandName, ...args) => { + mockCallsMade.push({ commandName, args }); + return Promise.resolve(true); + }, + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + return Promise.resolve(true); + }, + }, +})); + describe('logEntry component', () => { let wrapper: ReactWrapper; + let node; + let instance; + let props: LogEntryProps; + let mockNext; + let mockSelectedActivity; + let mockSetInspectorObjects; + let mockDispatch; beforeEach(() => { - const props: LogEntryProps = { - document: {}, + mockNext = jest.fn(() => null); + mockSelectedActivity = { next: mockNext }; + mockSetInspectorObjects = jest.fn(() => null); + mockRemoteCallsMade = []; + mockCallsMade = []; + props = { + document: { + documentId: 'someDocId', + selectedActivity$: mockSelectedActivity, + }, entry: { timestamp: 0, items: [], }, + setInspectorObjects: mockSetInspectorObjects, }; - wrapper = mount(); + const mockStore = createStore((_state, _action) => ({})); + mockDispatch = jest.spyOn(mockStore, 'dispatch'); + wrapper = mount( + + + + ); + node = wrapper.find(LogEntry); + instance = node.instance(); }); it('should render an outer entry component', () => { - expect(wrapper.find('div')).toHaveLength(1); + expect(node.find('div')).toHaveLength(1); }); it('should render a timestamped log entry with multiple items', () => { @@ -66,6 +115,7 @@ describe('logEntry component', () => { textItem(LogLevel.Debug, 'item3'), ], }; + wrapper = mount(); wrapper.setProps({ entry }); expect(wrapper.find('span.timestamp')).toHaveLength(1); expect(wrapper.find('span.text-item')).toHaveLength(3); @@ -74,7 +124,7 @@ describe('logEntry component', () => { expect(timestampNode.html()).toContain('12:34:56'); }); - test('number2', () => { + it('should truncate a number of more than 3 digits to 2 digits', () => { const num1 = 5; const num2 = 34; const num3 = 666; @@ -84,7 +134,7 @@ describe('logEntry component', () => { expect(number2(num3)).toBe('66'); }); - test('timestamp', () => { + it('should properly generate a timestamp', () => { const time = Date.now(); const date = new Date(time); const expectedHrs = number2(date.getHours()); @@ -94,4 +144,176 @@ describe('logEntry component', () => { expect(timestamp(time)).toBe(expectedTimestamp); }); + + it('should inspect an object', () => { + const mockInspectableObj = { some: 'data' }; + instance.inspect(mockInspectableObj); + + expect(mockNext).toHaveBeenCalledWith({ showInInspector: true }); + expect(mockDispatch).toHaveBeenCalledWith( + setInspectorObjects('someDocId', mockInspectableObj) + ); + }); + + it('should inspect and highlight an object', () => { + const mockInspectableObj = { some: 'data', type: 'message', id: 'someId' }; + instance.inspectAndHighlightInWebchat(mockInspectableObj); + + expect(mockNext).toHaveBeenCalledWith({ + ...mockInspectableObj, + showInInspector: true, + }); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual([ + 'log_inspectActivity', + { type: 'message' }, + ]); + + mockInspectableObj.type = undefined; + instance.inspectAndHighlightInWebchat(mockInspectableObj); + + expect(mockRemoteCallsMade[1].args).toEqual([ + 'log_inspectActivity', + { type: '' }, + ]); + }); + + it('should highlight an object', () => { + const mockInspectableObj = { some: 'data', type: 'message', id: 'someId' }; + instance.highlightInWebchat(mockInspectableObj); + + expect(mockNext).toHaveBeenCalledWith({ + ...mockInspectableObj, + showInInspector: false, + }); + }); + + it('should remove highlighting from an object', () => { + const mockInspectableObj = { id: 'activity1' }; + wrapper = mount(); + const mockCurrentlyInspectedActivity = { id: 'activity2' }; + wrapper.setProps({ + currentlyInspectedActivity: mockCurrentlyInspectedActivity, + }); + instance = wrapper.instance(); + instance.removeHighlightInWebchat(mockInspectableObj); + + expect(mockNext).toHaveBeenCalledWith({ + ...mockCurrentlyInspectedActivity, + showInInspector: true, + }); + + mockCurrentlyInspectedActivity.id = undefined; + instance.removeHighlightInWebchat(mockInspectableObj); + + expect(mockNext).toHaveBeenCalledWith({ showInInspector: false }); + }); + + it('should render a text item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const textElem = instance.renderItem( + { type: 'text', payload: { level: LogLevel.Debug, text: 'some text' } }, + 'someKey' + ); + expect(textElem).not.toBeNull(); + }); + + it('should render an external link item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const linkItem = instance.renderItem( + { + type: 'external-link', + payload: { hyperlink: 'https://aka.ms/bf-emulator', text: 'some text' }, + }, + 'someKey' + ); + expect(linkItem).not.toBeNull(); + }); + + it('should render an app settings item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const appSettingsItem = instance.renderItem( + { type: 'open-app-settings', payload: { text: 'some text' } }, + 'someKey' + ); + expect(appSettingsItem).not.toBeNull(); + }); + + it('should render an exception item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const exceptionItem = instance.renderItem( + { type: 'exception', payload: { err: 'some error' } }, + 'someKey' + ); + expect(exceptionItem).not.toBeNull(); + }); + + it('should render an inspectable object item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const inspectableObjItem = instance.renderItem( + { + type: 'inspectable-object', + payload: { obj: { id: 'someId', type: 'message' } }, + }, + 'someKey' + ); + expect(inspectableObjItem).not.toBeNull(); + expect(instance.inspectableObjects.someId).toBe(true); + }); + + it('should render a network request item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const networkReqItem = instance.renderItem( + { + type: 'network-request', + payload: { + facility: undefined, + body: { some: 'data' }, + headers: undefined, + method: 'GET', + url: undefined, + }, + }, + 'someKey' + ); + expect(networkReqItem).not.toBeNull(); + }); + + it('should render a network response item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const networkResItem = instance.renderItem( + { + type: 'network-response', + payload: { + body: { some: 'data' }, + headers: undefined, + statusCode: 404, + statusMessage: undefined, + srcUrl: undefined, + }, + }, + 'someKey' + ); + expect(networkResItem).not.toBeNull(); + }); + + it('should render an ngrok expiration item', () => { + wrapper = mount(); + instance = wrapper.instance(); + const ngrokitem = instance.renderItem( + { type: 'ngrok-expiration', payload: { text: 'some text' } }, + 'someKey' + ); + expect(ngrokitem).not.toBeNull(); + }); }); diff --git a/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.tsx b/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.tsx index 55ebad39c..6e9160c8d 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.tsx @@ -58,6 +58,7 @@ export interface LogEntryProps { setInspectorObjects?: (...args: any[]) => void; reconnectNgrok?: () => void; showAppSettings?: () => void; + trackEvent?: (name: string, properties?: { [key: string]: any }) => void; } export class LogEntry extends React.Component { @@ -84,6 +85,7 @@ export class LogEntry extends React.Component { showInInspector: true, }); } + this.props.trackEvent('log_inspectActivity', { type: obj.type || '' }); } /** Highlights an activity in webchat (triggered by hover in log) */ diff --git a/packages/app/client/src/ui/editor/emulator/parts/log/logEntryContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/log/logEntryContainer.ts index 185d0619f..0f514adcd 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/log/logEntryContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/log/logEntryContainer.ts @@ -57,6 +57,13 @@ function mapDispatchToProps(dispatch: any): Partial { const { UI } = SharedConstants.Commands; CommandServiceImpl.call(UI.ShowAppSettings); }, + trackEvent: (name: string, properties?: { [key: string]: any }) => { + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + name, + properties + ).catch(_e => void 0); + }, }; } diff --git a/packages/app/client/src/ui/helpers/activeBotHelper.spec.ts b/packages/app/client/src/ui/helpers/activeBotHelper.spec.ts index a0b17e355..ca45b7004 100644 --- a/packages/app/client/src/ui/helpers/activeBotHelper.spec.ts +++ b/packages/app/client/src/ui/helpers/activeBotHelper.spec.ts @@ -295,6 +295,11 @@ describe('ActiveBotHelper tests', () => { SharedConstants.Commands.Bot.SetActive, bot ); + expect(mockRemoteCall).toHaveBeenCalledWith( + SharedConstants.Commands.Telemetry.TrackEvent, + 'bot_open', + { method: 'file_browse', numOfServices: 0 } + ); ActiveBotHelper.browseForBotFile = backupBrowseForBotFile; ActiveBotHelper.botAlreadyOpen = backupBotAlreadyOpen; diff --git a/packages/app/client/src/ui/helpers/activeBotHelper.ts b/packages/app/client/src/ui/helpers/activeBotHelper.ts index cd7aa420a..051750c78 100644 --- a/packages/app/client/src/ui/helpers/activeBotHelper.ts +++ b/packages/app/client/src/ui/helpers/activeBotHelper.ts @@ -51,11 +51,13 @@ import { hasNonGlobalTabs } from '../../data/editorHelpers'; import { store } from '../../data/store'; import { CommandServiceImpl } from '../../platform/commands/commandServiceImpl'; +const { Bot, Electron, Telemetry } = SharedConstants.Commands; + export const ActiveBotHelper = new class { async confirmSwitchBot(): Promise { if (hasNonGlobalTabs()) { return await CommandServiceImpl.remoteCall( - SharedConstants.Commands.Electron.ShowMessageBox, + Electron.ShowMessageBox, true, { buttons: ['Cancel', 'OK'], @@ -123,7 +125,7 @@ export const ActiveBotHelper = new class { /** tell the server-side the active bot is now closed */ closeActiveBot(): Promise { - return CommandServiceImpl.remoteCall(SharedConstants.Commands.Bot.Close) + return CommandServiceImpl.remoteCall(Bot.Close) .then(() => { store.dispatch(BotActions.closeBot()); CommandServiceImpl.remoteCall( @@ -141,19 +143,15 @@ export const ActiveBotHelper = new class { async botAlreadyOpen(): Promise { // TODO - localization - return await CommandServiceImpl.remoteCall( - SharedConstants.Commands.Electron.ShowMessageBox, - true, - { - buttons: ['OK'], - cancelId: 0, - defaultId: 0, - message: - "This bot is already open. If you'd like to start a conversation, " + - 'click on an endpoint from the Bot Explorer pane.', - type: 'question', - } - ); + return await CommandServiceImpl.remoteCall(Electron.ShowMessageBox, true, { + buttons: ['OK'], + cancelId: 0, + defaultId: 0, + message: + "This bot is already open. If you'd like to start a conversation, " + + 'click on an endpoint from the Bot Explorer pane.', + type: 'question', + }); } async confirmAndCreateBot( @@ -199,20 +197,17 @@ export const ActiveBotHelper = new class { } browseForBotFile(): Promise { - return CommandServiceImpl.remoteCall( - SharedConstants.Commands.Electron.ShowOpenDialog, - { - buttonLabel: 'Choose file', - filters: [ - { - extensions: ['bot'], - name: 'Bot Files', - }, - ], - properties: ['openFile'], - title: 'Open bot file', - } - ); + return CommandServiceImpl.remoteCall(Electron.ShowOpenDialog, { + buttonLabel: 'Choose file', + filters: [ + { + extensions: ['bot'], + name: 'Bot Files', + }, + ], + properties: ['openFile'], + title: 'Open bot file', + }); } async confirmAndOpenBotFromFile(filename?: string): Promise { @@ -246,6 +241,11 @@ export const ActiveBotHelper = new class { bot ); await CommandServiceImpl.call(SharedConstants.Commands.Bot.Load, bot); + const numOfServices = bot.services && bot.services.length; + CommandServiceImpl.remoteCall(Telemetry.TrackEvent, `bot_open`, { + method: 'file_browse', + numOfServices, + }).catch(_e => void 0); } } } catch (err) { diff --git a/packages/app/client/src/ui/shell/mdi/tabBar/tabBar.spec.tsx b/packages/app/client/src/ui/shell/mdi/tabBar/tabBar.spec.tsx index 699e18538..c7a1f2e4b 100644 --- a/packages/app/client/src/ui/shell/mdi/tabBar/tabBar.spec.tsx +++ b/packages/app/client/src/ui/shell/mdi/tabBar/tabBar.spec.tsx @@ -35,6 +35,7 @@ import * as React from 'react'; import { mount } from 'enzyme'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; +import { SharedConstants } from '@bfemulator/app-shared'; import { enable } from '../../../../data/action/presentationActions'; import { @@ -74,6 +75,16 @@ jest.mock('../tab/tab', () => ({ }, })); +let mockRemoteCallsMade; +jest.mock('../../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + return Promise.resolve(true); + }, + }, +})); + describe('TabBar', () => { let wrapper; let node; @@ -112,6 +123,7 @@ describe('TabBar', () => { }; mockStore = createStore((_state, _action) => defaultState); mockDispatch = jest.spyOn(mockStore, 'dispatch'); + mockRemoteCallsMade = []; wrapper = mount( @@ -125,6 +137,11 @@ describe('TabBar', () => { instance.onPresentationModeClick(); expect(mockDispatch).toHaveBeenCalledWith(enable()); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual(['tabBar_presentationMode']); }); it('should load widgets', () => { @@ -208,6 +225,11 @@ describe('TabBar', () => { expect(mockDispatch).toHaveBeenCalledWith( splitTab('transcript', 'doc1', 'primary', 'secondary') ); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual(['tabBar_splitTab']); }); it('should handle a drag enter event', () => { diff --git a/packages/app/client/src/ui/shell/mdi/tabBar/tabBarContainer.ts b/packages/app/client/src/ui/shell/mdi/tabBar/tabBarContainer.ts index f6fea373b..2afea3d61 100644 --- a/packages/app/client/src/ui/shell/mdi/tabBar/tabBarContainer.ts +++ b/packages/app/client/src/ui/shell/mdi/tabBar/tabBarContainer.ts @@ -32,6 +32,7 @@ // import { connect } from 'react-redux'; +import { SharedConstants } from '@bfemulator/app-shared'; import { closeDocument } from '../../../../data/action/chatActions'; import { @@ -43,6 +44,7 @@ import { import { enable as enablePresentationMode } from '../../../../data/action/presentationActions'; import { getTabGroupForDocument } from '../../../../data/editorHelpers'; import { RootState } from '../../../../data/store'; +import { CommandServiceImpl } from '../../../../platform/commands/commandServiceImpl'; import { TabBar, TabBarProps } from './tabBar'; @@ -67,10 +69,22 @@ const mapDispatchToProps = (dispatch): TabBarProps => ({ documentId: string, srcEditorKey: string, destEditorKey: string - ) => dispatch(splitTab(contentType, documentId, srcEditorKey, destEditorKey)), + ) => { + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + 'tabBar_splitTab' + ).catch(_e => void 0); + dispatch(splitTab(contentType, documentId, srcEditorKey, destEditorKey)); + }, appendTab: (srcEditorKey: string, destEditorKey: string, tabId: string) => dispatch(appendTab(srcEditorKey, destEditorKey, tabId)), - enablePresentationMode: () => dispatch(enablePresentationMode()), + enablePresentationMode: () => { + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + 'tabBar_presentationMode' + ).catch(_e => void 0); + dispatch(enablePresentationMode()); + }, setActiveTab: (documentId: string) => dispatch(setActiveTab(documentId)), closeTab: (documentId: string) => { dispatch(close(getTabGroupForDocument(documentId), documentId)); diff --git a/packages/app/client/src/ui/shell/navBar/navBar.spec.tsx b/packages/app/client/src/ui/shell/navBar/navBar.spec.tsx new file mode 100644 index 000000000..e3659011a --- /dev/null +++ b/packages/app/client/src/ui/shell/navBar/navBar.spec.tsx @@ -0,0 +1,164 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import * as React from 'react'; +import { mount, shallow } from 'enzyme'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; +import { SharedConstants } from '@bfemulator/app-shared'; + +import * as Constants from '../../../constants'; +import { select } from '../../../data/action/navBarActions'; +import { open } from '../../../data/action/editorActions'; +import { showExplorer } from '../../../data/action/explorerActions'; + +import { NavBarComponent as NavBar } from './navBar'; +import { NavBar as NavBarContainer } from './navBarContainer'; + +let mockRemoteCallsMade; +jest.mock('../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + remoteCall: jest.fn((commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + return Promise.resolve(); + }), + }, +})); +jest.mock('./navBar.scss', () => ({})); + +let mockState; +const mockNotifications = { + id1: { read: true }, + id2: { read: true }, + id3: { read: false }, +}; +jest.mock('../../../notificationManager', () => ({ + NotificationManager: { + get: id => mockNotifications[id], + }, +})); + +describe('', () => { + let mockDispatch; + let wrapper; + let instance; + let node; + + beforeEach(() => { + mockState = { + bot: { + activeBot: {}, + }, + notification: { + allIds: Object.keys(mockNotifications), + }, + }; + const mockStore = createStore((_state, _action) => mockState); + mockDispatch = jest.spyOn(mockStore, 'dispatch'); + mockRemoteCallsMade = []; + wrapper = mount( + + + + ); + node = wrapper.find(NavBar); + instance = node.instance(); + }); + + it('should render links for each section', () => { + expect(instance).not.toBeNull(); + expect(instance.links).toHaveLength(4); + }); + + it('should render a notification badge', () => { + const badge = shallow(instance.renderNotificationBadge('Notifications')); + expect(badge.html()).not.toBeNull(); + expect(badge.html().includes('1')).toBe(true); + }); + + it('should select the corresponding nav section', () => { + const parentElement: any = { + children: ['botExplorer', 'resources', 'settings'], + }; + const currentTarget = { + name: 'notifications', + parentElement, + }; + // wedge notifications "anchor" in between "resources" and "settings" + parentElement.children.splice(2, 0, currentTarget); + const mockEvent = { + currentTarget, + }; + instance.onLinkClick(mockEvent); + + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Telemetry.TrackEvent + ); + expect(mockRemoteCallsMade[0].args).toEqual([ + 'navbar_selection', + { selection: 'notifications' }, + ]); + expect(mockDispatch).toHaveBeenCalledWith(select('navbar.notifications')); + expect(instance.state.selection).toBe('navbar.notifications'); + }); + + it('should open the app settings editor', () => { + const parentElement: any = { + children: ['botExplorer', 'resources', 'notifications'], + }; + const currentTarget = { + name: 'settings', + parentElement, + }; + const mockEvent = { + currentTarget, + }; + instance.onLinkClick(mockEvent); + + expect(mockDispatch).toHaveBeenCalledWith( + open({ + contentType: Constants.CONTENT_TYPE_APP_SETTINGS, + documentId: Constants.DOCUMENT_ID_APP_SETTINGS, + isGlobal: true, + meta: null, + }) + ); + }); + + it('should show / hide the explorer', () => { + instance.props.showExplorer(true); + + expect(mockDispatch).toHaveBeenCalledWith(showExplorer(true)); + }); +}); diff --git a/packages/app/client/src/ui/shell/navBar/navBar.tsx b/packages/app/client/src/ui/shell/navBar/navBar.tsx index 7bf6d6711..9e9e800c9 100644 --- a/packages/app/client/src/ui/shell/navBar/navBar.tsx +++ b/packages/app/client/src/ui/shell/navBar/navBar.tsx @@ -47,6 +47,7 @@ export interface NavBarProps { notifications?: string[]; explorerIsVisible?: boolean; botIsOpen?: boolean; + trackEvent?: (name: string, properties?: { [key: string]: any }) => void; } export interface NavBarState { @@ -97,6 +98,11 @@ export class NavBarComponent extends React.Component { // TODO: Re-enable once webchat reset bug is fixed // (https://github.com/Microsoft/BotFramework-Emulator/issues/825) // this.props.showExplorer(true); + if (index === 2) { + this.props.trackEvent('navbar_selection', { + selection: 'notifications', + }); + } this.props.navBarSelectionChanged(selectionMap[index]); this.setState({ selection: selectionMap[index] }); } diff --git a/packages/app/client/src/ui/shell/navBar/navBarContainer.ts b/packages/app/client/src/ui/shell/navBar/navBarContainer.ts index 66696ebc0..9d0778413 100644 --- a/packages/app/client/src/ui/shell/navBar/navBarContainer.ts +++ b/packages/app/client/src/ui/shell/navBar/navBarContainer.ts @@ -30,13 +30,16 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + import { connect } from 'react-redux'; +import { SharedConstants } from '@bfemulator/app-shared'; import * as Constants from '../../../constants'; import * as EditorActions from '../../../data/action/editorActions'; import * as ExplorerActions from '../../../data/action/explorerActions'; import * as NavBarActions from '../../../data/action/navBarActions'; import { RootState } from '../../../data/store'; +import { CommandServiceImpl } from '../../../platform/commands/commandServiceImpl'; import { NavBarComponent, NavBarProps } from './navBar'; @@ -60,6 +63,13 @@ const mapDispatchToProps = (dispatch): NavBarProps => ({ }) ); }, + trackEvent: (name: string, properties?: { [key: string]: any }) => { + CommandServiceImpl.remoteCall( + SharedConstants.Commands.Telemetry.TrackEvent, + name, + properties + ).catch(_e => void 0); + }, }); export const NavBar = connect( diff --git a/packages/app/main/package.json b/packages/app/main/package.json index 1e175a22a..88e8b8237 100644 --- a/packages/app/main/package.json +++ b/packages/app/main/package.json @@ -113,6 +113,7 @@ "@bfemulator/extension-qnamaker-client": "^0.1.0", "@bfemulator/sdk-main": "^1.0.0", "@bfemulator/sdk-shared": "^1.0.0", + "applicationinsights": "^1.0.8", "async": "^2.6.0", "base64url": "2.0.0", "botframework-config": "4.0.0-preview1.3.4", diff --git a/packages/app/main/src/appMenuBuilder.ts b/packages/app/main/src/appMenuBuilder.ts index 38ddb1fb0..849becb0e 100644 --- a/packages/app/main/src/appMenuBuilder.ts +++ b/packages/app/main/src/appMenuBuilder.ts @@ -35,6 +35,7 @@ import { BotInfo, SharedConstants } from '@bfemulator/app-shared'; import { ConversationService } from '@bfemulator/sdk-shared'; import * as Electron from 'electron'; +import { TelemetryService } from './telemetry'; import { AppUpdater, UpdateStatus } from './appUpdater'; import { getActiveBot } from './botHelpers'; import { emulator } from './emulator'; @@ -318,10 +319,13 @@ export class AppMenuBuilder { const getServiceUrl = () => emulator.framework.serverUrl.replace('[::]', 'localhost'); - const createClickHandler = serviceFunction => async () => { + const createClickHandler = (serviceFunction, callback?) => async () => { const conversationId = await getConversationId(); - return serviceFunction(getServiceUrl(), conversationId); + serviceFunction(getServiceUrl(), conversationId); + if (callback) { + callback(); + } }; const enabled = @@ -338,37 +342,56 @@ export class AppMenuBuilder { submenu: [ { label: 'conversationUpdate ( user added )', - click: createClickHandler(ConversationService.addUser), + click: createClickHandler(ConversationService.addUser, () => + TelemetryService.trackEvent('sendActivity_addUser') + ), enabled, }, { label: 'conversationUpdate ( user removed )', - click: createClickHandler(ConversationService.removeUser), + click: createClickHandler(ConversationService.removeUser, () => + TelemetryService.trackEvent('sendActivity_removeUser') + ), enabled, }, { label: 'contactRelationUpdate ( bot added )', - click: createClickHandler(ConversationService.botContactAdded), + click: createClickHandler( + ConversationService.botContactAdded, + () => + TelemetryService.trackEvent('sendActivity_botContactAdded') + ), enabled, }, { label: 'contactRelationUpdate ( bot removed )', - click: createClickHandler(ConversationService.botContactRemoved), + click: createClickHandler( + ConversationService.botContactRemoved, + () => + TelemetryService.trackEvent('sendActivity_botContactRemoved') + ), enabled, }, { label: 'typing', - click: createClickHandler(ConversationService.typing), + click: createClickHandler(ConversationService.typing, () => + TelemetryService.trackEvent('sendActivity_typing') + ), enabled, }, { label: 'ping', - click: createClickHandler(ConversationService.ping), + click: createClickHandler(ConversationService.ping, () => + TelemetryService.trackEvent('sendActivity_ping') + ), enabled, }, { label: 'deleteUserData', - click: createClickHandler(ConversationService.deleteUserData), + click: createClickHandler( + ConversationService.deleteUserData, + () => TelemetryService.trackEvent('sendActivity_deleteUserData') + ), enabled, }, ], diff --git a/packages/app/main/src/appUpdater.spec.ts b/packages/app/main/src/appUpdater.spec.ts index b33b727d0..734be6bd1 100644 --- a/packages/app/main/src/appUpdater.spec.ts +++ b/packages/app/main/src/appUpdater.spec.ts @@ -32,6 +32,7 @@ // import { AppUpdater } from './appUpdater'; +import { TelemetryService } from './telemetry'; let mockAutoUpdater: any = { quitAndInstall: null, @@ -59,9 +60,18 @@ jest.mock('./settingsData/store', () => ({ })); describe('AppUpdater', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + beforeEach(() => { mockAutoUpdater = {}; mockSettings = { ...defaultSettings }; + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; }); it('should get userInitiated', () => { @@ -171,15 +181,13 @@ describe('AppUpdater', () => { AppUpdater.checkForUpdates = tmp; }); - it('should check for updates from the stable release repo', () => { + it('should check for updates from the stable release repo', async () => { const mockSetFeedURL = jest.fn((_options: any) => null); - const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => - Promise.resolve() - ); + const mockCheckForUpdates = jest.fn(() => Promise.resolve()); mockAutoUpdater.setFeedURL = mockSetFeedURL; mockAutoUpdater.checkForUpdates = mockCheckForUpdates; - AppUpdater.checkForUpdates(true); + await AppUpdater.checkForUpdates(true); expect(AppUpdater.userInitiated).toBe(true); @@ -190,18 +198,20 @@ describe('AppUpdater', () => { }); expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledWith('update_check', { + auto: !AppUpdater.userInitiated, + prerelease: false, + }); }); - it('should check for updates from the nightly release repo', () => { + it('should check for updates from the nightly release repo', async () => { mockSettings.usePrereleases = true; const mockSetFeedURL = jest.fn((_options: any) => null); - const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => - Promise.resolve() - ); + const mockCheckForUpdates = jest.fn(() => Promise.resolve()); mockAutoUpdater.setFeedURL = mockSetFeedURL; mockAutoUpdater.checkForUpdates = mockCheckForUpdates; - AppUpdater.checkForUpdates(false); + await AppUpdater.checkForUpdates(false); expect(mockSetFeedURL).toHaveBeenCalledWith({ repo: 'BotFramework-Emulator-Nightlies', @@ -210,12 +220,14 @@ describe('AppUpdater', () => { }); expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledWith('update_check', { + auto: !AppUpdater.userInitiated, + prerelease: true, + }); }); it('should throw if there is an error while trying to check for updates', async () => { - const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => - Promise.reject('ERROR') - ); + const mockCheckForUpdates = jest.fn(() => Promise.reject('ERROR')); mockAutoUpdater.checkForUpdates = mockCheckForUpdates; mockAutoUpdater.setFeedURL = () => null; diff --git a/packages/app/main/src/appUpdater.ts b/packages/app/main/src/appUpdater.ts index 0d2a24449..9ee629de8 100644 --- a/packages/app/main/src/appUpdater.ts +++ b/packages/app/main/src/appUpdater.ts @@ -36,6 +36,7 @@ import { EventEmitter } from 'events'; import { ProgressInfo } from 'builder-util-runtime'; import { autoUpdater as electronUpdater, UpdateInfo } from 'electron-updater'; +import { TelemetryService } from './telemetry'; import { getSettings } from './settingsData/store'; export enum UpdateStatus { @@ -131,6 +132,10 @@ class EmulatorUpdater extends EventEmitter { this.emit('download-progress', progress); }); electronUpdater.on('update-downloaded', (updateInfo: UpdateInfo) => { + TelemetryService.trackEvent('update_download', { + version: updateInfo.version, + installAfterDownload: this._installAfterDownload, + }); if (this._installAfterDownload) { this.quitAndInstall(); return; @@ -160,6 +165,10 @@ class EmulatorUpdater extends EventEmitter { try { await electronUpdater.checkForUpdates(); + TelemetryService.trackEvent('update_check', { + auto: !userInitiated, + prerelease: this.allowPrerelease, + }); } catch (e) { throw new Error( `There was an error while checking for the latest update: ${e}` diff --git a/packages/app/main/src/commands/botCommands.spec.ts b/packages/app/main/src/commands/botCommands.spec.ts index 2522947fd..fecc3aa67 100644 --- a/packages/app/main/src/commands/botCommands.spec.ts +++ b/packages/app/main/src/commands/botCommands.spec.ts @@ -55,6 +55,7 @@ import { chatWatcher, transcriptsWatcher, } from '../watchers'; +import { TelemetryService } from '../telemetry'; import { registerCommands } from './botCommands'; @@ -133,6 +134,18 @@ jest.mock('chokidar', () => ({ const { Bot } = SharedConstants.Commands; describe('The botCommands', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; + }); + it('should create/save a new bot', async () => { const botToSave = BotConfigWithPathImpl.fromJSON(mockBot as any); const patchBotInfoSpy = jest.spyOn( @@ -153,6 +166,10 @@ describe('The botCommands', () => { expect(patchBotInfoSpy).toHaveBeenCalledWith(botToSave.path, mockBotInfo); expect(saveBotSpy).toHaveBeenCalledWith(botToSave); expect(result).toEqual(botToSave); + expect(mockTrackEvent).toHaveBeenCalledWith('bot_create', { + path: mockBotInfo.path, + hasSecret: true, + }); }); it('should open a bot and set the default transcript and chat path if none exists', async () => { diff --git a/packages/app/main/src/commands/botCommands.ts b/packages/app/main/src/commands/botCommands.ts index 650b03dbb..0d5e70b72 100644 --- a/packages/app/main/src/commands/botCommands.ts +++ b/packages/app/main/src/commands/botCommands.ts @@ -71,6 +71,7 @@ import { chatWatcher, transcriptsWatcher, } from '../watchers'; +import { TelemetryService } from '../telemetry'; /** Registers bot commands */ export function registerCommands(commandRegistry: CommandRegistryImpl) { @@ -105,6 +106,8 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { throw e; } + const telemetryInfo = { path: bot.path, hasSecret: !!secret }; + TelemetryService.trackEvent('bot_create', telemetryInfo); return bot; } ); @@ -186,7 +189,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { chatsPath = path.join(dirName, './dialogs'), transcriptsPath = path.join(dirName, './transcripts'), } = botInfo; - const botFilePath = path.parse(botInfo.botFilePath || '').dir; + const botFilePath = path.parse(botInfo.path || '').dir; const relativeChatsPath = path.relative(botFilePath, chatsPath); const relativeTranscriptsPath = path.relative( botFilePath, @@ -300,6 +303,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { throw new Error('serviceType does not match'); } botConfig.connectService(service); + TelemetryService.trackEvent('service_add', { type: service.type }); } try { await saveBot(botConfig); diff --git a/packages/app/main/src/commands/electronCommands.spec.ts b/packages/app/main/src/commands/electronCommands.spec.ts index 6fd1a5dec..cf1bc40f2 100644 --- a/packages/app/main/src/commands/electronCommands.spec.ts +++ b/packages/app/main/src/commands/electronCommands.spec.ts @@ -39,6 +39,7 @@ import * as Electron from 'electron'; import { load } from '../botData/actions/botActions'; import { getStore } from '../botData/store'; import { mainWindow } from '../main'; +import { TelemetryService } from '../telemetry'; import { registerCommands } from './electronCommands'; @@ -50,6 +51,7 @@ jest.mock('fs-extra', () => ({ rename: async (...args: any[]) => (renameArgs = args), })); +let mockOpenExternal; jest.mock('electron', () => ({ app: { getName: () => 'BotFramework Emulator', @@ -65,6 +67,11 @@ jest.mock('electron', () => ({ showOpenDialog: () => void 0, showSaveDialog: () => void 0, }, + shell: { + get openExternal() { + return mockOpenExternal; + }, + }, })); jest.mock('../main', () => ({ @@ -140,6 +147,18 @@ const mockCommandRegistry = new CommandRegistryImpl(); registerCommands(mockCommandRegistry); describe('the electron commands', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; + }); + it('should show a message box', async () => { const { handler } = mockCommandRegistry.getCommand( SharedConstants.Commands.Electron.ShowMessageBox @@ -294,4 +313,16 @@ describe('the electron commands', () => { } expect(threw).toBeTruthy(); }); + + it('should open an external link', async () => { + mockOpenExternal = jest.fn(() => null); + const { handler } = mockCommandRegistry.getCommand( + SharedConstants.Commands.Electron.OpenExternal + ); + const url = 'https://aka.ms/bf-emulator-testing'; + await handler(url); + + expect(mockTrackEvent).toHaveBeenCalledWith('app_openLink', { url }); + expect(mockOpenExternal).toHaveBeenCalledWith(url, { activate: true }); + }); }); diff --git a/packages/app/main/src/commands/electronCommands.ts b/packages/app/main/src/commands/electronCommands.ts index 2d9dc8a8f..805b5a4cf 100644 --- a/packages/app/main/src/commands/electronCommands.ts +++ b/packages/app/main/src/commands/electronCommands.ts @@ -43,6 +43,7 @@ import { getStore } from '../botData/store'; import { mainWindow } from '../main'; import { ContextMenuService } from '../services/contextMenuService'; import { showOpenDialog, showSaveDialog } from '../utils'; +import { TelemetryService } from '../telemetry'; const { shell } = Electron; @@ -164,6 +165,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { // --------------------------------------------------------------------------- // Opens an external link commandRegistry.registerCommand(Commands.OpenExternal, (url: string) => { + TelemetryService.trackEvent('app_openLink', { url }); shell.openExternal(url, { activate: true }); }); diff --git a/packages/app/main/src/commands/emulatorCommands.spec.ts b/packages/app/main/src/commands/emulatorCommands.spec.ts index 3ba6be6c3..831d6d6b9 100644 --- a/packages/app/main/src/commands/emulatorCommands.spec.ts +++ b/packages/app/main/src/commands/emulatorCommands.spec.ts @@ -50,6 +50,7 @@ import * as utils from '../utils'; import * as botHelpers from '../botHelpers'; import { bot } from '../botData/reducers/bot'; import * as BotActions from '../botData/actions/botActions'; +import { TelemetryService } from '../telemetry'; import { mainWindow } from '../main'; import { registerCommands } from './emulatorCommands'; @@ -391,8 +392,17 @@ registerCommands(mockCommandRegistry); const { Emulator } = SharedConstants.Commands; describe('The emulatorCommands', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + beforeEach(() => { mockUsers = { users: {} }; + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + }); + + beforeAll(() => { + TelemetryService.trackEvent = trackEventBackup; }); it('should save a transcript to file based on the transcripts path in the botInfo', async () => { @@ -451,6 +461,7 @@ describe('The emulatorCommands', () => { newPath, Object.assign({}, mockInfo, { path: newPath }) ); + expect(mockTrackEvent).toHaveBeenCalledWith('transcript_save'); }); it('should feed a transcript from disk to a conversation', async () => { diff --git a/packages/app/main/src/commands/emulatorCommands.ts b/packages/app/main/src/commands/emulatorCommands.ts index 06a16c1fa..92db45f18 100644 --- a/packages/app/main/src/commands/emulatorCommands.ts +++ b/packages/app/main/src/commands/emulatorCommands.ts @@ -60,6 +60,7 @@ import { CustomActivity, } from '../utils/conversation'; import { botProjectFileWatcher } from '../watchers'; +import { TelemetryService } from '../telemetry'; /** Registers emulator (actual conversation emulation logic) commands */ export function registerCommands(commandRegistry: CommandRegistryImpl) { @@ -124,6 +125,7 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { mkdirpSync(path.dirname(filename)); const transcripts = await convo.getTranscript(); writeFile(filename, transcripts); + TelemetryService.trackEvent('transcript_save'); } } ); diff --git a/packages/app/main/src/commands/registerAllCommands.ts b/packages/app/main/src/commands/registerAllCommands.ts index 4935c66cf..20907f213 100644 --- a/packages/app/main/src/commands/registerAllCommands.ts +++ b/packages/app/main/src/commands/registerAllCommands.ts @@ -43,17 +43,19 @@ import { registerCommands as registerFileCommands } from './fileCommands'; import { registerCommands as registerNgrokCommands } from './ngrokCommands'; import { registerCommands as registerOAuthCommands } from './oauthCommands'; import { registerCommands as registerSettingsCommands } from './settingsCommands'; +import { registerCommands as registerTelemetryCommands } from './telemetryCommands'; /** Registers all commands */ export function registerAllCommands(commandRegistry: CommandRegistryImpl) { + registerAzureCommands(commandRegistry); registerBotCommands(commandRegistry); registerClientInitCommands(commandRegistry); registerElectronCommands(commandRegistry); registerEmulatorCommands(commandRegistry); registerFileCommands(commandRegistry); + registerLuisCommands(commandRegistry); registerNgrokCommands(commandRegistry); - registerAzureCommands(commandRegistry); registerOAuthCommands(commandRegistry); registerSettingsCommands(commandRegistry); - registerLuisCommands(commandRegistry); + registerTelemetryCommands(commandRegistry); } diff --git a/packages/app/main/src/commands/settingsCommands.spec.ts b/packages/app/main/src/commands/settingsCommands.spec.ts new file mode 100644 index 000000000..eb2577d8e --- /dev/null +++ b/packages/app/main/src/commands/settingsCommands.spec.ts @@ -0,0 +1,87 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { SharedConstants } from '@bfemulator/app-shared'; +import { CommandRegistryImpl } from '@bfemulator/sdk-shared'; + +import { TelemetryService } from '../telemetry'; +import { setFramework } from '../settingsData/actions/frameworkActions'; + +import { registerCommands } from './settingsCommands'; + +const mockSettings = { framework: { ngrokPath: 'path/to/ngrok.exe' } }; +let mockDispatch; +jest.mock('../settingsData/store', () => ({ + get dispatch() { + return mockDispatch; + }, + getSettings: () => mockSettings, +})); + +const mockRegistry = new CommandRegistryImpl(); +registerCommands(mockRegistry); + +describe('The settings commands', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + mockDispatch = jest.fn(() => null); + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; + }); + + it('should save the global app settings', async () => { + const { handler } = mockRegistry.getCommand( + SharedConstants.Commands.Settings.SaveAppSettings + ); + const mockSettings = { ngrokPath: 'other/path/to/ngrok.exe' }; + await handler(mockSettings); + + expect(mockTrackEvent).toHaveBeenCalledWith('app_configureNgrok'); + expect(mockDispatch).toHaveBeenCalledWith(setFramework(mockSettings)); + }); + + it('should load the app settings from the store', async () => { + const { handler } = mockRegistry.getCommand( + SharedConstants.Commands.Settings.LoadAppSettings + ); + const appSettings = await handler(); + + expect(appSettings).toBe(mockSettings.framework); + }); +}); diff --git a/packages/app/main/src/commands/settingsCommands.ts b/packages/app/main/src/commands/settingsCommands.ts index 44d95e8c5..3799d2246 100644 --- a/packages/app/main/src/commands/settingsCommands.ts +++ b/packages/app/main/src/commands/settingsCommands.ts @@ -36,6 +36,7 @@ import { CommandRegistryImpl } from '@bfemulator/sdk-shared'; import { setFramework } from '../settingsData/actions/frameworkActions'; import { dispatch, getSettings } from '../settingsData/store'; +import { TelemetryService } from '../telemetry'; /** Registers settings commands */ export function registerCommands(commandRegistry: CommandRegistryImpl) { @@ -46,6 +47,12 @@ export function registerCommands(commandRegistry: CommandRegistryImpl) { commandRegistry.registerCommand( Commands.SaveAppSettings, (settings: FrameworkSettings): any => { + const frameworkSettings = getSettings().framework; + const { ngrokPath = '' } = frameworkSettings; + const { ngrokPath: newNgrokPath } = settings; + if (newNgrokPath !== ngrokPath) { + TelemetryService.trackEvent('app_configureNgrok'); + } dispatch(setFramework(settings)); } ); diff --git a/packages/app/main/src/utils/isLocalHostUrl.spec.ts b/packages/app/main/src/commands/telemetryCommands.spec.ts similarity index 57% rename from packages/app/main/src/utils/isLocalHostUrl.spec.ts rename to packages/app/main/src/commands/telemetryCommands.spec.ts index 08709af94..3c0fb9e5f 100644 --- a/packages/app/main/src/utils/isLocalHostUrl.spec.ts +++ b/packages/app/main/src/commands/telemetryCommands.spec.ts @@ -30,14 +30,42 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { isLocalhostUrl } from './isLocalhostUrl'; -describe('The isLocalHostUrl util', () => { - it('should return true for urls that contain "localhost"', () => { - expect(isLocalhostUrl('http://localhost')).toBeTruthy(); +import { SharedConstants } from '@bfemulator/app-shared'; +import { CommandRegistryImpl } from '@bfemulator/sdk-shared'; + +import { TelemetryService } from '../telemetry'; + +import { registerCommands } from './telemetryCommands'; + +jest.mock('../settingsData/store', () => ({ + getSettings: () => ({ + framework: {}, + }), +})); + +const mockRegistry = new CommandRegistryImpl(); +registerCommands(mockRegistry); + +describe('The telemetry commands', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => Promise.resolve()); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; }); - it('should return true for urls that contain "127.0.0.1" ', () => { - expect(isLocalhostUrl('http://127.0.0.1')).toBeTruthy(); + it('should track events to App Insights', async () => { + const { handler } = mockRegistry.getCommand( + SharedConstants.Commands.Telemetry.TrackEvent + ); + await handler('test_event', { some: 'data' }); + + expect(mockTrackEvent).toHaveBeenCalledWith('test_event', { some: 'data' }); }); }); diff --git a/packages/emulator/core/src/utils/isLocalhostUrl.ts b/packages/app/main/src/commands/telemetryCommands.ts similarity index 68% rename from packages/emulator/core/src/utils/isLocalhostUrl.ts rename to packages/app/main/src/commands/telemetryCommands.ts index 8851a80e7..21294a887 100644 --- a/packages/emulator/core/src/utils/isLocalhostUrl.ts +++ b/packages/app/main/src/commands/telemetryCommands.ts @@ -31,12 +31,21 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { parse } from 'url'; +import { CommandRegistryImpl } from '@bfemulator/sdk-shared'; +import { SharedConstants } from '@bfemulator/app-shared'; -const LOCALHOST_NAMES = ['localhost', '127.0.0.1', '::1']; +import { TelemetryService } from '../telemetry'; -export default function isLocalhostUrl(urlStr: string): boolean { - const { hostname } = parse(urlStr); +/** Registers telemetry commands */ +export function registerCommands(commandRegistry: CommandRegistryImpl) { + const Commands = SharedConstants.Commands.Telemetry; - return LOCALHOST_NAMES.includes(hostname); + // --------------------------------------------------------------------------- + // Track event to App Insights + commandRegistry.registerCommand( + Commands.TrackEvent, + (name: string, properties?: { [key: string]: any }): any => { + TelemetryService.trackEvent(name, properties); + } + ); } diff --git a/packages/app/main/src/main.ts b/packages/app/main/src/main.ts index bdf134e5f..10d21fafd 100644 --- a/packages/app/main/src/main.ts +++ b/packages/app/main/src/main.ts @@ -77,11 +77,17 @@ import { import { openFileFromCommandLine } from './utils/openFileFromCommandLine'; import { sendNotificationToClient } from './utils/sendNotificationToClient'; import { WindowManager } from './windowManager'; +import { Protocol } from './constants'; +import { TelemetryService } from './telemetry'; export let mainWindow: Window; export let windowManager: WindowManager; +// start app startup timer +const beginStartupTime = Date.now(); + const store = getStore(); + // ----------------------------------------------------------------------------- (process as NodeJS.EventEmitter).on('uncaughtException', (error: Error) => { // eslint-disable-next-line no-console @@ -492,6 +498,15 @@ const createMainWindow = async () => { await openFileFromCommandLine(fileToOpen, mainWindow.commandService); fileToOpen = null; } + + // log app startup time in seconds + const endStartupTime = Date.now(); + const startupTime = (endStartupTime - beginStartupTime) / 1000; + const launchedByProtocol = process.argv.some(arg => arg.includes(Protocol)); + TelemetryService.trackEvent('app_launch', { + method: launchedByProtocol ? 'protocol' : 'binary', + startupTime, + }); }); mainWindow.browserWindow.once('close', async function(event: Event) { diff --git a/packages/app/main/src/ngrokService.ts b/packages/app/main/src/ngrokService.ts index 602cec5ba..3440c6671 100644 --- a/packages/app/main/src/ngrokService.ts +++ b/packages/app/main/src/ngrokService.ts @@ -36,6 +36,7 @@ import { promisify } from 'util'; import { FrameworkSettings } from '@bfemulator/app-shared'; import { ILogItem } from '@bfemulator/sdk-shared'; import { LogLevel } from '@bfemulator/sdk-shared'; +import { isLocalHostUrl } from '@bfemulator/sdk-shared'; import { appSettingsItem, exceptionItem, @@ -48,7 +49,6 @@ import { emulator } from './emulator'; import { mainWindow } from './main'; import * as ngrok from './ngrok'; import { getStore } from './settingsData/store'; -import { isLocalhostUrl } from './utils'; let ngrokInstance: NgrokService; @@ -68,7 +68,7 @@ export class NgrokService { public async getServiceUrl(botUrl: string): Promise { const bypassNgrokLocalhost = getStore().getState().framework .bypassNgrokLocalhost; - if (botUrl && isLocalhostUrl(botUrl) && bypassNgrokLocalhost) { + if (botUrl && isLocalHostUrl(botUrl) && bypassNgrokLocalhost) { // Do not use ngrok const port = emulator.framework.serverPort; diff --git a/packages/app/main/src/protocolHandler.spec.ts b/packages/app/main/src/protocolHandler.spec.ts index f4818d6d0..44173c68a 100644 --- a/packages/app/main/src/protocolHandler.spec.ts +++ b/packages/app/main/src/protocolHandler.spec.ts @@ -40,18 +40,110 @@ // will be overwritten due to the call to "jest.mock('./main', ...)" import './fetchProxy'; +import { SharedConstants, newBot, newEndpoint } from '@bfemulator/app-shared'; +import { + applyBotConfigOverrides, + BotConfigWithPathImpl, +} from '@bfemulator/sdk-shared'; + import { Protocol, ProtocolHandler, parseEndpointOverrides, } from './protocolHandler'; -jest.mock('./main', () => ({})); +import { TelemetryService } from './telemetry'; + +let mockCallsMade, mockRemoteCallsMade; +let mockOpenedBot; +const mockSharedConstants = SharedConstants; +jest.mock('./main', () => ({ + mainWindow: { + commandService: { + call: (commandName, ...args) => { + mockCallsMade.push({ commandName, args }); + if (commandName === mockSharedConstants.Commands.Bot.Open) { + return Promise.resolve(mockOpenedBot); + } + }, + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + }, + }, + }, +})); jest.mock('./globals', () => ({ getGlobal: () => ({}), setGlobal: () => null, })); +let mockNgrokPath; +jest.mock('./settingsData/store', () => ({ + getSettings: () => ({ + framework: { + ngrokPath: mockNgrokPath, + }, + }), +})); +jest.mock('./emulator', () => ({ + emulator: { + ngrok: { + getSpawnStatus: () => ({ triedToSpawn: true }), + }, + }, +})); + +let mockRunningStatus; +jest.mock('./ngrok', () => ({ + ngrokEmitter: { + once: (_eventName, cb) => cb(), + }, + running: () => mockRunningStatus, +})); + +let mockSendNotificationToClient; +jest.mock('./utils/sendNotificationToClient', () => ({ + sendNotificationToClient: () => mockSendNotificationToClient(), +})); + +let mockGotReturnValue; +jest.mock('got', () => { + return jest.fn(() => Promise.resolve(mockGotReturnValue)); +}); + describe('Protocol handler tests', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => null); + TelemetryService.trackEvent = mockTrackEvent; + mockCallsMade = []; + mockRemoteCallsMade = []; + mockOpenedBot = { + name: 'someBot', + description: '', + path: 'path/to/bot.bot', + services: [ + { + appId: 'someAppId', + appPassword: 'somePw', + endpoint: 'https://www.myendpoint.com', + }, + ], + }; + mockRunningStatus = true; + mockNgrokPath = 'path/to/ngrok.exe'; + mockSendNotificationToClient = jest.fn(() => null); + mockGotReturnValue = { + statusCode: 200, + body: '["activity1", "activity2", "activity3"]', + }; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; + }); + describe('parseProtocolUrl() functionality', () => { it('should return an info object about the parsed URL', () => { const info: Protocol = ProtocolHandler.parseProtocolUrl( @@ -108,7 +200,7 @@ describe('Protocol handler tests', () => { ProtocolHandler.performTranscriptAction = tmpPerformTranscriptAction; }); - it('shouldn\t do anything on an unrecognized action', () => { + it("shouldn't do anything on an unrecognized action", () => { const mockPerformBotAction = jest.fn(() => null); ProtocolHandler.performBotAction = mockPerformBotAction; const mockPerformLiveChatAction = jest.fn(() => null); @@ -188,7 +280,249 @@ describe('Protocol handler tests', () => { }); }); - // unmock mainWindow - jest.unmock('./main'); - jest.unmock('./globals'); + it('should open a bot when ngrok is running', async () => { + const protocol = { + parsedArgs: { + id: 'someIdOverride', + path: 'path/to/bot.bot', + secret: 'someSecret', + }, + }; + const overrides = { endpoint: parseEndpointOverrides(protocol.parsedArgs) }; + const overriddenBot = applyBotConfigOverrides(mockOpenedBot, overrides); + + await ProtocolHandler.openBot(protocol); + + expect(mockCallsMade).toHaveLength(2); + expect(mockCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Open + ); + expect(mockCallsMade[0].args).toEqual(['path/to/bot.bot', 'someSecret']); + expect(mockCallsMade[1].commandName).toBe( + SharedConstants.Commands.Bot.SetActive + ); + expect(mockCallsMade[1].args).toEqual([overriddenBot]); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Load + ); + expect(mockRemoteCallsMade[0].args).toEqual([overriddenBot]); + expect(mockTrackEvent).toHaveBeenCalledWith('bot_open', { + method: 'protocol', + numOfServices: 1, + }); + }); + + it('should open a bot when ngrok is configured but not running', async () => { + mockRunningStatus = false; + const protocol = { + parsedArgs: { + id: 'someIdOverride', + path: 'path/to/bot.bot', + secret: 'someSecret', + }, + }; + const overrides = { endpoint: parseEndpointOverrides(protocol.parsedArgs) }; + const overriddenBot = applyBotConfigOverrides(mockOpenedBot, overrides); + + await ProtocolHandler.openBot(protocol); + + expect(mockCallsMade).toHaveLength(2); + expect(mockCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Open + ); + expect(mockCallsMade[0].args).toEqual(['path/to/bot.bot', 'someSecret']); + expect(mockCallsMade[1].commandName).toBe( + SharedConstants.Commands.Bot.SetActive + ); + expect(mockCallsMade[1].args).toEqual([overriddenBot]); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Load + ); + expect(mockRemoteCallsMade[0].args).toEqual([overriddenBot]); + expect(mockTrackEvent).toHaveBeenCalledWith('bot_open', { + method: 'protocol', + numOfServices: 1, + }); + }); + + it('should open a bot when ngrok is not configured', async () => { + mockNgrokPath = undefined; + const protocol = { + parsedArgs: { + id: 'someIdOverride', + path: 'path/to/bot.bot', + secret: 'someSecret', + }, + }; + const overrides = { endpoint: parseEndpointOverrides(protocol.parsedArgs) }; + const overriddenBot = applyBotConfigOverrides(mockOpenedBot, overrides); + + await ProtocolHandler.openBot(protocol); + + expect(mockCallsMade).toHaveLength(2); + expect(mockCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Open + ); + expect(mockCallsMade[0].args).toEqual(['path/to/bot.bot', 'someSecret']); + expect(mockCallsMade[1].commandName).toBe( + SharedConstants.Commands.Bot.SetActive + ); + expect(mockCallsMade[1].args).toEqual([overriddenBot]); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.Load + ); + expect(mockRemoteCallsMade[0].args).toEqual([overriddenBot]); + expect(mockTrackEvent).toHaveBeenCalledWith('bot_open', { + method: 'protocol', + numOfServices: 1, + }); + }); + + it('should open a livechat if ngrok is running', async () => { + const protocol = { + parsedArgs: { + botUrl: 'someUrl', + msaAppId: 'someAppId', + msaPassword: 'somePw', + }, + }; + const mockedBot = BotConfigWithPathImpl.fromJSON(newBot()); + mockedBot.name = ''; + mockedBot.path = SharedConstants.TEMP_BOT_IN_MEMORY_PATH; + + const mockEndpoint = newEndpoint(); + mockEndpoint.appId = protocol.parsedArgs.msaAppId; + mockEndpoint.appPassword = protocol.parsedArgs.msaPassword; + mockEndpoint.id = mockEndpoint.endpoint = protocol.parsedArgs.botUrl; + mockEndpoint.name = 'New livechat'; + mockedBot.services.push(mockEndpoint); + + await ProtocolHandler.openLiveChat(protocol); + + expect(mockCallsMade).toHaveLength(1); + expect(mockCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.RestartEndpointService + ); + expect(mockCallsMade[0].args).toEqual([]); + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.SetActive + ); + expect(mockRemoteCallsMade[0].args).toEqual([mockedBot, '']); + expect(mockRemoteCallsMade[1].commandName).toBe( + SharedConstants.Commands.Emulator.NewLiveChat + ); + expect(mockRemoteCallsMade[1].args).toEqual([mockEndpoint]); + }); + + it('should open a livechat if ngrok is configured but not running', async () => { + mockRunningStatus = false; + const protocol = { + parsedArgs: { + botUrl: 'someUrl', + msaAppId: 'someAppId', + msaPassword: 'somePw', + }, + }; + const mockedBot = BotConfigWithPathImpl.fromJSON(newBot()); + mockedBot.name = ''; + mockedBot.path = SharedConstants.TEMP_BOT_IN_MEMORY_PATH; + + const mockEndpoint = newEndpoint(); + mockEndpoint.appId = protocol.parsedArgs.msaAppId; + mockEndpoint.appPassword = protocol.parsedArgs.msaPassword; + mockEndpoint.id = mockEndpoint.endpoint = protocol.parsedArgs.botUrl; + mockEndpoint.name = 'New livechat'; + mockedBot.services.push(mockEndpoint); + + await ProtocolHandler.openLiveChat(protocol); + + expect(mockCallsMade).toHaveLength(1); + expect(mockCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.RestartEndpointService + ); + expect(mockCallsMade[0].args).toEqual([]); + expect(mockRemoteCallsMade).toHaveLength(2); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Bot.SetActive + ); + expect(mockRemoteCallsMade[0].args).toEqual([mockedBot, '']); + expect(mockRemoteCallsMade[1].commandName).toBe( + SharedConstants.Commands.Emulator.NewLiveChat + ); + expect(mockRemoteCallsMade[1].args).toEqual([mockEndpoint]); + }); + + it('should open a livechat if ngrok is not configured', async () => { + mockNgrokPath = undefined; + const protocol = { + parsedArgs: { + botUrl: 'someUrl', + msaAppId: 'someAppId', + msaPassword: 'somePw', + }, + }; + const mockedBot = BotConfigWithPathImpl.fromJSON(newBot()); + mockedBot.name = ''; + mockedBot.path = SharedConstants.TEMP_BOT_IN_MEMORY_PATH; + + const mockEndpoint = newEndpoint(); + mockEndpoint.appId = protocol.parsedArgs.msaAppId; + mockEndpoint.appPassword = protocol.parsedArgs.msaPassword; + mockEndpoint.id = mockEndpoint.endpoint = protocol.parsedArgs.botUrl; + mockEndpoint.name = 'New livechat'; + mockedBot.services.push(mockEndpoint); + + await ProtocolHandler.openLiveChat(protocol); + + expect(mockCallsMade).toHaveLength(0); + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Emulator.NewLiveChat + ); + expect(mockRemoteCallsMade[0].args).toEqual([mockEndpoint]); + }); + + it('should open a transcript from a url', async () => { + const protocol = { + parsedArgs: { url: 'https://www.test.com/convo1.transcript' }, + }; + + await ProtocolHandler.openTranscript(protocol); + + expect(mockRemoteCallsMade).toHaveLength(1); + expect(mockRemoteCallsMade[0].commandName).toBe( + SharedConstants.Commands.Emulator.OpenTranscript + ); + expect(mockRemoteCallsMade[0].args).toEqual([ + 'deepLinkedTranscript', + { + activities: ['activity1', 'activity2', 'activity3'], + inMemory: true, + fileName: 'convo1.transcript', + }, + ]); + }); + + it('should send a notification if trying to open a transcript from a url results in a 401 or 404', async () => { + const protocol = { + parsedArgs: { url: 'https://www.test.com/convo1.transcript' }, + }; + mockGotReturnValue = { statusCode: 401 }; + + await ProtocolHandler.openTranscript(protocol); + + expect(mockRemoteCallsMade).toHaveLength(0); + expect(mockSendNotificationToClient).toHaveBeenCalledTimes(1); + + mockGotReturnValue = { statusCode: 404 }; + + await ProtocolHandler.openTranscript(protocol); + + expect(mockRemoteCallsMade).toHaveLength(0); + expect(mockSendNotificationToClient).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/app/main/src/protocolHandler.ts b/packages/app/main/src/protocolHandler.ts index f45eeb070..cbb709964 100644 --- a/packages/app/main/src/protocolHandler.ts +++ b/packages/app/main/src/protocolHandler.ts @@ -41,14 +41,14 @@ import { newNotification, SharedConstants, } from '@bfemulator/app-shared'; +import got from 'got'; +import { IEndpointService } from 'botframework-config/lib/schema'; import { applyBotConfigOverrides, BotConfigOverrides, BotConfigWithPath, BotConfigWithPathImpl, } from '@bfemulator/sdk-shared'; -import { IEndpointService } from 'botframework-config/lib/schema'; -import got from 'got'; import * as BotActions from './botData/actions/botActions'; import { getStore } from './botData/store'; @@ -58,6 +58,7 @@ import { mainWindow } from './main'; import { ngrokEmitter, running } from './ngrok'; import { getSettings } from './settingsData/store'; import { sendNotificationToClient } from './utils/sendNotificationToClient'; +import { TelemetryService } from './telemetry'; enum ProtocolDomains { livechat, @@ -262,7 +263,7 @@ export const ProtocolHandler = new class ProtocolHandlerImpl const { url } = protocol.parsedArgs; const options = { url }; - got(options) + return got(options) .then(res => { if (/^2\d\d$/.test(res.statusCode)) { if (res.body) { @@ -334,12 +335,12 @@ export const ProtocolHandler = new class ProtocolHandlerImpl ); if (!bot) { throw new Error( - `Error occurred while trying to open bot at: ${path} inside of protocol handler.` + `Error occurred while trying to open bot at ${path} inside of protocol handler: Bot is invalid.` ); } } catch (e) { throw new Error( - `Error occurred while trying to open bot at: ${path} inside of protocol handler.` + `Error occurred while trying to open bot at ${path} inside of protocol handler: ${e}` ); } @@ -373,7 +374,7 @@ export const ProtocolHandler = new class ProtocolHandlerImpl ); } catch (e) { throw new Error( - `(ngrok running) Error occurred while trying to deep link to bot project at: ${path}.` + `(ngrok running) Error occurred while trying to deep link to bot project at ${path}: ${e}` ); } } else { @@ -392,7 +393,8 @@ export const ProtocolHandler = new class ProtocolHandlerImpl ); } catch (e) { throw new Error( - `(ngrok running but not connected) Error occurred while trying to deep link to bot project at: ${path}.` + `(ngrok running but not connected) Error occurred while ` + + `trying to deep link to bot project at ${path}: ${e}` ); } } @@ -410,10 +412,15 @@ export const ProtocolHandler = new class ProtocolHandlerImpl ); } catch (e) { throw new Error( - `(ngrok not configured) Error occurred while trying to deep link to bot project at: ${path}` + `(ngrok not configured) Error occurred while trying to deep link to bot project at ${path}: ${e}` ); } } + const numOfServices = bot.services && bot.services.length; + TelemetryService.trackEvent('bot_open', { + method: 'protocol', + numOfServices, + }); } }(); diff --git a/packages/app/main/src/utils/isLocalhostUrl.ts b/packages/app/main/src/telemetry/index.ts similarity index 86% rename from packages/app/main/src/utils/isLocalhostUrl.ts rename to packages/app/main/src/telemetry/index.ts index 7cee3a785..315830c05 100644 --- a/packages/app/main/src/utils/isLocalhostUrl.ts +++ b/packages/app/main/src/telemetry/index.ts @@ -31,11 +31,4 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import * as url from 'url'; - -export const isLocalhostUrl = (urlStr: string): boolean => { - const parsedUrl = url.parse(urlStr); - return ( - parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1' - ); -}; +export * from './telemetryService'; diff --git a/packages/app/main/src/telemetry/telemetryService.spec.ts b/packages/app/main/src/telemetry/telemetryService.spec.ts new file mode 100644 index 000000000..5815e21da --- /dev/null +++ b/packages/app/main/src/telemetry/telemetryService.spec.ts @@ -0,0 +1,149 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { TelemetryService } from './telemetryService'; + +let mockAppInsights; +let mockSetup; +let mockDefaultClient; +let mockStart; +jest.mock('applicationinsights', () => ({ + get defaultClient() { + return mockDefaultClient; + }, + get setup() { + return mockSetup; + }, + get start() { + return mockStart; + }, +})); + +let mockSettings; +jest.mock('../settingsData/store', () => ({ + getSettings: () => mockSettings, +})); + +describe('TelemetryService', () => { + let tmpClient; + let tmpStartup; + + beforeEach(() => { + mockDefaultClient = { + context: { + keys: { + cloudRoleInstance: 'cloudRoleInstance', + }, + tags: { + cloudRoleInstance: 'SOME-MACHINE-NAME', + }, + }, + }; + mockAppInsights = {}; + mockSettings = { framework: { collectUsageData: true } }; + mockSetup = jest.fn((_iKey: string) => mockAppInsights); + mockStart = jest.fn(() => null); + tmpClient = (TelemetryService as any)._client; + tmpStartup = (TelemetryService as any).startup; + }); + + afterEach(() => { + (TelemetryService as any)._client = tmpClient; + (TelemetryService as any).startup = tmpStartup; + }); + + it('should startup', () => { + const mockAutoCollect = jest.fn(_config => mockAppInsights); + mockAppInsights = { + setAutoCollectConsole: mockAutoCollect, + setAutoCollectDependencies: mockAutoCollect, + setAutoCollectExceptions: mockAutoCollect, + setAutoCollectPerformance: mockAutoCollect, + setAutoCollectRequests: mockAutoCollect, + }; + (TelemetryService as any).startup(); + + expect(mockSetup).toHaveBeenCalledTimes(1); + expect(mockSetup).toHaveBeenCalledWith( + '631faf57-1d84-40b4-9a71-fce28a3934a8' + ); + expect(mockAutoCollect).toHaveBeenCalledTimes(5); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockDefaultClient.context.tags.cloudRoleInstance).toBe(''); + expect((TelemetryService as any)._hasStarted).toBe(true); + expect((TelemetryService as any)._client).toBe(mockDefaultClient); + }); + + it('should toggle enabled / disabled state based on app settings', () => { + mockSettings = { framework: { collectUsageData: false } }; + expect((TelemetryService as any).enabled).toBe(false); + + mockSettings = { framework: { collectUsageData: true } }; + expect((TelemetryService as any).enabled).toBe(true); + }); + + it('should not track events if disabled or if no name is provided', () => { + const mockAITrackEvent = jest.fn((_name, _properties) => null); + (TelemetryService as any)._client = { trackEvent: mockAITrackEvent }; + + mockSettings = { framework: { collectUsageData: false } }; + TelemetryService.trackEvent(null, null); + expect(mockAITrackEvent).not.toHaveBeenCalled(); + + mockSettings = { framework: { collectUsageData: true } }; + TelemetryService.trackEvent('', {}); + expect(mockAITrackEvent).not.toHaveBeenCalled(); + }); + + it('should track events', () => { + const mockStartup = jest.fn(() => null); + (TelemetryService as any).startup = mockStartup; + const mockAutoCollect = jest.fn(_config => mockAppInsights); + mockAppInsights = { + setAutoCollectConsole: mockAutoCollect, + setAutoCollectDependencies: mockAutoCollect, + setAutoCollectExceptions: mockAutoCollect, + setAutoCollectPerformance: mockAutoCollect, + setAutoCollectRequests: mockAutoCollect, + }; + const mockAITrackEvent = jest.fn((_name, _properties) => null); + (TelemetryService as any)._client = { trackEvent: mockAITrackEvent }; + + TelemetryService.trackEvent('someEvent', { some: 'property' }); + expect(mockStartup).toHaveBeenCalled; + expect(mockAITrackEvent).toHaveBeenCalledWith({ + name: 'someEvent', + properties: { some: 'property' }, + }); + }); +}); diff --git a/packages/app/main/src/telemetry/telemetryService.ts b/packages/app/main/src/telemetry/telemetryService.ts new file mode 100644 index 000000000..c126e97da --- /dev/null +++ b/packages/app/main/src/telemetry/telemetryService.ts @@ -0,0 +1,90 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import * as AppInsights from 'applicationinsights'; +import { SettingsImpl } from '@bfemulator/app-shared'; + +import { getSettings } from '../settingsData/store'; + +const INSTRUMENTATION_KEY = '631faf57-1d84-40b4-9a71-fce28a3934a8'; + +export class TelemetryService { + private static _client: AppInsights.TelemetryClient; + private static _hasStarted: boolean = false; + + public static trackEvent( + name: string, + properties?: { [key: string]: any } + ): void { + if (!this.enabled || !name) { + return; + } + if (!this._client) { + this.startup(); + } + try { + this._client.trackEvent({ name, properties }); + } catch (e) { + // swallow the exception; we don't want to crash the app + // on a failed attempt to collect usage data + } + } + + private static get enabled(): boolean { + const settings: SettingsImpl = getSettings() || ({} as SettingsImpl); + const { framework = {} } = settings; + return framework.collectUsageData; + } + + private static startup(): void { + if (!this._hasStarted) { + AppInsights.setup(INSTRUMENTATION_KEY) + // turn off extra instrumentation + .setAutoCollectConsole(false) + .setAutoCollectDependencies(false) + .setAutoCollectExceptions(false) + .setAutoCollectPerformance(false) + .setAutoCollectRequests(false) + // Fix for zonejs / restify conflict (https://github.com/Microsoft/ApplicationInsights-node.js/issues/460) + .setAutoDependencyCorrelation(false); + // do not collect the user's machine name + AppInsights.defaultClient.context.tags[ + AppInsights.defaultClient.context.keys.cloudRoleInstance + ] = ''; + AppInsights.start(); + + this._client = AppInsights.defaultClient; + this._hasStarted = true; + } + } +} diff --git a/packages/app/main/src/utils/index.ts b/packages/app/main/src/utils/index.ts index 61109ecda..d73d368f6 100644 --- a/packages/app/main/src/utils/index.ts +++ b/packages/app/main/src/utils/index.ts @@ -39,7 +39,6 @@ export * from './getDirectories'; export * from './getFilesInDir'; export * from './getSafeBotName'; export * from './isDev'; -export * from './isLocalhostUrl'; export * from './loadSettings'; export * from './parseActivitiesFromChatFile'; export * from './readFileSync'; diff --git a/packages/app/main/src/utils/openFileFromCommandLine.spec.ts b/packages/app/main/src/utils/openFileFromCommandLine.spec.ts index fc943b3ea..66b923c85 100644 --- a/packages/app/main/src/utils/openFileFromCommandLine.spec.ts +++ b/packages/app/main/src/utils/openFileFromCommandLine.spec.ts @@ -30,6 +30,7 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + import { CommandHandler, CommandRegistry, @@ -38,9 +39,13 @@ import { DisposableImpl, } from '@bfemulator/sdk-shared'; +import { TelemetryService } from '../telemetry'; + import { openFileFromCommandLine } from './openFileFromCommandLine'; -import * as readFileSyncUtil from './readFileSync'; +jest.mock('../settingsData/store', () => ({ + getSettings: () => null, +})); jest.mock('./readFileSync', () => ({ readFileSync: file => { if (file.includes('error.transcript')) { @@ -78,8 +83,17 @@ class MockCommandService extends DisposableImpl implements CommandService { describe('The openFileFromCommandLine util', () => { let commandService: MockCommandService; + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + beforeEach(() => { commandService = new MockCommandService(); + mockTrackEvent = jest.fn(() => null); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; }); it('should make the appropriate calls to open a .bot file', async () => { @@ -103,6 +117,9 @@ describe('The openFileFromCommandLine util', () => { }, ], ]); + expect(mockTrackEvent).toHaveBeenCalledWith('transcriptFile_open', { + method: 'protocol', + }); }); it('should throw when the transcript is not an array', async () => { diff --git a/packages/app/main/src/utils/openFileFromCommandLine.ts b/packages/app/main/src/utils/openFileFromCommandLine.ts index d8a480491..8d8f011d0 100644 --- a/packages/app/main/src/utils/openFileFromCommandLine.ts +++ b/packages/app/main/src/utils/openFileFromCommandLine.ts @@ -30,11 +30,14 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + import * as path from 'path'; import { SharedConstants } from '@bfemulator/app-shared'; import { CommandService } from '@bfemulator/sdk-shared'; +import { TelemetryService } from '../telemetry'; + import { readFileSync } from './readFileSync'; export async function openFileFromCommandLine( @@ -71,5 +74,6 @@ export async function openFileFromCommandLine( inMemory: true, } ); + TelemetryService.trackEvent('transcriptFile_open', { method: 'protocol' }); } } diff --git a/packages/app/shared/src/constants/sharedConstants.ts b/packages/app/shared/src/constants/sharedConstants.ts index b1c72b82f..f8afaa5bb 100644 --- a/packages/app/shared/src/constants/sharedConstants.ts +++ b/packages/app/shared/src/constants/sharedConstants.ts @@ -149,6 +149,10 @@ export const SharedConstants = { PushClientAwareSettings: 'push-client-aware-settings', }, + Telemetry: { + TrackEvent: 'telemetry:track-event', + }, + UI: { ShowWelcomePage: 'welcome-page:showExplorer', ShowBotCreationDialog: 'bot-creation:showExplorer', diff --git a/packages/app/shared/src/index.ts b/packages/app/shared/src/index.ts index b8d84af74..6db4884cd 100644 --- a/packages/app/shared/src/index.ts +++ b/packages/app/shared/src/index.ts @@ -31,9 +31,9 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -export * from './paymentEncoder'; export * from './activityVisitor'; -export * from './utils'; -export * from './types'; export * from './constants'; export * from './enums'; +export * from './paymentEncoder'; +export * from './types'; +export * from './utils'; diff --git a/packages/app/shared/src/types/serverSettingsTypes.ts b/packages/app/shared/src/types/serverSettingsTypes.ts index d99b7d97c..8b013d896 100644 --- a/packages/app/shared/src/types/serverSettingsTypes.ts +++ b/packages/app/shared/src/types/serverSettingsTypes.ts @@ -54,6 +54,8 @@ export interface FrameworkSettings { autoUpdate?: boolean; // enables pre-release updates usePrereleases?: boolean; + // enables instrumentation + collectUsageData?: boolean; } export interface WindowStateSettings { @@ -125,6 +127,7 @@ export const frameworkDefault: FrameworkSettings = { locale: '', usePrereleases: false, autoUpdate: true, + collectUsageData: true, }; export const windowStateDefault: WindowStateSettings = { diff --git a/packages/emulator/core/src/directLine/middleware/directLineMiddleware.spec.ts b/packages/emulator/core/src/directLine/middleware/directLineMiddleware.spec.ts index 71eac6c80..e08ecbcf4 100644 --- a/packages/emulator/core/src/directLine/middleware/directLineMiddleware.spec.ts +++ b/packages/emulator/core/src/directLine/middleware/directLineMiddleware.spec.ts @@ -74,7 +74,11 @@ describe('The directLine middleware', () => { }; emulator = { facilities: { - logger: { logMessage: () => true, logActivity: () => true }, + logger: { + logMessage: () => true, + logActivity: () => true, + logException: () => null, + }, }, } as any; emulator.facilities.conversations = new ConversationSet(); diff --git a/packages/emulator/core/src/facility/conversation.ts b/packages/emulator/core/src/facility/conversation.ts index d1d9d5f61..628375bc9 100644 --- a/packages/emulator/core/src/facility/conversation.ts +++ b/packages/emulator/core/src/facility/conversation.ts @@ -31,6 +31,10 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // +import { EventEmitter } from 'events'; + +import * as HttpStatus from 'http-status-codes'; +import updateIn from 'simple-update-in'; import { Activity, appSettingsItem, @@ -43,6 +47,7 @@ import { externalLinkItem, GenericActivity, InvokeActivity, + isLocalHostUrl, LogLevel, MessageActivity, PaymentOperations, @@ -54,15 +59,11 @@ import { TranscriptRecord, User, } from '@bfemulator/sdk-shared'; -import { EventEmitter } from 'events'; -import * as HttpStatus from 'http-status-codes'; -import updateIn from 'simple-update-in'; import { BotEmulator } from '../botEmulator'; import { TokenCache } from '../userToken/tokenCache'; import createAPIException from '../utils/createResponse/apiException'; import createResourceResponse from '../utils/createResponse/resource'; -import isLocalhostUrl from '../utils/isLocalhostUrl'; import OAuthClientEncoder from '../utils/oauthClientEncoder'; import PaymentEncoder from '../utils/paymentEncoder'; import uniqueId from '../utils/uniqueId'; @@ -157,8 +158,8 @@ export default class Conversation extends EventEmitter { if ( !this.conversationIsTranscript && - !isLocalhostUrl(this.botEndpoint.botUrl) && - isLocalhostUrl(activity.serviceUrl) + !isLocalHostUrl(this.botEndpoint.botUrl) && + isLocalHostUrl(activity.serviceUrl) ) { this.botEmulator.facilities.logger.logMessage( this.conversationId, @@ -864,6 +865,6 @@ class DataUrlEncoder { } protected shouldBeDataUrl(url: string): boolean { - return url && (isLocalhostUrl(url) || url.indexOf('ngrok') !== -1); + return url && (isLocalHostUrl(url) || url.indexOf('ngrok') !== -1); } } diff --git a/packages/extensions/debug/client/public/qna.js b/packages/extensions/debug/client/public/qna.js index fa0467ddf..fc721a467 100644 --- a/packages/extensions/debug/client/public/qna.js +++ b/packages/extensions/debug/client/public/qna.js @@ -458,7 +458,7 @@ object-assign }, function(e, t) { e.exports = - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4NDEuOSA1OTUuMyI+CiAgICA8ZyBmaWxsPSIjNjFEQUZCIj4KICAgICAgICA8cGF0aCBkPSJNNjY2LjMgMjk2LjVjMC0zMi41LTQwLjctNjMuMy0xMDMuMS04Mi40IDE0LjQtNjMuNiA4LTExNC4yLTIwLjItMTMwLjQtNi41LTMuOC0xNC4xLTUuNi0yMi40LTUuNnYyMi4zYzQuNiAwIDguMy45IDExLjQgMi42IDEzLjYgNy44IDE5LjUgMzcuNSAxNC45IDc1LjctMS4xIDkuNC0yLjkgMTkuMy01LjEgMjkuNC0xOS42LTQuOC00MS04LjUtNjMuNS0xMC45LTEzLjUtMTguNS0yNy41LTM1LjMtNDEuNi01MCAzMi42LTMwLjMgNjMuMi00Ni45IDg0LTQ2LjlWNzhjLTI3LjUgMC02My41IDE5LjYtOTkuOSA1My42LTM2LjQtMzMuOC03Mi40LTUzLjItOTkuOS01My4ydjIyLjNjMjAuNyAwIDUxLjQgMTYuNSA4NCA0Ni42LTE0IDE0LjctMjggMzEuNC00MS4zIDQ5LjktMjIuNiAyLjQtNDQgNi4xLTYzLjYgMTEtMi4zLTEwLTQtMTkuNy01LjItMjktNC43LTM4LjIgMS4xLTY3LjkgMTQuNi03NS44IDMtMS44IDYuOS0yLjYgMTEuNS0yLjZWNzguNWMtOC40IDAtMTYgMS44LTIyLjYgNS42LTI4LjEgMTYuMi0zNC40IDY2LjctMTkuOSAxMzAuMS02Mi4yIDE5LjItMTAyLjcgNDkuOS0xMDIuNyA4Mi4zIDAgMzIuNSA0MC43IDYzLjMgMTAzLjEgODIuNC0xNC40IDYzLjYtOCAxMTQuMiAyMC4yIDEzMC40IDYuNSAzLjggMTQuMSA1LjYgMjIuNSA1LjYgMjcuNSAwIDYzLjUtMTkuNiA5OS45LTUzLjYgMzYuNCAzMy44IDcyLjQgNTMuMiA5OS45IDUzLjIgOC40IDAgMTYtMS44IDIyLjYtNS42IDI4LjEtMTYuMiAzNC40LTY2LjcgMTkuOS0xMzAuMSA2Mi0xOS4xIDEwMi41LTQ5LjkgMTAyLjUtODIuM3ptLTEzMC4yLTY2LjdjLTMuNyAxMi45LTguMyAyNi4yLTEzLjUgMzkuNS00LjEtOC04LjQtMTYtMTMuMS0yNC00LjYtOC05LjUtMTUuOC0xNC40LTIzLjQgMTQuMiAyLjEgMjcuOSA0LjcgNDEgNy45em0tNDUuOCAxMDYuNWMtNy44IDEzLjUtMTUuOCAyNi4zLTI0LjEgMzguMi0xNC45IDEuMy0zMCAyLTQ1LjIgMi0xNS4xIDAtMzAuMi0uNy00NS0xLjktOC4zLTExLjktMTYuNC0yNC42LTI0LjItMzgtNy42LTEzLjEtMTQuNS0yNi40LTIwLjgtMzkuOCA2LjItMTMuNCAxMy4yLTI2LjggMjAuNy0zOS45IDcuOC0xMy41IDE1LjgtMjYuMyAyNC4xLTM4LjIgMTQuOS0xLjMgMzAtMiA0NS4yLTIgMTUuMSAwIDMwLjIuNyA0NSAxLjkgOC4zIDExLjkgMTYuNCAyNC42IDI0LjIgMzggNy42IDEzLjEgMTQuNSAyNi40IDIwLjggMzkuOC02LjMgMTMuNC0xMy4yIDI2LjgtMjAuNyAzOS45em0zMi4zLTEzYzUuNCAxMy40IDEwIDI2LjggMTMuOCAzOS44LTEzLjEgMy4yLTI2LjkgNS45LTQxLjIgOCA0LjktNy43IDkuOC0xNS42IDE0LjQtMjMuNyA0LjYtOCA4LjktMTYuMSAxMy0yNC4xek00MjEuMiA0MzBjLTkuMy05LjYtMTguNi0yMC4zLTI3LjgtMzIgOSAuNCAxOC4yLjcgMjcuNS43IDkuNCAwIDE4LjctLjIgMjcuOC0uNy05IDExLjctMTguMyAyMi40LTI3LjUgMzJ6bS03NC40LTU4LjljLTE0LjItMi4xLTI3LjktNC43LTQxLTcuOSAzLjctMTIuOSA4LjMtMjYuMiAxMy41LTM5LjUgNC4xIDggOC40IDE2IDEzLjEgMjQgNC43IDggOS41IDE1LjggMTQuNCAyMy40ek00MjAuNyAxNjNjOS4zIDkuNiAxOC42IDIwLjMgMjcuOCAzMi05LS40LTE4LjItLjctMjcuNS0uNy05LjQgMC0xOC43LjItMjcuOC43IDktMTEuNyAxOC4zLTIyLjQgMjcuNS0zMnptLTc0IDU4LjljLTQuOSA3LjctOS44IDE1LjYtMTQuNCAyMy43LTQuNiA4LTguOSAxNi0xMyAyNC01LjQtMTMuNC0xMC0yNi44LTEzLjgtMzkuOCAxMy4xLTMuMSAyNi45LTUuOCA0MS4yLTcuOXptLTkwLjUgMTI1LjJjLTM1LjQtMTUuMS01OC4zLTM0LjktNTguMy01MC42IDAtMTUuNyAyMi45LTM1LjYgNTguMy01MC42IDguNi0zLjcgMTgtNyAyNy43LTEwLjEgNS43IDE5LjYgMTMuMiA0MCAyMi41IDYwLjktOS4yIDIwLjgtMTYuNiA0MS4xLTIyLjIgNjAuNi05LjktMy4xLTE5LjMtNi41LTI4LTEwLjJ6TTMxMCA0OTBjLTEzLjYtNy44LTE5LjUtMzcuNS0xNC45LTc1LjcgMS4xLTkuNCAyLjktMTkuMyA1LjEtMjkuNCAxOS42IDQuOCA0MSA4LjUgNjMuNSAxMC45IDEzLjUgMTguNSAyNy41IDM1LjMgNDEuNiA1MC0zMi42IDMwLjMtNjMuMiA0Ni45LTg0IDQ2LjktNC41LS4xLTguMy0xLTExLjMtMi43em0yMzcuMi03Ni4yYzQuNyAzOC4yLTEuMSA2Ny45LTE0LjYgNzUuOC0zIDEuOC02LjkgMi42LTExLjUgMi42LTIwLjcgMC01MS40LTE2LjUtODQtNDYuNiAxNC0xNC43IDI4LTMxLjQgNDEuMy00OS45IDIyLjYtMi40IDQ0LTYuMSA2My42LTExIDIuMyAxMC4xIDQuMSAxOS44IDUuMiAyOS4xem0zOC41LTY2LjdjLTguNiAzLjctMTggNy0yNy43IDEwLjEtNS43LTE5LjYtMTMuMi00MC0yMi41LTYwLjkgOS4yLTIwLjggMTYuNi00MS4xIDIyLjItNjAuNiA5LjkgMy4xIDE5LjMgNi41IDI4LjEgMTAuMiAzNS40IDE1LjEgNTguMyAzNC45IDU4LjMgNTAuNi0uMSAxNS43LTIzIDM1LjYtNTguNCA1MC42ek0zMjAuOCA3OC40eiIvPgogICAgICAgIDxjaXJjbGUgY3g9IjQyMC45IiBjeT0iMjk2LjUiIHI9IjQ1LjciLz4KICAgICAgICA8cGF0aCBkPSJNNTIwLjUgNzguMXoiLz4KICAgIDwvZz4KPC9zdmc+Cg=='; + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4NDEuOSA1OTUuMyI+DQogICAgPGcgZmlsbD0iIzYxREFGQiI+DQogICAgICAgIDxwYXRoIGQ9Ik02NjYuMyAyOTYuNWMwLTMyLjUtNDAuNy02My4zLTEwMy4xLTgyLjQgMTQuNC02My42IDgtMTE0LjItMjAuMi0xMzAuNC02LjUtMy44LTE0LjEtNS42LTIyLjQtNS42djIyLjNjNC42IDAgOC4zLjkgMTEuNCAyLjYgMTMuNiA3LjggMTkuNSAzNy41IDE0LjkgNzUuNy0xLjEgOS40LTIuOSAxOS4zLTUuMSAyOS40LTE5LjYtNC44LTQxLTguNS02My41LTEwLjktMTMuNS0xOC41LTI3LjUtMzUuMy00MS42LTUwIDMyLjYtMzAuMyA2My4yLTQ2LjkgODQtNDYuOVY3OGMtMjcuNSAwLTYzLjUgMTkuNi05OS45IDUzLjYtMzYuNC0zMy44LTcyLjQtNTMuMi05OS45LTUzLjJ2MjIuM2MyMC43IDAgNTEuNCAxNi41IDg0IDQ2LjYtMTQgMTQuNy0yOCAzMS40LTQxLjMgNDkuOS0yMi42IDIuNC00NCA2LjEtNjMuNiAxMS0yLjMtMTAtNC0xOS43LTUuMi0yOS00LjctMzguMiAxLjEtNjcuOSAxNC42LTc1LjggMy0xLjggNi45LTIuNiAxMS41LTIuNlY3OC41Yy04LjQgMC0xNiAxLjgtMjIuNiA1LjYtMjguMSAxNi4yLTM0LjQgNjYuNy0xOS45IDEzMC4xLTYyLjIgMTkuMi0xMDIuNyA0OS45LTEwMi43IDgyLjMgMCAzMi41IDQwLjcgNjMuMyAxMDMuMSA4Mi40LTE0LjQgNjMuNi04IDExNC4yIDIwLjIgMTMwLjQgNi41IDMuOCAxNC4xIDUuNiAyMi41IDUuNiAyNy41IDAgNjMuNS0xOS42IDk5LjktNTMuNiAzNi40IDMzLjggNzIuNCA1My4yIDk5LjkgNTMuMiA4LjQgMCAxNi0xLjggMjIuNi01LjYgMjguMS0xNi4yIDM0LjQtNjYuNyAxOS45LTEzMC4xIDYyLTE5LjEgMTAyLjUtNDkuOSAxMDIuNS04Mi4zem0tMTMwLjItNjYuN2MtMy43IDEyLjktOC4zIDI2LjItMTMuNSAzOS41LTQuMS04LTguNC0xNi0xMy4xLTI0LTQuNi04LTkuNS0xNS44LTE0LjQtMjMuNCAxNC4yIDIuMSAyNy45IDQuNyA0MSA3Ljl6bS00NS44IDEwNi41Yy03LjggMTMuNS0xNS44IDI2LjMtMjQuMSAzOC4yLTE0LjkgMS4zLTMwIDItNDUuMiAyLTE1LjEgMC0zMC4yLS43LTQ1LTEuOS04LjMtMTEuOS0xNi40LTI0LjYtMjQuMi0zOC03LjYtMTMuMS0xNC41LTI2LjQtMjAuOC0zOS44IDYuMi0xMy40IDEzLjItMjYuOCAyMC43LTM5LjkgNy44LTEzLjUgMTUuOC0yNi4zIDI0LjEtMzguMiAxNC45LTEuMyAzMC0yIDQ1LjItMiAxNS4xIDAgMzAuMi43IDQ1IDEuOSA4LjMgMTEuOSAxNi40IDI0LjYgMjQuMiAzOCA3LjYgMTMuMSAxNC41IDI2LjQgMjAuOCAzOS44LTYuMyAxMy40LTEzLjIgMjYuOC0yMC43IDM5Ljl6bTMyLjMtMTNjNS40IDEzLjQgMTAgMjYuOCAxMy44IDM5LjgtMTMuMSAzLjItMjYuOSA1LjktNDEuMiA4IDQuOS03LjcgOS44LTE1LjYgMTQuNC0yMy43IDQuNi04IDguOS0xNi4xIDEzLTI0LjF6TTQyMS4yIDQzMGMtOS4zLTkuNi0xOC42LTIwLjMtMjcuOC0zMiA5IC40IDE4LjIuNyAyNy41LjcgOS40IDAgMTguNy0uMiAyNy44LS43LTkgMTEuNy0xOC4zIDIyLjQtMjcuNSAzMnptLTc0LjQtNTguOWMtMTQuMi0yLjEtMjcuOS00LjctNDEtNy45IDMuNy0xMi45IDguMy0yNi4yIDEzLjUtMzkuNSA0LjEgOCA4LjQgMTYgMTMuMSAyNCA0LjcgOCA5LjUgMTUuOCAxNC40IDIzLjR6TTQyMC43IDE2M2M5LjMgOS42IDE4LjYgMjAuMyAyNy44IDMyLTktLjQtMTguMi0uNy0yNy41LS43LTkuNCAwLTE4LjcuMi0yNy44LjcgOS0xMS43IDE4LjMtMjIuNCAyNy41LTMyem0tNzQgNTguOWMtNC45IDcuNy05LjggMTUuNi0xNC40IDIzLjctNC42IDgtOC45IDE2LTEzIDI0LTUuNC0xMy40LTEwLTI2LjgtMTMuOC0zOS44IDEzLjEtMy4xIDI2LjktNS44IDQxLjItNy45em0tOTAuNSAxMjUuMmMtMzUuNC0xNS4xLTU4LjMtMzQuOS01OC4zLTUwLjYgMC0xNS43IDIyLjktMzUuNiA1OC4zLTUwLjYgOC42LTMuNyAxOC03IDI3LjctMTAuMSA1LjcgMTkuNiAxMy4yIDQwIDIyLjUgNjAuOS05LjIgMjAuOC0xNi42IDQxLjEtMjIuMiA2MC42LTkuOS0zLjEtMTkuMy02LjUtMjgtMTAuMnpNMzEwIDQ5MGMtMTMuNi03LjgtMTkuNS0zNy41LTE0LjktNzUuNyAxLjEtOS40IDIuOS0xOS4zIDUuMS0yOS40IDE5LjYgNC44IDQxIDguNSA2My41IDEwLjkgMTMuNSAxOC41IDI3LjUgMzUuMyA0MS42IDUwLTMyLjYgMzAuMy02My4yIDQ2LjktODQgNDYuOS00LjUtLjEtOC4zLTEtMTEuMy0yLjd6bTIzNy4yLTc2LjJjNC43IDM4LjItMS4xIDY3LjktMTQuNiA3NS44LTMgMS44LTYuOSAyLjYtMTEuNSAyLjYtMjAuNyAwLTUxLjQtMTYuNS04NC00Ni42IDE0LTE0LjcgMjgtMzEuNCA0MS4zLTQ5LjkgMjIuNi0yLjQgNDQtNi4xIDYzLjYtMTEgMi4zIDEwLjEgNC4xIDE5LjggNS4yIDI5LjF6bTM4LjUtNjYuN2MtOC42IDMuNy0xOCA3LTI3LjcgMTAuMS01LjctMTkuNi0xMy4yLTQwLTIyLjUtNjAuOSA5LjItMjAuOCAxNi42LTQxLjEgMjIuMi02MC42IDkuOSAzLjEgMTkuMyA2LjUgMjguMSAxMC4yIDM1LjQgMTUuMSA1OC4zIDM0LjkgNTguMyA1MC42LS4xIDE1LjctMjMgMzUuNi01OC40IDUwLjZ6TTMyMC44IDc4LjR6Ii8+DQogICAgICAgIDxjaXJjbGUgY3g9IjQyMC45IiBjeT0iMjk2LjUiIHI9IjQ1LjciLz4NCiAgICAgICAgPHBhdGggZD0iTTUyMC41IDc4LjF6Ii8+DQogICAgPC9nPg0KPC9zdmc+DQo='; }, function(e, t, n) { 'use strict'; @@ -532,10 +532,10 @@ object-assign (C.prototype = b.prototype); var T = (x.prototype = new C()); (T.constructor = x), r(T, b.prototype), (T.isPureReactComponent = !0); - var k = { current: null }, + var M = { current: null }, w = Object.prototype.hasOwnProperty, - M = { key: !0, ref: !0, __self: !0, __source: !0 }; - function E(e, t, n) { + k = { key: !0, ref: !0, __self: !0, __source: !0 }; + function S(e, t, n) { var r = void 0, o = {}, a = null, @@ -544,7 +544,7 @@ object-assign for (r in (void 0 !== t.ref && (i = t.ref), void 0 !== t.key && (a = '' + t.key), t)) - w.call(t, r) && !M.hasOwnProperty(r) && (o[r] = t[r]); + w.call(t, r) && !k.hasOwnProperty(r) && (o[r] = t[r]); var l = arguments.length - 2; if (1 === l) o.children = n; else if (1 < l) { @@ -559,17 +559,17 @@ object-assign key: a, ref: i, props: o, - _owner: k.current, + _owner: M.current, }; } - function S(e) { + function E(e) { return 'object' == typeof e && null !== e && e.$$typeof === u; } var N = /\/+/g, - L = []; - function I(e, t, n, r) { - if (L.length) { - var o = L.pop(); + I = []; + function L(e, t, n, r) { + if (I.length) { + var o = I.pop(); return ( (o.result = e), (o.keyPrefix = t), @@ -581,15 +581,15 @@ object-assign } return { result: e, keyPrefix: t, func: n, context: r, count: 0 }; } - function j(e) { + function _(e) { (e.result = null), (e.keyPrefix = null), (e.func = null), (e.context = null), (e.count = 0), - 10 > L.length && L.push(e); + 10 > I.length && I.push(e); } - function _(e, t, n, r) { + function j(e, t, n, r) { var o = typeof e; ('undefined' !== o && 'boolean' !== o) || (e = null); var a = !1; @@ -607,11 +607,11 @@ object-assign a = !0; } } - if (a) return n(r, e, '' === t ? '.' + P(e, 0) : t), 1; + if (a) return n(r, e, '' === t ? '.' + D(e, 0) : t), 1; if (((a = 0), (t = '' === t ? '.' : t + ':'), Array.isArray(e))) for (var i = 0; i < e.length; i++) { - var l = t + P((o = e[i]), i); - a += _(o, l, n, r); + var l = t + D((o = e[i]), i); + a += j(o, l, n, r); } else if ( (null === e || void 0 === e @@ -623,7 +623,7 @@ object-assign 'function' == typeof l) ) for (e = l.call(e), i = 0; !(o = e.next()).done; ) - a += _((o = o.value), (l = t + P(o, i++)), n, r); + a += j((o = o.value), (l = t + D(o, i++)), n, r); else 'object' === o && y( @@ -635,7 +635,7 @@ object-assign ); return a; } - function P(e, t) { + function D(e, t) { return 'object' == typeof e && null !== e && null != e.key ? (function(e) { var t = { '=': '=0', ':': '=2' }; @@ -651,14 +651,14 @@ object-assign function O(e, t) { e.func.call(e.context, t, e.count++); } - function D(e, t, n) { + function P(e, t, n) { var r = e.result, o = e.keyPrefix; (e = e.func.call(e.context, t, e.count++)), Array.isArray(e) ? A(e, r, n, i.thatReturnsArgument) : null != e && - (S(e) && + (E(e) && ((t = o + (!e.key || (t && t.key === e.key) @@ -678,9 +678,9 @@ object-assign function A(e, t, n, r, o) { var a = ''; null != n && (a = ('' + n).replace(N, '$&/') + '/'), - (t = I(t, a, r, o)), - null == e || _(e, '', D, t), - j(t); + (t = L(t, a, r, o)), + null == e || j(e, '', P, t), + _(t); } var U = { Children: { @@ -691,17 +691,17 @@ object-assign }, forEach: function(e, t, n) { if (null == e) return e; - (t = I(null, null, t, n)), null == e || _(e, '', O, t), j(t); + (t = L(null, null, t, n)), null == e || j(e, '', O, t), _(t); }, count: function(e) { - return null == e ? 0 : _(e, '', i.thatReturnsNull, null); + return null == e ? 0 : j(e, '', i.thatReturnsNull, null); }, toArray: function(e) { var t = []; return A(e, t, null, i.thatReturnsArgument), t; }, only: function(e) { - return S(e) || y('143'), e; + return E(e) || y('143'), e; }, }, createRef: function() { @@ -730,7 +730,7 @@ object-assign Fragment: s, StrictMode: f, unstable_AsyncMode: h, - createElement: E, + createElement: S, cloneElement: function(e, t, n) { (null === e || void 0 === e) && y('267', e); var o = void 0, @@ -739,7 +739,7 @@ object-assign l = e.ref, c = e._owner; if (null != t) { - void 0 !== t.ref && ((l = t.ref), (c = k.current)), + void 0 !== t.ref && ((l = t.ref), (c = M.current)), void 0 !== t.key && (i = '' + t.key); var s = void 0; for (o in (e.type && @@ -747,7 +747,7 @@ object-assign (s = e.type.defaultProps), t)) w.call(t, o) && - !M.hasOwnProperty(o) && + !k.hasOwnProperty(o) && (a[o] = void 0 === t[o] && void 0 !== s ? s[o] : t[o]); } if (1 === (o = arguments.length - 2)) a.children = n; @@ -766,13 +766,13 @@ object-assign }; }, createFactory: function(e) { - var t = E.bind(null, e); + var t = S.bind(null, e); return (t.type = e), t; }, - isValidElement: S, + isValidElement: E, version: '16.3.2', __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { - ReactCurrentOwner: k, + ReactCurrentOwner: M, assign: r, }, }, @@ -908,7 +908,7 @@ object-assign function T(e) { h && p('101'), (h = Array.prototype.slice.call(e)), g(); } - function k(e) { + function M(e) { var t, n = !1; for (t in e) @@ -926,18 +926,18 @@ object-assign registrationNameDependencies: x, possibleRegistrationNames: null, injectEventPluginOrder: T, - injectEventPluginsByName: k, + injectEventPluginsByName: M, }), - M = null, - E = null, - S = null; + k = null, + S = null, + E = null; function N(e, t, n, r) { (t = e.type || 'unknown-event'), - (e.currentTarget = S(r)), + (e.currentTarget = E(r)), d.invokeGuardedCallbackAndCatchFirstError(t, n, void 0, e), (e.currentTarget = null); } - function L(e, t) { + function I(e, t) { return ( null == t && p('30'), null == e @@ -951,11 +951,11 @@ object-assign : [e, t] ); } - function I(e, t, n) { + function L(e, t, n) { Array.isArray(e) ? e.forEach(t, n) : e && t.call(n, e); } - var j = null; - function _(e, t) { + var _ = null; + function j(e, t) { if (e) { var n = e._dispatchListeners, r = e._dispatchInstances; @@ -968,17 +968,17 @@ object-assign e.isPersistent() || e.constructor.release(e); } } - function P(e) { - return _(e, !0); + function D(e) { + return j(e, !0); } function O(e) { - return _(e, !1); + return j(e, !1); } - var D = { injectEventPluginOrder: T, injectEventPluginsByName: k }; + var P = { injectEventPluginOrder: T, injectEventPluginsByName: M }; function A(e, t) { var n = e.stateNode; if (!n) return null; - var r = M(n); + var r = k(n); if (!r) return null; n = r[t]; e: switch (t) { @@ -1009,20 +1009,20 @@ object-assign : (n && 'function' != typeof n && p('231', t, typeof n), n); } function U(e, t) { - null !== e && (j = L(j, e)), - (e = j), - (j = null), - e && (I(e, t ? P : O), j && p('95'), d.rethrowCaughtError()); + null !== e && (_ = I(_, e)), + (e = _), + (_ = null), + e && (L(e, t ? D : O), _ && p('95'), d.rethrowCaughtError()); } function z(e, t, n, r) { for (var o = null, a = 0; a < v.length; a++) { var i = v[a]; - i && (i = i.extractEvents(e, t, n, r)) && (o = L(o, i)); + i && (i = i.extractEvents(e, t, n, r)) && (o = I(o, i)); } U(o, !1); } var R = Object.freeze({ - injection: D, + injection: P, getListener: A, runEventsInBatch: U, runExtractedEventsInBatch: z, @@ -1031,7 +1031,7 @@ object-assign .toString(36) .slice(2), H = '__reactInternalInstance$' + F, - B = '__reactEventHandlers$' + F; + Q = '__reactEventHandlers$' + F; function V(e) { if (e[H]) return e[H]; for (; !e[H]; ) { @@ -1040,12 +1040,12 @@ object-assign } return 5 === (e = e[H]).tag || 6 === e.tag ? e : null; } - function Q(e) { + function B(e) { if (5 === e.tag || 6 === e.tag) return e.stateNode; p('33'); } function W(e) { - return e[B] || null; + return e[Q] || null; } var Y = Object.freeze({ precacheFiberNode: function(e, t) { @@ -1055,10 +1055,10 @@ object-assign getInstanceFromNode: function(e) { return !(e = e[H]) || (5 !== e.tag && 6 !== e.tag) ? null : e; }, - getNodeFromInstance: Q, + getNodeFromInstance: B, getFiberCurrentPropsFromNode: W, updateFiberProps: function(e, t) { - e[B] = t; + e[Q] = t; }, }); function K(e) { @@ -1074,8 +1074,8 @@ object-assign } function q(e, t, n) { (t = A(e, n.dispatchConfig.phasedRegistrationNames[t])) && - ((n._dispatchListeners = L(n._dispatchListeners, t)), - (n._dispatchInstances = L(n._dispatchInstances, e))); + ((n._dispatchListeners = I(n._dispatchListeners, t)), + (n._dispatchInstances = I(n._dispatchInstances, e))); } function G(e) { e && e.dispatchConfig.phasedRegistrationNames && $(e._targetInst, q, e); @@ -1086,19 +1086,19 @@ object-assign $((t = t ? K(t) : null), q, e); } } - function J(e, t, n) { + function Z(e, t, n) { e && n && n.dispatchConfig.registrationName && (t = A(e, n.dispatchConfig.registrationName)) && - ((n._dispatchListeners = L(n._dispatchListeners, t)), - (n._dispatchInstances = L(n._dispatchInstances, e))); + ((n._dispatchListeners = I(n._dispatchListeners, t)), + (n._dispatchInstances = I(n._dispatchInstances, e))); } - function Z(e) { - e && e.dispatchConfig.registrationName && J(e._targetInst, null, e); + function J(e) { + e && e.dispatchConfig.registrationName && Z(e._targetInst, null, e); } function ee(e) { - I(e, G); + L(e, G); } function te(e, t, n, r) { if (n && r) @@ -1123,17 +1123,17 @@ object-assign o.push(n), (n = K(n)); for (n = []; r && r !== a && (null === (i = r.alternate) || i !== a); ) n.push(r), (r = K(r)); - for (r = 0; r < o.length; r++) J(o[r], 'bubbled', e); - for (e = n.length; 0 < e--; ) J(n[e], 'captured', t); + for (r = 0; r < o.length; r++) Z(o[r], 'bubbled', e); + for (e = n.length; 0 < e--; ) Z(n[e], 'captured', t); } var ne = Object.freeze({ accumulateTwoPhaseDispatches: ee, accumulateTwoPhaseDispatchesSkipTarget: function(e) { - I(e, X); + L(e, X); }, accumulateEnterLeaveDispatches: te, accumulateDirectDispatches: function(e) { - I(e, Z); + L(e, J); }, }), re = null; @@ -1319,7 +1319,7 @@ object-assign ), }, }, - ke = !1; + Me = !1; function we(e, t) { switch (e) { case 'topKeyUp': @@ -1334,11 +1334,11 @@ object-assign return !1; } } - function Me(e) { + function ke(e) { return 'object' == typeof (e = e.detail) && 'data' in e ? e.data : null; } - var Ee = !1; - var Se = { + var Se = !1; + var Ee = { eventTypes: Te, extractEvents: function(e, t, n, r) { var o = void 0, @@ -1359,7 +1359,7 @@ object-assign o = void 0; } else - Ee + Se ? we(e, n) && (o = Te.compositionEnd) : 'topKeyDown' === e && 229 === n.keyCode && @@ -1367,11 +1367,11 @@ object-assign return ( o ? (Ce && - (Ee || o !== Te.compositionStart - ? o === Te.compositionEnd && Ee && (a = ie()) - : ((ae._root = r), (ae._startText = le()), (Ee = !0))), + (Se || o !== Te.compositionStart + ? o === Te.compositionEnd && Se && (a = ie()) + : ((ae._root = r), (ae._startText = le()), (Se = !0))), (o = he.getPooled(o, t, n, r)), - a ? (o.data = a) : null !== (a = Me(n)) && (o.data = a), + a ? (o.data = a) : null !== (a = ke(n)) && (o.data = a), ee(o), (a = o)) : (a = null), @@ -1379,23 +1379,23 @@ object-assign ? (function(e, t) { switch (e) { case 'topCompositionEnd': - return Me(t); + return ke(t); case 'topKeyPress': - return 32 !== t.which ? null : ((ke = !0), xe); + return 32 !== t.which ? null : ((Me = !0), xe); case 'topTextInput': - return (e = t.data) === xe && ke ? null : e; + return (e = t.data) === xe && Me ? null : e; default: return null; } })(e, n) : (function(e, t) { - if (Ee) + if (Se) return 'topCompositionEnd' === e || (!ye && we(e, t)) ? ((e = ie()), (ae._root = null), (ae._startText = null), (ae._fallbackText = null), - (Ee = !1), + (Se = !1), e) : null; switch (e) { @@ -1423,39 +1423,39 @@ object-assign }, }, Ne = null, - Le = { + Ie = { injectFiberControlledHostComponent: function(e) { Ne = e; }, }, - Ie = null, - je = null; - function _e(e) { - if ((e = E(e))) { + Le = null, + _e = null; + function je(e) { + if ((e = S(e))) { (Ne && 'function' == typeof Ne.restoreControlledState) || p('194'); - var t = M(e.stateNode); + var t = k(e.stateNode); Ne.restoreControlledState(e.stateNode, e.type, t); } } - function Pe(e) { - Ie ? (je ? je.push(e) : (je = [e])) : (Ie = e); + function De(e) { + Le ? (_e ? _e.push(e) : (_e = [e])) : (Le = e); } function Oe() { - return null !== Ie || null !== je; - } - function De() { - if (Ie) { - var e = Ie, - t = je; - if (((je = Ie = null), _e(e), t)) - for (e = 0; e < t.length; e++) _e(t[e]); + return null !== Le || null !== _e; + } + function Pe() { + if (Le) { + var e = Le, + t = _e; + if (((_e = Le = null), je(e), t)) + for (e = 0; e < t.length; e++) je(t[e]); } } var Ae = Object.freeze({ - injection: Le, - enqueueStateRestore: Pe, + injection: Ie, + enqueueStateRestore: De, needsStateRestore: Oe, - restoreStateIfNeeded: De, + restoreStateIfNeeded: Pe, }); function Ue(e, t) { return e(t); @@ -1471,10 +1471,10 @@ object-assign try { return Ue(e, t); } finally { - (Fe = !1), Oe() && (Re(), De()); + (Fe = !1), Oe() && (Re(), Pe()); } } - var Be = { + var Qe = { color: !0, date: !0, datetime: !0, @@ -1493,9 +1493,9 @@ object-assign }; function Ve(e) { var t = e && e.nodeName && e.nodeName.toLowerCase(); - return 'input' === t ? !!Be[e.type] : 'textarea' === t; + return 'input' === t ? !!Qe[e.type] : 'textarea' === t; } - function Qe(e) { + function Be(e) { return ( (e = e.target || window).correspondingUseElement && (e = e.correspondingUseElement), @@ -1570,8 +1570,8 @@ object-assign o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner, Ge = 'function' == typeof Symbol && Symbol.for, Xe = Ge ? Symbol.for('react.element') : 60103, - Je = Ge ? Symbol.for('react.call') : 60104, - Ze = Ge ? Symbol.for('react.return') : 60105, + Ze = Ge ? Symbol.for('react.call') : 60104, + Je = Ge ? Symbol.for('react.return') : 60105, et = Ge ? Symbol.for('react.portal') : 60106, tt = Ge ? Symbol.for('react.fragment') : 60107, nt = Ge ? Symbol.for('react.strict_mode') : 60108, @@ -1595,9 +1595,9 @@ object-assign return 'ReactFragment'; case et: return 'ReactPortal'; - case Je: - return 'ReactCall'; case Ze: + return 'ReactCall'; + case Je: return 'ReactReturn'; } if ('object' == typeof e && null !== e) @@ -1776,7 +1776,7 @@ object-assign function Ct(e, t) { var n = null == t.defaultValue ? '' : t.defaultValue, r = null != t.checked ? t.checked : t.defaultChecked; - (n = Mt(null != t.value ? t.value : n)), + (n = kt(null != t.value ? t.value : n)), (e._wrapperState = { initialChecked: r, initialValue: n, @@ -1791,7 +1791,7 @@ object-assign } function Tt(e, t) { xt(e, t); - var n = Mt(t.value); + var n = kt(t.value); null != n && ('number' === t.type ? ((0 === n && '' === e.value) || e.value != n) && (e.value = '' + n) @@ -1799,12 +1799,12 @@ object-assign t.hasOwnProperty('value') ? wt(e, t.type, n) : t.hasOwnProperty('defaultValue') && - wt(e, t.type, Mt(t.defaultValue)), + wt(e, t.type, kt(t.defaultValue)), null == t.checked && null != t.defaultChecked && (e.defaultChecked = !!t.defaultChecked); } - function kt(e, t) { + function Mt(e, t) { (t.hasOwnProperty('value') || t.hasOwnProperty('defaultValue')) && ('' === e.value && (e.value = '' + e._wrapperState.initialValue), (e.defaultValue = '' + e._wrapperState.initialValue)), @@ -1819,7 +1819,7 @@ object-assign ? (e.defaultValue = '' + e._wrapperState.initialValue) : e.defaultValue !== '' + n && (e.defaultValue = '' + n)); } - function Mt(e) { + function kt(e) { switch (typeof e) { case 'boolean': case 'number': @@ -1848,7 +1848,7 @@ object-assign mt[t] = new ht(t, 1, !1, e, 'http://www.w3.org/XML/1998/namespace'); }), (mt.tabIndex = new ht('tabIndex', 1, !1, 'tabindex', null)); - var Et = { + var St = { change: { phasedRegistrationNames: { bubbled: 'onChange', @@ -1859,63 +1859,63 @@ object-assign ), }, }; - function St(e, t, n) { + function Et(e, t, n) { return ( - ((e = se.getPooled(Et.change, e, t, n)).type = 'change'), - Pe(n), + ((e = se.getPooled(St.change, e, t, n)).type = 'change'), + De(n), ee(e), e ); } var Nt = null, - Lt = null; - function It(e) { + It = null; + function Lt(e) { U(e, !1); } - function jt(e) { - if ($e(Q(e))) return e; + function _t(e) { + if ($e(B(e))) return e; } - function _t(e, t) { + function jt(e, t) { if ('topChange' === e) return t; } - var Pt = !1; + var Dt = !1; function Ot() { - Nt && (Nt.detachEvent('onpropertychange', Dt), (Lt = Nt = null)); + Nt && (Nt.detachEvent('onpropertychange', Pt), (It = Nt = null)); } - function Dt(e) { - 'value' === e.propertyName && jt(Lt) && He(It, (e = St(Lt, e, Qe(e)))); + function Pt(e) { + 'value' === e.propertyName && _t(It) && He(Lt, (e = Et(It, e, Be(e)))); } function At(e, t, n) { 'topFocus' === e - ? (Ot(), (Lt = n), (Nt = t).attachEvent('onpropertychange', Dt)) + ? (Ot(), (It = n), (Nt = t).attachEvent('onpropertychange', Pt)) : 'topBlur' === e && Ot(); } function Ut(e) { if ('topSelectionChange' === e || 'topKeyUp' === e || 'topKeyDown' === e) - return jt(Lt); + return _t(It); } function zt(e, t) { - if ('topClick' === e) return jt(t); + if ('topClick' === e) return _t(t); } function Rt(e, t) { - if ('topInput' === e || 'topChange' === e) return jt(t); + if ('topInput' === e || 'topChange' === e) return _t(t); } a.canUseDOM && - (Pt = + (Dt = We('input') && (!document.documentMode || 9 < document.documentMode)); var Ft = { - eventTypes: Et, - _isInputEventSupported: Pt, + eventTypes: St, + _isInputEventSupported: Dt, extractEvents: function(e, t, n, r) { - var o = t ? Q(t) : window, + var o = t ? B(t) : window, a = void 0, i = void 0, l = o.nodeName && o.nodeName.toLowerCase(); if ( ('select' === l || ('input' === l && 'file' === o.type) - ? (a = _t) + ? (a = jt) : Ve(o) - ? Pt + ? Dt ? (a = Rt) : ((a = Ut), (i = At)) : (l = o.nodeName) && @@ -1924,7 +1924,7 @@ object-assign (a = zt), a && (a = a(e, t))) ) - return St(a, n, r); + return Et(a, n, r); i && i(e, o, t), 'topBlur' === e && null != t && @@ -1935,7 +1935,7 @@ object-assign }, }, Ht = se.extend({ view: null, detail: null }), - Bt = { + Qt = { Alt: 'altKey', Control: 'ctrlKey', Meta: 'metaKey', @@ -1945,9 +1945,9 @@ object-assign var t = this.nativeEvent; return t.getModifierState ? t.getModifierState(e) - : !!(e = Bt[e]) && !!t[e]; + : !!(e = Qt[e]) && !!t[e]; } - function Qt() { + function Bt() { return Vt; } var Wt = Ht.extend({ @@ -1961,7 +1961,7 @@ object-assign shiftKey: null, altKey: null, metaKey: null, - getModifierState: Qt, + getModifierState: Bt, button: null, buttons: null, relatedTarget: function(e) { @@ -2003,8 +2003,8 @@ object-assign e === t) ) return null; - var a = null == e ? o : Q(e); - o = null == t ? o : Q(t); + var a = null == e ? o : B(e); + o = null == t ? o : B(t); var i = Wt.getPooled(Yt.mouseLeave, e, n, r); return ( (i.type = 'mouseleave'), @@ -2081,7 +2081,7 @@ object-assign } return 3 !== n.tag && p('188'), n.stateNode.current === n ? e : t; } - function Jt(e) { + function Zt(e) { if (!(e = Xt(e))) return null; for (var t = e; ; ) { if (5 === t.tag || 6 === t.tag) return t; @@ -2097,7 +2097,7 @@ object-assign } return null; } - var Zt = se.extend({ + var Jt = se.extend({ animationName: null, elapsedTime: null, pseudoElement: null, @@ -2191,7 +2191,7 @@ object-assign metaKey: null, repeat: null, locale: null, - getModifierState: Qt, + getModifierState: Bt, charCode: function(e) { return 'keypress' === e.type ? nn(e) : 0; }, @@ -2215,7 +2215,7 @@ object-assign metaKey: null, ctrlKey: null, shiftKey: null, - getModifierState: Qt, + getModifierState: Bt, }), cn = se.extend({ propertyName: null, @@ -2314,7 +2314,7 @@ object-assign case 'topAnimationEnd': case 'topAnimationIteration': case 'topAnimationStart': - e = Zt; + e = Jt; break; case 'topTransitionEnd': e = cn; @@ -2352,7 +2352,7 @@ object-assign } while (t); for (n = 0; n < e.ancestors.length; n++) (t = e.ancestors[n]), - z(e.topLevelType, t, e.nativeEvent, Qe(e.nativeEvent)); + z(e.topLevelType, t, e.nativeEvent, Be(e.nativeEvent)); } var vn = !0; function bn(e) { @@ -2360,18 +2360,18 @@ object-assign } function Cn(e, t, n) { if (!n) return null; - (e = (mn(e) ? Tn : kn).bind(null, e)), n.addEventListener(t, e, !1); + (e = (mn(e) ? Tn : Mn).bind(null, e)), n.addEventListener(t, e, !1); } function xn(e, t, n) { if (!n) return null; - (e = (mn(e) ? Tn : kn).bind(null, e)), n.addEventListener(t, e, !0); + (e = (mn(e) ? Tn : Mn).bind(null, e)), n.addEventListener(t, e, !0); } function Tn(e, t) { - ze(kn, e, t); + ze(Mn, e, t); } - function kn(e, t) { + function Mn(e, t) { if (vn) { - var n = Qe(t); + var n = Be(t); if ( (null !== (n = V(n)) && 'number' == typeof n.tag && @@ -2407,9 +2407,9 @@ object-assign }, trapBubbledEvent: Cn, trapCapturedEvent: xn, - dispatchEvent: kn, + dispatchEvent: Mn, }); - function Mn(e, t) { + function kn(e, t) { var n = {}; return ( (n[e.toLowerCase()] = t.toLowerCase()), @@ -2420,33 +2420,33 @@ object-assign n ); } - var En = { - animationend: Mn('Animation', 'AnimationEnd'), - animationiteration: Mn('Animation', 'AnimationIteration'), - animationstart: Mn('Animation', 'AnimationStart'), - transitionend: Mn('Transition', 'TransitionEnd'), + var Sn = { + animationend: kn('Animation', 'AnimationEnd'), + animationiteration: kn('Animation', 'AnimationIteration'), + animationstart: kn('Animation', 'AnimationStart'), + transitionend: kn('Transition', 'TransitionEnd'), }, - Sn = {}, + En = {}, Nn = {}; - function Ln(e) { - if (Sn[e]) return Sn[e]; - if (!En[e]) return e; + function In(e) { + if (En[e]) return En[e]; + if (!Sn[e]) return e; var t, - n = En[e]; - for (t in n) if (n.hasOwnProperty(t) && t in Nn) return (Sn[e] = n[t]); + n = Sn[e]; + for (t in n) if (n.hasOwnProperty(t) && t in Nn) return (En[e] = n[t]); return e; } a.canUseDOM && ((Nn = document.createElement('div').style), 'AnimationEvent' in window || - (delete En.animationend.animation, - delete En.animationiteration.animation, - delete En.animationstart.animation), - 'TransitionEvent' in window || delete En.transitionend.transition); - var In = { - topAnimationEnd: Ln('animationend'), - topAnimationIteration: Ln('animationiteration'), - topAnimationStart: Ln('animationstart'), + (delete Sn.animationend.animation, + delete Sn.animationiteration.animation, + delete Sn.animationstart.animation), + 'TransitionEvent' in window || delete Sn.transitionend.transition); + var Ln = { + topAnimationEnd: In('animationend'), + topAnimationIteration: In('animationiteration'), + topAnimationStart: In('animationstart'), topBlur: 'blur', topCancel: 'cancel', topChange: 'change', @@ -2488,10 +2488,10 @@ object-assign topTouchEnd: 'touchend', topTouchMove: 'touchmove', topTouchStart: 'touchstart', - topTransitionEnd: Ln('transitionend'), + topTransitionEnd: In('transitionend'), topWheel: 'wheel', }, - jn = { + _n = { topAbort: 'abort', topCanPlay: 'canplay', topCanPlayThrough: 'canplaythrough', @@ -2516,14 +2516,14 @@ object-assign topVolumeChange: 'volumechange', topWaiting: 'waiting', }, - _n = {}, - Pn = 0, + jn = {}, + Dn = 0, On = '_reactListenersID' + ('' + Math.random()).slice(2); - function Dn(e) { + function Pn(e) { return ( Object.prototype.hasOwnProperty.call(e, On) || - ((e[On] = Pn++), (_n[e[On]] = {})), - _n[e[On]] + ((e[On] = Dn++), (jn[e[On]] = {})), + jn[e[On]] ); } function An(e) { @@ -2577,11 +2577,11 @@ object-assign }, }, Hn = null, - Bn = null, + Qn = null, Vn = null, - Qn = !1; + Bn = !1; function Wn(e, t) { - if (Qn || null == Hn || Hn !== u()) return null; + if (Bn || null == Hn || Hn !== u()) return null; var n = Hn; return ( 'selectionStart' in n && zn(n) @@ -2597,7 +2597,7 @@ object-assign Vn && c(Vn, n) ? null : ((Vn = n), - ((e = se.getPooled(Fn.select, Bn, e, t)).type = 'select'), + ((e = se.getPooled(Fn.select, Qn, e, t)).type = 'select'), (e.target = Hn), ee(e), e) @@ -2615,7 +2615,7 @@ object-assign : r.ownerDocument; if (!(o = !a)) { e: { - (a = Dn(a)), (o = x.onSelect); + (a = Pn(a)), (o = x.onSelect); for (var i = 0; i < o.length; i++) { var l = o[i]; if (!a.hasOwnProperty(l) || !a[l]) { @@ -2628,20 +2628,20 @@ object-assign o = !a; } if (o) return null; - switch (((a = t ? Q(t) : window), e)) { + switch (((a = t ? B(t) : window), e)) { case 'topFocus': (Ve(a) || 'true' === a.contentEditable) && - ((Hn = a), (Bn = t), (Vn = null)); + ((Hn = a), (Qn = t), (Vn = null)); break; case 'topBlur': - Vn = Bn = Hn = null; + Vn = Qn = Hn = null; break; case 'topMouseDown': - Qn = !0; + Bn = !0; break; case 'topContextMenu': case 'topMouseUp': - return (Qn = !1), Wn(n, r); + return (Bn = !1), Wn(n, r); case 'topSelectionChange': if (Rn) break; case 'topKeyDown': @@ -2708,10 +2708,10 @@ object-assign case nt: (a = 11), (t |= 2); break; - case Je: + case Ze: a = 7; break; - case Ze: + case Je: a = 9; break; default: @@ -2743,7 +2743,7 @@ object-assign function Xn(e, t, n) { return ((e = new Kn(6, e, null, t)).expirationTime = n), e; } - function Jn(e, t, n) { + function Zn(e, t, n) { return ( ((t = new Kn( 4, @@ -2759,22 +2759,22 @@ object-assign t ); } - D.injectEventPluginOrder( + P.injectEventPluginOrder( 'ResponderEventPlugin SimpleEventPlugin TapEventPlugin EnterLeaveEventPlugin ChangeEventPlugin SelectEventPlugin BeforeInputEventPlugin'.split( ' ' ) ), - (M = Y.getFiberCurrentPropsFromNode), - (E = Y.getInstanceFromNode), - (S = Y.getNodeFromInstance), - D.injectEventPluginsByName({ + (k = Y.getFiberCurrentPropsFromNode), + (S = Y.getInstanceFromNode), + (E = Y.getNodeFromInstance), + P.injectEventPluginsByName({ SimpleEventPlugin: hn, EnterLeaveEventPlugin: Kt, ChangeEventPlugin: Ft, SelectEventPlugin: Yn, - BeforeInputEventPlugin: Se, + BeforeInputEventPlugin: Ee, }); - var Zn = null, + var Jn = null, er = null; function tr(e) { return function(t) { @@ -2784,7 +2784,7 @@ object-assign }; } function nr(e) { - 'function' == typeof Zn && Zn(e); + 'function' == typeof Jn && Jn(e); } function rr(e) { 'function' == typeof er && er(e); @@ -2981,7 +2981,7 @@ object-assign 4 !== t.tag || t.stateNode.containerInfo !== n.containerInfo || t.stateNode.implementation !== n.implementation - ? (((t = Jn(n, e.mode, r)).return = e), t) + ? (((t = Zn(n, e.mode, r)).return = e), t) : (((t = o(t, n.children || [], r)).return = e), t); } function s(e, t, n, r, a) { @@ -2999,7 +2999,7 @@ object-assign ((n = qn(t, e.mode, n)).ref = hr(e, null, t)), (n.return = e), n ); case et: - return ((t = Jn(t, e.mode, n)).return = e), t; + return ((t = Zn(t, e.mode, n)).return = e), t; } if (dr(t) || ut(t)) return ((t = Gn(t, e.mode, n, null)).return = e), t; @@ -3193,7 +3193,7 @@ object-assign } t(e, r), (r = r.sibling); } - ((r = Jn(a, e.mode, l)).return = e), (e = r); + ((r = Zn(a, e.mode, l)).return = e), (e = r); } return i(e); } @@ -3247,8 +3247,8 @@ object-assign function m(e) { var t = e.stateNode; t.pendingContext - ? S(e, t.pendingContext, t.pendingContext !== t.context) - : t.context && S(e, t.context, !1), + ? E(e, t.pendingContext, t.pendingContext !== t.context) + : t.context && E(e, t.context, !1), x(e, t.containerInfo); } function g(e, t, n, r) { @@ -3319,16 +3319,16 @@ object-assign C = t.pushHostContext, x = t.pushHostContainer, T = r.pushProvider, - k = n.getMaskedContext, + M = n.getMaskedContext, w = n.getUnmaskedContext, - M = n.hasContextChanged, - E = n.pushContextProvider, - S = n.pushTopLevelContextObject, + k = n.hasContextChanged, + S = n.pushContextProvider, + E = n.pushTopLevelContextObject, N = n.invalidateContextProvider, - L = o.enterHydrationState, - I = o.resetHydrationState, - j = o.tryToClaimNextHydratableInstance, - _ = (e = (function(e, t, n, r, o) { + I = o.enterHydrationState, + L = o.resetHydrationState, + _ = o.tryToClaimNextHydratableInstance, + j = (e = (function(e, t, n, r, o) { function a(e, t, n, r, o, a) { if ( null === t || @@ -3589,9 +3589,9 @@ object-assign e.memoizedState = t; } )).adoptClassInstance, - P = e.callGetDerivedStateFromProps, + D = e.callGetDerivedStateFromProps, O = e.constructClassInstance, - D = e.mountClassInstance, + P = e.mountClassInstance, A = e.resumeMountClassInstance, U = e.updateClassInstance; return { @@ -3602,7 +3602,7 @@ object-assign m(t); break; case 2: - E(t); + S(t); break; case 4: x(t, t.stateNode.containerInfo); @@ -3619,7 +3619,7 @@ object-assign o = t.pendingProps, a = w(t); return ( - (r = r(o, (a = k(t, a)))), + (r = r(o, (a = M(t, a)))), (t.effectTag |= 1), 'object' == typeof r && null !== r && @@ -3630,12 +3630,12 @@ object-assign (t.memoizedState = null !== r.state && void 0 !== r.state ? r.state : null), 'function' == typeof a.getDerivedStateFromProps && - (null !== (o = P(t, r, o, t.memoizedState)) && + (null !== (o = D(t, r, o, t.memoizedState)) && void 0 !== o && (t.memoizedState = i({}, t.memoizedState, o))), - (o = E(t)), - _(t, r), - D(t, n), + (o = S(t)), + j(t, r), + P(t, n), (e = h(e, t, !0, o, !1, n))) : ((t.tag = 1), u(e, t, r), @@ -3647,9 +3647,9 @@ object-assign return ( (o = t.type), (n = t.pendingProps), - M() || t.memoizedProps !== n + k() || t.memoizedProps !== n ? ((r = w(t)), - (o = o(n, (r = k(t, r)))), + (o = o(n, (r = M(t, r)))), (t.effectTag |= 1), u(e, t, o), (t.memoizedProps = n), @@ -3658,10 +3658,10 @@ object-assign e ); case 2: - (o = E(t)), + (o = S(t)), null === e ? null === t.stateNode - ? (O(t, t.pendingProps), D(t, n), (r = !0)) + ? (O(t, t.pendingProps), P(t, n), (r = !0)) : (r = A(t, n)) : (r = U(e, t, n)), (a = !1); @@ -3681,28 +3681,28 @@ object-assign r = null; else { if (a === o) { - I(), (e = y(e, t)); + L(), (e = y(e, t)); break e; } r = o.element; } (a = t.stateNode), - (null === e || null === e.child) && a.hydrate && L(t) + (null === e || null === e.child) && a.hydrate && I(t) ? ((t.effectTag |= 2), (t.child = vr(t, null, r, n))) - : (I(), u(e, t, r)), + : (L(), u(e, t, r)), (t.memoizedState = o), (e = t.child); - } else I(), (e = y(e, t)); + } else L(), (e = y(e, t)); return e; case 5: return ( C(t), - null === e && j(t), + null === e && _(t), (o = t.type), (l = t.memoizedProps), (r = t.pendingProps), (a = null !== e ? e.memoizedProps : null), - M() || + k() || l !== r || ((l = 1 & t.mode && b(o, r)) && (t.expirationTime = 1073741823), l && 1073741823 === n) @@ -3719,14 +3719,14 @@ object-assign ); case 6: return ( - null === e && j(t), (t.memoizedProps = t.pendingProps), null + null === e && _(t), (t.memoizedProps = t.pendingProps), null ); case 8: t.tag = 7; case 7: return ( (o = t.pendingProps), - M() || t.memoizedProps !== o || (o = t.memoizedProps), + k() || t.memoizedProps !== o || (o = t.memoizedProps), (r = o.children), (t.stateNode = null === e @@ -3741,7 +3741,7 @@ object-assign return ( x(t, t.stateNode.containerInfo), (o = t.pendingProps), - M() || t.memoizedProps !== o + k() || t.memoizedProps !== o ? (null === e ? (t.child = yr(t, null, o, n)) : u(e, t, o), (t.memoizedProps = o), (e = t.child)) @@ -3757,7 +3757,7 @@ object-assign case 10: return ( (n = t.pendingProps), - M() || t.memoizedProps !== n + k() || t.memoizedProps !== n ? (u(e, t, n), (t.memoizedProps = n), (e = t.child)) : (e = y(e, t)), e @@ -3765,7 +3765,7 @@ object-assign case 11: return ( (n = t.pendingProps.children), - M() || (null !== n && t.memoizedProps !== n) + k() || (null !== n && t.memoizedProps !== n) ? (u(e, t, n), (t.memoizedProps = n), (e = t.child)) : (e = y(e, t)), e @@ -3775,7 +3775,7 @@ object-assign var r = t.type._context, o = t.pendingProps, a = t.memoizedProps; - if (!M() && a === o) return (t.stateNode = 0), T(t), y(e, t); + if (!k() && a === o) return (t.stateNode = 0), T(t), y(e, t); var i = o.value; if (((t.memoizedProps = o), null === a)) i = 1073741823; else if (a.value === o.value) { @@ -3811,7 +3811,7 @@ object-assign (l = t.memoizedProps), (o = r._currentValue); var c = r._changedBits; - if (M() || 0 !== c || l !== a) { + if (k() || 0 !== c || l !== a) { t.memoizedProps = a; var s = a.unstable_observedBits; if ( @@ -3862,7 +3862,7 @@ object-assign n = e.return, r = e.sibling; if (0 == (512 & e.effectTag)) { - t = j(t, e, ne); + t = _(t, e, ne); var o = e; if (1073741823 === ne || 1073741823 !== o.expirationTime) { e: switch (o.tag) { @@ -3904,7 +3904,7 @@ object-assign } e = n; } else { - if (null !== (e = P(e))) return (e.effectTag &= 2559), e; + if (null !== (e = D(e))) return (e.effectTag &= 2559), e; if ( (null !== n && ((n.firstEffect = n.lastEffect = null), (n.effectTag |= 512)), @@ -3918,12 +3918,12 @@ object-assign return null; } function o(e) { - var t = I(e.alternate, e, ne); + var t = L(e.alternate, e, ne); return null === t && (t = r(e)), (qe.current = null), t; } function a(e, n, a) { - Z && p('243'), - (Z = !0), + J && p('243'), + (J = !0), (n === ne && e === te && null !== ee) || (t(), (ne = n), @@ -3935,20 +3935,20 @@ object-assign else for (; null !== ee; ) ee = o(ee); } catch (e) { if (null === ee) { - (i = !0), M(e); + (i = !0), k(e); break; } var l = (a = ee).return; if (null === l) { - (i = !0), M(e); + (i = !0), k(e); break; } - _(l, a, e), (ee = r(a)); + j(l, a, e), (ee = r(a)); } break; } return ( - (Z = !1), + (J = !1), i || null !== ee ? null : ae @@ -3970,7 +3970,7 @@ object-assign } function u(e, t) { e: { - Z && !oe && p('263'); + J && !oe && p('263'); for (var r = e.return; null !== r; ) { switch (r.tag) { case 2: @@ -3996,9 +3996,9 @@ object-assign function c(e) { return ( (e = - 0 !== J - ? J - : Z + 0 !== Z + ? Z + : J ? oe ? 1 : ne @@ -4028,9 +4028,9 @@ object-assign break e; } var r = e.stateNode; - !Z && 0 !== ne && n < ne && t(), - (Z && !oe && te === r) || g(r, n), - we > ke && p('185'); + !J && 0 !== ne && n < ne && t(), + (J && !oe && te === r) || g(r, n), + we > Me && p('185'); } e = e.return; } @@ -4039,15 +4039,15 @@ object-assign return n; } function d() { - return (G = Q() - q), 2 + ((G / 10) | 0); + return (G = B() - q), 2 + ((G / 10) | 0); } function h(e, t, n, r, o) { - var a = J; - J = 1; + var a = Z; + Z = 1; try { return e(t, n, r, o); } finally { - J = a; + Z = a; } } function m(e) { @@ -4055,7 +4055,7 @@ object-assign if (e > ce) return; Y(se); } - var t = Q() - q; + var t = B() - q; (ce = e), (se = W(v, { timeout: 10 * (e - 2) - t })); } function g(e, t) { @@ -4154,17 +4154,17 @@ object-assign (fe = !0), n ? null !== (n = e.finishedWork) - ? k(e, n, t) + ? M(e, n, t) : ((e.finishedWork = null), null !== (n = a(e, t, !0)) && - (w() ? (e.finishedWork = n) : k(e, n, t))) + (w() ? (e.finishedWork = n) : M(e, n, t))) : null !== (n = e.finishedWork) - ? k(e, n, t) + ? M(e, n, t) : ((e.finishedWork = null), - null !== (n = a(e, t, !1)) && k(e, n, t)), + null !== (n = a(e, t, !1)) && M(e, n, t)), (fe = !1); } - function k(e, t, n) { + function M(e, t, n) { var r = e.firstBatch; if ( null !== r && @@ -4173,7 +4173,7 @@ object-assign ) return (e.finishedWork = t), void (e.remainingExpirationTime = 0); (e.finishedWork = null), - (oe = Z = !0), + (oe = J = !0), (n = t.stateNode).current === t && p('177'), 0 === (r = n.pendingCommitExpirationTime) && p('261'), (n.pendingCommitExpirationTime = 0); @@ -4189,7 +4189,7 @@ object-assign l = void 0; try { for (; null !== re; ) - 2048 & re.effectTag && D(re.alternate, re), (re = re.nextEffect); + 2048 & re.effectTag && P(re.alternate, re), (re = re.nextEffect); } catch (e) { (i = !0), (l = e); } @@ -4236,8 +4236,8 @@ object-assign for (a = n, i = o, l = r; null !== re; ) { var f = re.effectTag; 36 & f && F(a, re.alternate, re, i, l), - 256 & f && H(re, M), - 128 & f && B(re); + 256 & f && H(re, k), + 128 & f && Q(re); var h = re.nextEffect; (re.nextEffect = null), (re = h); } @@ -4249,20 +4249,20 @@ object-assign u(re, s), null !== re && (re = re.nextEffect)); } - (Z = oe = !1), + (J = oe = !1), nr(t.stateNode), 0 === (t = n.current.expirationTime) && (ie = null), (e.remainingExpirationTime = t); } function w() { - return !(null === ve || ve.timeRemaining() > Me) && (me = !0); + return !(null === ve || ve.timeRemaining() > ke) && (me = !0); } - function M(e) { + function k(e) { null === pe && p('246'), (pe.remainingExpirationTime = 0), ge || ((ge = !0), (ye = e)); } - var E = (function() { + var S = (function() { var e = [], t = -1; return { @@ -4282,7 +4282,7 @@ object-assign resetStackAfterFatalErrorInDev: function() {}, }; })(), - S = (function(e, t) { + E = (function(e, t) { function n(e) { return e === xr && p('174'), e; } @@ -4321,7 +4321,7 @@ object-assign o !== (t = r(o, e.type, t)) && (a(u, e, e), a(l, t, e)); }, }; - })(e, E), + })(e, S), N = (function(e) { function t(e, t, n) { ((e = e.stateNode).__reactInternalMemoizedUnmaskedChildContext = t), @@ -4408,8 +4408,8 @@ object-assign return e.stateNode.context; }, }; - })(E); - E = (function(e) { + })(S); + S = (function(e) { var t = e.createCursor, n = e.push, r = e.pop, @@ -4435,8 +4435,8 @@ object-assign (e._changedBits = t); }, }; - })(E); - var L = (function(e) { + })(S); + var I = (function(e) { function t(e, t) { var n = new Kn(5, null, null, 0); (n.type = 'DELETED'), @@ -4537,8 +4537,8 @@ object-assign }, }; })(e), - I = br(e, S, N, E, L, s, c).beginWork, - j = (function(e, t, n, r, o) { + L = br(e, E, N, S, I, s, c).beginWork, + _ = (function(e, t, n, r, o) { function a(e) { e.effectTag |= 4; } @@ -4558,16 +4558,16 @@ object-assign C = o.prepareToHydrateHostInstance, x = o.prepareToHydrateHostTextInstance, T = o.popHydrationState, - k = void 0, + M = void 0, w = void 0, - M = void 0; + k = void 0; return ( e.mutation - ? ((k = function() {}), + ? ((M = function() {}), (w = function(e, t, n) { (t.updateQueue = n) && a(t); }), - (M = function(e, t, n, r) { + (k = function(e, t, n, r) { n !== r && a(t); })) : p(f ? '235' : '236'), @@ -4598,7 +4598,7 @@ object-assign (r.pendingContext = null)), (null !== e && null !== e.child) || (T(t), (t.effectTag &= -3)), - k(t), + M(t), null !== (e = t.updateQueue) && null !== e.capturedValues && (t.effectTag |= 256), @@ -4609,28 +4609,28 @@ object-assign var o = t.type; if (null !== e && null != t.stateNode) { var f = e.memoizedProps, - E = t.stateNode, - S = m(); - (E = s(E, o, f, r, n, S)), - w(e, t, E, o, f, r, n, S), + S = t.stateNode, + E = m(); + (S = s(S, o, f, r, n, E)), + w(e, t, S, o, f, r, n, E), e.ref !== t.ref && (t.effectTag |= 128); } else { if (!r) return null === t.stateNode && p('166'), null; if (((e = m()), T(t))) C(t, n, e) && a(t); else { f = i(o, r, n, e, t); - e: for (S = t.child; null !== S; ) { - if (5 === S.tag || 6 === S.tag) u(f, S.stateNode); - else if (4 !== S.tag && null !== S.child) { - (S.child.return = S), (S = S.child); + e: for (E = t.child; null !== E; ) { + if (5 === E.tag || 6 === E.tag) u(f, E.stateNode); + else if (4 !== E.tag && null !== E.child) { + (E.child.return = E), (E = E.child); continue; } - if (S === t) break; - for (; null === S.sibling; ) { - if (null === S.return || S.return === t) break e; - S = S.return; + if (E === t) break; + for (; null === E.sibling; ) { + if (null === E.return || E.return === t) break e; + E = E.return; } - (S.sibling.return = S.return), (S = S.sibling); + (E.sibling.return = E.return), (E = E.sibling); } c(f, o, r, n, e) && a(t), (t.stateNode = f); } @@ -4638,7 +4638,7 @@ object-assign } return null; case 6: - if (e && null != t.stateNode) M(e, t, e.memoizedProps, r); + if (e && null != t.stateNode) k(e, t, e.memoizedProps, r); else { if ('string' != typeof r) return null === t.stateNode && p('166'), null; @@ -4675,7 +4675,7 @@ object-assign case 11: return null; case 4: - return g(t), k(t), null; + return g(t), M(t), null; case 13: return b(t), null; case 12: @@ -4688,8 +4688,8 @@ object-assign }, } ); - })(e, S, N, E, L).completeWork, - _ = (S = (function(e, t, n, r, o) { + })(e, E, N, S, I).completeWork, + j = (E = (function(e, t, n, r, o) { var a = e.popHostContainer, i = e.popHostContext, l = t.popContextProvider, @@ -4772,10 +4772,10 @@ object-assign } }, }; - })(S, N, E, 0, n)).throwException, - P = S.unwindWork, - O = S.unwindInterruptedWork, - D = (S = (function(e, t, n, r, o) { + })(E, N, S, 0, n)).throwException, + D = E.unwindWork, + O = E.unwindInterruptedWork, + P = (E = (function(e, t, n, r, o) { function a(e) { var n = e.ref; if (null !== n) @@ -5108,24 +5108,24 @@ object-assign })(e, u, 0, 0, function(e) { null === ie ? (ie = new Set([e])) : ie.add(e); })).commitBeforeMutationLifeCycles, - A = S.commitResetTextContent, - U = S.commitPlacement, - z = S.commitDeletion, - R = S.commitWork, - F = S.commitLifeCycles, - H = S.commitErrorLogging, - B = S.commitAttachRef, - V = S.commitDetachRef, - Q = e.now, + A = E.commitResetTextContent, + U = E.commitPlacement, + z = E.commitDeletion, + R = E.commitWork, + F = E.commitLifeCycles, + H = E.commitErrorLogging, + Q = E.commitAttachRef, + V = E.commitDetachRef, + B = e.now, W = e.scheduleDeferredCallback, Y = e.cancelDeferredCallback, K = e.prepareForCommit, $ = e.resetAfterCommit, - q = Q(), + q = B(), G = q, X = 0, - J = 0, - Z = !1, + Z = 0, + J = !1, ee = null, te = null, ne = 0, @@ -5149,9 +5149,9 @@ object-assign Ce = !1, xe = !1, Te = null, - ke = 1e3, + Me = 1e3, we = 0, - Me = 1; + ke = 1; return { recalculateCurrentTime: d, computeExpirationForFiber: c, @@ -5200,12 +5200,12 @@ object-assign } }, deferredUpdates: function(e) { - var t = J; - J = 25 * (1 + (((d() + 500) / 25) | 0)); + var t = Z; + Z = 25 * (1 + (((d() + 500) / 25) | 0)); try { return e(); } finally { - J = t; + Z = t; } }, syncUpdates: h, @@ -5231,7 +5231,7 @@ object-assign legacyContext: N, }; } - function kr(e) { + function Mr(e) { function t(e, t, n, r, o, i) { if (((r = t.current), n)) { n = n._reactInternalFiber; @@ -5314,7 +5314,7 @@ object-assign ('function' == typeof e.render ? p('188') : p('268', Object.keys(e))), - null === (e = Jt(t)) ? null : e.stateNode + null === (e = Zt(t)) ? null : e.stateNode ); }, findHostInstanceWithNoPortals: function(e) { @@ -5346,7 +5346,7 @@ object-assign if (t.isDisabled || !t.supportsFiber) return !0; try { var n = t.inject(e); - (Zn = tr(function(e) { + (Jn = tr(function(e) { return t.onCommitFiberRoot(n, e); })), (er = tr(function(e) { @@ -5357,7 +5357,7 @@ object-assign })( i({}, e, { findHostInstanceByFiber: function(e) { - return null === (e = Jt(e)) ? null : e.stateNode; + return null === (e = Zt(e)) ? null : e.stateNode; }, findFiberByHostInstance: function(e) { return t ? t(e) : null; @@ -5367,46 +5367,46 @@ object-assign }, }; } - var wr = Object.freeze({ default: kr }), - Mr = (wr && kr) || wr, - Er = Mr.default ? Mr.default : Mr; - var Sr = + var wr = Object.freeze({ default: Mr }), + kr = (wr && Mr) || wr, + Sr = kr.default ? kr.default : kr; + var Er = 'object' == typeof performance && 'function' == typeof performance.now, Nr = void 0; - Nr = Sr + Nr = Er ? function() { return performance.now(); } : function() { return Date.now(); }; - var Lr = void 0, - Ir = void 0; + var Ir = void 0, + Lr = void 0; if (a.canUseDOM) if ( 'function' != typeof requestIdleCallback || 'function' != typeof cancelIdleCallback ) { - var jr = null, - _r = !1, - Pr = -1, + var _r = null, + jr = !1, + Dr = -1, Or = !1, - Dr = 0, + Pr = 0, Ar = 33, Ur = 33, zr = void 0; - zr = Sr + zr = Er ? { didTimeout: !1, timeRemaining: function() { - var e = Dr - performance.now(); + var e = Pr - performance.now(); return 0 < e ? e : 0; }, } : { didTimeout: !1, timeRemaining: function() { - var e = Dr - Date.now(); + var e = Pr - Date.now(); return 0 < e ? e : 0; }, }; @@ -5419,42 +5419,42 @@ object-assign 'message', function(e) { if (e.source === window && e.data === Rr) { - if (((_r = !1), (e = Nr()), 0 >= Dr - e)) { - if (!(-1 !== Pr && Pr <= e)) + if (((jr = !1), (e = Nr()), 0 >= Pr - e)) { + if (!(-1 !== Dr && Dr <= e)) return void (Or || ((Or = !0), requestAnimationFrame(Fr))); zr.didTimeout = !0; } else zr.didTimeout = !1; - (Pr = -1), (e = jr), (jr = null), null !== e && e(zr); + (Dr = -1), (e = _r), (_r = null), null !== e && e(zr); } }, !1 ); var Fr = function(e) { Or = !1; - var t = e - Dr + Ur; + var t = e - Pr + Ur; t < Ur && Ar < Ur ? (8 > t && (t = 8), (Ur = t < Ar ? Ar : t)) : (Ar = t), - (Dr = e + Ur), - _r || ((_r = !0), window.postMessage(Rr, '*')); + (Pr = e + Ur), + jr || ((jr = !0), window.postMessage(Rr, '*')); }; - (Lr = function(e, t) { + (Ir = function(e, t) { return ( - (jr = e), + (_r = e), null != t && 'number' == typeof t.timeout && - (Pr = Nr() + t.timeout), + (Dr = Nr() + t.timeout), Or || ((Or = !0), requestAnimationFrame(Fr)), 0 ); }), - (Ir = function() { - (jr = null), (_r = !1), (Pr = -1); + (Lr = function() { + (_r = null), (jr = !1), (Dr = -1); }); } else - (Lr = window.requestIdleCallback), (Ir = window.cancelIdleCallback); + (Ir = window.requestIdleCallback), (Lr = window.cancelIdleCallback); else - (Lr = function(e) { + (Ir = function(e) { return setTimeout(function() { e({ timeRemaining: function() { @@ -5464,7 +5464,7 @@ object-assign }); }); }), - (Ir = function(e) { + (Lr = function(e) { clearTimeout(e); }); function Hr(e, t) { @@ -5484,7 +5484,7 @@ object-assign e ); } - function Br(e, t, n, r) { + function Qr(e, t, n, r) { if (((e = e.options), t)) { t = {}; for (var o = 0; o < n.length; o++) t['$' + n[o]] = !0; @@ -5510,7 +5510,7 @@ object-assign wasMultiple: !!t.multiple, }; } - function Qr(e, t) { + function Br(e, t) { return ( null != t.dangerouslySetInnerHTML && p('91'), i({}, t, { @@ -5565,7 +5565,7 @@ object-assign : e; } var Xr = void 0, - Jr = (function(e) { + Zr = (function(e) { return 'undefined' != typeof MSApp && MSApp.execUnsafeLocalFunction ? function(t, n, r, o) { MSApp.execUnsafeLocalFunction(function() { @@ -5587,7 +5587,7 @@ object-assign for (; t.firstChild; ) e.appendChild(t.firstChild); } }); - function Zr(e, t) { + function Jr(e, t) { if (t) { var n = e.firstChild; if (n && n === e.lastChild && 3 === n.nodeType) @@ -5713,7 +5713,7 @@ object-assign } var io = l.thatReturns(''); function lo(e, t) { - var n = Dn( + var n = Pn( (e = 9 === e.nodeType || 11 === e.nodeType ? e : e.ownerDocument) ); t = x[t]; @@ -5732,7 +5732,7 @@ object-assign (n.topCancel = !0)) : 'topClose' === o ? (We('close', !0) && xn('topClose', 'close', e), (n.topClose = !0)) - : In.hasOwnProperty(o) && Cn(o, In[o], e), + : Ln.hasOwnProperty(o) && Cn(o, Ln[o], e), (n[o] = !0)); } } @@ -5765,7 +5765,7 @@ object-assign break; case 'video': case 'audio': - for (a in jn) jn.hasOwnProperty(a) && Cn(a, jn[a], e); + for (a in _n) _n.hasOwnProperty(a) && Cn(a, _n[a], e); a = n; break; case 'source': @@ -5799,7 +5799,7 @@ object-assign break; case 'textarea': Wr(e, n), - (a = Qr(e, n)), + (a = Br(e, n)), Cn('topInvalid', 'invalid', e), lo(r, 'onChange'); break; @@ -5815,11 +5815,11 @@ object-assign 'style' === u ? no(e, s) : 'dangerouslySetInnerHTML' === u - ? null != (s = s ? s.__html : void 0) && Jr(e, s) + ? null != (s = s ? s.__html : void 0) && Zr(e, s) : 'children' === u ? 'string' == typeof s - ? ('textarea' !== t || '' !== s) && Zr(e, s) - : 'number' == typeof s && Zr(e, '' + s) + ? ('textarea' !== t || '' !== s) && Jr(e, s) + : 'number' == typeof s && Jr(e, '' + s) : 'suppressContentEditableWarning' !== u && 'suppressHydrationWarning' !== u && 'autoFocus' !== u && @@ -5829,7 +5829,7 @@ object-assign } switch (t) { case 'input': - Ke(e), kt(e, n); + Ke(e), Mt(e, n); break; case 'textarea': Ke(e), Kr(e); @@ -5840,9 +5840,9 @@ object-assign case 'select': (e.multiple = !!n.multiple), null != (t = n.value) - ? Br(e, !!n.multiple, t, !1) + ? Qr(e, !!n.multiple, t, !1) : null != n.defaultValue && - Br(e, !!n.multiple, n.defaultValue, !0); + Qr(e, !!n.multiple, n.defaultValue, !0); break; default: 'function' == typeof a.onClick && (e.onclick = l); @@ -5863,7 +5863,7 @@ object-assign (a = []); break; case 'textarea': - (n = Qr(e, n)), (r = Qr(e, r)), (a = []); + (n = Br(e, n)), (r = Br(e, r)), (a = []); break; default: 'function' != typeof n.onClick && @@ -5930,9 +5930,9 @@ object-assign 'style' === i ? no(e, l) : 'dangerouslySetInnerHTML' === i - ? Jr(e, l) - : 'children' === i ? Zr(e, l) + : 'children' === i + ? Jr(e, l) : vt(e, i, l, r); } switch (n) { @@ -5947,11 +5947,11 @@ object-assign (t = e._wrapperState.wasMultiple), (e._wrapperState.wasMultiple = !!o.multiple), null != (n = o.value) - ? Br(e, !!o.multiple, n, !1) + ? Qr(e, !!o.multiple, n, !1) : t !== !!o.multiple && (null != o.defaultValue - ? Br(e, !!o.multiple, o.defaultValue, !0) - : Br(e, !!o.multiple, o.multiple ? [] : '', !1)); + ? Qr(e, !!o.multiple, o.defaultValue, !0) + : Qr(e, !!o.multiple, o.multiple ? [] : '', !1)); } } function ho(e, t, n, r, o) { @@ -5962,7 +5962,7 @@ object-assign break; case 'video': case 'audio': - for (var a in jn) jn.hasOwnProperty(a) && Cn(a, jn[a], e); + for (var a in _n) _n.hasOwnProperty(a) && Cn(a, _n[a], e); break; case 'source': Cn('topError', 'error', e); @@ -5999,7 +5999,7 @@ object-assign : C.hasOwnProperty(i) && null != a && lo(o, i)); switch (t) { case 'input': - Ke(e), kt(e, n); + Ke(e), Mt(e, n); break; case 'textarea': Ke(e), Kr(e); @@ -6053,11 +6053,11 @@ object-assign Yr(e, n); break; case 'select': - null != (t = n.value) && Br(e, !!n.multiple, t, !1); + null != (t = n.value) && Qr(e, !!n.multiple, t, !1); } }, }); - Le.injectFiberControlledHostComponent(go); + Ie.injectFiberControlledHostComponent(go); var yo = null, vo = null; function bo(e) { @@ -6085,7 +6085,7 @@ object-assign (8 !== e.nodeType || ' react-mount-point-unstable ' !== e.nodeValue)) ); } - function ko(e, t) { + function Mo(e, t) { switch (e) { case 'button': case 'input': @@ -6198,7 +6198,7 @@ object-assign } return e; }); - var wo = Er({ + var wo = Sr({ getRootHostContext: function(e) { var t = e.nodeType; switch (t) { @@ -6325,13 +6325,13 @@ object-assign (vo = null), bn(yo), (yo = null); }, createInstance: function(e, t, n, r, o) { - return ((e = uo(e, t, n, r))[H] = o), (e[B] = t), e; + return ((e = uo(e, t, n, r))[H] = o), (e[Q] = t), e; }, appendInitialChild: function(e, t) { e.appendChild(t); }, finalizeInitialChildren: function(e, t, n, r) { - return so(e, t, n, r), ko(t, n); + return so(e, t, n, r), Mo(t, n); }, prepareUpdate: function(e, t, n, r, o) { return fo(e, t, n, r, o); @@ -6355,13 +6355,13 @@ object-assign now: Nr, mutation: { commitMount: function(e, t, n) { - ko(t, n) && e.focus(); + Mo(t, n) && e.focus(); }, commitUpdate: function(e, t, n, r, o) { - (e[B] = o), po(e, t, n, r, o); + (e[Q] = o), po(e, t, n, r, o); }, resetTextContent: function(e) { - Zr(e, ''); + Jr(e, ''); }, commitTextUpdate: function(e, t, n) { e.nodeValue = n; @@ -6410,7 +6410,7 @@ object-assign return e; }, hydrateInstance: function(e, t, n, r, o, a) { - return (e[H] = a), (e[B] = n), ho(e, t, n, o, r); + return (e[H] = a), (e[Q] = n), ho(e, t, n, o, r); }, hydrateTextInstance: function(e, t, n) { return (e[H] = n), mo(e, t); @@ -6424,11 +6424,11 @@ object-assign didNotFindHydratableInstance: function() {}, didNotFindHydratableTextInstance: function() {}, }, - scheduleDeferredCallback: Lr, - cancelDeferredCallback: Ir, + scheduleDeferredCallback: Ir, + cancelDeferredCallback: Lr, }), - Mo = wo; - function Eo(e, t, n, r, o) { + ko = wo; + function So(e, t, n, r, o) { To(n) || p('200'); var a = n._reactRootContainer; if (a) { @@ -6477,7 +6477,7 @@ object-assign } return wo.getPublicRootInstance(a._internalRoot); } - function So(e, t) { + function Eo(e, t) { var n = 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null; return ( @@ -6497,24 +6497,24 @@ object-assign })(e, t, null, n) ); } - (Ue = Mo.batchedUpdates), - (ze = Mo.interactiveUpdates), - (Re = Mo.flushInteractiveUpdates); + (Ue = ko.batchedUpdates), + (ze = ko.interactiveUpdates), + (Re = ko.flushInteractiveUpdates); var No = { - createPortal: So, + createPortal: Eo, findDOMNode: function(e) { return null == e ? null : 1 === e.nodeType ? e : wo.findHostInstance(e); }, hydrate: function(e, t, n) { - return Eo(null, e, t, !0, n); + return So(null, e, t, !0, n); }, render: function(e, t, n) { - return Eo(null, e, t, !1, n); + return So(null, e, t, !1, n); }, unstable_renderSubtreeIntoContainer: function(e, t, n, r) { return ( (null == e || void 0 === e._reactInternalFiber) && p('38'), - Eo(e, t, n, !1, r) + So(e, t, n, !1, r) ); }, unmountComponentAtNode: function(e) { @@ -6522,7 +6522,7 @@ object-assign To(e) || p('40'), !!e._reactRootContainer && (wo.unbatchedUpdates(function() { - Eo(null, null, e, !1, function() { + So(null, null, e, !1, function() { e._reactRootContainer = null; }); }), @@ -6530,7 +6530,7 @@ object-assign ); }, unstable_createPortal: function() { - return So.apply(void 0, arguments); + return Eo.apply(void 0, arguments); }, unstable_batchedUpdates: wo.batchedUpdates, unstable_deferredUpdates: wo.deferredUpdates, @@ -6554,9 +6554,9 @@ object-assign version: '16.3.3', rendererPackageName: 'react-dom', }); - var Lo = Object.freeze({ default: No }), - Io = (Lo && No) || Lo; - e.exports = Io.default ? Io.default : Io; + var Io = Object.freeze({ default: No }), + Lo = (Io && No) || Io; + e.exports = Lo.default ? Lo.default : Lo; }, function(e, t, n) { 'use strict'; diff --git a/packages/extensions/luis/client/src/App.tsx b/packages/extensions/luis/client/src/App.tsx index d3545020b..d915fda5d 100644 --- a/packages/extensions/luis/client/src/App.tsx +++ b/packages/extensions/luis/client/src/App.tsx @@ -333,8 +333,10 @@ export class App extends Component { pendingTrain: false, pendingPublish: true, }); + $host.trackEvent('luis_trainSuccess'); } catch (err) { $host.logger.error(err.message); + $host.trackEvent('luis_trainFailure', { error: err.message }); } finally { $host.setAccessoryState(TrainAccessoryId, AccessoryDefaultState); } @@ -352,8 +354,10 @@ export class App extends Component { pendingPublish: false, pendingTrain: false, }); + $host.trackEvent('luis_publishSuccess'); } catch (err) { $host.logger.error(err.message); + $host.trackEvent('luis_publishFailure', { error: err.message }); } finally { $host.setAccessoryState(TrainAccessoryId, AccessoryDefaultState); } diff --git a/packages/extensions/luis/client/src/Controls/IntentEditor/IntentEditor.tsx b/packages/extensions/luis/client/src/Controls/IntentEditor/IntentEditor.tsx index e56f04bd9..e8a3e190e 100644 --- a/packages/extensions/luis/client/src/Controls/IntentEditor/IntentEditor.tsx +++ b/packages/extensions/luis/client/src/Controls/IntentEditor/IntentEditor.tsx @@ -38,6 +38,9 @@ import { IntentInfo } from '../../Luis/IntentInfo'; import { Intent } from '../../Models/Intent'; import * as styles from './IntentEditor.scss'; +import { InspectorHost } from '@bfemulator/sdk-client'; + +let $host: InspectorHost = (window as any).host; const TraceIntentStatesKey: string = Symbol( 'PersistedTraceIntentStates' @@ -152,6 +155,7 @@ class IntentEditor extends Component { this.setAndPersistTraceIntentStates(currentTraceIntentStates); if (this.props.intentReassigner) { this.props.intentReassigner(newIntent, needsRetrain).catch(); + $host.trackEvent('luis_reassignIntent'); } }; diff --git a/packages/extensions/qnamaker/client/src/App.tsx b/packages/extensions/qnamaker/client/src/App.tsx index 94a33fa58..b2d3f48e5 100644 --- a/packages/extensions/qnamaker/client/src/App.tsx +++ b/packages/extensions/qnamaker/client/src/App.tsx @@ -283,16 +283,15 @@ export class App extends React.Component { body ); success = response.status === 200; - $host.logger.log( - 'Successfully trained Knowledge Base ' + - this.state.traceInfo.knowledgeBaseId - ); + $host.logger.log('Successfully trained Knowledge Base ' + this.state.traceInfo.knowledgeBaseId); + $host.trackEvent('qna_trainSuccess'); } else { $host.logger.error('Select an answer before trying to train.'); } } } catch (err) { $host.logger.error(err.message); + $host.trackEvent('qna_trainFailure', { error: err.message }); } finally { $host.setAccessoryState(TrainAccessoryId, AccessoryDefaultState); this.setAppPersistentState({ @@ -313,18 +312,16 @@ export class App extends React.Component { ); success = response.status === 204; if (success) { - $host.logger.log( - 'Successfully published Knowledge Base ' + - this.state.traceInfo.knowledgeBaseId - ); + $host.logger.log('Successfully published Knowledge Base ' + this.state.traceInfo.knowledgeBaseId); + $host.trackEvent('qna_publishSuccess'); } else { - $host.logger.error( - 'Request to QnA Maker failed. ' + response.statusText - ); + $host.logger.error('Request to QnA Maker failed. ' + response.statusText); + $host.trackEvent('qna_publishFailure', { error: response.statusText }); } } } catch (err) { $host.logger.error(err.message); + $host.trackEvent('qna_publishFailure', { error: err.message }); } finally { $host.setAccessoryState(PublishAccessoryId, AccessoryDefaultState); } diff --git a/packages/sdk/client/src/extensions/host.ts b/packages/sdk/client/src/extensions/host.ts index b0858d893..38027d253 100644 --- a/packages/sdk/client/src/extensions/host.ts +++ b/packages/sdk/client/src/extensions/host.ts @@ -67,4 +67,7 @@ export interface InspectorHost { // Set inspector title setInspectorTitle(title: string): void; + + // Tracks a telemetry event to App Insights + trackEvent(name: string, properties?: { [key: string]: any }): void; } diff --git a/packages/sdk/shared/src/utils/misc.spec.ts b/packages/sdk/shared/src/utils/misc.spec.ts index e50d31609..c376112cb 100644 --- a/packages/sdk/shared/src/utils/misc.spec.ts +++ b/packages/sdk/shared/src/utils/misc.spec.ts @@ -31,7 +31,7 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { isObject, uniqueId, uniqueIdv4 } from './misc'; +import { isLocalHostUrl, isObject, uniqueId, uniqueIdv4 } from './misc'; describe('Misc utility function tests', () => { it('should generate a uniqueId', () => { @@ -69,4 +69,11 @@ describe('Misc utility function tests', () => { expect(id1).not.toEqual(id3); expect(id2).not.toEqual(id3); }); + + it('should determine whether a url is a localhost url or not', () => { + expect(isLocalHostUrl('http://localhost')).toBeTruthy(); + expect(isLocalHostUrl('http://127.0.0.1')).toBeTruthy(); + expect(isLocalHostUrl('https://aka.ms/bot-framework-emulator')).toBeFalsy(); + expect(isLocalHostUrl('dasdagidsd812931239@#232')).toBeFalsy(); + }); }); diff --git a/packages/sdk/shared/src/utils/misc.ts b/packages/sdk/shared/src/utils/misc.ts index d3469d5dc..cdbc646c2 100644 --- a/packages/sdk/shared/src/utils/misc.ts +++ b/packages/sdk/shared/src/utils/misc.ts @@ -45,3 +45,14 @@ export function uniqueId(): string { export function isObject(item: any): boolean { return item && typeof item === 'object' && !Array.isArray(item); } + +export function isLocalHostUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + const localhostNames = ['localhost', '127.0.0.1', '::1']; + return localhostNames.some(name => parsedUrl.hostname === name); + } catch (e) { + // invalid url was passed in + return false; + } +}