diff --git a/packages/app/client/src/commands/botCommands.spec.ts b/packages/app/client/src/commands/botCommands.spec.ts index e2643a759..c5c909dcf 100644 --- a/packages/app/client/src/commands/botCommands.spec.ts +++ b/packages/app/client/src/commands/botCommands.spec.ts @@ -87,12 +87,20 @@ 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/emulatorCommands.spec.ts b/packages/app/client/src/commands/emulatorCommands.spec.ts index 1d518391d..e2c62d4c4 100644 --- a/packages/app/client/src/commands/emulatorCommands.spec.ts +++ b/packages/app/client/src/commands/emulatorCommands.spec.ts @@ -140,6 +140,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/uiCommands.spec.ts b/packages/app/client/src/commands/uiCommands.spec.ts index 0fbb1dd42..62ecfb396 100644 --- a/packages/app/client/src/commands/uiCommands.spec.ts +++ b/packages/app/client/src/commands/uiCommands.spec.ts @@ -32,7 +32,6 @@ // import { SharedConstants } from '@bfemulator/app-shared'; import { CommandRegistryImpl } from '@bfemulator/sdk-shared'; - import { CONTENT_TYPE_APP_SETTINGS, DOCUMENT_ID_APP_SETTINGS, @@ -57,8 +56,9 @@ import { OpenBotDialogContainer, SecretPromptDialogContainer, } from '../ui/dialogs'; - import { registerCommands } from './uiCommands'; +import { CommandServiceImpl } from '../platform/commands/commandServiceImpl'; + jest.mock('../ui/dialogs', () => ({ AzureLoginPromptDialogContainer: class {}, AzureLoginSuccessDialogContainer: class {}, @@ -153,10 +153,16 @@ describe('the uiCommands', () => { }); it('should set the proper href on the theme tag when the SwitchTheme command is dispatched', () => { - const link = document.createElement('link'); + const remoteCallSpy = jest.spyOn(CommandServiceImpl, 'remoteCall'); + let 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/data/sagas/azureAuthSaga.spec.ts b/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts index 756805842..8cc9e195e 100644 --- a/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts +++ b/packages/app/client/src/data/sagas/azureAuthSaga.spec.ts @@ -182,6 +182,7 @@ 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 +258,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/resourceSagas.spec.ts b/packages/app/client/src/data/sagas/resourceSagas.spec.ts index 49f6b61b4..2fc706041 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({ @@ -256,9 +256,15 @@ describe('The ResourceSagas', () => { expect(mockLocalCommandsCalled).toEqual([ { commandName: 'chat:open', - args: ['the/file/path/chat.chat', true], - }, + 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 () => { @@ -267,9 +273,14 @@ describe('The ResourceSagas', () => { expect(mockLocalCommandsCalled).toEqual([ { commandName: 'transcript:open', - args: ['the/file/path/transcript.transcript'], - }, + 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/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx new file mode 100644 index 000000000..d8baebb56 --- /dev/null +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -0,0 +1,394 @@ +// +// 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 { Emulator, EmulatorComponent, RestartConversationOptions } from './emulator'; +import { disable, enable } from '../../../data/action/presentationActions'; +import { + clearLog, + newConversation, + setInspectorObjects, + updateChat +} from '../../../data/action/chatActions'; +import { updateDocument } from '../../../data/action/editorActions'; +import { SharedConstants } from '@bfemulator/app-shared'; +import base64Url from 'base64url'; +const { encode } = base64Url; + +let mockCallsMade, mockRemoteCallsMade; +let 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; + }); + let 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; + let 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; + let 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 ab3e49c06..58297d938 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', }; @@ -93,7 +93,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 +227,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}` 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 facf72674..57374d770 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 @@ -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 * as React from 'react'; import { Provider } from 'react-redux'; import { mount } from 'enzyme'; @@ -39,7 +40,6 @@ import { textItem, } from '@bfemulator/emulator-core/lib/types/log/util'; import LogLevel from '@bfemulator/emulator-core/lib/types/log/level'; - import { bot } from '../../../../../data/reducer/bot'; import { clientAwareSettings } from '../../../../../data/reducer/clientAwareSettingsReducer'; import { load, setActive } from '../../../../../data/action/botActions'; @@ -47,7 +47,7 @@ import { theme } from '../../../../../data/reducer/themeReducer'; import { switchTheme } from '../../../../../data/action/themeActions'; import { ExtensionManager } from '../../../../../extensions'; import { LogService } from '../../../../../platform/log/logService'; - +import { SharedConstants } from '@bfemulator/app-shared'; import { InspectorContainer } from './inspectorContainer'; import { Inspector } from './inspector'; @@ -64,6 +64,15 @@ jest.mock('../../../../../data/store', () => ({ }, })); +let mockRemoteCallsMade; +jest.mock('../../../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + remoteCall: (commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + } + } +})); + const mockState = { bot: { description: '', @@ -268,6 +277,7 @@ describe('The Inspector component', () => { mockStore.dispatch(switchTheme('light', ['vars.css', 'light.css'])); mockStore.dispatch(load([mockState.bot])); mockStore.dispatch(setActive(mockState.bot as any)); + mockRemoteCallsMade = []; parent = mount( @@ -384,5 +394,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/log/logEntry.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/log/logEntry.spec.tsx index 0bea83418..7054d8333 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 @@ -33,29 +33,81 @@ import * as React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; import LogLevel from '@bfemulator/emulator-core/lib/types/log/level'; import { textItem } from '@bfemulator/emulator-core/lib/types/log/util'; +import { + number2, + timestamp, + LogEntry, + LogEntryProps +} from './logEntry'; +import { LogEntry as LogEntryContainer } from './logEntryContainer'; +import { SharedConstants } from '@bfemulator/app-shared'; +import { setInspectorObjects } from '../../../../../data/action/chatActions'; -import { number2, timestamp, LogEntry, LogEntryProps } from './logEntry'; +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: [], + 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', () => { @@ -67,6 +119,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); @@ -75,7 +128,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; @@ -85,7 +138,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()); @@ -95,4 +148,149 @@ 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/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/shell/mdi/tabBar/tabBar.spec.tsx b/packages/app/client/src/ui/shell/mdi/tabBar/tabBar.spec.tsx index 699e18538..da6760d93 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 @@ -48,6 +48,7 @@ import { CONTENT_TYPE_TRANSCRIPT, CONTENT_TYPE_WELCOME_PAGE, } from '../../../../constants'; +import { SharedConstants } from '@bfemulator/app-shared'; import { TabBarContainer } from './tabBarContainer'; import { TabBar } from './tabBar'; @@ -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,9 @@ 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', () => { @@ -204,10 +219,13 @@ describe('TabBar', () => { it('should handle a split click', () => { instance.onSplitClick(); - + 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/navBar/navBar.spec.tsx b/packages/app/client/src/ui/shell/navBar/navBar.spec.tsx new file mode 100644 index 000000000..1465feee4 --- /dev/null +++ b/packages/app/client/src/ui/shell/navBar/navBar.spec.tsx @@ -0,0 +1,154 @@ +// +// 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 { NavBar as NavBarContainer } from './navBarContainer'; +import { NavBarComponent as NavBar } from './navBar'; +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'; + +let mockRemoteCallsMade; +jest.mock('../../../platform/commands/commandServiceImpl', () => ({ + CommandServiceImpl: { + remoteCall: jest.fn((commandName, ...args) => { + mockRemoteCallsMade.push({ commandName, args }); + }) + } +})); +jest.mock('./navBar.scss', () => ({})); + +let mockState; +let 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/main/src/appUpdater.spec.ts b/packages/app/main/src/appUpdater.spec.ts index b33b727d0..77735308b 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,19 @@ 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); +<<<<<<< HEAD const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.resolve() ); +======= + const mockCheckForUpdates = jest.fn(() => Promise.resolve()); +>>>>>>> 9112640c... Added tests for Telemetry. mockAutoUpdater.setFeedURL = mockSetFeedURL; mockAutoUpdater.checkForUpdates = mockCheckForUpdates; - AppUpdater.checkForUpdates(true); + await AppUpdater.checkForUpdates(true); expect(AppUpdater.userInitiated).toBe(true); @@ -190,18 +204,26 @@ 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); +<<<<<<< HEAD const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.resolve() ); +======= + const mockCheckForUpdates = jest.fn(() => Promise.resolve()); +>>>>>>> 9112640c... Added tests for Telemetry. mockAutoUpdater.setFeedURL = mockSetFeedURL; mockAutoUpdater.checkForUpdates = mockCheckForUpdates; - AppUpdater.checkForUpdates(false); + await AppUpdater.checkForUpdates(false); expect(mockSetFeedURL).toHaveBeenCalledWith({ repo: 'BotFramework-Emulator-Nightlies', @@ -210,12 +232,20 @@ 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 () => { +<<<<<<< HEAD const mockCheckForUpdates = jest.fn((_userInitiated: boolean) => Promise.reject('ERROR') ); +======= + const mockCheckForUpdates = jest.fn(() => Promise.reject('ERROR')); +>>>>>>> 9112640c... Added tests for Telemetry. mockAutoUpdater.checkForUpdates = mockCheckForUpdates; mockAutoUpdater.setFeedURL = () => null; diff --git a/packages/app/main/src/commands/botCommands.spec.ts b/packages/app/main/src/commands/botCommands.spec.ts index 2522947fd..575407280 100644 --- a/packages/app/main/src/commands/botCommands.spec.ts +++ b/packages/app/main/src/commands/botCommands.spec.ts @@ -57,6 +57,7 @@ import { } from '../watchers'; import { registerCommands } from './botCommands'; +import { TelemetryService } from '../telemetry'; const mockBotConfig = BotConfiguration; let mockStore; @@ -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,7 @@ 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/electronCommands.spec.ts b/packages/app/main/src/commands/electronCommands.spec.ts index 6fd1a5dec..2303bdb80 100644 --- a/packages/app/main/src/commands/electronCommands.spec.ts +++ b/packages/app/main/src/commands/electronCommands.spec.ts @@ -41,6 +41,7 @@ import { getStore } from '../botData/store'; import { mainWindow } from '../main'; import { registerCommands } from './electronCommands'; +import { TelemetryService } from '../telemetry'; let renameArgs; jest.mock('fs-extra', () => ({ @@ -50,6 +51,7 @@ jest.mock('fs-extra', () => ({ rename: async (...args: any[]) => (renameArgs = args), })); +let mockOpenExternal; jest.mock('electron', () => ({ app: { getName: () => 'BotFramework Emulator', @@ -63,8 +65,11 @@ jest.mock('electron', () => ({ dialog: { showMessageBox: () => void 0, showOpenDialog: () => void 0, - showSaveDialog: () => void 0, + showSaveDialog: () => void 0 }, + shell: { + get openExternal() { return mockOpenExternal; } + } })); jest.mock('../main', () => ({ @@ -140,6 +145,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 +311,14 @@ 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/emulatorCommands.spec.ts b/packages/app/main/src/commands/emulatorCommands.spec.ts index 3ba6be6c3..b86182376 100644 --- a/packages/app/main/src/commands/emulatorCommands.spec.ts +++ b/packages/app/main/src/commands/emulatorCommands.spec.ts @@ -34,7 +34,6 @@ import '../fetchProxy'; import * as path from 'path'; - import { combineReducers, createStore } from 'redux'; import { BotConfigWithPathImpl, @@ -43,15 +42,14 @@ import { import { BotConfiguration } from 'botframework-config'; import { newBot, newEndpoint, SharedConstants } from '@bfemulator/app-shared'; import { Conversation } from '@bfemulator/emulator-core'; - import * as store from '../botData/store'; import { emulator } from '../emulator'; 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'; const mockBotConfig = BotConfiguration; @@ -391,8 +389,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 () => { @@ -447,10 +454,15 @@ describe('The emulatorCommands', () => { const newPath = path.normalize('chosen/AuthBot.bot'); expect(getBotInfoByPathSpy).toHaveBeenCalledWith('some/path'); expect(toSavableBotSpy).toHaveBeenCalledWith(mockBot, mockInfo.secret); +<<<<<<< HEAD expect(patchBotJsonSpy).toHaveBeenCalledWith( newPath, Object.assign({}, mockInfo, { path: newPath }) ); +======= + expect(patchBotJsonSpy).toHaveBeenCalledWith(newPath, Object.assign({}, mockInfo, { path: newPath })); + expect(mockTrackEvent).toHaveBeenCalledWith('transcript_save'); +>>>>>>> 9112640c... Added tests for Telemetry. }); it('should feed a transcript from disk to a conversation', async () => { 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..c87330c73 --- /dev/null +++ b/packages/app/main/src/commands/settingsCommands.spec.ts @@ -0,0 +1,80 @@ +import { CommandRegistryImpl } from "@bfemulator/sdk-shared"; + +// +// 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 { registerCommands } from './settingsCommands'; +import { TelemetryService } from "../telemetry"; +import { SharedConstants } from "@bfemulator/app-shared"; +import { setFramework } from '../settingsData/actions/frameworkActions'; + +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/telemetryCommands.spec.ts b/packages/app/main/src/commands/telemetryCommands.spec.ts new file mode 100644 index 000000000..d728b55b8 --- /dev/null +++ b/packages/app/main/src/commands/telemetryCommands.spec.ts @@ -0,0 +1,68 @@ +import { CommandRegistryImpl } from "@bfemulator/sdk-shared"; + +// +// 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 { registerCommands } from './telemetryCommands'; +import { TelemetryService } from "../telemetry"; +import { SharedConstants } from "@bfemulator/app-shared"; + +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 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/app/main/src/protocolHandler.spec.ts b/packages/app/main/src/protocolHandler.spec.ts index f4818d6d0..2e6184d50 100644 --- a/packages/app/main/src/protocolHandler.spec.ts +++ b/packages/app/main/src/protocolHandler.spec.ts @@ -45,13 +45,99 @@ import { ProtocolHandler, parseEndpointOverrides, } from './protocolHandler'; -jest.mock('./main', () => ({})); +import { TelemetryService } from './telemetry'; +import { SharedConstants, newBot, newEndpoint } from '@bfemulator/app-shared'; +import { applyBotConfigOverrides, BotConfigWithPathImpl } from '@bfemulator/sdk-shared'; + +let mockCallsMade, mockRemoteCallsMade; +let mockOpenedBot; +let 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, + 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 +194,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 +274,202 @@ 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 a6cc1a22b..286b4d18b 100644 --- a/packages/app/main/src/protocolHandler.ts +++ b/packages/app/main/src/protocolHandler.ts @@ -167,7 +167,6 @@ export const ProtocolHandler = new class ProtocolHandlerImpl switch (ProtocolTranscriptActions[protocol.action]) { case ProtocolTranscriptActions.open: this.openTranscript(protocol); - TelemetryService.trackEvent('transcriptFile_open', { method: 'protocol' }); break; default: @@ -263,7 +262,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,14 +333,10 @@ export const ProtocolHandler = new class ProtocolHandlerImpl secret ); if (!bot) { - throw new Error( - `Error occurred while trying to open bot at: ${path} inside of protocol handler.` - ); + throw new Error(`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.` - ); + throw new Error(`Error occurred while trying to open bot at ${path} inside of protocol handler: ${e}`); } // apply any overrides @@ -374,7 +369,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 { @@ -393,7 +388,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}` ); } } @@ -411,7 +407,7 @@ 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}` ); } } diff --git a/packages/app/main/src/services/conversationService.spec.ts b/packages/app/main/src/services/conversationService.spec.ts index 956599d18..f68825877 100644 --- a/packages/app/main/src/services/conversationService.spec.ts +++ b/packages/app/main/src/services/conversationService.spec.ts @@ -30,14 +30,15 @@ // 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 '../fetchProxy'; +import { TelemetryService } from '../telemetry'; +import '../fetchProxy'; import { ConversationService, headers as headersInstance, } from './conversationService'; -let mockFetchArgs: MockFetch; +let mockFetchArgs: MockFetch; jest.mock('node-fetch', () => { const fetch = (url, opts) => { mockFetchArgs = { url, opts }; @@ -51,6 +52,9 @@ jest.mock('node-fetch', () => { (fetch as any).Response = class {}; return fetch; }); +jest.mock('../settingsData/store', () => ({ + getSettings: () => null +})); interface MockOpts { headers: Headers; @@ -64,6 +68,18 @@ interface MockFetch { } describe('The ConversationService should call "fetch" with the expected parameters when executing', () => { + let mockTrackEvent; + const trackEventBackup = TelemetryService.trackEvent; + + beforeEach(() => { + mockTrackEvent = jest.fn(() => null); + TelemetryService.trackEvent = mockTrackEvent; + }); + + afterAll(() => { + TelemetryService.trackEvent = trackEventBackup; + }); + test('the "addUser" function', () => { ConversationService.addUser('http://localhost', 'abcdef'); const { url, opts } = mockFetchArgs; @@ -76,6 +92,7 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(members[0].name).toBeFalsy(); expect(members[0].id).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_addUser'); }); test('the "removeUser" function', () => { @@ -89,6 +106,7 @@ describe('The ConversationService should call "fetch" with the expected paramete const users = JSON.parse(body); expect(users[0].id).toBe('1234'); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_removeUser'); }); test('the "removeRandomUser" function', () => { @@ -113,6 +131,7 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(method).toBe('POST'); expect(body).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_botContactAdded'); }); test('the "botContactRemoved" function', () => { @@ -125,6 +144,7 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(method).toBe('DELETE'); expect(body).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_botContactRemoved'); }); test('the "typing" function', () => { @@ -137,6 +157,7 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(method).toBe('POST'); expect(body).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_typing'); }); test('the "ping" function', () => { @@ -149,6 +170,7 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(method).toBe('POST'); expect(body).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_ping'); }); test('the "deleteUserData" function', () => { @@ -161,5 +183,6 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(method).toBe('DELETE'); expect(body).toBeFalsy(); expect(headersInstance).toEqual(headers); + expect(mockTrackEvent).toHaveBeenCalledWith('sendActivity_deleteUserData'); }); }); 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..9d8d054ab --- /dev/null +++ b/packages/app/main/src/telemetry/telemetryService.spec.ts @@ -0,0 +1,138 @@ +// +// 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 index dfd5a6ac6..e33adf71b 100644 --- a/packages/app/main/src/telemetry/telemetryService.ts +++ b/packages/app/main/src/telemetry/telemetryService.ts @@ -60,7 +60,7 @@ export class TelemetryService { private static startup(): void { if (!this._hasStarted) { AppInsights - .setup(INSTRUMENTATION_KEY) + .setup(INSTRUMENTATION_KEY) // turn off extra instrmentation .setAutoCollectConsole(false) .setAutoCollectDependencies(false) diff --git a/packages/app/main/src/utils/openFileFromCommandLine.spec.ts b/packages/app/main/src/utils/openFileFromCommandLine.spec.ts index fc943b3ea..587b75d4c 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, @@ -39,8 +40,11 @@ import { } from '@bfemulator/sdk-shared'; import { openFileFromCommandLine } from './openFileFromCommandLine'; -import * as readFileSyncUtil from './readFileSync'; +import { TelemetryService } from '../telemetry'; +jest.mock('../settingsData/store', () => ({ + getSettings: () => null +})); jest.mock('./readFileSync', () => ({ readFileSync: file => { if (file.includes('error.transcript')) { @@ -78,8 +82,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 +116,7 @@ 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..4a3ff99db 100644 --- a/packages/app/main/src/utils/openFileFromCommandLine.ts +++ b/packages/app/main/src/utils/openFileFromCommandLine.ts @@ -30,11 +30,11 @@ // 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 * 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 +71,6 @@ export async function openFileFromCommandLine( inMemory: true, } ); + TelemetryService.trackEvent('transcriptFile_open', { method: 'protocol' }); } }