From 4d110bd5493797198aae084b1b287e920238ca0e Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Wed, 12 Feb 2020 09:39:34 -0800 Subject: [PATCH 01/11] Saga and conv updated Signed-off-by: Srinaath Ravichandran get activities for a conversation Signed-off-by: Srinaath Ravichandran Return updated activity Signed-off-by: Srinaath Ravichandran Adding declaration maps for inspection Signed-off-by: Srinaath Ravichandran Conversation routes set for fetch activities Signed-off-by: Srinaath Ravichandran Safe commit Signed-off-by: Srinaath Ravichandran Queues updated Signed-off-by: Srinaath Ravichandran Safe commit Signed-off-by: Srinaath Ravichandran Safe saga commit Signed-off-by: Srinaath Ravichandran Resart multiple times the same conversation working. Major landmark Signed-off-by: Srinaath Ravichandran Stable replay Signed-off-by: Srinaath Ravichandran Restart from specific activity working Signed-off-by: Srinaath Ravichandran UI updates Signed-off-by: Srinaath Ravichandran Safe saga flow Signed-off-by: Srinaath Ravichandran Safe replay Signed-off-by: Srinaath Ravichandran Forked post activity to let moving on to next activity Signed-off-by: Srinaath Ravichandran Blocking webchat Signed-off-by: Srinaath Ravichandran Simenatous conversations complete Signed-off-by: Srinaath Ravichandran Error notficaition viewer completed Signed-off-by: Srinaath Ravichandran Handling Dlspeech bot sniffer Signed-off-by: Srinaath Ravichandran Conversation service spec completed Signed-off-by: Srinaath Ravichandran Post activity test Signed-off-by: Srinaath Ravichandran Mount conversation routes test Signed-off-by: Srinaath Ravichandran Spec files updated Signed-off-by: Srinaath Ravichandran Tests wrapped up for conversation queue Signed-off-by: Srinaath Ravichandran Progressive response handling Signed-off-by: Srinaath Ravichandran Stable commit to replay chat Signed-off-by: Srinaath Ravichandran UI tests restored to normality Signed-off-by: Srinaath Ravichandran Tests working for conversationQueue Signed-off-by: Srinaath Ravichandran Chatsagas stable tests Signed-off-by: Srinaath Ravichandran one more test working Signed-off-by: Srinaath Ravichandran All tests passing Signed-off-by: Srinaath Ravichandran Chat saga test wrapped up Signed-off-by: Srinaath Ravichandran Restart conversation queue tests completed Signed-off-by: Srinaath Ravichandran UI tests updated Signed-off-by: Srinaath Ravichandran --- .../client/mocks/conversationQueueMocks.ts | 1112 +++++++++++++++++ packages/app/client/src/index.tsx | 7 + .../client/src/state/sagas/chatSagas.spec.ts | 367 +++++- .../app/client/src/state/sagas/chatSagas.ts | 412 ++++-- .../sagas/webchatActivityChannel.spec.ts | 98 ++ .../src/state/sagas/webchatActivityChannel.ts | 94 ++ .../src/ui/editor/emulator/emulator.scss | 6 +- .../src/ui/editor/emulator/emulator.scss.d.ts | 1 + .../src/ui/editor/emulator/emulator.spec.tsx | 1 + .../src/ui/editor/emulator/emulator.tsx | 56 +- .../ui/editor/emulator/emulatorContainer.ts | 12 +- .../emulator/parts/chat/activityWrapper.tsx | 17 +- .../ui/editor/emulator/parts/chat/chat.scss | 11 + .../editor/emulator/parts/chat/chat.scss.d.ts | 2 + .../editor/emulator/parts/chat/chat.spec.tsx | 69 +- .../ui/editor/emulator/parts/chat/chat.tsx | 7 +- .../emulator/parts/chat/chatContainer.ts | 2 +- .../parts/chat/outerActivityWrapper.spec.tsx | 88 +- .../parts/chat/outerActivityWrapper.tsx | 23 +- .../chat/outerActivityWrapperContainer.ts | 13 +- .../utils/restartConversationQueue.spec.ts | 277 ++++ .../src/utils/restartConversationQueue.ts | 201 +++ packages/app/client/webpack.config.js | 3 +- .../getActivitiesForConversation.spec.ts | 70 ++ .../handlers/getActivitiesForConversation.ts | 55 + .../mountConversationsRoutes.spec.ts | 11 + .../conversations/mountConversationsRoutes.ts | 8 + .../directLine/handlers/postActivity.spec.ts | 28 +- .../directLine/handlers/postActivity.ts | 11 +- .../src/server/state/conversation.spec.ts | 21 +- .../app/main/src/server/state/conversation.ts | 6 +- packages/app/shared/package.json | 3 +- .../shared/src/constants/sharedConstants.ts | 4 + .../src/state/actions/chatActions.spec.ts | 66 + .../shared/src/state/actions/chatActions.ts | 61 +- .../shared/src/state/reducers/chat.spec.ts | 148 ++- .../app/shared/src/state/reducers/chat.ts | 85 +- packages/sdk/shared/package.json | 3 +- .../emulatorApi/conversationService.spec.ts | 8 + .../src/emulatorApi/conversationService.ts | 11 + 40 files changed, 3281 insertions(+), 197 deletions(-) create mode 100644 packages/app/client/mocks/conversationQueueMocks.ts create mode 100644 packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts create mode 100644 packages/app/client/src/state/sagas/webchatActivityChannel.ts create mode 100644 packages/app/client/src/utils/restartConversationQueue.spec.ts create mode 100644 packages/app/client/src/utils/restartConversationQueue.ts create mode 100644 packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.spec.ts create mode 100644 packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.ts diff --git a/packages/app/client/mocks/conversationQueueMocks.ts b/packages/app/client/mocks/conversationQueueMocks.ts new file mode 100644 index 000000000..f484e00b3 --- /dev/null +++ b/packages/app/client/mocks/conversationQueueMocks.ts @@ -0,0 +1,1112 @@ +// +// 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. +// +export const activities = [ + { + type: 'conversationUpdate', + membersAdded: [ + { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + }, + { + id: '', + name: 'User', + }, + ], + membersRemoved: [], + channelId: 'emulator', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '4ab7cd80-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:05-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + timestamp: '2020-02-25T19:09:05.496Z', + from: { + id: '', + name: 'User', + role: 'user', + }, + locale: 'en-US', + serviceUrl: 'http://localhost:50438', + }, + { + channelData: { + clientActivityID: '1582657741644hl73t73n83', + clientTimestamp: '2020-02-25T19:09:01.644Z', + originalActivityId: '4871ae10-5802-11ea-afe8-c12c2746983b', + matchIndexes: [1], + }, + text: 'Hi', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:05.521Z', + entities: [ + { + requiresBotState: true, + supportsListening: true, + supportsTts: true, + type: 'ClientCapabilities', + }, + { + requiresBotState: true, + supportsListening: true, + supportsTts: true, + type: 'ClientCapabilities', + }, + ], + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + localTimestamp: '2020-02-25T11:09:05-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + id: '4abb9e10-5802-11ea-afe8-c12c2746983b', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Please enter your mode of transport.', + inputHint: 'expectingInput', + suggestedActions: { + actions: [ + { + type: 'imBack', + title: 'Car', + value: 'Car', + }, + { + type: 'imBack', + title: 'Bus', + value: 'Bus', + }, + { + type: 'imBack', + title: 'Bicycle', + value: 'Bicycle', + }, + ], + }, + replyToId: '4abb9e10-5802-11ea-afe8-c12c2746983b', + id: '4abc8870-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:05-08:00', + timestamp: '2020-02-25T19:09:05.527Z', + locale: 'en-US', + }, + { + channelData: { + clientActivityID: '1582657743316a25l7a6etzt', + clientTimestamp: '2020-02-25T19:09:03.316Z', + originalActivityId: '496b9e70-5802-11ea-afe8-c12c2746983b', + matchIndexes: [3], + }, + text: 'Car', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:05.598Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + localTimestamp: '2020-02-25T11:09:05-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + id: '4ac75de0-5802-11ea-afe8-c12c2746983b', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Please enter your name.', + inputHint: 'expectingInput', + replyToId: '4ac75de0-5802-11ea-afe8-c12c2746983b', + id: '4ac84840-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:05-08:00', + timestamp: '2020-02-25T19:09:05.604Z', + locale: 'en-US', + }, + { + channelData: { + clientActivityID: '158265776185762jv8ehugsq', + clientTimestamp: '2020-02-25T19:09:21.857Z', + }, + text: 'tester', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:21.892Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '547da240-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:21-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Thanks tester.', + inputHint: 'acceptingInput', + replyToId: '547da240-5802-11ea-afe8-c12c2746983b', + id: '547eb3b0-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:21-08:00', + timestamp: '2020-02-25T19:09:21.899Z', + locale: 'en-US', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Do you want to give your age?', + inputHint: 'expectingInput', + suggestedActions: { + actions: [ + { + type: 'imBack', + title: 'Yes', + value: 'Yes', + }, + { + type: 'imBack', + title: 'No', + value: 'No', + }, + ], + }, + replyToId: '547da240-5802-11ea-afe8-c12c2746983b', + id: '547f28e0-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:21-08:00', + timestamp: '2020-02-25T19:09:21.902Z', + locale: 'en-US', + }, + { + channelData: { + clientActivityID: '1582657763139pa3ds9m7h9f', + clientTimestamp: '2020-02-25T19:09:23.139Z', + }, + text: 'Yes', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:23.141Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '553c3750-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:23-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Please enter your age.', + inputHint: 'expectingInput', + replyToId: '553c3750-5802-11ea-afe8-c12c2746983b', + id: '553ea850-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:23-08:00', + timestamp: '2020-02-25T19:09:23.157Z', + locale: 'en-US', + }, + { + channelData: { + clientActivityID: '1582657766339zujfevrrg9q', + clientTimestamp: '2020-02-25T19:09:26.339Z', + }, + text: '62', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:26.342Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '5724a660-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:26-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'I have your age as 29.', + inputHint: 'acceptingInput', + replyToId: '5724a660-5802-11ea-afe8-c12c2746983b', + id: '57267b20-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:26-08:00', + timestamp: '2020-02-25T19:09:26.354Z', + locale: 'en-US', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Please attach a profile picture (or type any message to skip).', + inputHint: 'expectingInput', + replyToId: '5724a660-5802-11ea-afe8-c12c2746983b', + id: '57273e70-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:26-08:00', + timestamp: '2020-02-25T19:09:26.358Z', + locale: 'en-US', + }, + { + attachments: [ + { + name: 'IMG-2744.jpg', + contentUrl: 'data:application/octet-stream;base64,/9j/', + contentType: 'image/jpeg', + }, + ], + channelData: { + clientActivityID: '1582657774063k2trifj0lj', + clientTimestamp: '2020-02-25T19:09:34.063Z', + attachmentSizes: [1613564], + }, + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:34.141Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '5bcaaed1-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:34-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'Is this okay?', + inputHint: 'expectingInput', + suggestedActions: { + actions: [ + { + type: 'imBack', + title: 'Yes', + value: 'Yes', + }, + { + type: 'imBack', + title: 'No', + value: 'No', + }, + ], + }, + replyToId: '5bcaaed1-5802-11ea-afe8-c12c2746983b', + id: '5bccd1b0-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:34-08:00', + timestamp: '2020-02-25T19:09:34.154Z', + locale: 'en-US', + }, + { + channelData: { + clientActivityID: '1582657775458y5rx1hagzw', + clientTimestamp: '2020-02-25T19:09:35.458Z', + }, + text: 'Yes', + textFormat: 'plain', + type: 'message', + channelId: 'emulator', + from: { + id: 'r_1582657745', + name: 'User', + role: 'user', + }, + locale: 'en-US', + timestamp: '2020-02-25T19:09:35.520Z', + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + id: '5c9d1a00-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:35-08:00', + recipient: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + serviceUrl: 'http://localhost:50438', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + text: 'I have your mode of transport as Car and your name as tester and your age as 29.', + inputHint: 'acceptingInput', + replyToId: '5c9d1a00-5802-11ea-afe8-c12c2746983b', + id: '5c9e5280-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:35-08:00', + timestamp: '2020-02-25T19:09:35.528Z', + locale: 'en-US', + }, + { + type: 'message', + serviceUrl: 'http://localhost:50438', + channelId: 'emulator', + from: { + id: '3fe76690-5802-11ea-bb6a-31d3402f2821', + name: 'Bot', + role: 'bot', + }, + conversation: { + id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', + }, + recipient: { + id: '', + role: 'user', + }, + attachmentLayout: 'list', + text: 'This is your profile picture.', + inputHint: 'acceptingInput', + attachments: [ + { + contentType: 'image/jpeg', + name: 'IMG-2744.jpg', + contentUrl: 'data:application/octet-stream;base64,/9j/', + }, + ], + replyToId: '5c9d1a00-5802-11ea-afe8-c12c2746983b', + id: '5c9eeec0-5802-11ea-afe8-c12c2746983b', + localTimestamp: '2020-02-25T11:09:35-08:00', + timestamp: '2020-02-25T19:09:35.532Z', + locale: 'en-US', + }, +]; + +export const replayData = { + incomingActivities: [ + { + id: '6675c360-5816-11ea-9b99-bfd896d3b875', + }, + { + id: '6941dbb0-5816-11ea-9b99-bfd896d3b875', + replyToId: '69411860-5816-11ea-9b99-bfd896d3b875', + test: 'Please enter your mode of transport.', + }, + { + id: '69411860-5816-11ea-9b99-bfd896d3b875', + test: 'Hi', + }, + { + id: '69fd3c70-5816-11ea-9b99-bfd896d3b875', + replyToId: '69fc7920-5816-11ea-9b99-bfd896d3b875', + test: 'Please enter your name.', + }, + { + id: '69fc7920-5816-11ea-9b99-bfd896d3b875', + test: 'Car', + }, + { + id: '6bdf69f0-5816-11ea-9b99-bfd896d3b875', + replyToId: '6bde5880-5816-11ea-9b99-bfd896d3b875', + test: 'Thanks tester.', + }, + { + id: '6be00630-5816-11ea-9b99-bfd896d3b875', + replyToId: '6bde5880-5816-11ea-9b99-bfd896d3b875', + test: 'Do you want to give your age?', + }, + { + id: '6bde5880-5816-11ea-9b99-bfd896d3b875', + test: 'tester', + }, + { + id: '6ce31e50-5816-11ea-9b99-bfd896d3b875', + replyToId: '6ce12280-5816-11ea-9b99-bfd896d3b875', + test: 'Please enter your age.', + }, + { + id: '6ce12280-5816-11ea-9b99-bfd896d3b875', + test: 'Yes', + }, + { + id: '6e8842d0-5816-11ea-9b99-bfd896d3b875', + replyToId: '6e86bc30-5816-11ea-9b99-bfd896d3b875', + test: 'I have your age as 30.', + }, + { + id: '6e88b800-5816-11ea-9b99-bfd896d3b875', + replyToId: '6e86bc30-5816-11ea-9b99-bfd896d3b875', + test: 'Please attach a profile picture (or type any message to skip).', + }, + { + id: '6e86bc30-5816-11ea-9b99-bfd896d3b875', + test: '30', + }, + { + id: '73573000-5816-11ea-9b99-bfd896d3b875', + replyToId: '73555b41-5816-11ea-9b99-bfd896d3b875', + test: 'Is this okay?', + }, + { + id: '74f7c0a0-5816-11ea-9b99-bfd896d3b875', + replyToId: '74f6af30-5816-11ea-9b99-bfd896d3b875', + test: 'I have your mode of transport as Car and your name as tester and your age as 30.', + }, + { + id: '74f835d0-5816-11ea-9b99-bfd896d3b875', + replyToId: '74f6af30-5816-11ea-9b99-bfd896d3b875', + test: 'This is your profile picture.', + }, + { + id: '74f6af30-5816-11ea-9b99-bfd896d3b875', + test: 'Yes', + }, + ], + postActivitiesSlots: [1, 3, 5, 8, 10, 13, 14], +}; + +export const replayScenarios = [ + { + incomingActivities: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'bot2a', + replyToId: 'act-2', + }, + { + id: 'bot2b', + replyToId: 'act-2', + }, + { + id: 'act-2', + }, + { + id: 'bot3a', + replyToId: 'act-3', + }, + { + id: 'bot3b', + replyToId: 'act-3', + }, + { + id: 'act-3', + }, + ], + postActivitiesSlots: [1, 4], + activitiesToBePosted: [ + { id: 'act-2', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-3', from: { role: 'user' }, channelData: { test: true } }, + ], + botResponsesForActivity: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'act-bot2a', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2b', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { id: 'bdc9da50-30d0-4611-967e-182db8882533', from: { role: 'bot' }, channelData: { matchIndexes: [1, 2] } }, + ], + }, + { + activitiesToBePosted: [ + { id: 'act-2', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-3', from: { role: 'user' }, channelData: { test: true } }, + ], + incomingActivities: [ + { + // type: 'conversationUpdate', + id: 'conv-1', + }, + { + // type: 'conversationUpdate', + id: 'conv-2', + }, + { + id: 'bot2a', + replyToId: 'act-2', + }, + { + id: 'bot2b', + replyToId: 'act-2', + }, + { + id: 'conv-3', + }, + { + // type: 'conversationUpdate', + id: 'act-2', + }, + { + id: 'bot3a', + replyToId: 'act-3', + }, + { + id: 'bot3b', + replyToId: 'act-3', + }, + { + id: 'act-3', + }, + ], + postActivitiesSlots: [2, 6], + botResponsesForActivity: [ + { + // type: 'conversationUpdate', + id: 'dummy-conv-update-1', + }, + { + // type: 'conversationUpdate', + id: 'dummy-conv-update-2', + }, + { + id: 'act-bot2a', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2b', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + // type: 'conversationUpdate', + id: 'dummy-conv-update-3', + }, + { id: 'bdc9da50-30d0-4611-967e-182db8882533', from: { role: 'bot' }, channelData: { matchIndexes: [2, 3] } }, + { + id: 'act-bot3a', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882534', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3b', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882534', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { id: 'bdc9da50-30d0-4611-967e-182db8882534', from: { role: 'bot' }, channelData: { matchIndexes: [2, 3] } }, + ], + }, + { + incomingActivities: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'bot2a', + replyToId: 'act-2', + }, + + { + id: 'bot3a', + replyToId: 'act-3', + }, + { + id: 'bot3b', + replyToId: 'act-3', + }, + { + id: 'bot2b', + replyToId: 'act-2', + }, + { + id: 'bot3c', + replyToId: 'act-3', + }, + { + id: 'act-3', + }, + { + id: 'bot2c', + replyToId: 'act-2', + }, + { + id: 'bot2d', + replyToId: 'act-2', + }, + { + id: 'act-2', + }, + { + id: 'bot4a', + replyToId: 'act-4', + }, + { + id: 'bot4b', + replyToId: 'act-4', + }, + { + id: 'bot4c', + replyToId: 'act-4', + }, + { + id: 'act-4', + }, + ], + postActivitiesSlots: [1, 2, 10], + activitiesToBePosted: [ + { id: 'act-2', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-3', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-4', from: { role: 'user' }, channelData: { test: true } }, + ], + botResponsesForActivity: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'act-bot2a', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3a', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3b', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2b', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3c', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { matchIndexes: [2, 3, 5] }, + }, + { + id: 'act-bot2c', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2d', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { matchIndexes: [1, 4, 7, 8] }, + }, + { + id: 'act-bot4a', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act4', + from: { role: 'bot' }, + channelData: { test: true }, + }, + ], + }, + { + incomingActivities: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'bot2a', + replyToId: 'act-2', + }, + + { + id: 'bot3a', + replyToId: 'act-3', + }, + { + id: 'bot3b', + replyToId: 'act-3', + }, + { + id: 'bot2b', + replyToId: 'act-2', + }, + { + id: 'bot3c', + replyToId: 'act-3', + }, + { + id: 'act-3', + }, + { + id: 'bot2c', + replyToId: 'act-2', + }, + { + id: 'bot2d', + replyToId: 'act-2', + }, + { + id: 'act-2', + }, + { + id: 'bot4a', + replyToId: 'act-4', + }, + { + id: 'bot4b', + replyToId: 'act-4', + }, + { + id: 'bot4c', + replyToId: 'act-4', + }, + { + id: 'bot2ProgressiveA', + replyToId: 'act-2', + }, + { + id: 'bot2ProgressiveB', + replyToId: 'act-2', + }, + { + id: 'act-4', + }, + { + id: 'bot5A', + replyToId: 'act-5', + }, + { + id: 'act-5', + }, + ], + postActivitiesSlots: [1, 2, 10, 16], + activitiesToBePosted: [ + { id: 'act-2', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-3', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-4', from: { role: 'user' }, channelData: { test: true } }, + { id: 'act-5', from: { role: 'user' }, channelData: { test: true } }, + ], + botResponsesForActivity: [ + { + // type: 'conversationUpdate', + id: '1', + }, + { + id: 'act-bot2a', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3a', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3b', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2b', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot3c', + replyToId: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'bdc9da50-30d0-4611-967e-182db8882533Act3', + from: { role: 'bot' }, + channelData: { matchIndexes: [2, 3, 5] }, + }, + { + id: 'act-bot2c', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2d', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { matchIndexes: [1, 4, 7, 8, 13, 14] }, + }, + { + id: 'act-bot4a', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act4', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot4B', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act4', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot4C', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act4', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'act-bot2PRE', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: 'ProgressiveResponse' }, + }, + { + id: 'act-bot2PRF', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act2', + from: { role: 'bot' }, + channelData: { test: 'ProgressiveResponse' }, + }, + { + id: 'act-bot5a', + replyToId: 'bdc9da50-30d0-4611-967e-182db888253Act5', + from: { role: 'bot' }, + channelData: { test: true }, + }, + { + id: 'bdc9da50-30d0-4611-967e-182db888253Act5', + from: { role: 'bot' }, + channelData: { matchIndexes: [15] }, + }, + ], + }, +]; diff --git a/packages/app/client/src/index.tsx b/packages/app/client/src/index.tsx index b0b96596c..4fb5217ea 100644 --- a/packages/app/client/src/index.tsx +++ b/packages/app/client/src/index.tsx @@ -48,6 +48,13 @@ interceptError(); interceptHyperlink(); if (!remote.app.isPackaged) { + // Enable reload + document.addEventListener('keydown', function(e) { + // Fn + f5 for reloading on dev + if (e.which === 116) { + location.reload(); + } + }); // enable react & react-redux dev tools installExtension([REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]) /* eslint-disable no-console */ diff --git a/packages/app/client/src/state/sagas/chatSagas.spec.ts b/packages/app/client/src/state/sagas/chatSagas.spec.ts index 50973b34b..f8a6046a1 100644 --- a/packages/app/client/src/state/sagas/chatSagas.spec.ts +++ b/packages/app/client/src/state/sagas/chatSagas.spec.ts @@ -31,8 +31,16 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { call, put, select, takeEvery } from 'redux-saga/effects'; -import { CommandServiceImpl, CommandServiceInstance, ConversationService, json2HTML } from '@bfemulator/sdk-shared'; +import { call, put, select, takeEvery, fork } from 'redux-saga/effects'; +import { + CommandServiceImpl, + CommandServiceInstance, + ConversationService, + json2HTML, + logEntry, + textItem, + LogLevel, +} from '@bfemulator/sdk-shared'; import * as sdkSharedUtils from '@bfemulator/sdk-shared/build/utils/misc'; import { clearLog, @@ -46,11 +54,21 @@ import { ChatActions, SharedConstants, updateSpeechAdapters, + incomingActivity, + postActivity, + RestartConversationStatus, + setRestartConversationStatus, + RestartConversationPayload, + ChatReplayData, } from '@bfemulator/app-shared'; import { createCognitiveServicesSpeechServicesPonyfillFactory, createDirectLineSpeechAdapters, } from 'botframework-webchat'; +import { Activity } from 'botframework-schema'; + +import { WebchatEvents, ConversationQueue } from '../../utils/restartConversationQueue'; +import { logService } from '../../platform/log/logService'; import { chatSagas, @@ -61,14 +79,29 @@ import { getCustomUserGUID, getWebSpeechFactoryForDocumentId, } from './chatSagas'; +import { createWebchatActivityChannel, ChannelPayload, ReplayActivitySnifferProps } from './webchatActivityChannel'; +const mockChatStore = jest.fn(args => { + return {}; +}); const mockWebChatStore = {}; + jest.mock('botframework-webchat-core', () => ({ - createStore: () => mockWebChatStore, + createStore: (...args) => { + return mockChatStore({ ...args }); + }, })); jest.mock('../../ui/dialogs', () => ({})); +jest.mock('../../platform/log/logService', () => { + return { + logService: { + logToDocument: jest.fn(), + }, + }; +}); + const mockWriteText = jest.fn(); jest.mock('electron', () => { return { @@ -107,7 +140,9 @@ jest.mock('botframework-webchat', () => { describe('The ChatSagas,', () => { let commandService: CommandServiceImpl; + const oldDateNow = Date.now; beforeAll(() => { + Date.now = jest.fn(); const decorator = CommandServiceInstance(); const descriptor = decorator({ descriptor: {} }, 'none') as any; commandService = descriptor.descriptor.get(); @@ -117,13 +152,18 @@ describe('The ChatSagas,', () => { jest.spyOn(sdkSharedUtils, 'uniqueIdv4').mockReturnValue('someUniqueIdv4'); }); + afterAll(() => { + Date.now = oldDateNow; + }); + beforeEach(() => { mockWriteText.mockClear(); + mockChatStore.mockClear(); }); it('should initialize the root saga', () => { const gen = chatSagas(); - + expect(gen.next().value).toEqual(fork(ChatSagas.watchForWcEvents)); expect(gen.next().value).toEqual( takeEvery(ChatActions.showContextMenuForActivity, ChatSagas.showContextMenuForActivity) ); @@ -561,7 +601,8 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(select(getServerUrl)); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + const result = gen.next(); + expect(result.value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -1112,4 +1153,320 @@ describe('The ChatSagas,', () => { call([ConversationService, ConversationService.sendActivityToBot], serverUrl, payload.conversationId, activity) ); }); + + describe('Replay conversation upto selected activity', () => { + it('should watch for incoming activity events dispatched from webchat store', () => { + const wcMockChannel = createWebchatActivityChannel(); + ChatSagas.wcActivityChannel = wcMockChannel; + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.incomingActivity, + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: jest.fn(), + meta: undefined, + }; + const gen = ChatSagas.watchForWcEvents(); + gen.next(); + expect(gen.next(payload).value).toEqual( + put(incomingActivity(payload.action.payload.activity, payload.documentId)) + ); + }); + + it('should watch for post activity events dispatched from webchat store', () => { + const wcMockChannel = createWebchatActivityChannel(); + ChatSagas.wcActivityChannel = wcMockChannel; + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: jest.fn(), + meta: undefined, + }; + const gen = ChatSagas.watchForWcEvents(); + gen.next(); + expect(gen.next(payload).value).toEqual(put(postActivity(payload.action.payload.activity, payload.documentId))); + }); + + it('should not dispatch anything for other webchat activities', () => { + const wcMockChannel = createWebchatActivityChannel(); + ChatSagas.wcActivityChannel = wcMockChannel; + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: 'WEBCHAT/SEND_TYPING', + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: jest.fn(), + meta: undefined, + }; + const gen = ChatSagas.watchForWcEvents(); + let res = gen.next(); + res = gen.next(payload); + expect(res.value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); + }); + + it('should fork a call to handle replay if conversation queue is available', () => { + const wcMockChannel = createWebchatActivityChannel(); + ChatSagas.wcActivityChannel = wcMockChannel; + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: jest.fn(), + meta: undefined, + }; + const gen = ChatSagas.watchForWcEvents(); + gen.next(); + gen.next(payload); + expect(gen.next().value).toEqual(fork(ChatSagas.handleReplayIfRequired, { ...payload })); + }); + + it('should handle replay only if validateIfReplayFlow is true', () => { + const validateIfReplayFlow = jest.fn(() => false); + const mock: any = { + validateIfReplayFlow, + }; + const dispatcherMock = jest.fn(); + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: dispatcherMock, + meta: { + conversationQueue: mock, + }, + }; + const gen = ChatSagas.handleReplayIfRequired({ ...payload }); + gen.next(); + gen.next(); + expect(dispatcherMock).not.toHaveBeenCalled(); + }); + + it('should not dispatch activity to webchat if no activity available to post', () => { + const mock: any = { + validateIfReplayFlow: jest.fn(() => true), + incomingActivity: jest.fn(), + getNextActivityForPost: jest.fn(() => undefined), + }; + const dispatcherMock = jest.fn(); + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: 'activity-1', + } as Activity, + }, + }, + dispatch: dispatcherMock, + meta: { + conversationQueue: mock, + }, + }; + const gen = ChatSagas.handleReplayIfRequired({ ...payload }); + let res; + res = gen.next(); + res = gen.next(RestartConversationStatus.Started); + res = gen.next(); + res = gen.next(); + expect(res.done).toBeTruthy(); + expect(dispatcherMock).not.toHaveBeenCalled(); + }); + + it('should not dispatch activity to webchat if activity available to post', () => { + const activity: Activity = { + id: 'activity-1', + } as Activity; + const mock: any = { + validateIfReplayFlow: jest.fn(() => true), + incomingActivity: jest.fn(), + getNextActivityForPost: jest.fn(() => activity), + }; + const dispatcherMock = jest.fn(); + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: '0', + } as Activity, + }, + }, + dispatch: dispatcherMock, + meta: { + conversationQueue: mock, + }, + }; + const gen = ChatSagas.handleReplayIfRequired({ ...payload }); + let res = gen.next(); + res = gen.next(RestartConversationStatus.Started); + res = gen.next(); + res = gen.next(activity); + const args = [...res.value.CALL.args]; + args[0].call(args[1]); + expect(dispatcherMock).toHaveBeenCalledTimes(1); + res = gen.next(); + expect(res.done).toBeTruthy(); + }); + + it('should throw an error if there was an error replaying the conversation', () => { + const activity: Activity = { + id: 'activity-1', + } as Activity; + const mock: any = { + validateIfReplayFlow: jest.fn(() => true), + incomingActivity: jest.fn(), + getNextActivityForPost: jest.fn(() => activity), + }; + const dispatcherMock = jest.fn(); + const payload: ChannelPayload = { + documentId: 'some-id', + action: { + type: WebchatEvents.postActivity, + payload: { + activity: { + id: '0', + } as Activity, + }, + }, + dispatch: dispatcherMock, + meta: { + conversationQueue: mock, + }, + }; + const gen = ChatSagas.handleReplayIfRequired({ ...payload }); + let res = gen.next(); + res = gen.next(RestartConversationStatus.Started); + res = gen.next({ + ex: 'Failed replay', + }); + expect(res.value).toEqual( + put(setRestartConversationStatus(RestartConversationStatus.Rejected, payload.documentId)) + ); + res = gen.next(); + const errorMessage: string = `There was an error replaying the conversation. The Bot code seems to have changed causing an error while replaying.`; + expect(res.value).toEqual( + fork(logService.logToDocument, 'some-id', logEntry(textItem(LogLevel.Error, errorMessage))) + ); + }); + + it('should send conversation queue object if its a Conversation replay flow to replayActivitySniffer middleware', done => { + const webChatEventExpected = { + type: WebchatEvents.incomingActivity, + payload: { + activity: { + id: '1', + }, + }, + }; + + const conversationQueue = new ConversationQueue( + [ + { + id: '1', + from: { + role: 'user', + }, + } as Activity, + ], + { + incomingActivities: [ + { + id: '2', + replyToId: '1', + }, + ], + postActivitiesSlots: [1, 3], + }, + '123', + { + id: '2', + } as Activity, + jest.fn() + ); + + let eventReceivedCt = 0; + const channel: any = { + sendWcEvents: args => { + expect(args.action).toEqual(webChatEventExpected); + expect(args.meta.conversationQueue).toEqual(conversationQueue); + eventReceivedCt++; + if (eventReceivedCt === 1000) { + done(); + } + }, + }; + ChatSagas.wcActivityChannel = channel; + const payload: RestartConversationPayload = { + documentId: 'someDocId', + requireNewConversationId: true, + requireNewUserId: true, + activity: { + id: 'act-1', + } as Activity, + createObjectUrl: jest.fn(), + }; + const mockAction: any = { + payload, + }; + const gen = ChatSagas.restartConversation(mockAction); + + // select chat from document id + const chat = { + conversationId: 'someConvoId', + directLine: { + end: jest.fn(), + }, + mode: 'livechat' as any, + }; + gen.next(); + gen.next(chat); + gen.next(); + gen.next(); + gen.next(); + gen.next(conversationQueue); + gen.next(); + gen.next(); + const webchatStoreArgs = mockChatStore.mock.calls[0][0]; + const replaySnifferFn = webchatStoreArgs[Object.keys(webchatStoreArgs).pop()]; + const mockDispatcher = { + dispatch: jest.fn(), + }; + const mockNext = jest.fn(); + replaySnifferFn(mockDispatcher)(mockNext)(webChatEventExpected); + for (let i = 0; i < 999; i++) { + replaySnifferFn(mockDispatcher)(mockNext)(webChatEventExpected); + } + }); + }); }); diff --git a/packages/app/client/src/state/sagas/chatSagas.ts b/packages/app/client/src/state/sagas/chatSagas.ts index 067c4be40..20dc828ee 100644 --- a/packages/app/client/src/state/sagas/chatSagas.ts +++ b/packages/app/client/src/state/sagas/chatSagas.ts @@ -30,7 +30,6 @@ // 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 Electron from 'electron'; import { MenuItemConstructorOptions } from 'electron'; import { Activity } from 'botframework-schema'; @@ -41,17 +40,24 @@ import { open as openDocument, setInspectorObjects, updatePendingSpeechTokenRetrieval, - updateSpeechAdapters, webChatStoreUpdated, webSpeechFactoryUpdated, ChatAction, - ChatActions, ChatDocument, DocumentIdPayload, OpenTranscriptPayload, RestartConversationPayload, SharedConstants, ValueTypes, + incomingActivity, + postActivity, + RestartConversationStatus, + setRestartConversationStatus, + addNotification, + newNotification, + beginAdd, + NotificationType, + updateSpeechAdapters, } from '@bfemulator/app-shared'; import { CommandServiceImpl, @@ -61,19 +67,32 @@ import { uniqueId, EmulatorMode, User, + logEntry, + textItem, + LogLevel, } from '@bfemulator/sdk-shared'; +import { createStore as createWebChatStore } from 'botframework-webchat-core'; +import { call, ForkEffect, put, select, takeEvery, fork, take } from 'redux-saga/effects'; +import { encode } from 'base64url'; +import { ChatActions } from '@bfemulator/app-shared'; import { createCognitiveServicesSpeechServicesPonyfillFactory, createDirectLine, createDirectLineSpeechAdapters, } from 'botframework-webchat'; -import { createStore as createWebChatStore } from 'botframework-webchat-core'; -import { call, ForkEffect, put, select, takeEvery } from 'redux-saga/effects'; -import { encode } from 'base64url'; +import { logService } from '../../platform/log/logService'; import { RootState } from '../store'; +import { ConversationQueue, WebchatEvents, webchatEventsToWatch } from '../../utils/restartConversationQueue'; import { throwErrorFromResponse } from '../utils/throwErrorFromResponse'; +import { + createWebchatActivityChannel, + WebChatActivityChannel, + ChannelPayload, + ReplayActivitySnifferProps, +} from './webchatActivityChannel'; + export const getConversationIdFromDocumentId = (state: RootState, documentId: string) => { return (state.chat.chats[documentId] || { conversationId: null }).conversationId; }; @@ -94,6 +113,71 @@ export const getServerUrl = (state: RootState): string => { return state.clientAwareSettings.serverUrl; }; +export const getChatStoreFromDocumentId = (state: RootState, documentId: string): string => { + return state.chat.webChatStores[documentId]; +}; + +export const getRestartStatus = (state: RootState, documentId: string): RestartConversationStatus => { + if (!state.chat.restartStatus) { + return undefined; + } + return state.chat.restartStatus[documentId]; +}; + +export const getCurrentEmulatorMode = (state: RootState, documentId: string): EmulatorMode => { + return state.chat.chats[documentId].mode; +}; + +export const create = (classToInstantiate, ...args) => call(() => new classToInstantiate(...args)); + +const dispatchActivityToWebchat = (dispatch: Function, postActivity: Activity) => { + dispatch({ + type: WebchatEvents.postActivity, + payload: { + activity: { + ...postActivity, + }, + }, + meta: { + method: 'keyboard', + }, + }); +}; + +function createDLSpeechBotSniffer(isDLSpeechBot: boolean, conversationId: string, serverUrl: string) { + return () => next => async action => { + if (isDLSpeechBot && action.type === 'DIRECT_LINE/INCOMING_ACTIVITY') { + const res = await ConversationService.performTrackingForActivity( + serverUrl, + conversationId, + action.payload.activity + ); + if (!res.ok) { + let errText = ''; + if (res.text) { + errText = await res.text(); + } + console.error(`Failed to log DL Speech activity: ${errText}`); // eslint-disable-line no-console + } + } + return next(action); + }; +} + +function createReplayActivitySniffer(documentId: string, meta: ReplayActivitySnifferProps = undefined) { + return ({ dispatch }) => next => async action => { + if (action.payload && webchatEventsToWatch.includes(action.type)) { + ChatSagas.wcActivityChannel.sendWcEvents({ + documentId, + action, + dispatch, + meta, + }); + } + return next(action); + }; +} + interface BootstrapChatPayload { conversationId: string; documentId: string; @@ -101,14 +185,15 @@ interface BootstrapChatPayload { mode: EmulatorMode; msaAppId?: string; msaPassword?: string; + user: User; speechKey?: string; speechRegion?: string; - user: User; } export class ChatSagas { @CommandServiceInstance() private static commandService: CommandServiceImpl; + public static wcActivityChannel: WebChatActivityChannel; public static *showContextMenuForActivity(action: ChatAction): IterableIterator { const { payload: activity } = action; @@ -226,110 +311,176 @@ export class ChatSagas { } } - public static *bootstrapChat(payload: BootstrapChatPayload): IterableIterator { - const { - conversationId, - documentId, - endpointId, - mode, - msaAppId, - msaPassword, - speechKey, - speechRegion, - user, - } = payload; - const isDLSpeechBot = speechKey && speechRegion; - const serverUrl = yield select(getServerUrl); - const webChatStore = isDLSpeechBot - ? createWebChatStore({}, createWebChatActivitySniffer(conversationId, serverUrl)) - : createWebChatStore(); - // Create a new webchat store for this documentId - yield put(webChatStoreUpdated(documentId, webChatStore)); - // Each time a new chat is open, retrieve the speech token - // if the endpoint is speech enabled and create a bound speech - // pony fill factory. This is consumed by WebChat... - yield put(webSpeechFactoryUpdated(documentId, undefined)); // remove the old factory - - // create the DL object and update the chat in the store - const directLine = yield call( - [ChatSagas, ChatSagas.createDirectLineObject], - conversationId, - mode, - endpointId, - user.id - ); - yield put( - newChat(documentId, mode, { - conversationId, - directLine, - speechKey, - speechRegion, - userId: user.id, - }) - ); + public static *handleReplayIfRequired({ documentId, action, dispatch, meta }: ChannelPayload): IterableIterator { + const conversationQueue: ConversationQueue | undefined = meta ? meta.conversationQueue : undefined; + const replayStatus: RestartConversationStatus | undefined = yield select(getRestartStatus, documentId); - // initialize DL speech - if (isDLSpeechBot) { - yield put(updatePendingSpeechTokenRetrieval(documentId, true)); + if (conversationQueue && conversationQueue.validateIfReplayFlow(replayStatus, action.type)) { + const activityFlowError: string = yield call( + [conversationQueue, conversationQueue.incomingActivity], + action.payload.activity + ); + + if (activityFlowError || action.type === WebchatEvents.rejectedActivity) { + yield put(setRestartConversationStatus(RestartConversationStatus.Rejected, documentId)); + const errorMessage: string = + 'There was an error replaying the conversation. ' + + 'The Bot code seems to have changed causing an error while replaying.'; + + yield fork(logService.logToDocument, documentId, logEntry(textItem(LogLevel.Error, errorMessage))); + + const replayErrorNotification = yield call(newNotification, errorMessage, NotificationType.Error); + yield put(beginAdd(replayErrorNotification)); + return; + } + if (conversationQueue.replayComplete) { + yield put(setRestartConversationStatus(RestartConversationStatus.Completed, documentId)); + return; + } + + const postActivity: Activity | undefined = yield call([ + meta.conversationQueue, + meta.conversationQueue.getNextActivityForPost, + ]); + if (postActivity) { + yield call(dispatchActivityToWebchat, dispatch, postActivity); + } + } + } + + public static *watchForWcEvents() { + const wcEventChannel = ChatSagas.wcActivityChannel.getWebchatChannelSubscriber(); + while (true) { + const { documentId, action, dispatch, meta }: ChannelPayload = yield take(wcEventChannel); try { - const { directLine, webSpeechPonyfillFactory } = yield call(createDirectLineSpeechAdapters, { - fetchCredentials: { - region: speechRegion, - subscriptionKey: speechKey, - }, - }); - yield put(updateSpeechAdapters(documentId, directLine, webSpeechPonyfillFactory)); - } catch (e) { - throw new Error(`There was an error while initializing DL Speech: ${e}`); + switch (action.type) { + case WebchatEvents.postActivity: { + const activity: Activity = action.payload.activity; + yield put(postActivity(activity, documentId)); + break; + } + + case WebchatEvents.incomingActivity: { + const activity: Activity = action.payload.activity; + yield put(incomingActivity(activity, documentId)); + break; + } + } + } catch (err) { + wcEventChannel.close(); + // Restart the channel if error occurs + ChatSagas.wcActivityChannel = createWebchatActivityChannel(); } finally { - yield put(updatePendingSpeechTokenRetrieval(documentId, false)); + yield fork(ChatSagas.handleReplayIfRequired, { documentId, action, dispatch, meta }); } - return; } + } - // initialize speech - if (msaAppId && msaPassword) { - // Get a token for speech and setup speech integration with Web Chat - yield put(updatePendingSpeechTokenRetrieval(documentId, true)); - // If an existing factory is found, refresh the token - const existingFactory: string = yield select(getWebSpeechFactoryForDocumentId, documentId); - const { GetSpeechToken: command } = SharedConstants.Commands.Emulator; - - try { - const speechAuthenticationToken: Promise = ChatSagas.commandService.remoteCall( - command, - endpointId, - !!existingFactory - ); + public static *bootstrapChat(payload: BootstrapChatPayload): IterableIterator { + try { + const { + conversationId, + documentId, + endpointId, + mode, + msaAppId, + msaPassword, + user, + speechKey, + speechRegion, + } = payload; + const isDLSpeechBot: boolean = !!(speechKey && speechRegion); + const serverUrl = yield select(getServerUrl); + + yield put( + webChatStoreUpdated( + documentId, + createWebChatStore( + {}, + createDLSpeechBotSniffer(isDLSpeechBot, conversationId, serverUrl), + createReplayActivitySniffer(documentId) + ) + ) + ); + yield put(webSpeechFactoryUpdated(documentId, undefined)); // remove the old factory - const factory = yield call(createCognitiveServicesSpeechServicesPonyfillFactory, { - authorizationToken: speechAuthenticationToken, - region: 'westus', // Currently, the prod speech service is only deployed to westus - }); + // create the DL object and update the chat in the store + const directLine = yield call( + [ChatSagas, ChatSagas.createDirectLineObject], + conversationId, + mode, + endpointId, + user.id + ); + yield put( + newChat(documentId, mode, { + conversationId, + directLine, + userId: user.id, + speechKey, + speechRegion, + }) + ); - yield put(webSpeechFactoryUpdated(documentId, factory)); // Provide the new factory to the store - } catch (e) { - // No-op - this appId/pass combo is not provisioned to use the speech api + if (isDLSpeechBot) { + yield put(updatePendingSpeechTokenRetrieval(documentId, true)); + try { + const { directLine, webSpeechPonyfillFactory } = yield call(createDirectLineSpeechAdapters, { + fetchCredentials: { + region: speechRegion, + subscriptionKey: speechKey, + }, + }); + yield put(updateSpeechAdapters(documentId, directLine, webSpeechPonyfillFactory)); + } catch (e) { + throw new Error(`There was an error while initializing DL Speech: ${e}`); + } finally { + yield put(updatePendingSpeechTokenRetrieval(documentId, false)); + } + return; } - yield put(updatePendingSpeechTokenRetrieval(documentId, false)); + if (msaAppId && msaPassword) { + // Get a token for speech and setup speech integration with Web Chat + yield put(updatePendingSpeechTokenRetrieval(documentId, true)); + // If an existing factory is found, refresh the token + const existingFactory: string = yield select(getWebSpeechFactoryForDocumentId, documentId); + const { GetSpeechToken: command } = SharedConstants.Commands.Emulator; + + try { + const speechAuthenticationToken: Promise = ChatSagas.commandService.remoteCall( + command, + endpointId, + !!existingFactory + ); + + const factory = yield call(createCognitiveServicesSpeechServicesPonyfillFactory, { + authorizationToken: speechAuthenticationToken, + region: 'westus', // Currently, the prod speech service is only deployed to westus + }); + + yield put(webSpeechFactoryUpdated(documentId, factory)); // Provide the new factory to the store + } catch (e) { + // No-op - this appId/pass combo is not provisioned to use the speech api + } + + yield put(updatePendingSpeechTokenRetrieval(documentId, false)); + } + } catch (ex) { + throw new Error(`Error occurred while bootstrapping a new chat: ${ex}`); } } public static *restartConversation(action: ChatAction): IterableIterator { - const { documentId, requireNewConversationId, requireNewUserId } = action.payload; + const { documentId, requireNewConversationId, requireNewUserId, createObjectUrl } = action.payload; + const replayToActivity: Activity = action.payload.activity || undefined; const chat: ChatDocument = yield select(getChatFromDocumentId, documentId); const serverUrl = yield select(getServerUrl); - const isDLSpeechBot = chat.speechKey && chat.speechRegion; + const isDLSpeechBot: boolean = !!(chat.speechKey && chat.speechRegion); + let conversationQueue: ConversationQueue; - // re-init new directline object & update conversation object in server state - // set user id - let userId; - if (requireNewUserId) { - userId = uniqueIdv4(); - } else { - // use the previous id or the custom id from settings - userId = chat.userId || (yield select(getCustomUserGUID)); + if (chat.directLine) { + chat.directLine.end(); } let conversationId; @@ -339,18 +490,50 @@ export class ChatSagas { // preserve the current conversation id conversationId = chat.conversationId || `${uniqueId()}|${chat.mode}`; } - - if (chat.directLine) { - chat.directLine.end(); + if (replayToActivity) { + const activities: Activity[] = yield call( + [ConversationService, ConversationService.fetchActivitiesForAConversation], + serverUrl, + chat.conversationId + ); + yield put(setRestartConversationStatus(RestartConversationStatus.Started, documentId)); + conversationQueue = yield create( + ConversationQueue, + activities, + chat.replayData, + conversationId, + replayToActivity, + createObjectUrl + ); } + yield put(clearLog(documentId)); yield put(setInspectorObjects(documentId, [])); - const webChatStore = isDLSpeechBot - ? createWebChatStore({}, createWebChatActivitySniffer(conversationId, serverUrl)) - : createWebChatStore(); - yield put(webChatStoreUpdated(documentId, webChatStore)); // reset web chat store + yield put( + webChatStoreUpdated( + documentId, + createWebChatStore( + {}, + createDLSpeechBotSniffer(isDLSpeechBot, conversationId, serverUrl), + createReplayActivitySniffer(documentId, { + conversationQueue, + }) + ) + ) + ); + yield put(webSpeechFactoryUpdated(documentId, undefined)); // remove old speech token factory + // re-init new directline object & update conversation object in server state + // set user id + let userId; + if (requireNewUserId) { + userId = uniqueIdv4(); + } else { + // use the previous id or the custom id from settings + userId = chat.userId || (yield select(getCustomUserGUID)); + } + // update the main-side conversation object with conversation & user IDs, // and ensure that conversation is in a fresh state let res: Response = yield call( @@ -495,7 +678,10 @@ export class ChatSagas { const secret = encode(JSON.stringify(options)); const res: Response = yield fetch(`${serverUrl}/emulator/ws/port`); if (!res.ok) { - yield* throwErrorFromResponse('Error occurred while retrieving the web socket port', res); + throw new Error( + `Error occurred while retrieving the WebSocket server port: ${res.status}: ${res.statusText || + 'No status text'}` + ); } const webSocketPort = yield res.text(); const directLine = createDirectLine({ @@ -519,27 +705,9 @@ export class ChatSagas { } } -function createWebChatActivitySniffer(conversationId: string, serverUrl: string) { - return () => next => async action => { - if (action.type === 'DIRECT_LINE/INCOMING_ACTIVITY') { - const res = await ConversationService.performTrackingForActivity( - serverUrl, - conversationId, - action.payload.activity - ); - if (!res.ok) { - let errText = ''; - if (res.text) { - errText = await res.text(); - } - console.error(`Failed to log DL Speech activity: ${errText}`); // eslint-disable-line no-console - } - } - return next(action); - }; -} - export function* chatSagas(): IterableIterator { + ChatSagas.wcActivityChannel = createWebchatActivityChannel(); + yield fork(ChatSagas.watchForWcEvents); yield takeEvery(ChatActions.showContextMenuForActivity, ChatSagas.showContextMenuForActivity); yield takeEvery(ChatActions.closeConversation, ChatSagas.closeConversation); yield takeEvery(ChatActions.restartConversation, ChatSagas.restartConversation); diff --git a/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts b/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts new file mode 100644 index 000000000..a77a3dbe7 --- /dev/null +++ b/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts @@ -0,0 +1,98 @@ +// +// 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 { Activity } from 'botframework-schema'; + +import { createWebchatActivityChannel, WebChatActivityChannel, ChannelPayload } from './webchatActivityChannel'; + +describe('Webchat activity channel', () => { + let activityChannel: WebChatActivityChannel; + let emitterSubscriber; + beforeEach(() => { + activityChannel = createWebchatActivityChannel(); + emitterSubscriber = activityChannel.getWebchatChannelSubscriber(); + }); + + it('Receive events through emitter when sent to webchat channel exactly the same without mutation.', async () => { + const payloads: ChannelPayload[] = []; + const promiseResolvers = []; + const numOfEventsToSend = 150; + for (let i = 0; i < numOfEventsToSend; i++) { + promiseResolvers.push(new Promise(resolve => emitterSubscriber.take(resolve))); + const channelPayload: ChannelPayload = { + documentId: 'some-id' + i, + action: { + type: 'incoming-activity', + payload: { + activity: { + id: 'activity-1', + replyToId: 'original' + i, + } as Activity, + }, + }, + meta: undefined, + dispatch: jest.fn(), + }; + payloads.push(channelPayload); + } + + for (let i = 0; i < numOfEventsToSend; i++) { + activityChannel.sendWcEvents(payloads[i]); + } + + const receivedEvents: ChannelPayload[] = await Promise.all(promiseResolvers); + expect(receivedEvents).toEqual(payloads); + }); + + it('Should not receive events after closing the channel.', async () => { + const channelPayload: ChannelPayload = { + documentId: 'some-id', + action: { + type: 'incoming-activity', + payload: { + activity: { + id: 'activity-1', + replyToId: 'original', + } as Activity, + }, + }, + meta: undefined, + dispatch: jest.fn(), + }; + emitterSubscriber.close(); + const eventsReceived = new Promise(resolve => emitterSubscriber.take(resolve)); + + const unresolved = await eventsReceived; + activityChannel.sendWcEvents(channelPayload); + expect(unresolved).not.toEqual(channelPayload); + }); +}); diff --git a/packages/app/client/src/state/sagas/webchatActivityChannel.ts b/packages/app/client/src/state/sagas/webchatActivityChannel.ts new file mode 100644 index 000000000..d58686335 --- /dev/null +++ b/packages/app/client/src/state/sagas/webchatActivityChannel.ts @@ -0,0 +1,94 @@ +// +// 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 { EventEmitter } from 'events'; + +// import { Activity } from 'botframework-schema'; +import { eventChannel, Channel, buffers } from 'redux-saga'; +import { Activity } from 'botframework-schema'; + +import { ConversationQueue } from '../../utils/restartConversationQueue'; + +export interface WebChatActivityChannel { + sendWcEvents: (args: ChannelPayload) => void; + getWebchatChannelSubscriber: () => Channel; +} + +export interface WebchatEventPayload { + type: string; + payload: { + activity: Activity; + }; +} + +export interface ReplayActivitySnifferProps { + conversationQueue: ConversationQueue; +} + +export interface ChannelPayload { + documentId: string; + action: WebchatEventPayload; + dispatch: Function; + meta: ReplayActivitySnifferProps; +} + +export function createWebchatActivityChannel(): WebChatActivityChannel { + let emitterBoundWcStore: (args: ChannelPayload) => void; + let unsubscribeToEvents = false; + + function webChatStoreEvents(args: ChannelPayload) { + if (!unsubscribeToEvents) { + this.emitter(args); + } + } + + const getWebchatChannelSubscriber = (): Channel => { + return eventChannel(emitter => { + const self = { + emitter, + }; + emitterBoundWcStore = webChatStoreEvents.bind(self); + return () => { + unsubscribeToEvents = true; + }; + }, buffers.expanding(20)); + }; + + const sendWcEvents = async (args: ChannelPayload) => { + emitterBoundWcStore.call(null, { ...args }); + }; + + return { + getWebchatChannelSubscriber, + sendWcEvents, + }; +} diff --git a/packages/app/client/src/ui/editor/emulator/emulator.scss b/packages/app/client/src/ui/editor/emulator/emulator.scss index 50a9b0559..5e7551538 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.scss +++ b/packages/app/client/src/ui/editor/emulator/emulator.scss @@ -87,9 +87,13 @@ } .save-icon { margin-left: 20px; - &::before { -webkit-mask: url(../../media/ic_save.svg); } } + + .cancel-icon { + margin-left: 20px; + &::before { -webkit-mask: url(../../media/ic_cancel.svg); } + } } & .content { diff --git a/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts b/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts index f09615e2b..a24e33e0a 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts +++ b/packages/app/client/src/ui/editor/emulator/emulator.scss.d.ts @@ -5,6 +5,7 @@ export const header: string; export const toolbarIcon: string; export const restartIcon: string; export const saveIcon: string; +export const cancelIcon: string; export const content: string; export const presentation: string; export const chatPanel: string; diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index f36a4e44b..39ea462ff 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -162,6 +162,7 @@ describe('', () => { pendingSpeechTokenRetrieval: null, webChatStores: {}, webSpeechFactories: {}, + restartStatus: {}, }, editor: { activeEditor: 'primary', diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index 64e40fab7..8bd4a4f92 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -33,7 +33,7 @@ import { Activity } from 'botframework-schema'; import { DirectLine } from 'botframework-directlinejs'; -import { isMac } from '@bfemulator/app-shared'; +import { isMac, RestartConversationStatus } from '@bfemulator/app-shared'; import { EmulatorMode } from '@bfemulator/sdk-shared'; import { SplitButton, Splitter } from '@bfemulator/ui-react'; import * as React from 'react'; @@ -79,6 +79,8 @@ export interface EmulatorProps { updateDocument?: (documentId: string, updatedValues: Partial) => void; url?: string; userId?: string; + restartStatus: RestartConversationStatus; + onStopRestartConversationClick: (documentId: string) => void; } export class Emulator extends React.Component { @@ -138,6 +140,37 @@ export class Emulator extends React.Component { const { NewUserId, SameUserId } = RestartConversationOptions; const { mode, documentId } = this.props; + + const livechatRender = + this.props.restartStatus !== RestartConversationStatus.Started ? ( + <> + + + + ) : ( + + ); + return (
@@ -150,26 +183,7 @@ export class Emulator extends React.Component { Reconnect )} - {mode === 'livechat' && ( - <> - - - - )} + {mode === 'livechat' && livechatRender}
diff --git a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts index bf94cf0b6..f5486603f 100644 --- a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts @@ -45,13 +45,18 @@ import { Notification, SharedConstants, ValueTypesMask, + setRestartConversationStatus, + RestartConversationStatus, } from '@bfemulator/app-shared'; import { RootState } from '../../../state/store'; import { Emulator, EmulatorProps } from './emulator'; -const mapStateToProps = (state: RootState, { documentId, ...ownProps }: { documentId: string }): EmulatorProps => { +const mapStateToProps = ( + state: RootState, + { documentId, ...ownProps }: { documentId: string } +): Partial => { return { activeDocumentId: state.editor.editors[state.editor.activeEditor].activeDocumentId, activities: state.chat.chats[documentId].activities, @@ -65,11 +70,12 @@ const mapStateToProps = (state: RootState, { documentId, ...ownProps }: { docume ui: state.chat.chats[documentId].ui, url: state.clientAwareSettings.serverUrl, userId: state.chat.chats[documentId].userId, + restartStatus: state.chat.restartStatus[documentId], ...ownProps, }; }; -const mapDispatchToProps = (dispatch): EmulatorProps => ({ +const mapDispatchToProps = (dispatch): Partial => ({ clearLog: (documentId: string) => { dispatch(clearLog(documentId)); }, @@ -87,6 +93,8 @@ const mapDispatchToProps = (dispatch): EmulatorProps => ({ dispatch(executeCommand(true, SharedConstants.Commands.Telemetry.TrackEvent, null, name, properties)), updateChat: (documentId: string, updatedValues: any) => dispatch(updateChat(documentId, updatedValues)), updateDocument: (documentId, updatedValues: Partial) => dispatch(updateDocument(documentId, updatedValues)), + onStopRestartConversationClick: (documentId: string) => + dispatch(setRestartConversationStatus(RestartConversationStatus.Rejected, documentId)), }); export const EmulatorContainer = connect(mapStateToProps, mapDispatchToProps)(Emulator); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/activityWrapper.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/activityWrapper.tsx index fed5a32da..31ac89dec 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/activityWrapper.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/activityWrapper.tsx @@ -34,6 +34,7 @@ import * as React from 'react'; import { Component, HTMLAttributes, KeyboardEvent, MouseEvent, ReactNode } from 'react'; import { Activity } from 'botframework-schema'; +import { LinkButton } from '@bfemulator/ui-react'; import * as styles from './chat.scss'; @@ -41,6 +42,8 @@ interface ActivityWrapperProps extends HTMLAttributes { activity: Activity; children: ReactNode; isSelected: boolean; + isUserActivity: boolean; + onRestartConversationFromActivityClick: () => void; } // Returns false if the event target is normally an interactive element. @@ -62,8 +65,15 @@ function shouldSelectActivity(e: React.SyntheticEvent): boolean { export class ActivityWrapper extends Component { render() { - const { activity: _, children, isSelected, ...divProps } = this.props; + const { activity: _, children, isSelected, isUserActivity, ...divProps } = this.props; let classes = styles.chatActivity; + const restartConversationBubble = ( +
+ + Restart conversation from here + +
+ ); if (isSelected) { classes = `${classes} ${styles.selectedActivity}`; @@ -80,10 +90,15 @@ export class ActivityWrapper extends Component { tabIndex={0} > {children} + {restartConversationBubble}
); } + private replayConversation = () => { + this.props.onRestartConversationFromActivityClick(); + }; + private setSelectedActivity = (e: MouseEvent) => { if (shouldSelectActivity(e)) { this.props.onClick(e); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss index 51c2b19b2..a0beef150 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss @@ -75,6 +75,7 @@ } .chat-activity { + position: relative; border: 1px solid transparent; cursor: pointer; margin: 0 16px; @@ -91,6 +92,16 @@ } } +.hidden { + pointer-events: none; + visibility: hidden; +} + +.replay-bubble { + display: block; + text-align: right; +} + .selected-activity :global .bubble, .selected-activity :global .ac-container, .selected-activity :global .ac-textBlock { diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss.d.ts b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss.d.ts index 791c9e31a..fc21b6422 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss.d.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.scss.d.ts @@ -4,5 +4,7 @@ export const bubbleBackground: string; export const chat: string; export const disconnected: string; export const chatActivity: string; +export const hidden: string; +export const replayBubble: string; export const selectedActivity: string; export const botStateObject: string; diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.spec.tsx index cbbae5852..af21b8c2b 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.spec.tsx @@ -45,6 +45,7 @@ import { showContextMenuForActivity, setHighlightedObjects, ValueTypes, + RestartConversationStatus, } from '@bfemulator/app-shared'; import { combineReducers, createStore } from 'redux'; import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared'; @@ -106,6 +107,7 @@ const mockStore = createStore(combineReducers({ bot, chat, clientAwareSettings, chats: { doc1: defaultDocument, }, + restartStatus: {}, pendingSpeechTokenRetrieval: false, webChatStores: {}, webSpeechFactories: {}, @@ -130,14 +132,17 @@ describe('', () => { commandService = descriptor.descriptor.get(); }); + let props; + beforeEach(() => { - const props = { + props = { documentId: 'doc1', endpoint: {}, mode: 'livechat', onStartConversation: jest.fn(), locale: 'en-US', selectedActivity: {}, + restartStatus: undefined, } as ChatProps; wrapper = mount( @@ -146,6 +151,68 @@ describe('', () => { ); }); + it('should disable webchat if chat window is in restart conversation flow', () => { + let updatedProps = { + ...props, + restartStatus: RestartConversationStatus.Started, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeTruthy(); + + updatedProps = { + ...props, + restartStatus: RestartConversationStatus.Rejected, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeFalsy(); + + updatedProps = { + ...props, + restartStatus: RestartConversationStatus.Completed, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeFalsy(); + + updatedProps = { + ...props, + restartStatus: undefined, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeFalsy(); + + updatedProps = { + ...props, + restartStatus: RestartConversationStatus.Stop, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeFalsy(); + + updatedProps = { + ...props, + restartStatus: RestartConversationStatus.Started, + }; + + wrapper.setProps({ + children: , + }); + expect(wrapper.find(ReactWebChat).props().disabled).toBeTruthy(); + }); + describe('when there is no direct line client', () => { it('renders a `not connected` message', () => { wrapper = shallow(); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx index 753c85fdc..c9a91f086 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx @@ -31,7 +31,7 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { ValueTypes } from '@bfemulator/app-shared'; +import { ValueTypes, RestartConversationStatus } from '@bfemulator/app-shared'; import { User } from '@bfemulator/sdk-shared'; import { Activity, ActivityTypes } from 'botframework-schema'; import ReactWebChat, { createStyleSet } from 'botframework-webchat'; @@ -59,6 +59,7 @@ export interface ChatProps { setInspectorObject?: (documentId: string, activity: Partial) => void; webchatStore?: any; showOpenUrlDialog?: (url) => any; + restartStatus: RestartConversationStatus; } interface ChatState { @@ -82,7 +83,8 @@ export class Chat extends PureComponent { } = this.props; const currentUser = { id: currentUserId, name: 'User' }; - const isDisabled = mode === 'transcript' || mode === 'debug'; + const isDisabled = + mode === 'transcript' || mode === 'debug' || this.props.restartStatus === RestartConversationStatus.Started; // Due to needing to make idiosyncratic style changes, Emulator is using `createStyleSet` instead of `createStyleOptions`. The object below: {...webChatStyleOptions, hideSendBox...} was formerly passed into the `styleOptions` parameter of React Web Chat. If further styling modifications are desired using styleOptions, simply pass it into the same object in createStyleSet below. @@ -144,6 +146,7 @@ export class Chat extends PureComponent { onContextMenu={this.onContextMenu} onItemRendererClick={this.onItemRendererClick} onItemRendererKeyDown={this.onItemRendererKeyDown} + restartStatusForActivity={this.props.restartStatus} > {next(card)(children)} diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chatContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/chat/chatContainer.ts index caf3159a3..13dd94de6 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chatContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chatContainer.ts @@ -47,7 +47,6 @@ import { Chat, ChatProps } from './chat'; const mapStateToProps = (state: RootState, { documentId }): Partial => { const currentChat = state.chat.chats[documentId]; - return { botId: currentChat.botId, conversationId: currentChat.conversationId, @@ -57,6 +56,7 @@ const mapStateToProps = (state: RootState, { documentId }): Partial = locale: state.clientAwareSettings.locale || 'en-us', webSpeechPonyfillFactory: state.chat.webSpeechFactories[documentId], webchatStore: state.chat.webChatStores[documentId], + restartStatus: state.chat.restartStatus[documentId], }; }; diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx index e26ff07da..6cafb26ae 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx @@ -49,13 +49,25 @@ describe('', () => { highlightedObjects: [], inspectorObjects: [{ value: {}, valueType: ValueTypes.Activity }], }, + restartStatus: {}, + }, + }, + }; + const card = { + activity: { + id: 'card1', + from: { + role: 'user', }, }, }; - const card = { activity: { id: 'card1' } }; const wrapper = mount( state, storeState)}> - + ); @@ -63,10 +75,78 @@ describe('', () => { }); it('should determine if an activity should be selected', () => { - const card = { activity: { id: 'card1' } }; - const wrapper = shallow(); + const card = { + activity: { + id: 'card1', + from: { + role: 'user', + }, + }, + }; + const wrapper = shallow( + + ); const instance = wrapper.instance(); expect((instance as any).shouldBeSelected(card.activity)).toBe(false); }); + + it('should start restart flow from the selected activity when clicked', () => { + const card = { + activity: { + id: 'card1', + from: { + role: 'user', + }, + }, + }; + + const onRestartClick = jest.fn(); + const wrapper = shallow( + + ); + const instance = wrapper.instance(); + + (instance as any).propsBoundRestartActivityHandler(); + expect(onRestartClick).toHaveBeenCalledWith('some-id', card.activity); + }); + + it('should determine if an activity is user activity or not', () => { + const userCard = { + activity: { + id: 'card1', + from: { + role: 'user', + }, + channelData: { + test: true, + }, + }, + }; + + const botCard = { + activity: { + id: 'card1', + from: { + role: 'bot', + }, + }, + }; + const wrapper = shallow( + + ); + const instance = wrapper.instance(); + + expect((instance as any).isUserActivity(userCard.activity)).toBe(true); + expect((instance as any).isUserActivity(botCard.activity)).toBe(false); + }); }); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx index 810e9434c..5fe802e34 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx @@ -32,7 +32,9 @@ // import * as React from 'react'; +import { SharedConstants } from '@bfemulator/app-shared'; import { Activity } from 'botframework-schema'; +import { RestartConversationStatus } from '@bfemulator/app-shared'; import { areActivitiesEqual } from '../../../../../utils'; @@ -42,15 +44,20 @@ export interface OuterActivityWrapperProps { card?: any; children?: any; highlightedActivities?: Activity[]; + documentId: string; onContextMenu?: (event: React.MouseEvent) => void; onItemRendererClick?: (event: React.MouseEvent) => void; onItemRendererKeyDown?: (event: React.KeyboardEvent) => void; + onRestartConversationFromActivityClick?: (documentId: string, activity: Activity) => void; } export class OuterActivityWrapper extends React.Component { public render() { const { card, children, onContextMenu, onItemRendererClick, onItemRendererKeyDown } = this.props; + const isSelected = this.shouldBeSelected(card.activity); + const isUserActivity = this.isUserActivity(card.activity); + return ( {children} ); } + private propsBoundRestartActivityHandler = () => { + this.props.onRestartConversationFromActivityClick(this.props.documentId, this.props.card.activity); + }; + + private isUserActivity(activity: Activity) { + return !!( + activity.from.role === SharedConstants.Activity.FROM_USER_ROLE && + !activity.replyToId && + activity.channelData + ); + } + private shouldBeSelected(subject: Activity): boolean { return this.props.highlightedActivities.some(activity => areActivitiesEqual(activity, subject)); } diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts index e8b12ea50..a61684fbb 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts @@ -32,7 +32,9 @@ // import { connect } from 'react-redux'; -import { ValueTypes } from '@bfemulator/app-shared'; +import { ValueTypes, restartConversation } from '@bfemulator/app-shared'; +import { Action } from 'redux'; +import { Activity } from 'botframework-schema'; import { RootState } from '../../../../../state'; import { getActivityTargets } from '../../../../../utils'; @@ -53,7 +55,14 @@ function mapStateToProps(state: RootState, { documentId }: { documentId: string return { highlightedActivities, + documentId, }; } -export const OuterActivityWrapperContainer = connect(mapStateToProps, undefined)(OuterActivityWrapper); +const mapDispatchToProps = (dispatch: (action: Action) => void) => ({ + onRestartConversationFromActivityClick: (documentId: string, activity: Activity) => { + dispatch(restartConversation(documentId, true, false, activity, window.URL.createObjectURL)); + }, +}); + +export const OuterActivityWrapperContainer = connect(mapStateToProps, mapDispatchToProps)(OuterActivityWrapper); diff --git a/packages/app/client/src/utils/restartConversationQueue.spec.ts b/packages/app/client/src/utils/restartConversationQueue.spec.ts new file mode 100644 index 000000000..908d54660 --- /dev/null +++ b/packages/app/client/src/utils/restartConversationQueue.spec.ts @@ -0,0 +1,277 @@ +// +// 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 { ChatReplayData, RestartConversationStatus } from '@bfemulator/app-shared'; +import cloneDeep from 'clone-deep'; +import { Activity } from 'botframework-schema'; + +import { replayScenarios } from '../../mocks/conversationQueueMocks'; + +import { ConversationQueue, WebchatEvents } from './restartConversationQueue'; + +describe('Restart Conversation Queue', () => { + let scenarios; + beforeEach(() => { + scenarios = cloneDeep(replayScenarios); + }); + + it('should validate if it is in Replay conversational flow', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots } = scenarios[2]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 1], + jest.fn() + ); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebchatEvents.incomingActivity)).toBeTruthy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Rejected, WebchatEvents.incomingActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(undefined, WebchatEvents.incomingActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebchatEvents.postActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, 'WEBCHAT/SEND_TYPING')).toBeFalsy(); + }); + + it('should give the correct activity to be posted next if available in scenario[0]', () => { + const activities: any = scenarios[0].activitiesToBePosted; + const chatReplayData: ChatReplayData = { + incomingActivities: scenarios[0].incomingActivities, + postActivitiesSlots: scenarios[0].postActivitiesSlots, + }; + + const queue: ConversationQueue = new ConversationQueue(activities, chatReplayData, '123', activities[1], jest.fn()); + expect(queue.getNextActivityForPost()).toBeUndefined(); + expect(queue.replayComplete).toBeFalsy(); + + queue.incomingActivity(chatReplayData.incomingActivities[0] as Activity); + + const postActivity: Activity = queue.getNextActivityForPost(); + expect(postActivity.channelData.matchIndexes).toEqual([1, 2]); + + const botResponsesForActivity: Activity[] = scenarios[0].botResponsesForActivity; + + queue.incomingActivity(botResponsesForActivity[1]); + expect(queue.getNextActivityForPost()).toBeUndefined(); + + queue.incomingActivity(botResponsesForActivity[2]); + expect(queue.getNextActivityForPost()).toBeUndefined(); + + const err = queue.incomingActivity(botResponsesForActivity[3]); + expect(err).toBeUndefined(); + expect(queue.getNextActivityForPost()).toBeDefined(); + }); + + it('should throw error if activities arrive in the wrong order on replay in scenario[0]', () => { + const activities: any = scenarios[0].activitiesToBePosted; + const chatReplayData: ChatReplayData = { + incomingActivities: scenarios[0].incomingActivities, + postActivitiesSlots: scenarios[0].postActivitiesSlots, + }; + + const queue: ConversationQueue = new ConversationQueue(activities, chatReplayData, '123', activities[1], jest.fn()); + // Conversation Update + queue.incomingActivity(chatReplayData.incomingActivities[0] as Activity); + + const botResponsesForActivity: Activity[] = scenarios[0].botResponsesForActivity; + + queue.incomingActivity(botResponsesForActivity[1]); + expect(queue.getNextActivityForPost()).toBeUndefined(); + + // The original conversation had 2 bot responses for the activity before an echoback + const err = queue.incomingActivity(botResponsesForActivity[3]); + expect(err).toBeDefined(); + expect(queue.replayComplete).toBeFalsy(); + }); + + it('should replay scenario1 without errors and should set replay to complete - Scenario[1]', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots, botResponsesForActivity } = scenarios[1]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 1], + jest.fn() + ); + // Conversation Update + let err; + err = queue.incomingActivity(incomingActivities[0]); + expect(err).toBeUndefined(); + expect(queue.getNextActivityForPost()).toBeUndefined(); + queue.incomingActivity(incomingActivities[1]); + + let activity = queue.getNextActivityForPost(); + expect(activity).toBeDefined(); + err = queue.incomingActivity(botResponsesForActivity[2]); + err = queue.incomingActivity(botResponsesForActivity[3]); + expect(err).toBeUndefined(); + err = queue.incomingActivity(botResponsesForActivity[4]); + err = queue.incomingActivity(botResponsesForActivity[5]); + expect(err).toBeUndefined(); + + activity = queue.getNextActivityForPost(); + expect(activity).toBeDefined(); + err = queue.incomingActivity(botResponsesForActivity[6]); + err = queue.incomingActivity(botResponsesForActivity[7]); + expect(err).toBeUndefined(); + err = queue.incomingActivity(botResponsesForActivity[8]); + expect(err).toBeUndefined(); + expect(queue.replayComplete).toBeTruthy(); + }); + + it('should handle multiple events sent before we get completion for the first one - Scenario[2]', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots, botResponsesForActivity } = scenarios[2]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 1], + jest.fn() + ); + + let err; + queue.incomingActivity(botResponsesForActivity[0]); + + const postActivity2: Activity = queue.getNextActivityForPost(); + expect(postActivity2).toBeDefined(); + expect(postActivity2.channelData.matchIndexes).toEqual([1, 4, 7, 8]); + err = queue.incomingActivity(botResponsesForActivity[1]); + expect(err).toBeUndefined(); + + const postActivity3: Activity = queue.getNextActivityForPost(); + expect(postActivity3.channelData.matchIndexes).toEqual([2, 3, 5]); + + queue.incomingActivity(botResponsesForActivity[2]); + queue.incomingActivity(botResponsesForActivity[3]); + queue.incomingActivity(botResponsesForActivity[4]); + queue.incomingActivity(botResponsesForActivity[5]); + //Act3 completed + err = queue.incomingActivity(botResponsesForActivity[6]); + expect(err).toBeUndefined(); + expect(queue.getNextActivityForPost()).toBeUndefined(); + + queue.incomingActivity(botResponsesForActivity[7]); + err = queue.incomingActivity(botResponsesForActivity[8]); + expect(err).toBeUndefined(); + expect(queue.getNextActivityForPost()).toBeUndefined(); + expect(queue.replayComplete).toBeFalsy(); + }); + + it('should set replay complete once the last action that the user set for restart was fired - Scenario[2]', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots, botResponsesForActivity } = scenarios[2]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 2], + jest.fn() + ); + + queue.incomingActivity(botResponsesForActivity[0]); + queue.getNextActivityForPost(); + queue.incomingActivity(botResponsesForActivity[1]); + queue.getNextActivityForPost(); + queue.incomingActivity(botResponsesForActivity[2]); + // We have asked the queue to stop after posting 2 activities + expect(queue.replayComplete).toBeTruthy(); + }); + + it('should set replay complete after 3 actions have been posted - Scenario[2]', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots, botResponsesForActivity } = scenarios[2]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 1], + jest.fn() + ); + + queue.incomingActivity(botResponsesForActivity[0]); + queue.incomingActivity(botResponsesForActivity[1]); + queue.incomingActivity(botResponsesForActivity[2]); + queue.incomingActivity(botResponsesForActivity[3]); + queue.incomingActivity(botResponsesForActivity[4]); + queue.incomingActivity(botResponsesForActivity[5]); + queue.incomingActivity(botResponsesForActivity[6]); + queue.incomingActivity(botResponsesForActivity[7]); + expect(queue.replayComplete).toBeFalsy(); + queue.incomingActivity(botResponsesForActivity[8]); + queue.incomingActivity(botResponsesForActivity[9]); + const postActivity = queue.getNextActivityForPost(); + expect(postActivity).toBeDefined(); + queue.incomingActivity(botResponsesForActivity[10]); + expect(queue.replayComplete).toBeTruthy(); + }); + + it('should set replay complete after progressive responses arrive - Scenario[3]', () => { + const { activitiesToBePosted, incomingActivities, postActivitiesSlots, botResponsesForActivity } = scenarios[3]; + const queue: ConversationQueue = new ConversationQueue( + activitiesToBePosted, + { + incomingActivities, + postActivitiesSlots, + }, + '123', + activitiesToBePosted[activitiesToBePosted.length - 1], + jest.fn() + ); + + queue.incomingActivity(botResponsesForActivity[0]); + queue.incomingActivity(botResponsesForActivity[1]); + queue.incomingActivity(botResponsesForActivity[2]); + queue.incomingActivity(botResponsesForActivity[3]); + queue.incomingActivity(botResponsesForActivity[4]); + queue.incomingActivity(botResponsesForActivity[5]); + queue.incomingActivity(botResponsesForActivity[6]); + queue.incomingActivity(botResponsesForActivity[7]); + queue.incomingActivity(botResponsesForActivity[8]); + queue.incomingActivity(botResponsesForActivity[9]); + queue.incomingActivity(botResponsesForActivity[10]); + queue.incomingActivity(botResponsesForActivity[11]); + queue.incomingActivity(botResponsesForActivity[12]); + let err = queue.incomingActivity(botResponsesForActivity[13]); + // Progressive response for Act2 arrived at the correct spot + expect(err).toBeUndefined(); + err = queue.incomingActivity(botResponsesForActivity[14]); + // Another Progressive response for Act2 arrived at the correct spot + expect(err).toBeUndefined(); + }); +}); diff --git a/packages/app/client/src/utils/restartConversationQueue.ts b/packages/app/client/src/utils/restartConversationQueue.ts new file mode 100644 index 000000000..9be848106 --- /dev/null +++ b/packages/app/client/src/utils/restartConversationQueue.ts @@ -0,0 +1,201 @@ +// +// 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 { Activity } from 'botframework-schema'; +import { SharedConstants, RestartConversationStatus } from '@bfemulator/app-shared'; +import { ChatReplayData, HasIdAndReplyId } from '@bfemulator/app-shared'; + +export enum WebchatEvents { + postActivity = 'DIRECT_LINE/POST_ACTIVITY', + incomingActivity = 'DIRECT_LINE/INCOMING_ACTIVITY', + rejectedActivity = 'DIRECT_LINE/POST_ACTIVITY_REJECTED', +} + +export const webchatEventsToWatch: string[] = [WebchatEvents.postActivity, WebchatEvents.incomingActivity]; + +export class ConversationQueue { + private userActivities: Activity[] = []; + private replayDataFromOldConversation: ChatReplayData; + private receivedActivities: Activity[]; + private conversationId: string; + private nextActivityToBePosted = undefined; + private isReplayComplete = false; + private createObjectUrl; + private progressiveResponseValidationMap: Map; + + // private createObjectUrlFromWindow: Function; + + constructor( + activities: Activity[], + chatReplayData: ChatReplayData, + conversationId: string, + replayToActivity: Activity, + createObjectUrl: Function + ) { + this.createObjectUrl = createObjectUrl; + // Get all user activities + this.userActivities = activities.filter( + (activity: Activity) => activity.from.role === SharedConstants.Activity.FROM_USER_ROLE && activity.channelData + ); + + const trimActivityIndex: number = this.userActivities.findIndex(activity => activity.id === replayToActivity.id); + if (trimActivityIndex !== -1) { + this.userActivities = this.userActivities.splice(0, trimActivityIndex + 1); + } + + this.conversationId = conversationId; + this.replayDataFromOldConversation = chatReplayData; + this.receivedActivities = []; + this.progressiveResponseValidationMap = new Map(); + + this.checkIfActivityToBePosted = this.checkIfActivityToBePosted.bind(this); + this.incomingActivity = this.incomingActivity.bind(this); + } + + private static dataURLtoFile(dataurl: string, filename: string) { + var arr = dataurl.split(','), + mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), + n = bstr.length, + u8arr = new Uint8Array(n); + + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + + return new File([u8arr], filename, { type: mime }); + } + + private checkIfActivityToBePosted() { + try { + if ( + !this.replayDataFromOldConversation.postActivitiesSlots.includes(this.receivedActivities.length) || + this.userActivities.length === 0 + ) { + this.nextActivityToBePosted = undefined; + return; + } + const activity: Activity = this.userActivities.shift(); + + const matchIndexes = []; + this.replayDataFromOldConversation.incomingActivities.forEach( + (incomingActivity: HasIdAndReplyId, index: number) => { + if (incomingActivity.replyToId === activity.id) { + matchIndexes.push(index); + } + } + ); + + if (activity.attachments && activity.attachments.length >= 1) { + const mutatedAttachments = activity.attachments.map(attachment => { + // Convert back to file and create a temporary link using object URL + const fileFormat: File = ConversationQueue.dataURLtoFile(attachment.contentUrl, attachment.name); + return { + ...attachment, + contentUrl: this.createObjectUrl ? this.createObjectUrl(fileFormat) : fileFormat, + }; + }); + activity.attachments = mutatedAttachments; + } + + if (activity) { + activity.conversation = { + ...activity.conversation, + id: this.conversationId, + }; + activity.channelData = { + ...activity.channelData, + originalActivityId: activity.id, + matchIndexes, + }; + delete activity.id; + } + this.nextActivityToBePosted = activity; + } catch (ex) { + return undefined; + } + } + + public validateIfReplayFlow(replayStatus: RestartConversationStatus, actionType: string) { + return !!( + typeof replayStatus !== undefined && + actionType === WebchatEvents.incomingActivity && + replayStatus === RestartConversationStatus.Started + ); + } + + public getNextActivityForPost(): Activity | undefined { + return this.nextActivityToBePosted; + } + + public get replayComplete(): boolean { + return this.isReplayComplete; + } + + public incomingActivity(activity: Activity) { + if (this.isReplayComplete) { + return; + } + try { + const indexToBeInserted: number = this.receivedActivities.length; + if ( + this.progressiveResponseValidationMap.has(indexToBeInserted) && + this.progressiveResponseValidationMap.get(indexToBeInserted) !== activity.replyToId + ) { + throw new Error('Replayed activities not in order of original conversation'); + } else { + this.progressiveResponseValidationMap.delete(indexToBeInserted); + } + this.receivedActivities.push(activity); + + if (activity.channelData && !activity.replyToId) { + const matchIndexes: number[] = activity.channelData.matchIndexes; + if (matchIndexes) { + matchIndexes.forEach((index: number) => { + if (!this.receivedActivities[index]) { + this.progressiveResponseValidationMap.set(index, activity.id); + } else if (this.receivedActivities[index].replyToId !== activity.id) { + throw new Error('Replayed activities not in order of original conversation'); + } + }); + } + } + + if (this.userActivities.length === 0) { + this.isReplayComplete = true; + } + this.checkIfActivityToBePosted(); + } catch (ex) { + return ex; + } + } +} diff --git a/packages/app/client/webpack.config.js b/packages/app/client/webpack.config.js index 0bb90f8ea..9434e1cbc 100644 --- a/packages/app/client/webpack.config.js +++ b/packages/app/client/webpack.config.js @@ -35,7 +35,6 @@ const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const TerserWebpackPlugin = require('terser-webpack-plugin'); - const webpack = require('webpack'); const { DllPlugin, DllReferencePlugin, NamedModulesPlugin, DefinePlugin, WatchIgnorePlugin } = webpack; @@ -107,7 +106,7 @@ const defaultConfig = { ], }, - devtool: 'source-map', + devtool: 'eval-source-map', devServer: { hot: true, diff --git a/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.spec.ts b/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.spec.ts new file mode 100644 index 000000000..bdadfbaab --- /dev/null +++ b/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.spec.ts @@ -0,0 +1,70 @@ +// +// 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 { Activity } from 'botframework-schema'; +import { OK } from 'http-status-codes'; + +import { getActivitiesForConversation } from './getActivitiesForConversation'; + +describe('getActivitiesForConversation handler', () => { + it('should get all the activities for a conversation', async done => { + const transcripts: Activity[] = [ + { + id: '1', + } as Activity, + ]; + const state: any = { + conversations: { + conversationById: () => { + return { + getTranscript: () => transcripts, + }; + }, + }, + }; + + const req: any = { + params: { + conversationId: '123', + }, + }; + const res: any = { + end: jest.fn(), + send: jest.fn(), + }; + const activityHandler = getActivitiesForConversation(state); + await activityHandler(req, res, jest.fn()); + expect(res.send).toHaveBeenCalledWith(OK, transcripts); + done(); + }); +}); diff --git a/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.ts b/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.ts new file mode 100644 index 000000000..c0de463c1 --- /dev/null +++ b/packages/app/main/src/server/routes/channel/conversations/handlers/getActivitiesForConversation.ts @@ -0,0 +1,55 @@ +// +// 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 HttpStatus from 'http-status-codes'; +import { Next, Request, Response } from 'restify'; +import { Activity } from 'botframework-schema'; + +import { sendErrorResponse } from '../../../../utils/sendErrorResponse'; +import { ServerState } from '../../../../state/serverState'; +import { ConversationAPIPathParameters } from '../types/conversationAPIPathParameters'; + +export function getActivitiesForConversation(state: ServerState) { + return async (req: Request, res: Response, next: Next): Promise => { + try { + const conversationParameters: ConversationAPIPathParameters = req.params; + const conversation = state.conversations.conversationById(conversationParameters.conversationId); + const activities: Activity[] = await conversation.getTranscript(); + res.send(HttpStatus.OK, activities); + res.end(); + } catch (err) { + sendErrorResponse(req, res, next, err); + } finally { + next(); + } + }; +} diff --git a/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.spec.ts b/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.spec.ts index 58ce1e99b..38f605800 100644 --- a/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.spec.ts +++ b/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.spec.ts @@ -46,6 +46,7 @@ import { sendActivityToConversation } from './handlers/sendActivityToConversatio import { sendHistoryToConversation } from './handlers/sendHistoryToConversation'; import { updateActivity } from './handlers/updateActivity'; import { createUploadAttachmentHandler } from './handlers/uploadAttachment'; +import { getActivitiesForConversation } from './handlers/getActivitiesForConversation'; jest.mock('../../handlers/botFrameworkAuthentication', () => ({ createBotFrameworkAuthenticationMiddleware: jest.fn(), @@ -84,6 +85,9 @@ jest.mock('./handlers/updateActivity', () => ({ jest.mock('./handlers/uploadAttachment', () => ({ createUploadAttachmentHandler: jest.fn(), })); +jest.mock('./handlers/getActivitiesForConversation', () => ({ + getActivitiesForConversation: jest.fn(), +})); describe('mountConversationsRoutes', () => { it('should mount the routes', () => { @@ -169,6 +173,13 @@ describe('mountConversationsRoutes', () => { getActivityMembers ); + expect(get).toHaveBeenCalledWith( + '/v3/conversations/:conversationId/activities', + verifyBotFramework, + jsonBodyParser, + getActivitiesForConversation(emulatorServer) + ); + expect(post).toHaveBeenCalledWith( '/v3/conversations/:conversationId/attachments', verifyBotFramework, diff --git a/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.ts b/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.ts index cf63e87b8..bfd053315 100644 --- a/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.ts +++ b/packages/app/main/src/server/routes/channel/conversations/mountConversationsRoutes.ts @@ -47,6 +47,7 @@ import { updateActivity } from './handlers/updateActivity'; import { createUploadAttachmentHandler } from './handlers/uploadAttachment'; import { createGetConversationsHandler } from './handlers/getConversations'; import { createGetBotEndpointHandler } from './handlers/getBotEndpoint'; +import { getActivitiesForConversation } from './handlers/getActivitiesForConversation'; export function mountConversationsRoutes(emulatorServer: EmulatorRestServer) { const { server, state } = emulatorServer; @@ -123,4 +124,11 @@ export function mountConversationsRoutes(emulatorServer: EmulatorRestServer) { jsonBodyParser, createUploadAttachmentHandler(state) ); + + server.get( + '/v3/conversations/:conversationId/activities', + verifyBotFramework, + jsonBodyParser, + getActivitiesForConversation(state) + ); } diff --git a/packages/app/main/src/server/routes/directLine/handlers/postActivity.spec.ts b/packages/app/main/src/server/routes/directLine/handlers/postActivity.spec.ts index 7bf695872..a30f6a4f2 100644 --- a/packages/app/main/src/server/routes/directLine/handlers/postActivity.spec.ts +++ b/packages/app/main/src/server/routes/directLine/handlers/postActivity.spec.ts @@ -61,13 +61,14 @@ describe('postActivity handler', () => { logMessage: jest.fn(), }, }; + const activity = { + id: 'activity1', + }; const req: any = { - body: { - id: 'activity1', - }, + body: activity, conversation: { postActivityToBot: jest.fn().mockResolvedValueOnce({ - activityId: 'activity1', + updatedActivity: activity, response: {}, statusCode: HttpStatus.OK, }), @@ -84,7 +85,7 @@ describe('postActivity handler', () => { const postActivity = createPostActivityHandler(mockEmulatorServer); await postActivity(req, res, next); - expect(res.send).toHaveBeenCalledWith(HttpStatus.OK, { id: 'activity1' }); + expect(res.send).toHaveBeenCalledWith(HttpStatus.OK, activity); expect(res.end).toHaveBeenCalled(); expect(next).toHaveBeenCalled(); expect(mockSocket.send).toHaveBeenCalledWith( @@ -100,15 +101,16 @@ describe('postActivity handler', () => { logMessage: jest.fn(), }, }; + const activity = { + id: 'activity1', + text: '/INSPECT open', + type: 'message', + }; const req: any = { - body: { - id: 'activity1', - text: '/INSPECT open', - type: 'message', - }, + body: activity, conversation: { postActivityToBot: jest.fn().mockResolvedValueOnce({ - activityId: 'activity1', + updatedActivity: activity, response: {}, statusCode: HttpStatus.OK, }), @@ -143,7 +145,7 @@ describe('postActivity handler', () => { }, conversation: { postActivityToBot: jest.fn().mockResolvedValueOnce({ - activityId: 'activity1', + updatedActivity: {}, response: { text: jest.fn().mockResolvedValueOnce('Unauthorized'), }, @@ -253,7 +255,7 @@ describe('postActivity handler', () => { }, conversation: { postActivityToBot: jest.fn().mockResolvedValueOnce({ - activityId: 'activity1', + activity: undefined, response: { message: 'Request failed', status: undefined, diff --git a/packages/app/main/src/server/routes/directLine/handlers/postActivity.ts b/packages/app/main/src/server/routes/directLine/handlers/postActivity.ts index ce2b44d1c..1640c462c 100644 --- a/packages/app/main/src/server/routes/directLine/handlers/postActivity.ts +++ b/packages/app/main/src/server/routes/directLine/handlers/postActivity.ts @@ -57,10 +57,11 @@ export function createPostActivityHandler(emulatorServer: EmulatorRestServer) { return; } - const activity = req.body as Activity; + let activity = req.body as Activity; try { - const { activityId, response, statusCode } = await conversation.postActivityToBot(activity, true); + const { updatedActivity, response, statusCode } = await conversation.postActivityToBot(activity, true); + activity = updatedActivity; if (!statusCodeFamily(statusCode, 200)) { if (statusCode === HttpStatus.UNAUTHORIZED || statusCode === HttpStatus.FORBIDDEN) { @@ -77,16 +78,14 @@ export function createPostActivityHandler(emulatorServer: EmulatorRestServer) { } res.send(statusCode || HttpStatus.INTERNAL_SERVER_ERROR, err); } else { - res.send(statusCode, { id: activityId }); + res.send(statusCode, { id: activity.id }); // (filter out the /INSPECT open command because it doesn't originate from Web Chat) if (activity.type === 'message' && activity.text === '/INSPECT open') { res.end(); return next(); } - - // satisfy the Web Chat echoback requirement - const payload = { activities: [{ ...activity, id: activityId }] }; + const payload = { activities: [{ ...activity, id: activity.id }] }; const socket = WebSocketServer.getSocketByConversationId(conversation.conversationId); socket && socket.send(JSON.stringify(payload)); } diff --git a/packages/app/main/src/server/state/conversation.spec.ts b/packages/app/main/src/server/state/conversation.spec.ts index 794bd3c05..53ed67ab7 100644 --- a/packages/app/main/src/server/state/conversation.spec.ts +++ b/packages/app/main/src/server/state/conversation.spec.ts @@ -31,6 +31,8 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // +import { Activity } from 'botframework-schema'; + import { BotEndpoint } from './botEndpoint'; import { Conversation } from './conversation'; @@ -137,7 +139,12 @@ const mockActivity = { name: 'Bot', }, ], -}; +} as Activity; + +jest.mock('moment', () => () => ({ + format: () => '2020-02-24T14:55:52-08:00', + toISOString: () => '2020-02-24T14:55:52-08:00', +})); const mockUserActivity = { type: 'message', @@ -159,7 +166,7 @@ const mockUserActivity = { inputHint: 'acceptingInput', replyToId: '96547340-1f5c-11e9-9b39-f387f690c8a4', id: null, -}; +} as Activity; describe('Conversation class', () => { let botEndpointBotId; @@ -179,6 +186,7 @@ describe('Conversation class', () => { (fetch as any).Response = class {}; return fetch as any; })(); + beforeEach(() => { botEndpointBotId = 'someBotEndpointBotId'; botEndpoint = new BotEndpoint('123', botEndpointBotId, 'http://ngrok', null, null, null, null, { fetch }); @@ -276,9 +284,14 @@ describe('Conversation class', () => { } }); - it('should post an activity to the bot', async () => { + fit('should post an activity to the bot', async () => { + const formattedDataStr = '2020-02-24T14:55:52-08:00'; + const isoDateStr = '2020-02-24T14:55:52-08:00'; const result = await conversation.postActivityToBot(mockActivity, true); - expect(result.activityId).toEqual(jasmine.any(String)); + + const postedActivity: Activity = result.updatedActivity; + expect(postedActivity.localTimestamp).toBe(formattedDataStr); + expect(postedActivity.timestamp).toBe(isoDateStr); }); it('should send a conversation update', async () => { diff --git a/packages/app/main/src/server/state/conversation.ts b/packages/app/main/src/server/state/conversation.ts index 94f9ae2e4..b5d398354 100644 --- a/packages/app/main/src/server/state/conversation.ts +++ b/packages/app/main/src/server/state/conversation.ts @@ -174,7 +174,7 @@ export class Conversation extends EventEmitter { } return { - activityId: activity.id, + updatedActivity: activity, response: resp, statusCode: status, }; @@ -523,7 +523,6 @@ export class Conversation extends EventEmitter { public postage(recipientId: string, activity: Partial, isHistoric: boolean = false): Activity { const date = moment(); - const timestamp = isHistoric ? activity.timestamp : date.toISOString(); const recipient = isHistoric ? activity.recipient : ({ id: recipientId } as ChannelAccount); @@ -562,7 +561,6 @@ export class Conversation extends EventEmitter { // internal tracking this.addActivityToQueue(activity); this.transcript = [...this.transcript, { type: 'activity add', activity }]; - return activity; } @@ -590,7 +588,7 @@ export class Conversation extends EventEmitter { this.transcript = [...this.transcript, { type: 'activity add', activity }]; this.emit('transcriptupdate'); - return activity; + return { ...activity }; } private addActivityToQueue(activity: Activity) { diff --git a/packages/app/shared/package.json b/packages/app/shared/package.json index daa05f40d..3bd63f865 100644 --- a/packages/app/shared/package.json +++ b/packages/app/shared/package.json @@ -12,7 +12,8 @@ "lint": "eslint --color --quiet --ext .js,.jsx,.ts,.tsx ./src", "lint:fix": "npm run lint -- --fix", "prepare": "npm run clean", - "test": "jest" + "test": "jest", + "test:watch": "jest --watch" }, "author": "", "license": "ISC", diff --git a/packages/app/shared/src/constants/sharedConstants.ts b/packages/app/shared/src/constants/sharedConstants.ts index 68ec2e244..28c5083e1 100644 --- a/packages/app/shared/src/constants/sharedConstants.ts +++ b/packages/app/shared/src/constants/sharedConstants.ts @@ -215,4 +215,8 @@ export const SharedConstants = { NAVBAR_NOTIFICATIONS: 'navbar.notifications', NAVBAR_RESOURCES: 'navbar:resources', }, + Activity: { + FROM_USER_ROLE: 'user', + FROM_BOT_ROLE: 'bot', + }, }; diff --git a/packages/app/shared/src/state/actions/chatActions.spec.ts b/packages/app/shared/src/state/actions/chatActions.spec.ts index 9f8c7530b..e5458588d 100644 --- a/packages/app/shared/src/state/actions/chatActions.spec.ts +++ b/packages/app/shared/src/state/actions/chatActions.spec.ts @@ -31,6 +31,8 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // +import { Activity } from 'botframework-schema'; + import { ChatActions, inspectorChanged, @@ -52,6 +54,10 @@ import { openTranscript, restartConversation, updateSpeechAdapters, + setRestartConversationStatus, + RestartConversationStatus, + postActivity, + incomingActivity, } from './chatActions'; describe('chat actions', () => { @@ -275,4 +281,64 @@ describe('chat actions', () => { }, }); }); + + it('should create a setRestartConversationStatus action', () => { + const expectedPayload = { + documentId: 'abc', + status: RestartConversationStatus.Started, + }; + const action = setRestartConversationStatus(expectedPayload.status, expectedPayload.documentId); + + expect(action).toEqual({ + type: ChatActions.SetRestartConversationStatus, + payload: expectedPayload, + }); + }); + + it('should create a postActivity action', () => { + const activity: Activity = { + id: 'activ-1', + } as Activity; + + const expectedPayload = { + documentId: 'abc', + activity, + }; + const action = postActivity(activity, 'abc'); + + expect(action).toEqual({ + type: ChatActions.PostActivityEventWc, + payload: expectedPayload, + }); + }); + + it('should create an incoming activity action', () => { + const activity: Activity = { + id: 'activ-1', + } as Activity; + + const expectedPayload = { + documentId: 'abc', + activity, + }; + const action = incomingActivity(activity, 'abc'); + + expect(action).toEqual({ + type: ChatActions.IncomingActivityFromWc, + payload: expectedPayload, + }); + }); + + it('should create a set restart conversation status action', () => { + const expectedPayload = { + documentId: 'abc', + status: RestartConversationStatus.Started, + }; + const action = setRestartConversationStatus(expectedPayload.status, expectedPayload.documentId); + + expect(action).toEqual({ + type: ChatActions.SetRestartConversationStatus, + payload: expectedPayload, + }); + }); }); diff --git a/packages/app/shared/src/state/actions/chatActions.ts b/packages/app/shared/src/state/actions/chatActions.ts index 18ee81bd7..2880a397d 100644 --- a/packages/app/shared/src/state/actions/chatActions.ts +++ b/packages/app/shared/src/state/actions/chatActions.ts @@ -59,6 +59,16 @@ export enum ChatActions { updateSpeechAdapters = 'CHAT/SPEECH/DL/ADAPTERS', webSpeechFactoryUpdated = 'CHAT/SPEECH/TOKEN/RETRIEVED', webChatStoreUpdated = 'CHAT/STORE/UPDATED', + PostActivityEventWc = 'CHAT/POST_ACTIVITY_WEBCHAT', + IncomingActivityFromWc = 'CHAT/INCOMING_ACTIVITY_WEBCHAT', + SetRestartConversationStatus = 'CHAT/RESTART/ACTIVITY/STATUS', +} + +export enum RestartConversationStatus { + Started, + Rejected, + Completed, + Stop, } export interface ActiveInspectorChangedPayload { @@ -127,6 +137,18 @@ export interface RestartConversationPayload { documentId: string; requireNewConversationId: boolean; requireNewUserId: boolean; + activity?: Activity; + createObjectUrl: Function; +} + +export interface ActivityFromWebchatPayload { + documentId: string; + activity: Activity; +} + +export interface RestartConversationStatusPayload { + documentId: string; + status: RestartConversationStatus; } export interface UpdateSpeechAdaptersPayload { @@ -332,7 +354,9 @@ export function showContextMenuForActivity(activity: Partial): ChatAct export function restartConversation( documentId: string, requireNewConversationId: boolean = false, - requireNewUserId: boolean = false + requireNewUserId: boolean = false, + activity: Activity = undefined, + createObjectUrl: Function = undefined ): ChatAction { return { type: ChatActions.restartConversation, @@ -340,6 +364,41 @@ export function restartConversation( documentId, requireNewConversationId, requireNewUserId, + activity, + createObjectUrl, + }, + }; +} + +export function postActivity(activity: Activity, documentId: string): ChatAction { + return { + type: ChatActions.PostActivityEventWc, + payload: { + documentId, + activity, + }, + }; +} + +export function incomingActivity(activity: Activity, documentId: string): ChatAction { + return { + type: ChatActions.IncomingActivityFromWc, + payload: { + documentId, + activity, + }, + }; +} + +export function setRestartConversationStatus( + status: RestartConversationStatus, + documentId: string +): ChatAction { + return { + type: ChatActions.SetRestartConversationStatus, + payload: { + documentId, + status, }, }; } diff --git a/packages/app/shared/src/state/reducers/chat.spec.ts b/packages/app/shared/src/state/reducers/chat.spec.ts index aeff2e6f9..5c864110a 100644 --- a/packages/app/shared/src/state/reducers/chat.spec.ts +++ b/packages/app/shared/src/state/reducers/chat.spec.ts @@ -32,6 +32,7 @@ // import { LogEntry, LogItemType } from '@bfemulator/sdk-shared'; +import { Activity } from 'botframework-schema'; import { addTranscript, @@ -45,10 +46,14 @@ import { setInspectorObjects, updateChat, updateSpeechAdapters, + incomingActivity, + RestartConversationStatus, + postActivity, + setRestartConversationStatus, } from '../actions/chatActions'; import { closeNonGlobalTabs } from '../actions/editorActions'; -import { chat, ChatState } from './chat'; +import { chat, ChatState, HasIdAndReplyId } from './chat'; describe('Chat reducer tests', () => { const testChatId = 'testChat1'; @@ -62,6 +67,9 @@ describe('Chat reducer tests', () => { }, }, transcripts: [], + restartStatus: { + [testChatId]: RestartConversationStatus.Started, + }, } as any; it('should return unaltered state for non-matching action type', () => { @@ -251,4 +259,142 @@ describe('Chat reducer tests', () => { expect(state.chats.chat1.directLine).toEqual(directLine); expect(state.webSpeechFactories.chat1).toEqual(webSpeechPonyfillFactory); }); + + it('should add slots for post activity correctly', () => { + const documentId = 'chatId-1'; + const startingState = { + ...DEFAULT_STATE, + chats: { + ...DEFAULT_STATE.chats, + 'chatId-1': { + directLine: undefined, + documentId, + userId: 'user1', + replayData: {}, + }, + }, + webSpeechFactories: { + chat1: undefined, + }, + }; + const activities: Activity[] = [ + { + id: 'activ-1', + name: 'incoming-1', + replyToId: 'reply-to-1', + } as Activity, + { + id: 'activ-2', + name: 'incoming-2', + replyToId: 'reply-to-2', + } as Activity, + { + id: 'activ-3', + name: 'post-activity-1', + } as Activity, + { + id: 'activ-4', + name: 'incoming-3', + replyToId: 'post-activity-1', + } as Activity, + { + id: 'activ-5', + name: 'post-activity-2', + } as Activity, + ]; + + let transientState: ChatState = chat(startingState, incomingActivity(activities[0], documentId)); + transientState = chat(transientState, incomingActivity(activities[1], documentId)); + transientState = chat(transientState, postActivity(activities[2], documentId)); + expect(transientState.chats['chatId-1'].replayData.postActivitiesSlots.length).toBe(1); + expect(transientState.chats['chatId-1'].replayData.postActivitiesSlots[0]).toBe(2); + + transientState = chat(transientState, incomingActivity(activities[3], documentId)); + const finalState = chat(transientState, postActivity(activities[4], documentId)); + expect(finalState.chats['chatId-1'].replayData.postActivitiesSlots[1]).toBe(3); + }); + + it('should add an incoming activity inside the chatReplay object', () => { + const documentId = 'chatId-1'; + const startingState = { + ...DEFAULT_STATE, + chats: { + ...DEFAULT_STATE.chats, + 'chatId-1': { + directLine: undefined, + documentId, + userId: 'user1', + replayData: {}, + }, + }, + webSpeechFactories: { + chat1: undefined, + }, + }; + + const expectedActivity = { + id: 'activ-1', + name: 'test-activity-1', + replyToId: 'reply-to-1', + } as Activity; + + let action = incomingActivity(expectedActivity, documentId); + + const transientState = chat(startingState, action); + let incomingActivities = transientState.chats['chatId-1'].replayData.incomingActivities; + let lastActivity: HasIdAndReplyId = incomingActivities[incomingActivities.length - 1]; + expect(lastActivity.id).toBe(expectedActivity.id); + expect(lastActivity.replyToId).toBe(expectedActivity.replyToId); + + const anotherExpectedActivity = { + id: 'activ-2', + name: 'test-activity-2', + replyToId: 'reply-to-2', + } as Activity; + + action = incomingActivity(anotherExpectedActivity, documentId); + const finalState = chat(transientState, action); + incomingActivities = finalState.chats['chatId-1'].replayData.incomingActivities; + lastActivity = incomingActivities[incomingActivities.length - 1]; + expect(lastActivity.id).toBe(anotherExpectedActivity.id); + expect(lastActivity.replyToId).toBe(anotherExpectedActivity.replyToId); + }); + + it('should set restart conversation status', () => { + const documentId = 'chatId-1'; + const startingState = { + ...DEFAULT_STATE, + chats: { + ...DEFAULT_STATE.chats, + 'chatId-1': { + directLine: undefined, + documentId, + userId: 'user1', + replayData: {}, + }, + }, + webSpeechFactories: { + chat1: undefined, + }, + }; + + let transientState: ChatState = chat( + startingState, + setRestartConversationStatus(RestartConversationStatus.Started, documentId) + ); + expect(transientState.restartStatus[documentId]).toBe(RestartConversationStatus.Started); + transientState = chat( + transientState, + setRestartConversationStatus(RestartConversationStatus.Completed, documentId) + ); + expect(transientState.restartStatus[documentId]).toBe(RestartConversationStatus.Completed); + + transientState = chat(transientState, setRestartConversationStatus(RestartConversationStatus.Rejected, documentId)); + expect(transientState.restartStatus[documentId]).toBe(RestartConversationStatus.Rejected); + + transientState = chat(transientState, setRestartConversationStatus(RestartConversationStatus.Stop, documentId)); + expect(transientState.restartStatus[documentId]).toBe(RestartConversationStatus.Stop); + + expect(transientState.restartStatus['abc']).toBeUndefined(); + }); }); diff --git a/packages/app/shared/src/state/reducers/chat.ts b/packages/app/shared/src/state/reducers/chat.ts index 9fd96316b..80beb7831 100644 --- a/packages/app/shared/src/state/reducers/chat.ts +++ b/packages/app/shared/src/state/reducers/chat.ts @@ -41,6 +41,9 @@ import { WebChatStorePayload, WebSpeechFactoryPayload, UpdateSpeechAdaptersPayload, + ActivityFromWebchatPayload, + RestartConversationStatus, + RestartConversationStatusPayload, } from '../actions/chatActions'; import { EditorAction, EditorActions } from '../actions/editorActions'; @@ -52,6 +55,17 @@ export interface ChatState { webSpeechFactories?: { [documentId: string]: () => any }; webChatStores: { [documentId: string]: any }; transcripts?: string[]; + restartStatus: { [chatId: string]: RestartConversationStatus }; +} + +export interface HasIdAndReplyId { + id: string; + replyToId?: string; +} + +export interface ChatReplayData { + incomingActivities: HasIdAndReplyId[]; + postActivitiesSlots: number[]; } export interface ChatDocument extends Document { @@ -64,6 +78,8 @@ export interface ChatDocument extends Document { speechKey: string; speechRegion: string; ui: DocumentUI; + replayData: ChatReplayData; + isDisabled: boolean; } export interface ChatLog { @@ -76,6 +92,7 @@ const DEFAULT_STATE: ChatState = { transcripts: [], webSpeechFactories: {}, webChatStores: {}, + restartStatus: {}, }; export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | EditorAction): ChatState { @@ -109,7 +126,7 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit changeKey: state.changeKey + 1, chats: { ...state.chats, - [payload.documentId]: { ...payload }, + [payload.documentId]: { ...payload, replayData: {}, isDisabled: false }, }, }; break; @@ -161,6 +178,7 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit const copy = { ...state }; copy.changeKey += 1; delete copy.chats[documentId]; + delete copy.restartStatus[documentId]; state = { ...copy }; } break; @@ -278,6 +296,71 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit break; } + case ChatActions.IncomingActivityFromWc: { + const { documentId, activity } = action.payload as ActivityFromWebchatPayload; + const replayData: ChatReplayData = state.chats[documentId].replayData; + let incomingActivities: HasIdAndReplyId[] = []; + if (replayData.incomingActivities) { + incomingActivities = [...replayData.incomingActivities]; + } + incomingActivities.push({ + id: activity.id, + replyToId: activity.replyToId, + }); + state = { + ...state, + chats: { + ...state.chats, + [documentId]: { + ...state.chats[documentId], + replayData: { + ...state.chats[documentId].replayData, + incomingActivities, + }, + }, + }, + }; + break; + } + + case ChatActions.PostActivityEventWc: { + const { documentId } = action.payload as ActivityFromWebchatPayload; + let postActivitiesSlots: number[] = []; + if (state.chats[documentId].replayData.postActivitiesSlots) { + postActivitiesSlots = [...state.chats[documentId].replayData.postActivitiesSlots]; + } + const slot: number = state.chats[documentId].replayData.incomingActivities + ? state.chats[documentId].replayData.incomingActivities.length + : 0; + postActivitiesSlots.push(slot); + state = { + ...state, + chats: { + ...state.chats, + [documentId]: { + ...state.chats[documentId], + replayData: { + ...state.chats[documentId].replayData, + postActivitiesSlots, + }, + }, + }, + }; + break; + } + + case ChatActions.SetRestartConversationStatus: { + const { documentId, status } = action.payload as RestartConversationStatusPayload; + state = { + ...state, + restartStatus: { + ...state.restartStatus, + [documentId]: status, + }, + }; + break; + } + case EditorActions.closeAll: { // HACK. Need a better system. return DEFAULT_STATE; diff --git a/packages/sdk/shared/package.json b/packages/sdk/shared/package.json index 9f3af5bea..8466e9034 100644 --- a/packages/sdk/shared/package.json +++ b/packages/sdk/shared/package.json @@ -11,7 +11,8 @@ "lint": "eslint --color --quiet --ext .js,.jsx,.ts,.tsx ./src", "lint:fix": "npm run lint -- --fix", "prepare": "npm run clean", - "test": "jest" + "test": "jest", + "test:watch": "jest --watch" }, "author": "", "license": "ISC", diff --git a/packages/sdk/shared/src/emulatorApi/conversationService.spec.ts b/packages/sdk/shared/src/emulatorApi/conversationService.spec.ts index bdb4e53d2..903f15563 100644 --- a/packages/sdk/shared/src/emulatorApi/conversationService.spec.ts +++ b/packages/sdk/shared/src/emulatorApi/conversationService.spec.ts @@ -266,4 +266,12 @@ describe('The ConversationService should call "fetch" with the expected paramete expect(headers).toEqual({ 'Content-Type': 'application/json' }); expect(method).toBe('POST'); }); + + test('Send request to get all activities given a conversation Id', () => { + const mockConversationId = 'someConvoId'; + const serverUrl = 'http://localhost'; + ConversationService.fetchActivitiesForAConversation(serverUrl, mockConversationId); + const { url } = mockFetchArgs; + expect(url).toBe('http://localhost/v3/conversations/someConvoId/activities'); + }); }); diff --git a/packages/sdk/shared/src/emulatorApi/conversationService.ts b/packages/sdk/shared/src/emulatorApi/conversationService.ts index b7597599a..edf48b0a5 100644 --- a/packages/sdk/shared/src/emulatorApi/conversationService.ts +++ b/packages/sdk/shared/src/emulatorApi/conversationService.ts @@ -212,4 +212,15 @@ export class ConversationService { method: 'POST', }); } + + public static async fetchActivitiesForAConversation(serverUrl: string, conversationId: string): Promise { + try { + const url = `${serverUrl}/v3/conversations/${conversationId}/activities`; + const resp = await fetch(url); + const activities = await resp.json(); + return activities; + } catch (ex) { + return []; + } + } } From 4ba6b22f7af08385315baca978fcd24dfd6095c3 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Fri, 28 Feb 2020 12:26:19 -0800 Subject: [PATCH 02/11] More unit tests Signed-off-by: Srinaath Ravichandran More UI tests Signed-off-by: Srinaath Ravichandran --- .../client/mocks/conversationQueueMocks.ts | 633 ------------------ packages/app/client/src/index.tsx | 7 - .../client/src/state/sagas/chatSagas.spec.ts | 65 +- .../src/ui/editor/emulator/emulator.spec.tsx | 61 +- .../src/ui/editor/emulator/emulator.tsx | 16 +- .../shared/src/state/reducers/chat.spec.ts | 20 +- 6 files changed, 105 insertions(+), 697 deletions(-) diff --git a/packages/app/client/mocks/conversationQueueMocks.ts b/packages/app/client/mocks/conversationQueueMocks.ts index f484e00b3..6966bd513 100644 --- a/packages/app/client/mocks/conversationQueueMocks.ts +++ b/packages/app/client/mocks/conversationQueueMocks.ts @@ -30,639 +30,6 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -export const activities = [ - { - type: 'conversationUpdate', - membersAdded: [ - { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - }, - { - id: '', - name: 'User', - }, - ], - membersRemoved: [], - channelId: 'emulator', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '4ab7cd80-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:05-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - timestamp: '2020-02-25T19:09:05.496Z', - from: { - id: '', - name: 'User', - role: 'user', - }, - locale: 'en-US', - serviceUrl: 'http://localhost:50438', - }, - { - channelData: { - clientActivityID: '1582657741644hl73t73n83', - clientTimestamp: '2020-02-25T19:09:01.644Z', - originalActivityId: '4871ae10-5802-11ea-afe8-c12c2746983b', - matchIndexes: [1], - }, - text: 'Hi', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:05.521Z', - entities: [ - { - requiresBotState: true, - supportsListening: true, - supportsTts: true, - type: 'ClientCapabilities', - }, - { - requiresBotState: true, - supportsListening: true, - supportsTts: true, - type: 'ClientCapabilities', - }, - ], - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - localTimestamp: '2020-02-25T11:09:05-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - id: '4abb9e10-5802-11ea-afe8-c12c2746983b', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Please enter your mode of transport.', - inputHint: 'expectingInput', - suggestedActions: { - actions: [ - { - type: 'imBack', - title: 'Car', - value: 'Car', - }, - { - type: 'imBack', - title: 'Bus', - value: 'Bus', - }, - { - type: 'imBack', - title: 'Bicycle', - value: 'Bicycle', - }, - ], - }, - replyToId: '4abb9e10-5802-11ea-afe8-c12c2746983b', - id: '4abc8870-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:05-08:00', - timestamp: '2020-02-25T19:09:05.527Z', - locale: 'en-US', - }, - { - channelData: { - clientActivityID: '1582657743316a25l7a6etzt', - clientTimestamp: '2020-02-25T19:09:03.316Z', - originalActivityId: '496b9e70-5802-11ea-afe8-c12c2746983b', - matchIndexes: [3], - }, - text: 'Car', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:05.598Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - localTimestamp: '2020-02-25T11:09:05-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - id: '4ac75de0-5802-11ea-afe8-c12c2746983b', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Please enter your name.', - inputHint: 'expectingInput', - replyToId: '4ac75de0-5802-11ea-afe8-c12c2746983b', - id: '4ac84840-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:05-08:00', - timestamp: '2020-02-25T19:09:05.604Z', - locale: 'en-US', - }, - { - channelData: { - clientActivityID: '158265776185762jv8ehugsq', - clientTimestamp: '2020-02-25T19:09:21.857Z', - }, - text: 'tester', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:21.892Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '547da240-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:21-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Thanks tester.', - inputHint: 'acceptingInput', - replyToId: '547da240-5802-11ea-afe8-c12c2746983b', - id: '547eb3b0-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:21-08:00', - timestamp: '2020-02-25T19:09:21.899Z', - locale: 'en-US', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Do you want to give your age?', - inputHint: 'expectingInput', - suggestedActions: { - actions: [ - { - type: 'imBack', - title: 'Yes', - value: 'Yes', - }, - { - type: 'imBack', - title: 'No', - value: 'No', - }, - ], - }, - replyToId: '547da240-5802-11ea-afe8-c12c2746983b', - id: '547f28e0-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:21-08:00', - timestamp: '2020-02-25T19:09:21.902Z', - locale: 'en-US', - }, - { - channelData: { - clientActivityID: '1582657763139pa3ds9m7h9f', - clientTimestamp: '2020-02-25T19:09:23.139Z', - }, - text: 'Yes', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:23.141Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '553c3750-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:23-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Please enter your age.', - inputHint: 'expectingInput', - replyToId: '553c3750-5802-11ea-afe8-c12c2746983b', - id: '553ea850-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:23-08:00', - timestamp: '2020-02-25T19:09:23.157Z', - locale: 'en-US', - }, - { - channelData: { - clientActivityID: '1582657766339zujfevrrg9q', - clientTimestamp: '2020-02-25T19:09:26.339Z', - }, - text: '62', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:26.342Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '5724a660-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:26-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'I have your age as 29.', - inputHint: 'acceptingInput', - replyToId: '5724a660-5802-11ea-afe8-c12c2746983b', - id: '57267b20-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:26-08:00', - timestamp: '2020-02-25T19:09:26.354Z', - locale: 'en-US', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Please attach a profile picture (or type any message to skip).', - inputHint: 'expectingInput', - replyToId: '5724a660-5802-11ea-afe8-c12c2746983b', - id: '57273e70-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:26-08:00', - timestamp: '2020-02-25T19:09:26.358Z', - locale: 'en-US', - }, - { - attachments: [ - { - name: 'IMG-2744.jpg', - contentUrl: 'data:application/octet-stream;base64,/9j/', - contentType: 'image/jpeg', - }, - ], - channelData: { - clientActivityID: '1582657774063k2trifj0lj', - clientTimestamp: '2020-02-25T19:09:34.063Z', - attachmentSizes: [1613564], - }, - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:34.141Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '5bcaaed1-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:34-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'Is this okay?', - inputHint: 'expectingInput', - suggestedActions: { - actions: [ - { - type: 'imBack', - title: 'Yes', - value: 'Yes', - }, - { - type: 'imBack', - title: 'No', - value: 'No', - }, - ], - }, - replyToId: '5bcaaed1-5802-11ea-afe8-c12c2746983b', - id: '5bccd1b0-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:34-08:00', - timestamp: '2020-02-25T19:09:34.154Z', - locale: 'en-US', - }, - { - channelData: { - clientActivityID: '1582657775458y5rx1hagzw', - clientTimestamp: '2020-02-25T19:09:35.458Z', - }, - text: 'Yes', - textFormat: 'plain', - type: 'message', - channelId: 'emulator', - from: { - id: 'r_1582657745', - name: 'User', - role: 'user', - }, - locale: 'en-US', - timestamp: '2020-02-25T19:09:35.520Z', - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - id: '5c9d1a00-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:35-08:00', - recipient: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - serviceUrl: 'http://localhost:50438', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - text: 'I have your mode of transport as Car and your name as tester and your age as 29.', - inputHint: 'acceptingInput', - replyToId: '5c9d1a00-5802-11ea-afe8-c12c2746983b', - id: '5c9e5280-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:35-08:00', - timestamp: '2020-02-25T19:09:35.528Z', - locale: 'en-US', - }, - { - type: 'message', - serviceUrl: 'http://localhost:50438', - channelId: 'emulator', - from: { - id: '3fe76690-5802-11ea-bb6a-31d3402f2821', - name: 'Bot', - role: 'bot', - }, - conversation: { - id: '4aa44580-5802-11ea-bb6a-31d3402f2821|livechat', - }, - recipient: { - id: '', - role: 'user', - }, - attachmentLayout: 'list', - text: 'This is your profile picture.', - inputHint: 'acceptingInput', - attachments: [ - { - contentType: 'image/jpeg', - name: 'IMG-2744.jpg', - contentUrl: 'data:application/octet-stream;base64,/9j/', - }, - ], - replyToId: '5c9d1a00-5802-11ea-afe8-c12c2746983b', - id: '5c9eeec0-5802-11ea-afe8-c12c2746983b', - localTimestamp: '2020-02-25T11:09:35-08:00', - timestamp: '2020-02-25T19:09:35.532Z', - locale: 'en-US', - }, -]; - -export const replayData = { - incomingActivities: [ - { - id: '6675c360-5816-11ea-9b99-bfd896d3b875', - }, - { - id: '6941dbb0-5816-11ea-9b99-bfd896d3b875', - replyToId: '69411860-5816-11ea-9b99-bfd896d3b875', - test: 'Please enter your mode of transport.', - }, - { - id: '69411860-5816-11ea-9b99-bfd896d3b875', - test: 'Hi', - }, - { - id: '69fd3c70-5816-11ea-9b99-bfd896d3b875', - replyToId: '69fc7920-5816-11ea-9b99-bfd896d3b875', - test: 'Please enter your name.', - }, - { - id: '69fc7920-5816-11ea-9b99-bfd896d3b875', - test: 'Car', - }, - { - id: '6bdf69f0-5816-11ea-9b99-bfd896d3b875', - replyToId: '6bde5880-5816-11ea-9b99-bfd896d3b875', - test: 'Thanks tester.', - }, - { - id: '6be00630-5816-11ea-9b99-bfd896d3b875', - replyToId: '6bde5880-5816-11ea-9b99-bfd896d3b875', - test: 'Do you want to give your age?', - }, - { - id: '6bde5880-5816-11ea-9b99-bfd896d3b875', - test: 'tester', - }, - { - id: '6ce31e50-5816-11ea-9b99-bfd896d3b875', - replyToId: '6ce12280-5816-11ea-9b99-bfd896d3b875', - test: 'Please enter your age.', - }, - { - id: '6ce12280-5816-11ea-9b99-bfd896d3b875', - test: 'Yes', - }, - { - id: '6e8842d0-5816-11ea-9b99-bfd896d3b875', - replyToId: '6e86bc30-5816-11ea-9b99-bfd896d3b875', - test: 'I have your age as 30.', - }, - { - id: '6e88b800-5816-11ea-9b99-bfd896d3b875', - replyToId: '6e86bc30-5816-11ea-9b99-bfd896d3b875', - test: 'Please attach a profile picture (or type any message to skip).', - }, - { - id: '6e86bc30-5816-11ea-9b99-bfd896d3b875', - test: '30', - }, - { - id: '73573000-5816-11ea-9b99-bfd896d3b875', - replyToId: '73555b41-5816-11ea-9b99-bfd896d3b875', - test: 'Is this okay?', - }, - { - id: '74f7c0a0-5816-11ea-9b99-bfd896d3b875', - replyToId: '74f6af30-5816-11ea-9b99-bfd896d3b875', - test: 'I have your mode of transport as Car and your name as tester and your age as 30.', - }, - { - id: '74f835d0-5816-11ea-9b99-bfd896d3b875', - replyToId: '74f6af30-5816-11ea-9b99-bfd896d3b875', - test: 'This is your profile picture.', - }, - { - id: '74f6af30-5816-11ea-9b99-bfd896d3b875', - test: 'Yes', - }, - ], - postActivitiesSlots: [1, 3, 5, 8, 10, 13, 14], -}; export const replayScenarios = [ { diff --git a/packages/app/client/src/index.tsx b/packages/app/client/src/index.tsx index 4fb5217ea..b0b96596c 100644 --- a/packages/app/client/src/index.tsx +++ b/packages/app/client/src/index.tsx @@ -48,13 +48,6 @@ interceptError(); interceptHyperlink(); if (!remote.app.isPackaged) { - // Enable reload - document.addEventListener('keydown', function(e) { - // Fn + f5 for reloading on dev - if (e.which === 116) { - location.reload(); - } - }); // enable react & react-redux dev tools installExtension([REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]) /* eslint-disable no-console */ diff --git a/packages/app/client/src/state/sagas/chatSagas.spec.ts b/packages/app/client/src/state/sagas/chatSagas.spec.ts index f8a6046a1..9e598787f 100644 --- a/packages/app/client/src/state/sagas/chatSagas.spec.ts +++ b/packages/app/client/src/state/sagas/chatSagas.spec.ts @@ -40,6 +40,7 @@ import { logEntry, textItem, LogLevel, + EmulatorMode, } from '@bfemulator/sdk-shared'; import * as sdkSharedUtils from '@bfemulator/sdk-shared/build/utils/misc'; import { @@ -59,7 +60,6 @@ import { RestartConversationStatus, setRestartConversationStatus, RestartConversationPayload, - ChatReplayData, } from '@bfemulator/app-shared'; import { createCognitiveServicesSpeechServicesPonyfillFactory, @@ -79,28 +79,23 @@ import { getCustomUserGUID, getWebSpeechFactoryForDocumentId, } from './chatSagas'; -import { createWebchatActivityChannel, ChannelPayload, ReplayActivitySnifferProps } from './webchatActivityChannel'; +import { createWebchatActivityChannel, ChannelPayload } from './webchatActivityChannel'; -const mockChatStore = jest.fn(args => { +const mockChatStore = jest.fn((args = undefined) => { return {}; }); -const mockWebChatStore = {}; jest.mock('botframework-webchat-core', () => ({ - createStore: (...args) => { - return mockChatStore({ ...args }); - }, + createStore: (...args) => mockChatStore({ ...args }), })); jest.mock('../../ui/dialogs', () => ({})); -jest.mock('../../platform/log/logService', () => { - return { - logService: { - logToDocument: jest.fn(), - }, - }; -}); +jest.mock('../../platform/log/logService', () => ({ + logService: { + logToDocument: jest.fn(), + }, +})); const mockWriteText = jest.fn(); jest.mock('electron', () => { @@ -140,8 +135,9 @@ jest.mock('botframework-webchat', () => { describe('The ChatSagas,', () => { let commandService: CommandServiceImpl; - const oldDateNow = Date.now; + let oldDateNow; beforeAll(() => { + oldDateNow = Date.now; Date.now = jest.fn(); const decorator = CommandServiceInstance(); const descriptor = decorator({ descriptor: {} }, 'none') as any; @@ -533,7 +529,7 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(select(getServerUrl)); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -602,7 +598,7 @@ describe('The ChatSagas,', () => { // put webChatStoreUpdated const result = gen.next(); - expect(result.value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(result.value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -691,7 +687,7 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(put(setInspectorObjects(payload.documentId, []))); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -821,7 +817,7 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(put(setInspectorObjects(payload.documentId, []))); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -953,7 +949,7 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(put(setInspectorObjects(payload.documentId, []))); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -1079,7 +1075,7 @@ describe('The ChatSagas,', () => { expect(gen.next().value).toEqual(put(setInspectorObjects(payload.documentId, []))); // put webChatStoreUpdated - expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockWebChatStore))); + expect(gen.next().value).toEqual(put(webChatStoreUpdated(payload.documentId, mockChatStore()))); // put webSpeechFactoryUpdated expect(gen.next().value).toEqual(put(webSpeechFactoryUpdated(payload.documentId, undefined))); @@ -1176,6 +1172,7 @@ describe('The ChatSagas,', () => { expect(gen.next(payload).value).toEqual( put(incomingActivity(payload.action.payload.activity, payload.documentId)) ); + expect(gen.next().value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); }); it('should watch for post activity events dispatched from webchat store', () => { @@ -1197,6 +1194,7 @@ describe('The ChatSagas,', () => { const gen = ChatSagas.watchForWcEvents(); gen.next(); expect(gen.next(payload).value).toEqual(put(postActivity(payload.action.payload.activity, payload.documentId))); + expect(gen.next().value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); }); it('should not dispatch anything for other webchat activities', () => { @@ -1221,28 +1219,6 @@ describe('The ChatSagas,', () => { expect(res.value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); }); - it('should fork a call to handle replay if conversation queue is available', () => { - const wcMockChannel = createWebchatActivityChannel(); - ChatSagas.wcActivityChannel = wcMockChannel; - const payload: ChannelPayload = { - documentId: 'some-id', - action: { - type: WebchatEvents.postActivity, - payload: { - activity: { - id: 'activity-1', - } as Activity, - }, - }, - dispatch: jest.fn(), - meta: undefined, - }; - const gen = ChatSagas.watchForWcEvents(); - gen.next(); - gen.next(payload); - expect(gen.next().value).toEqual(fork(ChatSagas.handleReplayIfRequired, { ...payload })); - }); - it('should handle replay only if validateIfReplayFlow is true', () => { const validateIfReplayFlow = jest.fn(() => false); const mock: any = { @@ -1463,8 +1439,7 @@ describe('The ChatSagas,', () => { dispatch: jest.fn(), }; const mockNext = jest.fn(); - replaySnifferFn(mockDispatcher)(mockNext)(webChatEventExpected); - for (let i = 0; i < 999; i++) { + for (let i = 0; i < 1000; i++) { replaySnifferFn(mockDispatcher)(mockNext)(webChatEventExpected); } }); diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index 39ea462ff..65cc1e2a7 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -41,6 +41,7 @@ import { executeCommand, restartConversation, SharedConstants, + RestartConversationStatus, } from '@bfemulator/app-shared'; import base64Url from 'base64url'; import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared'; @@ -63,9 +64,7 @@ jest.mock('./emulator.scss', () => ({})); jest.mock('./parts', () => ({ InspectorContainer: jest.fn(() =>
), })); -jest.mock('./toolbar/toolbar', () => ({ - ToolBar: jest.fn(() =>
), -})); + jest.mock('@bfemulator/sdk-shared/build/utils/misc', () => ({ uniqueId: () => 'someUniqueId', uniqueIdv4: () => 'newUserId', @@ -342,4 +341,60 @@ describe('', () => { expect(instance.restartButtonRef).toBe(mockButtonRef); }); + + it('should show "Stop Replaying Conversation" when in Replay mode', () => { + let emulatorProps = { + documentId: 'doc1', + url: 'some-url', + mode: 'livechat', + conversationId: '123', + presentationModeEnabled: false, + restartStatus: RestartConversationStatus.Started, + ui: {}, + }; + const mockStore = createStore((_state, _action) => mockStoreState); + wrapper = mount( + + + + ); + node = wrapper.find(Emulator); + expect(wrapper.text().includes('Stop Replaying Conversation')).toBeTruthy(); + + emulatorProps = { + ...emulatorProps, + restartStatus: RestartConversationStatus.Stop, + }; + wrapper.setProps({ + children: , + }); + expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + + emulatorProps = { + ...emulatorProps, + restartStatus: undefined, + }; + wrapper.setProps({ + children: , + }); + expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + + emulatorProps = { + ...emulatorProps, + restartStatus: RestartConversationStatus.Rejected, + }; + wrapper.setProps({ + children: , + }); + expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + + emulatorProps = { + ...emulatorProps, + restartStatus: RestartConversationStatus.Started, + }; + wrapper.setProps({ + children: , + }); + expect(wrapper.text().includes('Stop Replaying Conversation')).toBeTruthy(); + }); }); diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index 8bd4a4f92..d40b2e13f 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -221,15 +221,19 @@ export class Emulator extends React.Component { } private getVerticalSplitterSizes = (): { [0]: string } => { - return { - 0: '' + this.props.ui.verticalSplitter[0].percentage, - }; + if (this.props.ui.verticalSplitter) { + return { + 0: '' + this.props.ui.verticalSplitter[0].percentage, + }; + } }; private getHorizontalSplitterSizes = (): { [0]: string } => { - return { - 0: '' + this.props.ui.horizontalSplitter[0].percentage, - }; + if (this.props.ui.horizontalSplitter) { + return { + 0: '' + this.props.ui.horizontalSplitter[0].percentage, + }; + } }; private getConversationId() { diff --git a/packages/app/shared/src/state/reducers/chat.spec.ts b/packages/app/shared/src/state/reducers/chat.spec.ts index 5c864110a..fb41a3eab 100644 --- a/packages/app/shared/src/state/reducers/chat.spec.ts +++ b/packages/app/shared/src/state/reducers/chat.spec.ts @@ -114,10 +114,24 @@ describe('Chat reducer tests', () => { }); it('should close a chat', () => { - let state = chat(DEFAULT_STATE, newChat(testChatId, 'livechat')); + let transientState = chat(DEFAULT_STATE, newChat(testChatId, 'livechat')); + transientState = chat( + transientState, + incomingActivity( + { + id: 'act-1', + } as Activity, + testChatId + ) + ); + + transientState = chat(transientState, setRestartConversationStatus(RestartConversationStatus.Started, testChatId)); + expect(transientState.restartStatus[testChatId]).not.toBeUndefined(); + expect(transientState.chats[testChatId].replayData.incomingActivities.length > 0).toBeTruthy(); const action = closeDocument(testChatId); - state = chat(DEFAULT_STATE, action); - expect(state.chats[testChatId]).toBeFalsy(); + transientState = chat(DEFAULT_STATE, action); + expect(transientState.chats[testChatId]).toBeFalsy(); + expect(transientState.restartStatus[testChatId]).toBeFalsy(); }); it('should append to the log', () => { From e2a86842aa1005d96d397dda282d576fed0d43ab Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Fri, 28 Feb 2020 16:55:22 -0800 Subject: [PATCH 03/11] Tests Cleanup Signed-off-by: Srinaath Ravichandran --- .../app/client/src/state/sagas/chatSagas.spec.ts | 10 ++++------ .../src/ui/editor/emulator/emulator.spec.tsx | 16 ++++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/app/client/src/state/sagas/chatSagas.spec.ts b/packages/app/client/src/state/sagas/chatSagas.spec.ts index 9e598787f..68371840d 100644 --- a/packages/app/client/src/state/sagas/chatSagas.spec.ts +++ b/packages/app/client/src/state/sagas/chatSagas.spec.ts @@ -1151,9 +1151,11 @@ describe('The ChatSagas,', () => { }); describe('Replay conversation upto selected activity', () => { - it('should watch for incoming activity events dispatched from webchat store', () => { + beforeEach(() => { const wcMockChannel = createWebchatActivityChannel(); ChatSagas.wcActivityChannel = wcMockChannel; + }); + it('should watch for incoming activity events dispatched from webchat store', () => { const payload: ChannelPayload = { documentId: 'some-id', action: { @@ -1176,8 +1178,6 @@ describe('The ChatSagas,', () => { }); it('should watch for post activity events dispatched from webchat store', () => { - const wcMockChannel = createWebchatActivityChannel(); - ChatSagas.wcActivityChannel = wcMockChannel; const payload: ChannelPayload = { documentId: 'some-id', action: { @@ -1198,8 +1198,6 @@ describe('The ChatSagas,', () => { }); it('should not dispatch anything for other webchat activities', () => { - const wcMockChannel = createWebchatActivityChannel(); - ChatSagas.wcActivityChannel = wcMockChannel; const payload: ChannelPayload = { documentId: 'some-id', action: { @@ -1278,7 +1276,7 @@ describe('The ChatSagas,', () => { expect(dispatcherMock).not.toHaveBeenCalled(); }); - it('should not dispatch activity to webchat if activity available to post', () => { + it('should dispatch activity to webchat if activity available to post', () => { const activity: Activity = { id: 'activity-1', } as Activity; diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index 65cc1e2a7..6285f918c 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -43,23 +43,27 @@ import { SharedConstants, RestartConversationStatus, } from '@bfemulator/app-shared'; -import base64Url from 'base64url'; import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared'; import { Emulator, RestartConversationOptions } from './emulator'; import { EmulatorContainer } from './emulatorContainer'; let mockCallsMade, mockRemoteCallsMade; +const replayConversationText = 'Stop Replaying Conversation'; const mockSharedConstants = SharedConstants; + jest.mock('./chatPanel/chatPanel', () => ({ ChatPanel: jest.fn(() =>
), })); + jest.mock('./logPanel/logPanel', () => { return jest.fn(() =>
); }); + jest.mock('./playbackBar/playbackBar', () => { return jest.fn(() =>
); }); + jest.mock('./emulator.scss', () => ({})); jest.mock('./parts', () => ({ InspectorContainer: jest.fn(() =>
), @@ -359,7 +363,7 @@ describe('', () => { ); node = wrapper.find(Emulator); - expect(wrapper.text().includes('Stop Replaying Conversation')).toBeTruthy(); + expect(wrapper.text().includes(replayConversationText)).toBeTruthy(); emulatorProps = { ...emulatorProps, @@ -368,7 +372,7 @@ describe('', () => { wrapper.setProps({ children: , }); - expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + expect(wrapper.text().includes(replayConversationText)).toBeFalsy(); emulatorProps = { ...emulatorProps, @@ -377,7 +381,7 @@ describe('', () => { wrapper.setProps({ children: , }); - expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + expect(wrapper.text().includes(replayConversationText)).toBeFalsy(); emulatorProps = { ...emulatorProps, @@ -386,7 +390,7 @@ describe('', () => { wrapper.setProps({ children: , }); - expect(wrapper.text().includes('Stop Replaying Conversation')).toBeFalsy(); + expect(wrapper.text().includes(replayConversationText)).toBeFalsy(); emulatorProps = { ...emulatorProps, @@ -395,6 +399,6 @@ describe('', () => { wrapper.setProps({ children: , }); - expect(wrapper.text().includes('Stop Replaying Conversation')).toBeTruthy(); + expect(wrapper.text().includes(replayConversationText)).toBeTruthy(); }); }); From 488d16c08fc4c151dd082602e9a30432e059db69 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Fri, 28 Feb 2020 17:08:26 -0800 Subject: [PATCH 04/11] Changelog updated Signed-off-by: Srinaath Ravichandran --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8adab2b..7c0ba9803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [client/main] Changed conversation infrastructure to use Web Sockets to communicate with Web Chat in PR [2034](https://github.com/microsoft/BotFramework-Emulator/pull/2034) - [client/main] Added new telemetry events and properties in PR [2063](https://github.com/microsoft/BotFramework-Emulator/pull/2063) - [client] Added support for talking to remote Direct Line Speech bots in PR [2079](https://github.com/microsoft/BotFramework-Emulator/pull/2079) +- [client/main] Added support to restart conversation from any point in PR [2089](https://github.com/microsoft/BotFramework-Emulator/pull/2089) ## Fixed - [client] Hid services pane by default in PR [2059](https://github.com/microsoft/BotFramework-Emulator/pull/2059) From e11217f07fe776d51e9e61a0d3185d010abf4d84 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Mon, 2 Mar 2020 20:30:13 -0800 Subject: [PATCH 05/11] Restart conversation options Signed-off-by: Srinaath Ravichandran --- .../src/ui/editor/emulator/emulator.spec.tsx | 49 ++++++++++++++- .../src/ui/editor/emulator/emulator.tsx | 60 ++++++++++++------- .../ui/editor/emulator/emulatorContainer.ts | 5 ++ .../parts/chat/outerActivityWrapper.spec.tsx | 5 +- .../parts/chat/outerActivityWrapper.tsx | 15 ++++- .../chat/outerActivityWrapperContainer.ts | 15 ++++- .../src/state/actions/chatActions.spec.ts | 15 +++++ .../shared/src/state/actions/chatActions.ts | 24 ++++++++ .../shared/src/state/reducers/chat.spec.ts | 29 +++++++++ .../app/shared/src/state/reducers/chat.ts | 18 ++++++ .../src/widget/splitButton/splitButton.tsx | 18 ++++-- 11 files changed, 214 insertions(+), 39 deletions(-) diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index 6285f918c..5b0ac0de2 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -44,8 +44,9 @@ import { RestartConversationStatus, } from '@bfemulator/app-shared'; import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared'; +import { RestartConversationOptions } from '@bfemulator/app-shared'; -import { Emulator, RestartConversationOptions } from './emulator'; +import { Emulator } from './emulator'; import { EmulatorContainer } from './emulatorContainer'; let mockCallsMade, mockRemoteCallsMade; @@ -318,7 +319,28 @@ describe('', () => { }); it('should start over a conversation with a new user id on click', () => { - instance.onStartOverClick(RestartConversationOptions.NewUserId); + const mockStore = createStore((_state, _action) => mockStoreState); + mockDispatch = jest.spyOn(mockStore, 'dispatch').mockImplementation((action: any) => { + if (action && action.payload && action.payload.resolver) { + action.payload.resolver(); + } + return action; + }); + wrapper = mount( + + + + ); + + node = wrapper.find(Emulator); + instance = node.instance(); + instance.onStartOverClick(); expect(mockDispatch).toHaveBeenCalledWith( executeCommand(true, SharedConstants.Commands.Telemetry.TrackEvent, null, 'conversation_restart', { @@ -329,7 +351,28 @@ describe('', () => { }); it('should start over a conversation with the same user id on click', () => { - instance.onStartOverClick(RestartConversationOptions.SameUserId); + const mockStore = createStore((_state, _action) => mockStoreState); + mockDispatch = jest.spyOn(mockStore, 'dispatch').mockImplementation((action: any) => { + if (action && action.payload && action.payload.resolver) { + action.payload.resolver(); + } + return action; + }); + wrapper = mount( + + + + ); + + node = wrapper.find(Emulator); + instance = node.instance(); + instance.onStartOverClick(); expect(mockDispatch).toHaveBeenCalledWith( executeCommand(true, SharedConstants.Commands.Telemetry.TrackEvent, null, 'conversation_restart', { diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index d40b2e13f..ca76dc347 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -33,13 +33,19 @@ import { Activity } from 'botframework-schema'; import { DirectLine } from 'botframework-directlinejs'; -import { isMac, RestartConversationStatus } from '@bfemulator/app-shared'; +import { + isMac, + RestartConversationStatus, + RestartConversationOptions, + setRestartConversationOption, + Document, + SplitterSize, +} from '@bfemulator/app-shared'; import { EmulatorMode } from '@bfemulator/sdk-shared'; import { SplitButton, Splitter } from '@bfemulator/ui-react'; import * as React from 'react'; -import { FrameworkSettings, newNotification, Notification, ValueTypesMask } from '@bfemulator/app-shared'; +import { FrameworkSettings, newNotification, Notification, ValueTypesMask, Rest } from '@bfemulator/app-shared'; -import { Document, SplitterSize } from '../../../state/reducers/editor'; import { debounce } from '../../../utils'; import { ChatPanelContainer } from './chatPanel'; @@ -49,7 +55,7 @@ import * as styles from './emulator.scss'; import { InspectorContainer } from './parts'; import { ToolBar } from './toolbar/toolbar'; -export const RestartConversationOptions = { +export const restartOptions = { NewUserId: 'Restart with new user ID', SameUserId: 'Restart with same user ID', }; @@ -81,6 +87,8 @@ export interface EmulatorProps { userId?: string; restartStatus: RestartConversationStatus; onStopRestartConversationClick: (documentId: string) => void; + currentRestartConversationOption: RestartConversationOptions; + onSetRestartConversationOptionClick: (documentId: string, option: RestartConversationOptions) => void; } export class Emulator extends React.Component { @@ -137,7 +145,7 @@ export class Emulator extends React.Component { } renderDefaultView(): JSX.Element { - const { NewUserId, SameUserId } = RestartConversationOptions; + const { NewUserId, SameUserId } = restartOptions; const { mode, documentId } = this.props; @@ -149,7 +157,8 @@ export class Emulator extends React.Component { defaultLabel="Restart conversation" buttonClass={styles.restartIcon} options={[NewUserId, SameUserId]} - onClick={this.onStartOverClick} + onClick={this.onRestartOptionSelected} + onDefaultButtonClick={this.onStartOverClick} buttonRef={this.setRestartButtonRef} submenuLabel={isMac() ? 'Restart conversation sub menu' : ''} /> @@ -244,33 +253,40 @@ export class Emulator extends React.Component { this.props.enablePresentationMode(enabled); }; - private onStartOverClick = (option: string = RestartConversationOptions.NewUserId): void => { - const { NewUserId, SameUserId } = RestartConversationOptions; - const { documentId } = this.props; - + private onRestartOptionSelected = (option: string = restartOptions.NewUserId): void => { + const { NewUserId, SameUserId } = restartOptions; + const { documentId, onSetRestartConversationOptionClick } = this.props; switch (option) { case NewUserId: { - this.props.trackEvent('conversation_restart', { - userId: 'new', - }); - // start conversation with new convo id & user id - this.props.restartConversation(documentId, true, true); + onSetRestartConversationOptionClick(documentId, RestartConversationOptions.NewUserId); break; } case SameUserId: { - this.props.trackEvent('conversation_restart', { - userId: 'same', - }); - // start conversation with new convo id - this.props.restartConversation(documentId, true, false); + onSetRestartConversationOptionClick(documentId, RestartConversationOptions.SameUserId); break; } + } + }; - default: - break; + private onStartOverClick = (): void => { + const { documentId } = this.props; + + if (this.props.currentRestartConversationOption === RestartConversationOptions.NewUserId) { + this.props.trackEvent('conversation_restart', { + userId: 'new', + }); + this.props.restartConversation(documentId, true, true); + } + + if (this.props.currentRestartConversationOption === RestartConversationOptions.SameUserId) { + this.props.trackEvent('conversation_restart', { + userId: 'same', + }); + this.props.restartConversation(documentId, true, false); } }; + // Uncomment when ready to export bot state // private onExportBotStateClick = async (): Promise => { // try { diff --git a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts index f5486603f..7e1846130 100644 --- a/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/emulatorContainer.ts @@ -47,6 +47,8 @@ import { ValueTypesMask, setRestartConversationStatus, RestartConversationStatus, + RestartConversationOptions, + setRestartConversationOption, } from '@bfemulator/app-shared'; import { RootState } from '../../../state/store'; @@ -71,6 +73,7 @@ const mapStateToProps = ( url: state.clientAwareSettings.serverUrl, userId: state.chat.chats[documentId].userId, restartStatus: state.chat.restartStatus[documentId], + currentRestartConversationOption: state.chat.chats[documentId].restartConversationOption, ...ownProps, }; }; @@ -95,6 +98,8 @@ const mapDispatchToProps = (dispatch): Partial => ({ updateDocument: (documentId, updatedValues: Partial) => dispatch(updateDocument(documentId, updatedValues)), onStopRestartConversationClick: (documentId: string) => dispatch(setRestartConversationStatus(RestartConversationStatus.Rejected, documentId)), + onSetRestartConversationOptionClick: (documentId: string, option: RestartConversationOptions) => + dispatch(setRestartConversationOption(documentId, option)), }); export const EmulatorContainer = connect(mapStateToProps, mapDispatchToProps)(Emulator); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx index 6cafb26ae..d96291760 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx @@ -35,7 +35,7 @@ import * as React from 'react'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; import { mount, shallow } from 'enzyme'; -import { ValueTypes } from '@bfemulator/app-shared'; +import { ValueTypes, RestartConversationOptions } from '@bfemulator/app-shared'; import { OuterActivityWrapper } from './outerActivityWrapper'; import { OuterActivityWrapperContainer } from './outerActivityWrapperContainer'; @@ -107,13 +107,14 @@ describe('', () => { card={card} highlightedActivities={[]} onRestartConversationFromActivityClick={onRestartClick} + currentRestartConversationOption={RestartConversationOptions.SameUserId} documentId="some-id" /> ); const instance = wrapper.instance(); (instance as any).propsBoundRestartActivityHandler(); - expect(onRestartClick).toHaveBeenCalledWith('some-id', card.activity); + expect(onRestartClick).toHaveBeenCalledWith('some-id', card.activity, RestartConversationOptions.SameUserId); }); it('should determine if an activity is user activity or not', () => { diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx index 5fe802e34..edbc8cfec 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx @@ -32,7 +32,7 @@ // import * as React from 'react'; -import { SharedConstants } from '@bfemulator/app-shared'; +import { SharedConstants, RestartConversationOptions } from '@bfemulator/app-shared'; import { Activity } from 'botframework-schema'; import { RestartConversationStatus } from '@bfemulator/app-shared'; @@ -48,7 +48,12 @@ export interface OuterActivityWrapperProps { onContextMenu?: (event: React.MouseEvent) => void; onItemRendererClick?: (event: React.MouseEvent) => void; onItemRendererKeyDown?: (event: React.KeyboardEvent) => void; - onRestartConversationFromActivityClick?: (documentId: string, activity: Activity) => void; + onRestartConversationFromActivityClick?: ( + documentId: string, + activity: Activity, + restartOption: RestartConversationOptions + ) => void; + currentRestartConversationOption: RestartConversationOptions; } export class OuterActivityWrapper extends React.Component { @@ -75,7 +80,11 @@ export class OuterActivityWrapper extends React.Component { - this.props.onRestartConversationFromActivityClick(this.props.documentId, this.props.card.activity); + this.props.onRestartConversationFromActivityClick( + this.props.documentId, + this.props.card.activity, + this.props.currentRestartConversationOption + ); }; private isUserActivity(activity: Activity) { diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts index a61684fbb..2bb7bef24 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts @@ -32,7 +32,7 @@ // import { connect } from 'react-redux'; -import { ValueTypes, restartConversation } from '@bfemulator/app-shared'; +import { ValueTypes, restartConversation, RestartConversationOptions } from '@bfemulator/app-shared'; import { Action } from 'redux'; import { Activity } from 'botframework-schema'; @@ -56,12 +56,21 @@ function mapStateToProps(state: RootState, { documentId }: { documentId: string return { highlightedActivities, documentId, + currentRestartConversationOption: state.chat.chats[documentId].restartConversationOption, }; } const mapDispatchToProps = (dispatch: (action: Action) => void) => ({ - onRestartConversationFromActivityClick: (documentId: string, activity: Activity) => { - dispatch(restartConversation(documentId, true, false, activity, window.URL.createObjectURL)); + onRestartConversationFromActivityClick: ( + documentId: string, + activity: Activity, + restartOption: RestartConversationOptions + ) => { + let requireUserId = true; + if (restartOption === RestartConversationOptions.SameUserId) { + requireUserId = false; + } + dispatch(restartConversation(documentId, true, requireUserId, activity, window.URL.createObjectURL)); }, }); diff --git a/packages/app/shared/src/state/actions/chatActions.spec.ts b/packages/app/shared/src/state/actions/chatActions.spec.ts index e5458588d..8299fb62a 100644 --- a/packages/app/shared/src/state/actions/chatActions.spec.ts +++ b/packages/app/shared/src/state/actions/chatActions.spec.ts @@ -58,6 +58,8 @@ import { RestartConversationStatus, postActivity, incomingActivity, + setRestartConversationOption, + RestartConversationOptions, } from './chatActions'; describe('chat actions', () => { @@ -341,4 +343,17 @@ describe('chat actions', () => { payload: expectedPayload, }); }); + + it('should set correct restart conversation option', () => { + const expectedPayload = { + documentId: 'abc', + option: RestartConversationOptions.NewUserId, + }; + const action = setRestartConversationOption(expectedPayload.documentId, expectedPayload.option); + + expect(action).toEqual({ + type: ChatActions.SetRestartConversationOption, + payload: expectedPayload, + }); + }); }); diff --git a/packages/app/shared/src/state/actions/chatActions.ts b/packages/app/shared/src/state/actions/chatActions.ts index 2880a397d..0be074047 100644 --- a/packages/app/shared/src/state/actions/chatActions.ts +++ b/packages/app/shared/src/state/actions/chatActions.ts @@ -62,6 +62,7 @@ export enum ChatActions { PostActivityEventWc = 'CHAT/POST_ACTIVITY_WEBCHAT', IncomingActivityFromWc = 'CHAT/INCOMING_ACTIVITY_WEBCHAT', SetRestartConversationStatus = 'CHAT/RESTART/ACTIVITY/STATUS', + SetRestartConversationOption = 'CHAT/RESTART/CONVERSATION_OPTION', } export enum RestartConversationStatus { @@ -71,6 +72,11 @@ export enum RestartConversationStatus { Stop, } +export enum RestartConversationOptions { + SameUserId, + NewUserId, +} + export interface ActiveInspectorChangedPayload { inspectorWebView: HTMLWebViewElement; } @@ -85,6 +91,11 @@ export interface WebChatStorePayload { store: any; } +export interface SetRestartConversationOptionPayload { + documentId: string; + option: RestartConversationOptions; +} + export interface PendingSpeechTokenRetrievalPayload { documentId: string; pending: boolean; @@ -417,3 +428,16 @@ export function updateSpeechAdapters( }, }; } + +export function setRestartConversationOption( + documentId: string, + option: RestartConversationOptions +): ChatAction { + return { + type: ChatActions.SetRestartConversationOption, + payload: { + documentId, + option, + }, + }; +} diff --git a/packages/app/shared/src/state/reducers/chat.spec.ts b/packages/app/shared/src/state/reducers/chat.spec.ts index fb41a3eab..f3cc35f1b 100644 --- a/packages/app/shared/src/state/reducers/chat.spec.ts +++ b/packages/app/shared/src/state/reducers/chat.spec.ts @@ -50,6 +50,8 @@ import { RestartConversationStatus, postActivity, setRestartConversationStatus, + setRestartConversationOption, + RestartConversationOptions, } from '../actions/chatActions'; import { closeNonGlobalTabs } from '../actions/editorActions'; @@ -374,6 +376,33 @@ describe('Chat reducer tests', () => { expect(lastActivity.replyToId).toBe(anotherExpectedActivity.replyToId); }); + it('should update restart type option correctly', () => { + const documentId = 'chatId-1'; + const startingState = { + ...DEFAULT_STATE, + chats: { + ...DEFAULT_STATE.chats, + 'chatId-1': { + directLine: undefined, + documentId, + userId: 'user1', + replayData: {}, + }, + }, + webSpeechFactories: { + chat1: undefined, + }, + }; + + let action = setRestartConversationOption(documentId, RestartConversationOptions.NewUserId); + let transientState = chat(startingState, action); + expect(transientState.chats['chatId-1'].restartConversationOption).toBe(RestartConversationOptions.NewUserId); + + action = setRestartConversationOption(documentId, RestartConversationOptions.SameUserId); + transientState = chat(transientState, action); + expect(transientState.chats['chatId-1'].restartConversationOption).toBe(RestartConversationOptions.SameUserId); + }); + it('should set restart conversation status', () => { const documentId = 'chatId-1'; const startingState = { diff --git a/packages/app/shared/src/state/reducers/chat.ts b/packages/app/shared/src/state/reducers/chat.ts index 80beb7831..f9a03732d 100644 --- a/packages/app/shared/src/state/reducers/chat.ts +++ b/packages/app/shared/src/state/reducers/chat.ts @@ -44,6 +44,8 @@ import { ActivityFromWebchatPayload, RestartConversationStatus, RestartConversationStatusPayload, + RestartConversationOptions, + SetRestartConversationOptionPayload, } from '../actions/chatActions'; import { EditorAction, EditorActions } from '../actions/editorActions'; @@ -80,6 +82,7 @@ export interface ChatDocument extends Document { ui: DocumentUI; replayData: ChatReplayData; isDisabled: boolean; + restartConversationOption: RestartConversationOptions; } export interface ChatLog { @@ -366,6 +369,21 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit return DEFAULT_STATE; } + case ChatActions.SetRestartConversationOption: { + const { documentId, option } = action.payload as SetRestartConversationOptionPayload; + state = { + ...state, + chats: { + ...state.chats, + [documentId]: { + ...state.chats[documentId], + restartConversationOption: option, + }, + }, + }; + break; + } + case ChatActions.updateSpeechAdapters: { const { payload } = action as ChatAction; const { directLine, documentId, webSpeechPonyfillFactory } = payload; diff --git a/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx b/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx index f2427d148..e04027b58 100644 --- a/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx +++ b/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx @@ -47,6 +47,7 @@ export interface SplitButtonProps { buttonRef?: (ref: HTMLButtonElement) => void; selected?: number; submenuLabel?: string; + onDefaultButtonClick?: (value: string) => any; } export interface SplitButtonState { @@ -140,13 +141,17 @@ export class SplitButton extends React.Component): void => { e.stopPropagation(); const { expanded } = this.state; - this.setState({ expanded: !expanded, selected: 0 }); + this.setState({ expanded: !expanded }); }; - private onClickDefault = (_e: React.SyntheticEvent): void => { - const { onClick, options = [] } = this.props; - if (onClick && options.length) { - onClick(options[0]); + private onClickDefault = (): void => { + const { onDefaultButtonClick, options = [] } = this.props; + if (onDefaultButtonClick && options.length) { + if (!this.state.selected) { + onDefaultButtonClick(options[0]); + } else { + onDefaultButtonClick(options[this.state.selected]); + } } }; @@ -154,6 +159,7 @@ export class SplitButton extends React.Component { this.caretRef.focus(); - this.setState({ expanded: false, selected: 0 }); + this.setState({ expanded: false }); }; private moveSelectionUp = (): void => { From 8562dabe28fde24dd673348b5cc7d901085a1693 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Tue, 3 Mar 2020 10:32:47 -0800 Subject: [PATCH 06/11] Split button tests passed Signed-off-by: Srinaath Ravichandran --- .../widget/splitButton/splitButton.spec.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx b/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx index 557e839e0..66211dba1 100644 --- a/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx +++ b/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx @@ -76,21 +76,27 @@ describe('', () => { it('should handle clicking the caret button', () => { const mockStopPropagation = jest.fn(() => null); const mockEvent = { stopPropagation: mockStopPropagation }; - instance.setState({ expanded: false, selected: 1 }); + instance.setState({ expanded: false }); instance.onClickCaret(mockEvent); expect(mockStopPropagation).toHaveBeenCalledTimes(1); expect(instance.state.expanded).toBe(true); - expect(instance.state.selected).toBe(0); }); it('should handle clicking the default button', () => { const mockOnClick = jest.fn((_value: number) => null); - wrapper = mount(); + const mockDefaultClick = jest.fn((_value: number) => null); + wrapper = mount( + + ); instance = wrapper.instance(); instance.onClickDefault(); - - expect(mockOnClick).toHaveBeenCalledWith('option1'); + expect(mockOnClick).not.toHaveBeenCalled(); + expect(mockDefaultClick).toHaveBeenCalledWith('option1'); + instance.setState({ selected: 1 }); + instance.onClickDefault(); + expect(mockOnClick).not.toHaveBeenCalled(); + expect(mockDefaultClick).toHaveBeenCalledWith('option2'); }); it('should handle clicking an option', () => { @@ -107,11 +113,10 @@ describe('', () => { it('should hide panel', () => { const mockFocus = jest.fn(() => null); instance.caretRef = { focus: mockFocus }; - instance.setState({ expanded: true, selected: 1 }); + instance.setState({ expanded: true }); instance.hidePanel(); expect(instance.state.expanded).toBe(false); - expect(instance.state.selected).toBe(0); expect(mockFocus).toHaveBeenCalledTimes(1); }); From 30e60b75205043608c5bdec56ebb316078f6ac93 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Tue, 3 Mar 2020 22:37:11 -0800 Subject: [PATCH 07/11] PR feedback addressed Renaming for consistent webchat captialized handling Renaming functions Signed-off-by: Srinaath Ravichandran --- .../client/src/state/sagas/chatSagas.spec.ts | 46 +++---- .../app/client/src/state/sagas/chatSagas.ts | 47 ++++--- .../sagas/webchatActivityChannel.spec.ts | 10 +- .../src/state/sagas/webchatActivityChannel.ts | 27 ++-- .../src/ui/editor/emulator/emulator.tsx | 4 +- .../ui/editor/emulator/parts/chat/chat.tsx | 5 + .../parts/chat/outerActivityWrapper.spec.tsx | 2 +- .../parts/chat/outerActivityWrapper.tsx | 4 +- .../utils/restartConversationQueue.spec.ts | 118 +++++++++--------- .../src/utils/restartConversationQueue.ts | 49 ++++---- .../shared/src/state/actions/chatActions.ts | 6 +- .../app/shared/src/state/reducers/chat.ts | 6 +- 12 files changed, 162 insertions(+), 162 deletions(-) diff --git a/packages/app/client/src/state/sagas/chatSagas.spec.ts b/packages/app/client/src/state/sagas/chatSagas.spec.ts index 68371840d..85c4514ff 100644 --- a/packages/app/client/src/state/sagas/chatSagas.spec.ts +++ b/packages/app/client/src/state/sagas/chatSagas.spec.ts @@ -67,7 +67,7 @@ import { } from 'botframework-webchat'; import { Activity } from 'botframework-schema'; -import { WebchatEvents, ConversationQueue } from '../../utils/restartConversationQueue'; +import { WebChatEvents, ConversationQueue } from '../../utils/restartConversationQueue'; import { logService } from '../../platform/log/logService'; import { @@ -79,7 +79,7 @@ import { getCustomUserGUID, getWebSpeechFactoryForDocumentId, } from './chatSagas'; -import { createWebchatActivityChannel, ChannelPayload } from './webchatActivityChannel'; +import { createWebChatActivityChannel, ChannelPayload } from './webchatActivityChannel'; const mockChatStore = jest.fn((args = undefined) => { return {}; @@ -159,7 +159,7 @@ describe('The ChatSagas,', () => { it('should initialize the root saga', () => { const gen = chatSagas(); - expect(gen.next().value).toEqual(fork(ChatSagas.watchForWcEvents)); + expect(gen.next().value).toEqual(fork(ChatSagas.watchForWebchatEvents)); expect(gen.next().value).toEqual( takeEvery(ChatActions.showContextMenuForActivity, ChatSagas.showContextMenuForActivity) ); @@ -1152,14 +1152,14 @@ describe('The ChatSagas,', () => { describe('Replay conversation upto selected activity', () => { beforeEach(() => { - const wcMockChannel = createWebchatActivityChannel(); + const wcMockChannel = createWebChatActivityChannel(); ChatSagas.wcActivityChannel = wcMockChannel; }); it('should watch for incoming activity events dispatched from webchat store', () => { const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.incomingActivity, + type: WebChatEvents.incomingActivity, payload: { activity: { id: 'activity-1', @@ -1169,7 +1169,7 @@ describe('The ChatSagas,', () => { dispatch: jest.fn(), meta: undefined, }; - const gen = ChatSagas.watchForWcEvents(); + const gen = ChatSagas.watchForWebchatEvents(); gen.next(); expect(gen.next(payload).value).toEqual( put(incomingActivity(payload.action.payload.activity, payload.documentId)) @@ -1181,7 +1181,7 @@ describe('The ChatSagas,', () => { const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { id: 'activity-1', @@ -1191,7 +1191,7 @@ describe('The ChatSagas,', () => { dispatch: jest.fn(), meta: undefined, }; - const gen = ChatSagas.watchForWcEvents(); + const gen = ChatSagas.watchForWebchatEvents(); gen.next(); expect(gen.next(payload).value).toEqual(put(postActivity(payload.action.payload.activity, payload.documentId))); expect(gen.next().value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); @@ -1211,7 +1211,7 @@ describe('The ChatSagas,', () => { dispatch: jest.fn(), meta: undefined, }; - const gen = ChatSagas.watchForWcEvents(); + const gen = ChatSagas.watchForWebchatEvents(); let res = gen.next(); res = gen.next(payload); expect(res.value).toEqual(fork(ChatSagas.handleReplayIfRequired, payload)); @@ -1226,7 +1226,7 @@ describe('The ChatSagas,', () => { const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { id: 'activity-1', @@ -1245,16 +1245,16 @@ describe('The ChatSagas,', () => { }); it('should not dispatch activity to webchat if no activity available to post', () => { - const mock: any = { + const mock: Partial = { validateIfReplayFlow: jest.fn(() => true), - incomingActivity: jest.fn(), + handleIncomingActivity: jest.fn(), getNextActivityForPost: jest.fn(() => undefined), }; const dispatcherMock = jest.fn(); const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { id: 'activity-1', @@ -1263,7 +1263,7 @@ describe('The ChatSagas,', () => { }, dispatch: dispatcherMock, meta: { - conversationQueue: mock, + conversationQueue: mock as ConversationQueue, }, }; const gen = ChatSagas.handleReplayIfRequired({ ...payload }); @@ -1280,16 +1280,18 @@ describe('The ChatSagas,', () => { const activity: Activity = { id: 'activity-1', } as Activity; - const mock: any = { + + const mock: Partial = { validateIfReplayFlow: jest.fn(() => true), - incomingActivity: jest.fn(), + handleIncomingActivity: jest.fn(), getNextActivityForPost: jest.fn(() => activity), }; + const dispatcherMock = jest.fn(); const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { id: '0', @@ -1298,7 +1300,7 @@ describe('The ChatSagas,', () => { }, dispatch: dispatcherMock, meta: { - conversationQueue: mock, + conversationQueue: mock as ConversationQueue, }, }; const gen = ChatSagas.handleReplayIfRequired({ ...payload }); @@ -1319,14 +1321,14 @@ describe('The ChatSagas,', () => { } as Activity; const mock: any = { validateIfReplayFlow: jest.fn(() => true), - incomingActivity: jest.fn(), + handleIncomingActivity: jest.fn(), getNextActivityForPost: jest.fn(() => activity), }; const dispatcherMock = jest.fn(); const payload: ChannelPayload = { documentId: 'some-id', action: { - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { id: '0', @@ -1356,7 +1358,7 @@ describe('The ChatSagas,', () => { it('should send conversation queue object if its a Conversation replay flow to replayActivitySniffer middleware', done => { const webChatEventExpected = { - type: WebchatEvents.incomingActivity, + type: WebChatEvents.incomingActivity, payload: { activity: { id: '1', @@ -1391,7 +1393,7 @@ describe('The ChatSagas,', () => { let eventReceivedCt = 0; const channel: any = { - sendWcEvents: args => { + sendWebChatEvents: args => { expect(args.action).toEqual(webChatEventExpected); expect(args.meta.conversationQueue).toEqual(conversationQueue); eventReceivedCt++; diff --git a/packages/app/client/src/state/sagas/chatSagas.ts b/packages/app/client/src/state/sagas/chatSagas.ts index 20dc828ee..6688647f0 100644 --- a/packages/app/client/src/state/sagas/chatSagas.ts +++ b/packages/app/client/src/state/sagas/chatSagas.ts @@ -83,15 +83,15 @@ import { import { logService } from '../../platform/log/logService'; import { RootState } from '../store'; -import { ConversationQueue, WebchatEvents, webchatEventsToWatch } from '../../utils/restartConversationQueue'; +import { ConversationQueue, WebChatEvents, webChatEventsToWatch } from '../../utils/restartConversationQueue'; import { throwErrorFromResponse } from '../utils/throwErrorFromResponse'; import { - createWebchatActivityChannel, + createWebChatActivityChannel, WebChatActivityChannel, ChannelPayload, ReplayActivitySnifferProps, -} from './webchatActivityChannel'; +} from './webChatActivityChannel'; export const getConversationIdFromDocumentId = (state: RootState, documentId: string) => { return (state.chat.chats[documentId] || { conversationId: null }).conversationId; @@ -130,9 +130,9 @@ export const getCurrentEmulatorMode = (state: RootState, documentId: string): Em export const create = (classToInstantiate, ...args) => call(() => new classToInstantiate(...args)); -const dispatchActivityToWebchat = (dispatch: Function, postActivity: Activity) => { +const dispatchActivityToWebChat = (dispatch: Function, postActivity: Activity) => { dispatch({ - type: WebchatEvents.postActivity, + type: WebChatEvents.postActivity, payload: { activity: { ...postActivity, @@ -165,9 +165,9 @@ function createDLSpeechBotSniffer(isDLSpeechBot: boolean, conversationId: string } function createReplayActivitySniffer(documentId: string, meta: ReplayActivitySnifferProps = undefined) { - return ({ dispatch }) => next => async action => { - if (action.payload && webchatEventsToWatch.includes(action.type)) { - ChatSagas.wcActivityChannel.sendWcEvents({ + return ({ dispatch }) => next => action => { + if (action.payload && webChatEventsToWatch.includes(action.type)) { + ChatSagas.wcActivityChannel.sendWebChatEvents({ documentId, action, dispatch, @@ -185,9 +185,9 @@ interface BootstrapChatPayload { mode: EmulatorMode; msaAppId?: string; msaPassword?: string; - user: User; speechKey?: string; speechRegion?: string; + user: User; } export class ChatSagas { @@ -317,11 +317,11 @@ export class ChatSagas { if (conversationQueue && conversationQueue.validateIfReplayFlow(replayStatus, action.type)) { const activityFlowError: string = yield call( - [conversationQueue, conversationQueue.incomingActivity], + [conversationQueue, conversationQueue.handleIncomingActivity], action.payload.activity ); - if (activityFlowError || action.type === WebchatEvents.rejectedActivity) { + if (activityFlowError || action.type === WebChatEvents.rejectedActivity) { yield put(setRestartConversationStatus(RestartConversationStatus.Rejected, documentId)); const errorMessage: string = 'There was an error replaying the conversation. ' + @@ -343,33 +343,33 @@ export class ChatSagas { meta.conversationQueue.getNextActivityForPost, ]); if (postActivity) { - yield call(dispatchActivityToWebchat, dispatch, postActivity); + yield call(dispatchActivityToWebChat, dispatch, postActivity); } } } - public static *watchForWcEvents() { - const wcEventChannel = ChatSagas.wcActivityChannel.getWebchatChannelSubscriber(); + public static *watchForWebchatEvents() { + const webChatEventChannel = ChatSagas.wcActivityChannel.getWebChatChannelSubscriber(); while (true) { - const { documentId, action, dispatch, meta }: ChannelPayload = yield take(wcEventChannel); + const { documentId, action, dispatch, meta }: ChannelPayload = yield take(webChatEventChannel); try { switch (action.type) { - case WebchatEvents.postActivity: { + case WebChatEvents.postActivity: { const activity: Activity = action.payload.activity; yield put(postActivity(activity, documentId)); break; } - case WebchatEvents.incomingActivity: { + case WebChatEvents.incomingActivity: { const activity: Activity = action.payload.activity; yield put(incomingActivity(activity, documentId)); break; } } } catch (err) { - wcEventChannel.close(); + webChatEventChannel.close(); // Restart the channel if error occurs - ChatSagas.wcActivityChannel = createWebchatActivityChannel(); + ChatSagas.wcActivityChannel = createWebChatActivityChannel(); } finally { yield fork(ChatSagas.handleReplayIfRequired, { documentId, action, dispatch, meta }); } @@ -678,10 +678,7 @@ export class ChatSagas { const secret = encode(JSON.stringify(options)); const res: Response = yield fetch(`${serverUrl}/emulator/ws/port`); if (!res.ok) { - throw new Error( - `Error occurred while retrieving the WebSocket server port: ${res.status}: ${res.statusText || - 'No status text'}` - ); + yield* throwErrorFromResponse('Error occurred while retrieving the web socket port', res); } const webSocketPort = yield res.text(); const directLine = createDirectLine({ @@ -706,8 +703,8 @@ export class ChatSagas { } export function* chatSagas(): IterableIterator { - ChatSagas.wcActivityChannel = createWebchatActivityChannel(); - yield fork(ChatSagas.watchForWcEvents); + ChatSagas.wcActivityChannel = createWebChatActivityChannel(); + yield fork(ChatSagas.watchForWebchatEvents); yield takeEvery(ChatActions.showContextMenuForActivity, ChatSagas.showContextMenuForActivity); yield takeEvery(ChatActions.closeConversation, ChatSagas.closeConversation); yield takeEvery(ChatActions.restartConversation, ChatSagas.restartConversation); diff --git a/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts b/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts index a77a3dbe7..8d4b3d315 100644 --- a/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts +++ b/packages/app/client/src/state/sagas/webchatActivityChannel.spec.ts @@ -32,14 +32,14 @@ // import { Activity } from 'botframework-schema'; -import { createWebchatActivityChannel, WebChatActivityChannel, ChannelPayload } from './webchatActivityChannel'; +import { createWebChatActivityChannel, WebChatActivityChannel, ChannelPayload } from './webchatActivityChannel'; describe('Webchat activity channel', () => { let activityChannel: WebChatActivityChannel; let emitterSubscriber; beforeEach(() => { - activityChannel = createWebchatActivityChannel(); - emitterSubscriber = activityChannel.getWebchatChannelSubscriber(); + activityChannel = createWebChatActivityChannel(); + emitterSubscriber = activityChannel.getWebChatChannelSubscriber(); }); it('Receive events through emitter when sent to webchat channel exactly the same without mutation.', async () => { @@ -66,7 +66,7 @@ describe('Webchat activity channel', () => { } for (let i = 0; i < numOfEventsToSend; i++) { - activityChannel.sendWcEvents(payloads[i]); + activityChannel.sendWebChatEvents(payloads[i]); } const receivedEvents: ChannelPayload[] = await Promise.all(promiseResolvers); @@ -92,7 +92,7 @@ describe('Webchat activity channel', () => { const eventsReceived = new Promise(resolve => emitterSubscriber.take(resolve)); const unresolved = await eventsReceived; - activityChannel.sendWcEvents(channelPayload); + activityChannel.sendWebChatEvents(channelPayload); expect(unresolved).not.toEqual(channelPayload); }); }); diff --git a/packages/app/client/src/state/sagas/webchatActivityChannel.ts b/packages/app/client/src/state/sagas/webchatActivityChannel.ts index d58686335..6e04c81b9 100644 --- a/packages/app/client/src/state/sagas/webchatActivityChannel.ts +++ b/packages/app/client/src/state/sagas/webchatActivityChannel.ts @@ -30,20 +30,17 @@ // 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 { EventEmitter } from 'events'; - -// import { Activity } from 'botframework-schema'; import { eventChannel, Channel, buffers } from 'redux-saga'; import { Activity } from 'botframework-schema'; import { ConversationQueue } from '../../utils/restartConversationQueue'; export interface WebChatActivityChannel { - sendWcEvents: (args: ChannelPayload) => void; - getWebchatChannelSubscriber: () => Channel; + sendWebChatEvents: (args: ChannelPayload) => void; + getWebChatChannelSubscriber: () => Channel; } -export interface WebchatEventPayload { +export interface WebChatEventPayload { type: string; payload: { activity: Activity; @@ -56,12 +53,12 @@ export interface ReplayActivitySnifferProps { export interface ChannelPayload { documentId: string; - action: WebchatEventPayload; - dispatch: Function; + action: WebChatEventPayload; + dispatch: () => void; meta: ReplayActivitySnifferProps; } -export function createWebchatActivityChannel(): WebChatActivityChannel { +export function createWebChatActivityChannel(): WebChatActivityChannel { let emitterBoundWcStore: (args: ChannelPayload) => void; let unsubscribeToEvents = false; @@ -71,7 +68,7 @@ export function createWebchatActivityChannel(): WebChatActivityChannel { } } - const getWebchatChannelSubscriber = (): Channel => { + const getWebChatChannelSubscriber = (): Channel => { return eventChannel(emitter => { const self = { emitter, @@ -83,12 +80,14 @@ export function createWebchatActivityChannel(): WebChatActivityChannel { }, buffers.expanding(20)); }; - const sendWcEvents = async (args: ChannelPayload) => { - emitterBoundWcStore.call(null, { ...args }); + const sendWebChatEvents = async (args: ChannelPayload) => { + if (emitterBoundWcStore) { + emitterBoundWcStore.call(null, { ...args }); + } }; return { - getWebchatChannelSubscriber, - sendWcEvents, + getWebChatChannelSubscriber, + sendWebChatEvents, }; } diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index ca76dc347..1788c09e2 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -149,7 +149,7 @@ export class Emulator extends React.Component { const { mode, documentId } = this.props; - const livechatRender = + const livechatHeaderRender = this.props.restartStatus !== RestartConversationStatus.Started ? ( <> { Reconnect )} - {mode === 'livechat' && livechatRender} + {mode === 'livechat' && livechatHeaderRender}
diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx index c9a91f086..24f53fced 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/chat.tsx @@ -80,11 +80,16 @@ export class Chat extends PureComponent { mode, webchatStore, webSpeechPonyfillFactory, + restartStatus, } = this.props; +<<<<<<< HEAD const currentUser = { id: currentUserId, name: 'User' }; const isDisabled = mode === 'transcript' || mode === 'debug' || this.props.restartStatus === RestartConversationStatus.Started; +======= + const isDisabled = mode === 'transcript' || mode === 'debug' || restartStatus === RestartConversationStatus.Started; +>>>>>>> PR feedback addressed // Due to needing to make idiosyncratic style changes, Emulator is using `createStyleSet` instead of `createStyleOptions`. The object below: {...webChatStyleOptions, hideSendBox...} was formerly passed into the `styleOptions` parameter of React Web Chat. If further styling modifications are desired using styleOptions, simply pass it into the same object in createStyleSet below. diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx index d96291760..e6de421b2 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.spec.tsx @@ -113,7 +113,7 @@ describe('', () => { ); const instance = wrapper.instance(); - (instance as any).propsBoundRestartActivityHandler(); + (instance as any).onRestartConversationFromActivityClick(); expect(onRestartClick).toHaveBeenCalledWith('some-id', card.activity, RestartConversationOptions.SameUserId); }); diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx index edbc8cfec..cab0ed9dd 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx @@ -72,14 +72,14 @@ export class OuterActivityWrapper extends React.Component {children} ); } - private propsBoundRestartActivityHandler = () => { + private onRestartConversationFromActivityClick = () => { this.props.onRestartConversationFromActivityClick( this.props.documentId, this.props.card.activity, diff --git a/packages/app/client/src/utils/restartConversationQueue.spec.ts b/packages/app/client/src/utils/restartConversationQueue.spec.ts index 908d54660..b0eb5dd0b 100644 --- a/packages/app/client/src/utils/restartConversationQueue.spec.ts +++ b/packages/app/client/src/utils/restartConversationQueue.spec.ts @@ -32,7 +32,7 @@ import { Activity } from 'botframework-schema'; import { replayScenarios } from '../../mocks/conversationQueueMocks'; -import { ConversationQueue, WebchatEvents } from './restartConversationQueue'; +import { ConversationQueue, WebChatEvents } from './restartConversationQueue'; describe('Restart Conversation Queue', () => { let scenarios; @@ -52,10 +52,10 @@ describe('Restart Conversation Queue', () => { activitiesToBePosted[activitiesToBePosted.length - 1], jest.fn() ); - expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebchatEvents.incomingActivity)).toBeTruthy(); - expect(queue.validateIfReplayFlow(RestartConversationStatus.Rejected, WebchatEvents.incomingActivity)).toBeFalsy(); - expect(queue.validateIfReplayFlow(undefined, WebchatEvents.incomingActivity)).toBeFalsy(); - expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebchatEvents.postActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebChatEvents.incomingActivity)).toBeTruthy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Rejected, WebChatEvents.incomingActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(undefined, WebChatEvents.incomingActivity)).toBeFalsy(); + expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, WebChatEvents.postActivity)).toBeFalsy(); expect(queue.validateIfReplayFlow(RestartConversationStatus.Started, 'WEBCHAT/SEND_TYPING')).toBeFalsy(); }); @@ -70,20 +70,20 @@ describe('Restart Conversation Queue', () => { expect(queue.getNextActivityForPost()).toBeUndefined(); expect(queue.replayComplete).toBeFalsy(); - queue.incomingActivity(chatReplayData.incomingActivities[0] as Activity); + queue.handleIncomingActivity(chatReplayData.incomingActivities[0] as Activity); const postActivity: Activity = queue.getNextActivityForPost(); expect(postActivity.channelData.matchIndexes).toEqual([1, 2]); const botResponsesForActivity: Activity[] = scenarios[0].botResponsesForActivity; - queue.incomingActivity(botResponsesForActivity[1]); + queue.handleIncomingActivity(botResponsesForActivity[1]); expect(queue.getNextActivityForPost()).toBeUndefined(); - queue.incomingActivity(botResponsesForActivity[2]); + queue.handleIncomingActivity(botResponsesForActivity[2]); expect(queue.getNextActivityForPost()).toBeUndefined(); - const err = queue.incomingActivity(botResponsesForActivity[3]); + const err = queue.handleIncomingActivity(botResponsesForActivity[3]); expect(err).toBeUndefined(); expect(queue.getNextActivityForPost()).toBeDefined(); }); @@ -97,15 +97,15 @@ describe('Restart Conversation Queue', () => { const queue: ConversationQueue = new ConversationQueue(activities, chatReplayData, '123', activities[1], jest.fn()); // Conversation Update - queue.incomingActivity(chatReplayData.incomingActivities[0] as Activity); + queue.handleIncomingActivity(chatReplayData.incomingActivities[0] as Activity); const botResponsesForActivity: Activity[] = scenarios[0].botResponsesForActivity; - queue.incomingActivity(botResponsesForActivity[1]); + queue.handleIncomingActivity(botResponsesForActivity[1]); expect(queue.getNextActivityForPost()).toBeUndefined(); // The original conversation had 2 bot responses for the activity before an echoback - const err = queue.incomingActivity(botResponsesForActivity[3]); + const err = queue.handleIncomingActivity(botResponsesForActivity[3]); expect(err).toBeDefined(); expect(queue.replayComplete).toBeFalsy(); }); @@ -124,26 +124,26 @@ describe('Restart Conversation Queue', () => { ); // Conversation Update let err; - err = queue.incomingActivity(incomingActivities[0]); + err = queue.handleIncomingActivity(incomingActivities[0]); expect(err).toBeUndefined(); expect(queue.getNextActivityForPost()).toBeUndefined(); - queue.incomingActivity(incomingActivities[1]); + queue.handleIncomingActivity(incomingActivities[1]); let activity = queue.getNextActivityForPost(); expect(activity).toBeDefined(); - err = queue.incomingActivity(botResponsesForActivity[2]); - err = queue.incomingActivity(botResponsesForActivity[3]); + err = queue.handleIncomingActivity(botResponsesForActivity[2]); + err = queue.handleIncomingActivity(botResponsesForActivity[3]); expect(err).toBeUndefined(); - err = queue.incomingActivity(botResponsesForActivity[4]); - err = queue.incomingActivity(botResponsesForActivity[5]); + err = queue.handleIncomingActivity(botResponsesForActivity[4]); + err = queue.handleIncomingActivity(botResponsesForActivity[5]); expect(err).toBeUndefined(); activity = queue.getNextActivityForPost(); expect(activity).toBeDefined(); - err = queue.incomingActivity(botResponsesForActivity[6]); - err = queue.incomingActivity(botResponsesForActivity[7]); + err = queue.handleIncomingActivity(botResponsesForActivity[6]); + err = queue.handleIncomingActivity(botResponsesForActivity[7]); expect(err).toBeUndefined(); - err = queue.incomingActivity(botResponsesForActivity[8]); + err = queue.handleIncomingActivity(botResponsesForActivity[8]); expect(err).toBeUndefined(); expect(queue.replayComplete).toBeTruthy(); }); @@ -162,28 +162,28 @@ describe('Restart Conversation Queue', () => { ); let err; - queue.incomingActivity(botResponsesForActivity[0]); + queue.handleIncomingActivity(botResponsesForActivity[0]); const postActivity2: Activity = queue.getNextActivityForPost(); expect(postActivity2).toBeDefined(); expect(postActivity2.channelData.matchIndexes).toEqual([1, 4, 7, 8]); - err = queue.incomingActivity(botResponsesForActivity[1]); + err = queue.handleIncomingActivity(botResponsesForActivity[1]); expect(err).toBeUndefined(); const postActivity3: Activity = queue.getNextActivityForPost(); expect(postActivity3.channelData.matchIndexes).toEqual([2, 3, 5]); - queue.incomingActivity(botResponsesForActivity[2]); - queue.incomingActivity(botResponsesForActivity[3]); - queue.incomingActivity(botResponsesForActivity[4]); - queue.incomingActivity(botResponsesForActivity[5]); + queue.handleIncomingActivity(botResponsesForActivity[2]); + queue.handleIncomingActivity(botResponsesForActivity[3]); + queue.handleIncomingActivity(botResponsesForActivity[4]); + queue.handleIncomingActivity(botResponsesForActivity[5]); //Act3 completed - err = queue.incomingActivity(botResponsesForActivity[6]); + err = queue.handleIncomingActivity(botResponsesForActivity[6]); expect(err).toBeUndefined(); expect(queue.getNextActivityForPost()).toBeUndefined(); - queue.incomingActivity(botResponsesForActivity[7]); - err = queue.incomingActivity(botResponsesForActivity[8]); + queue.handleIncomingActivity(botResponsesForActivity[7]); + err = queue.handleIncomingActivity(botResponsesForActivity[8]); expect(err).toBeUndefined(); expect(queue.getNextActivityForPost()).toBeUndefined(); expect(queue.replayComplete).toBeFalsy(); @@ -202,11 +202,11 @@ describe('Restart Conversation Queue', () => { jest.fn() ); - queue.incomingActivity(botResponsesForActivity[0]); + queue.handleIncomingActivity(botResponsesForActivity[0]); queue.getNextActivityForPost(); - queue.incomingActivity(botResponsesForActivity[1]); + queue.handleIncomingActivity(botResponsesForActivity[1]); queue.getNextActivityForPost(); - queue.incomingActivity(botResponsesForActivity[2]); + queue.handleIncomingActivity(botResponsesForActivity[2]); // We have asked the queue to stop after posting 2 activities expect(queue.replayComplete).toBeTruthy(); }); @@ -224,20 +224,20 @@ describe('Restart Conversation Queue', () => { jest.fn() ); - queue.incomingActivity(botResponsesForActivity[0]); - queue.incomingActivity(botResponsesForActivity[1]); - queue.incomingActivity(botResponsesForActivity[2]); - queue.incomingActivity(botResponsesForActivity[3]); - queue.incomingActivity(botResponsesForActivity[4]); - queue.incomingActivity(botResponsesForActivity[5]); - queue.incomingActivity(botResponsesForActivity[6]); - queue.incomingActivity(botResponsesForActivity[7]); + queue.handleIncomingActivity(botResponsesForActivity[0]); + queue.handleIncomingActivity(botResponsesForActivity[1]); + queue.handleIncomingActivity(botResponsesForActivity[2]); + queue.handleIncomingActivity(botResponsesForActivity[3]); + queue.handleIncomingActivity(botResponsesForActivity[4]); + queue.handleIncomingActivity(botResponsesForActivity[5]); + queue.handleIncomingActivity(botResponsesForActivity[6]); + queue.handleIncomingActivity(botResponsesForActivity[7]); expect(queue.replayComplete).toBeFalsy(); - queue.incomingActivity(botResponsesForActivity[8]); - queue.incomingActivity(botResponsesForActivity[9]); + queue.handleIncomingActivity(botResponsesForActivity[8]); + queue.handleIncomingActivity(botResponsesForActivity[9]); const postActivity = queue.getNextActivityForPost(); expect(postActivity).toBeDefined(); - queue.incomingActivity(botResponsesForActivity[10]); + queue.handleIncomingActivity(botResponsesForActivity[10]); expect(queue.replayComplete).toBeTruthy(); }); @@ -254,23 +254,23 @@ describe('Restart Conversation Queue', () => { jest.fn() ); - queue.incomingActivity(botResponsesForActivity[0]); - queue.incomingActivity(botResponsesForActivity[1]); - queue.incomingActivity(botResponsesForActivity[2]); - queue.incomingActivity(botResponsesForActivity[3]); - queue.incomingActivity(botResponsesForActivity[4]); - queue.incomingActivity(botResponsesForActivity[5]); - queue.incomingActivity(botResponsesForActivity[6]); - queue.incomingActivity(botResponsesForActivity[7]); - queue.incomingActivity(botResponsesForActivity[8]); - queue.incomingActivity(botResponsesForActivity[9]); - queue.incomingActivity(botResponsesForActivity[10]); - queue.incomingActivity(botResponsesForActivity[11]); - queue.incomingActivity(botResponsesForActivity[12]); - let err = queue.incomingActivity(botResponsesForActivity[13]); + queue.handleIncomingActivity(botResponsesForActivity[0]); + queue.handleIncomingActivity(botResponsesForActivity[1]); + queue.handleIncomingActivity(botResponsesForActivity[2]); + queue.handleIncomingActivity(botResponsesForActivity[3]); + queue.handleIncomingActivity(botResponsesForActivity[4]); + queue.handleIncomingActivity(botResponsesForActivity[5]); + queue.handleIncomingActivity(botResponsesForActivity[6]); + queue.handleIncomingActivity(botResponsesForActivity[7]); + queue.handleIncomingActivity(botResponsesForActivity[8]); + queue.handleIncomingActivity(botResponsesForActivity[9]); + queue.handleIncomingActivity(botResponsesForActivity[10]); + queue.handleIncomingActivity(botResponsesForActivity[11]); + queue.handleIncomingActivity(botResponsesForActivity[12]); + let err = queue.handleIncomingActivity(botResponsesForActivity[13]); // Progressive response for Act2 arrived at the correct spot expect(err).toBeUndefined(); - err = queue.incomingActivity(botResponsesForActivity[14]); + err = queue.handleIncomingActivity(botResponsesForActivity[14]); // Another Progressive response for Act2 arrived at the correct spot expect(err).toBeUndefined(); }); diff --git a/packages/app/client/src/utils/restartConversationQueue.ts b/packages/app/client/src/utils/restartConversationQueue.ts index 9be848106..7bcdaf78a 100644 --- a/packages/app/client/src/utils/restartConversationQueue.ts +++ b/packages/app/client/src/utils/restartConversationQueue.ts @@ -34,34 +34,29 @@ import { Activity } from 'botframework-schema'; import { SharedConstants, RestartConversationStatus } from '@bfemulator/app-shared'; import { ChatReplayData, HasIdAndReplyId } from '@bfemulator/app-shared'; -export enum WebchatEvents { +export enum WebChatEvents { postActivity = 'DIRECT_LINE/POST_ACTIVITY', incomingActivity = 'DIRECT_LINE/INCOMING_ACTIVITY', rejectedActivity = 'DIRECT_LINE/POST_ACTIVITY_REJECTED', } -export const webchatEventsToWatch: string[] = [WebchatEvents.postActivity, WebchatEvents.incomingActivity]; +export const webChatEventsToWatch: string[] = [WebChatEvents.postActivity, WebChatEvents.incomingActivity]; export class ConversationQueue { private userActivities: Activity[] = []; private replayDataFromOldConversation: ChatReplayData; private receivedActivities: Activity[]; private conversationId: string; - private nextActivityToBePosted = undefined; - private isReplayComplete = false; - private createObjectUrl; - private progressiveResponseValidationMap: Map; - - // private createObjectUrlFromWindow: Function; + private nextActivityToBePosted: Activity = undefined; + private isReplayComplete: boolean = false; + private proactiveResponseValidationMap: Map; constructor( activities: Activity[], chatReplayData: ChatReplayData, conversationId: string, - replayToActivity: Activity, - createObjectUrl: Function + replayToActivity: Activity ) { - this.createObjectUrl = createObjectUrl; // Get all user activities this.userActivities = activities.filter( (activity: Activity) => activity.from.role === SharedConstants.Activity.FROM_USER_ROLE && activity.channelData @@ -75,18 +70,21 @@ export class ConversationQueue { this.conversationId = conversationId; this.replayDataFromOldConversation = chatReplayData; this.receivedActivities = []; - this.progressiveResponseValidationMap = new Map(); + this.proactiveResponseValidationMap = new Map(); this.checkIfActivityToBePosted = this.checkIfActivityToBePosted.bind(this); - this.incomingActivity = this.incomingActivity.bind(this); + this.handleIncomingActivity = this.handleIncomingActivity.bind(this); } private static dataURLtoFile(dataurl: string, filename: string) { - var arr = dataurl.split(','), - mime = arr[0].match(/:(.*?);/)[1], - bstr = atob(arr[1]), - n = bstr.length, - u8arr = new Uint8Array(n); + const arr: string[] = dataurl.split(','); + if (arr.length !== 2) { + return undefined; + } + const mime = arr[0].match(/:(.*?);/)[1]; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); @@ -117,11 +115,10 @@ export class ConversationQueue { if (activity.attachments && activity.attachments.length >= 1) { const mutatedAttachments = activity.attachments.map(attachment => { - // Convert back to file and create a temporary link using object URL const fileFormat: File = ConversationQueue.dataURLtoFile(attachment.contentUrl, attachment.name); return { ...attachment, - contentUrl: this.createObjectUrl ? this.createObjectUrl(fileFormat) : fileFormat, + contentUrl: fileFormat ? window.URL.createObjectURL(fileFormat) : '', }; }); activity.attachments = mutatedAttachments; @@ -148,7 +145,7 @@ export class ConversationQueue { public validateIfReplayFlow(replayStatus: RestartConversationStatus, actionType: string) { return !!( typeof replayStatus !== undefined && - actionType === WebchatEvents.incomingActivity && + actionType === WebChatEvents.incomingActivity && replayStatus === RestartConversationStatus.Started ); } @@ -161,19 +158,19 @@ export class ConversationQueue { return this.isReplayComplete; } - public incomingActivity(activity: Activity) { + public handleIncomingActivity(activity: Activity) { if (this.isReplayComplete) { return; } try { const indexToBeInserted: number = this.receivedActivities.length; if ( - this.progressiveResponseValidationMap.has(indexToBeInserted) && - this.progressiveResponseValidationMap.get(indexToBeInserted) !== activity.replyToId + this.proactiveResponseValidationMap.has(indexToBeInserted) && + this.proactiveResponseValidationMap.get(indexToBeInserted) !== activity.replyToId ) { throw new Error('Replayed activities not in order of original conversation'); } else { - this.progressiveResponseValidationMap.delete(indexToBeInserted); + this.proactiveResponseValidationMap.delete(indexToBeInserted); } this.receivedActivities.push(activity); @@ -182,7 +179,7 @@ export class ConversationQueue { if (matchIndexes) { matchIndexes.forEach((index: number) => { if (!this.receivedActivities[index]) { - this.progressiveResponseValidationMap.set(index, activity.id); + this.proactiveResponseValidationMap.set(index, activity.id); } else if (this.receivedActivities[index].replyToId !== activity.id) { throw new Error('Replayed activities not in order of original conversation'); } diff --git a/packages/app/shared/src/state/actions/chatActions.ts b/packages/app/shared/src/state/actions/chatActions.ts index 0be074047..05d7c5276 100644 --- a/packages/app/shared/src/state/actions/chatActions.ts +++ b/packages/app/shared/src/state/actions/chatActions.ts @@ -152,7 +152,7 @@ export interface RestartConversationPayload { createObjectUrl: Function; } -export interface ActivityFromWebchatPayload { +export interface ActivityFromWebChatPayload { documentId: string; activity: Activity; } @@ -381,7 +381,7 @@ export function restartConversation( }; } -export function postActivity(activity: Activity, documentId: string): ChatAction { +export function postActivity(activity: Activity, documentId: string): ChatAction { return { type: ChatActions.PostActivityEventWc, payload: { @@ -391,7 +391,7 @@ export function postActivity(activity: Activity, documentId: string): ChatAction }; } -export function incomingActivity(activity: Activity, documentId: string): ChatAction { +export function incomingActivity(activity: Activity, documentId: string): ChatAction { return { type: ChatActions.IncomingActivityFromWc, payload: { diff --git a/packages/app/shared/src/state/reducers/chat.ts b/packages/app/shared/src/state/reducers/chat.ts index f9a03732d..b9c3ebcb6 100644 --- a/packages/app/shared/src/state/reducers/chat.ts +++ b/packages/app/shared/src/state/reducers/chat.ts @@ -41,7 +41,7 @@ import { WebChatStorePayload, WebSpeechFactoryPayload, UpdateSpeechAdaptersPayload, - ActivityFromWebchatPayload, + ActivityFromWebChatPayload, RestartConversationStatus, RestartConversationStatusPayload, RestartConversationOptions, @@ -300,7 +300,7 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit } case ChatActions.IncomingActivityFromWc: { - const { documentId, activity } = action.payload as ActivityFromWebchatPayload; + const { documentId, activity } = action.payload as ActivityFromWebChatPayload; const replayData: ChatReplayData = state.chats[documentId].replayData; let incomingActivities: HasIdAndReplyId[] = []; if (replayData.incomingActivities) { @@ -327,7 +327,7 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit } case ChatActions.PostActivityEventWc: { - const { documentId } = action.payload as ActivityFromWebchatPayload; + const { documentId } = action.payload as ActivityFromWebChatPayload; let postActivitiesSlots: number[] = []; if (state.chats[documentId].replayData.postActivitiesSlots) { postActivitiesSlots = [...state.chats[documentId].replayData.postActivitiesSlots]; From 7bec918413652d7aed2d57b72a15fc63f7854bc1 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Tue, 3 Mar 2020 22:40:03 -0800 Subject: [PATCH 08/11] Revertng webpack config Signed-off-by: Srinaath Ravichandran More PR feedback handled Signed-off-by: Srinaath Ravichandran --- packages/app/client/src/state/sagas/chatSagas.ts | 13 +++++++++---- .../parts/chat/outerActivityWrapperContainer.ts | 2 +- .../client/src/utils/restartConversationQueue.ts | 4 ++-- packages/app/client/webpack.config.js | 2 +- .../app/shared/src/state/actions/chatActions.ts | 5 +---- packages/app/shared/src/state/reducers/chat.spec.ts | 4 ++-- packages/app/shared/src/state/reducers/chat.ts | 6 +++--- .../shared/src/emulatorApi/conversationService.ts | 11 ++--------- 8 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/app/client/src/state/sagas/chatSagas.ts b/packages/app/client/src/state/sagas/chatSagas.ts index 6688647f0..114f76719 100644 --- a/packages/app/client/src/state/sagas/chatSagas.ts +++ b/packages/app/client/src/state/sagas/chatSagas.ts @@ -472,7 +472,7 @@ export class ChatSagas { } public static *restartConversation(action: ChatAction): IterableIterator { - const { documentId, requireNewConversationId, requireNewUserId, createObjectUrl } = action.payload; + const { documentId, requireNewConversationId, requireNewUserId } = action.payload; const replayToActivity: Activity = action.payload.activity || undefined; const chat: ChatDocument = yield select(getChatFromDocumentId, documentId); const serverUrl = yield select(getServerUrl); @@ -491,19 +491,24 @@ export class ChatSagas { conversationId = chat.conversationId || `${uniqueId()}|${chat.mode}`; } if (replayToActivity) { - const activities: Activity[] = yield call( + const res = yield call( [ConversationService, ConversationService.fetchActivitiesForAConversation], serverUrl, chat.conversationId ); + + if (!res.ok) { + yield* throwErrorFromResponse('Error occurred while fetching activities in transcript while replaying', res); + } + const activities: Activity[] = yield res.json(); yield put(setRestartConversationStatus(RestartConversationStatus.Started, documentId)); + conversationQueue = yield create( ConversationQueue, activities, chat.replayData, conversationId, - replayToActivity, - createObjectUrl + replayToActivity ); } diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts index 2bb7bef24..230997455 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapperContainer.ts @@ -70,7 +70,7 @@ const mapDispatchToProps = (dispatch: (action: Action) => void) => ({ if (restartOption === RestartConversationOptions.SameUserId) { requireUserId = false; } - dispatch(restartConversation(documentId, true, requireUserId, activity, window.URL.createObjectURL)); + dispatch(restartConversation(documentId, true, requireUserId, activity)); }, }); diff --git a/packages/app/client/src/utils/restartConversationQueue.ts b/packages/app/client/src/utils/restartConversationQueue.ts index 7bcdaf78a..2f53c3ece 100644 --- a/packages/app/client/src/utils/restartConversationQueue.ts +++ b/packages/app/client/src/utils/restartConversationQueue.ts @@ -32,7 +32,7 @@ // import { Activity } from 'botframework-schema'; import { SharedConstants, RestartConversationStatus } from '@bfemulator/app-shared'; -import { ChatReplayData, HasIdAndReplyId } from '@bfemulator/app-shared'; +import { ChatReplayData, IncomingActivityRecord } from '@bfemulator/app-shared'; export enum WebChatEvents { postActivity = 'DIRECT_LINE/POST_ACTIVITY', @@ -106,7 +106,7 @@ export class ConversationQueue { const matchIndexes = []; this.replayDataFromOldConversation.incomingActivities.forEach( - (incomingActivity: HasIdAndReplyId, index: number) => { + (incomingActivity: IncomingActivityRecord, index: number) => { if (incomingActivity.replyToId === activity.id) { matchIndexes.push(index); } diff --git a/packages/app/client/webpack.config.js b/packages/app/client/webpack.config.js index 9434e1cbc..3d3ad64db 100644 --- a/packages/app/client/webpack.config.js +++ b/packages/app/client/webpack.config.js @@ -106,7 +106,7 @@ const defaultConfig = { ], }, - devtool: 'eval-source-map', + devtool: 'source-map', devServer: { hot: true, diff --git a/packages/app/shared/src/state/actions/chatActions.ts b/packages/app/shared/src/state/actions/chatActions.ts index 05d7c5276..b0fe6abec 100644 --- a/packages/app/shared/src/state/actions/chatActions.ts +++ b/packages/app/shared/src/state/actions/chatActions.ts @@ -149,7 +149,6 @@ export interface RestartConversationPayload { requireNewConversationId: boolean; requireNewUserId: boolean; activity?: Activity; - createObjectUrl: Function; } export interface ActivityFromWebChatPayload { @@ -366,8 +365,7 @@ export function restartConversation( documentId: string, requireNewConversationId: boolean = false, requireNewUserId: boolean = false, - activity: Activity = undefined, - createObjectUrl: Function = undefined + activity: Activity = undefined ): ChatAction { return { type: ChatActions.restartConversation, @@ -376,7 +374,6 @@ export function restartConversation( requireNewConversationId, requireNewUserId, activity, - createObjectUrl, }, }; } diff --git a/packages/app/shared/src/state/reducers/chat.spec.ts b/packages/app/shared/src/state/reducers/chat.spec.ts index f3cc35f1b..e617c8406 100644 --- a/packages/app/shared/src/state/reducers/chat.spec.ts +++ b/packages/app/shared/src/state/reducers/chat.spec.ts @@ -55,7 +55,7 @@ import { } from '../actions/chatActions'; import { closeNonGlobalTabs } from '../actions/editorActions'; -import { chat, ChatState, HasIdAndReplyId } from './chat'; +import { chat, ChatState, IncomingActivityRecord } from './chat'; describe('Chat reducer tests', () => { const testChatId = 'testChat1'; @@ -358,7 +358,7 @@ describe('Chat reducer tests', () => { const transientState = chat(startingState, action); let incomingActivities = transientState.chats['chatId-1'].replayData.incomingActivities; - let lastActivity: HasIdAndReplyId = incomingActivities[incomingActivities.length - 1]; + let lastActivity: IncomingActivityRecord = incomingActivities[incomingActivities.length - 1]; expect(lastActivity.id).toBe(expectedActivity.id); expect(lastActivity.replyToId).toBe(expectedActivity.replyToId); diff --git a/packages/app/shared/src/state/reducers/chat.ts b/packages/app/shared/src/state/reducers/chat.ts index b9c3ebcb6..b8bd774a9 100644 --- a/packages/app/shared/src/state/reducers/chat.ts +++ b/packages/app/shared/src/state/reducers/chat.ts @@ -60,13 +60,13 @@ export interface ChatState { restartStatus: { [chatId: string]: RestartConversationStatus }; } -export interface HasIdAndReplyId { +export interface IncomingActivityRecord { id: string; replyToId?: string; } export interface ChatReplayData { - incomingActivities: HasIdAndReplyId[]; + incomingActivities: IncomingActivityRecord[]; postActivitiesSlots: number[]; } @@ -302,7 +302,7 @@ export function chat(state: ChatState = DEFAULT_STATE, action: ChatAction | Edit case ChatActions.IncomingActivityFromWc: { const { documentId, activity } = action.payload as ActivityFromWebChatPayload; const replayData: ChatReplayData = state.chats[documentId].replayData; - let incomingActivities: HasIdAndReplyId[] = []; + let incomingActivities: IncomingActivityRecord[] = []; if (replayData.incomingActivities) { incomingActivities = [...replayData.incomingActivities]; } diff --git a/packages/sdk/shared/src/emulatorApi/conversationService.ts b/packages/sdk/shared/src/emulatorApi/conversationService.ts index edf48b0a5..03b9ba934 100644 --- a/packages/sdk/shared/src/emulatorApi/conversationService.ts +++ b/packages/sdk/shared/src/emulatorApi/conversationService.ts @@ -213,14 +213,7 @@ export class ConversationService { }); } - public static async fetchActivitiesForAConversation(serverUrl: string, conversationId: string): Promise { - try { - const url = `${serverUrl}/v3/conversations/${conversationId}/activities`; - const resp = await fetch(url); - const activities = await resp.json(); - return activities; - } catch (ex) { - return []; - } + public static async fetchActivitiesForAConversation(serverUrl: string, conversationId: string): Promise { + return fetch(`${serverUrl}/v3/conversations/${conversationId}/activities`); } } From 3b2cc84d49117ca2cf7775c6e8cccaa0603631c6 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Tue, 3 Mar 2020 23:52:35 -0800 Subject: [PATCH 09/11] Renaming user role Signed-off-by: Srinaath Ravichandran --- .../ui/editor/emulator/parts/chat/outerActivityWrapper.tsx | 6 +----- packages/app/client/src/utils/restartConversationQueue.ts | 2 +- packages/app/shared/src/constants/sharedConstants.ts | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx index cab0ed9dd..6cf1c14bf 100644 --- a/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx +++ b/packages/app/client/src/ui/editor/emulator/parts/chat/outerActivityWrapper.tsx @@ -88,11 +88,7 @@ export class OuterActivityWrapper extends React.Component activity.from.role === SharedConstants.Activity.FROM_USER_ROLE && activity.channelData + (activity: Activity) => activity.from.role === SharedConstants.Activity.USER_ROLE && activity.channelData ); const trimActivityIndex: number = this.userActivities.findIndex(activity => activity.id === replayToActivity.id); diff --git a/packages/app/shared/src/constants/sharedConstants.ts b/packages/app/shared/src/constants/sharedConstants.ts index 28c5083e1..034263a41 100644 --- a/packages/app/shared/src/constants/sharedConstants.ts +++ b/packages/app/shared/src/constants/sharedConstants.ts @@ -216,7 +216,7 @@ export const SharedConstants = { NAVBAR_RESOURCES: 'navbar:resources', }, Activity: { - FROM_USER_ROLE: 'user', - FROM_BOT_ROLE: 'bot', + USER_ROLE: 'user', + BOT_ROLE: 'bot', }, }; From 6ae438e3ab55e33da0c66f1c2867bf8ac3accd47 Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Wed, 4 Mar 2020 13:23:00 -0800 Subject: [PATCH 10/11] Stable PR updates Signed-off-by: Srinaath Ravichandran --- .../client/src/state/sagas/chatSagas.spec.ts | 24 +++++++++---------- .../src/ui/editor/emulator/emulator.spec.tsx | 1 + .../src/ui/editor/emulator/emulator.tsx | 4 ++-- .../src/widget/button/button.spec.tsx | 2 +- .../widget/splitButton/splitButton.spec.tsx | 14 +++++++++-- .../src/widget/splitButton/splitButton.tsx | 9 ++++--- 6 files changed, 34 insertions(+), 20 deletions(-) diff --git a/packages/app/client/src/state/sagas/chatSagas.spec.ts b/packages/app/client/src/state/sagas/chatSagas.spec.ts index 85c4514ff..41e0d2001 100644 --- a/packages/app/client/src/state/sagas/chatSagas.spec.ts +++ b/packages/app/client/src/state/sagas/chatSagas.spec.ts @@ -1365,16 +1365,17 @@ describe('The ChatSagas,', () => { }, }, }; + const activitiesFetched = [ + { + id: '1', + from: { + role: 'user', + }, + } as Activity, + ]; const conversationQueue = new ConversationQueue( - [ - { - id: '1', - from: { - role: 'user', - }, - } as Activity, - ], + activitiesFetched, { incomingActivities: [ { @@ -1387,8 +1388,7 @@ describe('The ChatSagas,', () => { '123', { id: '2', - } as Activity, - jest.fn() + } as Activity ); let eventReceivedCt = 0; @@ -1410,7 +1410,6 @@ describe('The ChatSagas,', () => { activity: { id: 'act-1', } as Activity, - createObjectUrl: jest.fn(), }; const mockAction: any = { payload, @@ -1428,7 +1427,8 @@ describe('The ChatSagas,', () => { gen.next(); gen.next(chat); gen.next(); - gen.next(); + gen.next({ ok: true, json: jest.fn() }); + gen.next(activitiesFetched); gen.next(); gen.next(conversationQueue); gen.next(); diff --git a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx index 5b0ac0de2..ac9544cc0 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.spec.tsx @@ -397,6 +397,7 @@ describe('', () => { conversationId: '123', presentationModeEnabled: false, restartStatus: RestartConversationStatus.Started, + onSetRestartConversationOptionClick: jest.fn(), ui: {}, }; const mockStore = createStore((_state, _action) => mockStoreState); diff --git a/packages/app/client/src/ui/editor/emulator/emulator.tsx b/packages/app/client/src/ui/editor/emulator/emulator.tsx index 1788c09e2..221b0035f 100644 --- a/packages/app/client/src/ui/editor/emulator/emulator.tsx +++ b/packages/app/client/src/ui/editor/emulator/emulator.tsx @@ -56,8 +56,8 @@ import { InspectorContainer } from './parts'; import { ToolBar } from './toolbar/toolbar'; export const restartOptions = { - NewUserId: 'Restart with new user ID', - SameUserId: 'Restart with same user ID', + NewUserId: 'Restart Conversation - New User ID', + SameUserId: 'Restart Conversation - Same User ID', }; export interface EmulatorProps { diff --git a/packages/sdk/ui-react/src/widget/button/button.spec.tsx b/packages/sdk/ui-react/src/widget/button/button.spec.tsx index bef137d72..3fcb49e6e 100644 --- a/packages/sdk/ui-react/src/widget/button/button.spec.tsx +++ b/packages/sdk/ui-react/src/widget/button/button.spec.tsx @@ -84,7 +84,7 @@ describe('The LinkButton component', () => { let parent; let node; beforeEach(() => { - parent = mount(Learn more); + parent = mount(Learn more); node = parent.find(LinkButton); }); diff --git a/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx b/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx index 66211dba1..b80fff7c1 100644 --- a/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx +++ b/packages/sdk/ui-react/src/widget/splitButton/splitButton.spec.tsx @@ -59,6 +59,18 @@ describe('', () => { expect(node.html()).not.toBe(null); }); + it('should select first option by default', () => { + const mockOnClick = jest.fn((value: string) => { + expect(value).toBe('option1'); + }); + const mockDefaultClick = jest.fn((value: number) => null); + wrapper = mount( + + ); + instance = wrapper.instance(); + expect(instance.state.selected).toBe(0); + }); + it('should pass the primary button ref to the buttonRef prop', () => { const mockButtonRef = jest.fn(() => null); wrapper = mount(); @@ -91,11 +103,9 @@ describe('', () => { ); instance = wrapper.instance(); instance.onClickDefault(); - expect(mockOnClick).not.toHaveBeenCalled(); expect(mockDefaultClick).toHaveBeenCalledWith('option1'); instance.setState({ selected: 1 }); instance.onClickDefault(); - expect(mockOnClick).not.toHaveBeenCalled(); expect(mockDefaultClick).toHaveBeenCalledWith('option2'); }); diff --git a/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx b/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx index e04027b58..0e430520f 100644 --- a/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx +++ b/packages/sdk/ui-react/src/widget/splitButton/splitButton.tsx @@ -63,10 +63,13 @@ export class SplitButton extends React.Component - {defaultLabel} + {options[this.state.selected]}