@servicetitan/titan-chat-ui-common 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/models/__mocks__/support-chat.mock.d.ts +9 -0
- package/dist/models/__mocks__/support-chat.mock.d.ts.map +1 -0
- package/dist/models/__mocks__/support-chat.mock.js +82 -0
- package/dist/models/__mocks__/support-chat.mock.js.map +1 -0
- package/dist/models/chat-customizations.d.ts +42 -0
- package/dist/models/chat-customizations.d.ts.map +1 -0
- package/dist/models/chat-customizations.js +2 -0
- package/dist/models/chat-customizations.js.map +1 -0
- package/dist/models/file-descriptor.d.ts +7 -0
- package/dist/models/file-descriptor.d.ts.map +1 -0
- package/dist/models/file-descriptor.js +2 -0
- package/dist/models/file-descriptor.js.map +1 -0
- package/dist/models/index.d.ts +4 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +4 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/support-chat.d.ts +76 -0
- package/dist/models/support-chat.d.ts.map +1 -0
- package/dist/models/support-chat.js +75 -0
- package/dist/models/support-chat.js.map +1 -0
- package/dist/stores/__tests__/chat-ui.store.test.d.ts +2 -0
- package/dist/stores/__tests__/chat-ui.store.test.d.ts.map +1 -0
- package/dist/stores/__tests__/chat-ui.store.test.js +424 -0
- package/dist/stores/__tests__/chat-ui.store.test.js.map +1 -0
- package/dist/stores/chat-ui-backend-echo.store.d.ts +13 -0
- package/dist/stores/chat-ui-backend-echo.store.d.ts.map +1 -0
- package/dist/stores/chat-ui-backend-echo.store.js +115 -0
- package/dist/stores/chat-ui-backend-echo.store.js.map +1 -0
- package/dist/stores/chat-ui-backend.store.d.ts +6 -0
- package/dist/stores/chat-ui-backend.store.d.ts.map +1 -0
- package/dist/stores/chat-ui-backend.store.js +3 -0
- package/dist/stores/chat-ui-backend.store.js.map +1 -0
- package/dist/stores/chat-ui.store.d.ts +153 -0
- package/dist/stores/chat-ui.store.d.ts.map +1 -0
- package/dist/stores/chat-ui.store.js +683 -0
- package/dist/stores/chat-ui.store.js.map +1 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.d.ts.map +1 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/index.js.map +1 -0
- package/dist/utils/__tests__/text-utils.test.d.ts +2 -0
- package/dist/utils/__tests__/text-utils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/text-utils.test.js +59 -0
- package/dist/utils/__tests__/text-utils.test.js.map +1 -0
- package/dist/utils/test-utils.d.ts +5 -0
- package/dist/utils/test-utils.d.ts.map +1 -0
- package/dist/utils/test-utils.js +17 -0
- package/dist/utils/test-utils.js.map +1 -0
- package/dist/utils/text-utils.d.ts +6 -0
- package/dist/utils/text-utils.d.ts.map +1 -0
- package/dist/utils/text-utils.js +33 -0
- package/dist/utils/text-utils.js.map +1 -0
- package/package.json +36 -0
- package/src/cypress.d.ts +10 -0
- package/src/index.ts +4 -0
- package/src/models/__mocks__/support-chat.mock.ts +105 -0
- package/src/models/chat-customizations.ts +50 -0
- package/src/models/file-descriptor.ts +6 -0
- package/src/models/index.ts +3 -0
- package/src/models/support-chat.ts +117 -0
- package/src/stores/__tests__/chat-ui.store.test.ts +531 -0
- package/src/stores/chat-ui-backend-echo.store.ts +98 -0
- package/src/stores/chat-ui-backend.store.ts +10 -0
- package/src/stores/chat-ui.store.ts +539 -0
- package/src/stores/index.ts +11 -0
- package/src/utils/__tests__/text-utils.test.ts +70 -0
- package/src/utils/test-utils.ts +22 -0
- package/src/utils/text-utils.ts +36 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { expect } from '@jest/globals';
|
|
2
|
+
import { FileDescriptor } from '@servicetitan/form';
|
|
3
|
+
import { Container } from '@servicetitan/react-ioc';
|
|
4
|
+
import {
|
|
5
|
+
ChatCustomizations,
|
|
6
|
+
ChatEndReason,
|
|
7
|
+
ChatError,
|
|
8
|
+
ChatMessageModelBase,
|
|
9
|
+
ChatMessageModelText,
|
|
10
|
+
ChatMessageModelWelcome,
|
|
11
|
+
ChatMessageState,
|
|
12
|
+
ChatParticipantIcon,
|
|
13
|
+
ChatRunState,
|
|
14
|
+
} from '../../models';
|
|
15
|
+
import { initTestContainer } from '../../utils/test-utils';
|
|
16
|
+
import { ChatUiEvent, ChatUiStore, symbolAgent, symbolUser } from '../chat-ui.store';
|
|
17
|
+
|
|
18
|
+
const mockAudio = {
|
|
19
|
+
Audio: {
|
|
20
|
+
play: jest.fn().mockResolvedValue(undefined),
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const mockEventEmitter = {
|
|
24
|
+
on: jest.fn(),
|
|
25
|
+
off: jest.fn(),
|
|
26
|
+
emit: jest.fn(),
|
|
27
|
+
listeners: jest.fn(),
|
|
28
|
+
listenerCount: jest.fn(),
|
|
29
|
+
};
|
|
30
|
+
jest.mock('events', () => ({
|
|
31
|
+
EventEmitter: class {
|
|
32
|
+
on = mockEventEmitter.on;
|
|
33
|
+
off = mockEventEmitter.off;
|
|
34
|
+
emit = mockEventEmitter.emit;
|
|
35
|
+
listeners = mockEventEmitter.listeners;
|
|
36
|
+
listenerCount = mockEventEmitter.listenerCount;
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const initContainer = initTestContainer(ChatUiStore, () => {});
|
|
41
|
+
|
|
42
|
+
describe('[ChatUiStore]', () => {
|
|
43
|
+
let container: Container;
|
|
44
|
+
let store: ChatUiStore<ChatCustomizations>;
|
|
45
|
+
let audioBackup: any;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
container = initContainer();
|
|
49
|
+
store = container.get(ChatUiStore);
|
|
50
|
+
jest.useFakeTimers();
|
|
51
|
+
jest.setSystemTime(Date.parse('2021-01-01T00:00:00.000Z'));
|
|
52
|
+
audioBackup = global.Audio;
|
|
53
|
+
global.Audio = jest.fn().mockImplementation(() => ({
|
|
54
|
+
play: mockAudio.Audio.play,
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
if (audioBackup) {
|
|
60
|
+
global.Audio = audioBackup;
|
|
61
|
+
}
|
|
62
|
+
jest.useRealTimers();
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
mockEventEmitter.on.mockClear();
|
|
65
|
+
mockEventEmitter.off.mockClear();
|
|
66
|
+
mockEventEmitter.emit.mockClear();
|
|
67
|
+
mockEventEmitter.listeners.mockClear();
|
|
68
|
+
mockEventEmitter.listenerCount.mockClear();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('should initialize with default values', () => {
|
|
72
|
+
expect(store.isAgentTyping).toBe(false);
|
|
73
|
+
expect(store.status).toBe(ChatRunState.Offline);
|
|
74
|
+
expect(store.isFilePickerEnabled).toBe(false);
|
|
75
|
+
expect(store.file).toBeUndefined();
|
|
76
|
+
expect(store.currentFileMessage).toBeUndefined();
|
|
77
|
+
expect(store.error).toBeUndefined();
|
|
78
|
+
expect(store.endReason).toBeUndefined();
|
|
79
|
+
expect(store.messages).toEqual([]);
|
|
80
|
+
expect(store.scrollCounter).toBe(1);
|
|
81
|
+
expect(store.timer).toBeUndefined();
|
|
82
|
+
expect(store.participants[symbolAgent].isAgent).toBe(true);
|
|
83
|
+
expect(store.participants[symbolAgent].name).toBe('Agent');
|
|
84
|
+
expect(store.participants[symbolAgent].icon).toBe(ChatParticipantIcon.Initials);
|
|
85
|
+
expect(store.participants[symbolUser].isAgent).toBe(false);
|
|
86
|
+
expect(store.participants[symbolUser].name).toBe('User');
|
|
87
|
+
expect(store.participants[symbolUser].icon).toBe(ChatParticipantIcon.Initials);
|
|
88
|
+
// Getters
|
|
89
|
+
expect(store.agent).toBe(store.participants[symbolAgent]);
|
|
90
|
+
expect(store.user).toBe(store.participants[symbolUser]);
|
|
91
|
+
expect(store.isStarted).toBe(false);
|
|
92
|
+
expect(store.isError).toBe(false);
|
|
93
|
+
expect(store.isEnded).toBe(false);
|
|
94
|
+
expect(store.isStarting).toBe(false);
|
|
95
|
+
expect(store.customizations).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should subscribe and unsubscribe to events', () => {
|
|
99
|
+
const mockCallback = jest.fn();
|
|
100
|
+
store.on('testEvent', mockCallback);
|
|
101
|
+
expect(mockEventEmitter.on).toHaveBeenCalledWith('testEvent', mockCallback);
|
|
102
|
+
|
|
103
|
+
store.off('testEvent', mockCallback);
|
|
104
|
+
expect(mockEventEmitter.off).toHaveBeenCalledWith('testEvent', mockCallback);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should set customizations context', async () => {
|
|
108
|
+
const customizations = { abc: '123' } as ChatCustomizations;
|
|
109
|
+
store.setCustomizationContext(customizations);
|
|
110
|
+
expect(store.customizations).toEqual(customizations);
|
|
111
|
+
await checkScrollTriggered();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('should getLastFailedMessages', () => {
|
|
115
|
+
store.addMessage(false, 'message1');
|
|
116
|
+
const m2 = store.addMessage(false, 'message2');
|
|
117
|
+
const m3 = store.addMessage(false, 'message3');
|
|
118
|
+
expect(store.getLastFailedMessages()).toEqual([]);
|
|
119
|
+
|
|
120
|
+
store.setMessageState(m3, ChatMessageState.Failed);
|
|
121
|
+
let failedMessages = store.getLastFailedMessages();
|
|
122
|
+
expect(failedMessages).toEqual([m3]);
|
|
123
|
+
|
|
124
|
+
store.setMessageState(m2, ChatMessageState.Failed);
|
|
125
|
+
failedMessages = store.getLastFailedMessages();
|
|
126
|
+
expect(failedMessages).toEqual([m3, m2]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('with file messages', () => {
|
|
130
|
+
test('should set file', () => {
|
|
131
|
+
const file = mockFile();
|
|
132
|
+
store.setFile(file);
|
|
133
|
+
expect(store.file).toBe(file);
|
|
134
|
+
|
|
135
|
+
store.setFile(undefined);
|
|
136
|
+
expect(store.file).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('should set file picker enabled state', async () => {
|
|
140
|
+
store.setFile(mockFile());
|
|
141
|
+
store.setFilePickerEnabled(true);
|
|
142
|
+
expect(store.isFilePickerEnabled).toBe(true);
|
|
143
|
+
expect(store.file).toBeUndefined();
|
|
144
|
+
await checkScrollTriggered();
|
|
145
|
+
|
|
146
|
+
store.setFile(mockFile());
|
|
147
|
+
store.setFilePickerEnabled(false);
|
|
148
|
+
expect(store.isFilePickerEnabled).toBe(false);
|
|
149
|
+
expect(store.file).toBeUndefined();
|
|
150
|
+
await checkScrollTriggered();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('with chat errors', () => {
|
|
155
|
+
test('should set error', async () => {
|
|
156
|
+
store.setError('Test error', {
|
|
157
|
+
title: 'Test error title',
|
|
158
|
+
recoverStrategy: { recoverButtonTitle: 'Test error button' },
|
|
159
|
+
data: { some: 'data' },
|
|
160
|
+
});
|
|
161
|
+
expect(store.error?.toJSON()).toEqual({
|
|
162
|
+
data: { some: 'data' },
|
|
163
|
+
message: 'Test error',
|
|
164
|
+
name: 'ChatError',
|
|
165
|
+
recoverStrategy: {
|
|
166
|
+
recoverButtonTitle: 'Test error button',
|
|
167
|
+
},
|
|
168
|
+
title: 'Test error title',
|
|
169
|
+
});
|
|
170
|
+
expect(store.status).toBe(ChatRunState.Error);
|
|
171
|
+
expect(store.isError).toBe(true);
|
|
172
|
+
await checkScrollTriggered();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('should reset error', async () => {
|
|
176
|
+
store.setError('Test error');
|
|
177
|
+
store.resetError(ChatRunState.Started);
|
|
178
|
+
expect(store.error).toBeUndefined();
|
|
179
|
+
expect(store.status).toBe(ChatRunState.Started);
|
|
180
|
+
await checkScrollTriggered();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('with messages', () => {
|
|
185
|
+
function expectText(message: ChatMessageModelBase, expected: string) {
|
|
186
|
+
expect(message.type).toBe('message');
|
|
187
|
+
expect((message as ChatMessageModelText).message).toBe(expected);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test('should add message without sound', async () => {
|
|
191
|
+
store.addMessageWelcome('welcome');
|
|
192
|
+
await jest.runOnlyPendingTimersAsync();
|
|
193
|
+
expect(mockAudio.Audio.play).toHaveBeenCalledTimes(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('should add message', async () => {
|
|
197
|
+
store.setIncomingSound('non-existing-sound.mp3');
|
|
198
|
+
store.setIncomingSound('non-existing-sound2.mp3');
|
|
199
|
+
|
|
200
|
+
// Welcome message
|
|
201
|
+
store.addMessageWelcome('welcome');
|
|
202
|
+
await jest.runOnlyPendingTimersAsync();
|
|
203
|
+
expect(mockAudio.Audio.play).toHaveBeenCalledTimes(1);
|
|
204
|
+
|
|
205
|
+
// Incoming message
|
|
206
|
+
store.addMessage(true, 'message1');
|
|
207
|
+
await jest.runOnlyPendingTimersAsync();
|
|
208
|
+
expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2);
|
|
209
|
+
|
|
210
|
+
// Outgoing message
|
|
211
|
+
store.addMessage(false, 'message2');
|
|
212
|
+
await jest.runOnlyPendingTimersAsync();
|
|
213
|
+
expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2); // No sound for outgoing messages
|
|
214
|
+
|
|
215
|
+
// File message
|
|
216
|
+
store.addMessageFile({
|
|
217
|
+
file: new File([], 'fileName'),
|
|
218
|
+
displayName: 'displayName',
|
|
219
|
+
});
|
|
220
|
+
await jest.runOnlyPendingTimersAsync();
|
|
221
|
+
expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2); // No sound for outgoing messages
|
|
222
|
+
|
|
223
|
+
expect(store.messages.length).toBe(4);
|
|
224
|
+
expect(store.messages[0].participant.isAgent).toBe(true);
|
|
225
|
+
expect((store.messages[0] as ChatMessageModelWelcome).message).toBe('welcome');
|
|
226
|
+
expect(store.messages[1].participant.isAgent).toBe(true);
|
|
227
|
+
expectText(store.messages[1], 'message1');
|
|
228
|
+
expect(store.messages[2].participant.isAgent).toBe(false);
|
|
229
|
+
expectText(store.messages[2], 'message2');
|
|
230
|
+
expect(store.messages[3].participant.isAgent).toBe(false);
|
|
231
|
+
expect(store.messages[3].type).toBe('file');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('should addMessageTimeout', async () => {
|
|
235
|
+
// Adding timeout message should not happen when no messages are present
|
|
236
|
+
store.addMessageTimeout();
|
|
237
|
+
expect(store.messages.length).toBe(0);
|
|
238
|
+
|
|
239
|
+
// Adding timeout message should not happen when no non-welcome messages are present
|
|
240
|
+
store.addMessageWelcome('welcome');
|
|
241
|
+
store.addMessageTimeout();
|
|
242
|
+
expect(store.messages.length).toBe(1);
|
|
243
|
+
expect(store.messages[0].type).toBe('welcome');
|
|
244
|
+
|
|
245
|
+
// Adding timeout message should happen when at least one message is present
|
|
246
|
+
store.addMessage(true, 'message1');
|
|
247
|
+
store.addMessageTimeout();
|
|
248
|
+
expect(store.messages.length).toBe(3);
|
|
249
|
+
expect(store.messages.at(-1)?.type).toBe('timeout');
|
|
250
|
+
|
|
251
|
+
// Adding new message should dismiss the timeout message
|
|
252
|
+
store.addMessage(true, 'message2');
|
|
253
|
+
expect(store.messages.some(message => message.type === 'timeout')).toBe(false);
|
|
254
|
+
await checkScrollTriggered();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('should configure', () => {
|
|
259
|
+
store.configure({
|
|
260
|
+
agentIcon: ChatParticipantIcon.Bot,
|
|
261
|
+
agentName: 'Test Agent',
|
|
262
|
+
});
|
|
263
|
+
expect(store.participants[symbolAgent].icon).toBe(ChatParticipantIcon.Bot);
|
|
264
|
+
expect(store.participants[symbolAgent].name).toBe('Test Agent');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('with chat events', () => {
|
|
268
|
+
test('should run', async () => {
|
|
269
|
+
// Without subscription: should throw an error
|
|
270
|
+
mockEventEmitter.listeners.mockImplementation(() => []);
|
|
271
|
+
await expect(store.run()).rejects.toThrow(
|
|
272
|
+
"No listeners for ChatUiEvent.eventRun ('eventRun')"
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const spyConfigure = jest.spyOn(store, 'configure');
|
|
276
|
+
mockEmit();
|
|
277
|
+
|
|
278
|
+
await store.run(
|
|
279
|
+
{
|
|
280
|
+
agentIcon: ChatParticipantIcon.Bot,
|
|
281
|
+
agentName: 'Test Agent',
|
|
282
|
+
},
|
|
283
|
+
'chat data'
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(spyConfigure).toHaveBeenCalled();
|
|
287
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
288
|
+
ChatUiEvent.eventRun,
|
|
289
|
+
expect.any(Function),
|
|
290
|
+
expect.any(Function),
|
|
291
|
+
'chat data'
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('should recover', async () => {
|
|
296
|
+
mockEmit();
|
|
297
|
+
store.setError('Test error');
|
|
298
|
+
await store.recover();
|
|
299
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
300
|
+
ChatUiEvent.eventRecover,
|
|
301
|
+
expect.any(Function),
|
|
302
|
+
expect.any(Function),
|
|
303
|
+
new ChatError('Test error')
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('should destroy', async () => {
|
|
308
|
+
const spyReset = jest.spyOn(store, 'reset');
|
|
309
|
+
mockEmit();
|
|
310
|
+
await store.destroy();
|
|
311
|
+
expect(spyReset).toHaveBeenCalledWith();
|
|
312
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
313
|
+
ChatUiEvent.eventDestroy,
|
|
314
|
+
expect.any(Function),
|
|
315
|
+
expect.any(Function)
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('should restart', async () => {
|
|
320
|
+
const spyReset = jest.spyOn(store, 'reset');
|
|
321
|
+
mockEmit();
|
|
322
|
+
await store.restart();
|
|
323
|
+
expect(spyReset).toHaveBeenCalledWith();
|
|
324
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
325
|
+
ChatUiEvent.eventRestart,
|
|
326
|
+
expect.any(Function),
|
|
327
|
+
expect.any(Function)
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('should cancelChat', async () => {
|
|
332
|
+
mockEmit();
|
|
333
|
+
await store.cancelChat();
|
|
334
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
335
|
+
ChatUiEvent.eventCancel,
|
|
336
|
+
expect.any(Function),
|
|
337
|
+
expect.any(Function)
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('should endChat', async () => {
|
|
342
|
+
mockEmit();
|
|
343
|
+
await store.endChat(ChatEndReason.ByAgent);
|
|
344
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
345
|
+
ChatUiEvent.eventEndChat,
|
|
346
|
+
expect.any(Function),
|
|
347
|
+
expect.any(Function),
|
|
348
|
+
ChatEndReason.ByAgent
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('should chasitorTyping', async () => {
|
|
353
|
+
mockEmit();
|
|
354
|
+
await store.chasitorTyping(false);
|
|
355
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
356
|
+
ChatUiEvent.eventChasitorTyping,
|
|
357
|
+
expect.any(Function),
|
|
358
|
+
expect.any(Function),
|
|
359
|
+
false
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
await store.chasitorTyping(true);
|
|
363
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
364
|
+
ChatUiEvent.eventChasitorTyping,
|
|
365
|
+
expect.any(Function),
|
|
366
|
+
expect.any(Function),
|
|
367
|
+
true
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('should sendMessageText', async () => {
|
|
372
|
+
mockEmit();
|
|
373
|
+
await store.sendMessageText('Hello, world!');
|
|
374
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
375
|
+
ChatUiEvent.eventMessageSend,
|
|
376
|
+
expect.any(Function),
|
|
377
|
+
expect.any(Function),
|
|
378
|
+
expect.objectContaining({
|
|
379
|
+
message: 'Hello, world!',
|
|
380
|
+
type: 'message',
|
|
381
|
+
state: ChatMessageState.Delivering,
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
expect(store.messages.length).toBe(1);
|
|
385
|
+
expect(store.messages[0].type).toBe('message');
|
|
386
|
+
expect((store.messages[0] as ChatMessageModelText).message).toBe('Hello, world!');
|
|
387
|
+
await checkScrollTriggered();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('should sendMessageText with file', async () => {
|
|
391
|
+
mockEmit();
|
|
392
|
+
const file = mockFile();
|
|
393
|
+
store.setFilePickerEnabled(true);
|
|
394
|
+
store.setFile(file);
|
|
395
|
+
await store.sendMessageText('Hello, world!');
|
|
396
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
397
|
+
ChatUiEvent.eventMessageSendFile,
|
|
398
|
+
expect.any(Function),
|
|
399
|
+
expect.any(Function),
|
|
400
|
+
store.currentFileMessage,
|
|
401
|
+
store.file,
|
|
402
|
+
expect.objectContaining({
|
|
403
|
+
message: 'Hello, world!',
|
|
404
|
+
type: 'message',
|
|
405
|
+
state: ChatMessageState.Delivering,
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
expect(store.messages.length).toBe(2);
|
|
409
|
+
expect(store.messages[0].type).toBe('file');
|
|
410
|
+
expect(store.messages[1].type).toBe('message');
|
|
411
|
+
expect((store.messages[1] as ChatMessageModelText).message).toBe('Hello, world!');
|
|
412
|
+
expect(store.currentFileMessage).toBeDefined();
|
|
413
|
+
await checkScrollTriggered();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('should sendMessageText empty', async () => {
|
|
417
|
+
mockEmit();
|
|
418
|
+
await store.sendMessageText(' ');
|
|
419
|
+
expect(mockEventEmitter.emit).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('should sendMessageRetry', async () => {
|
|
423
|
+
mockEmit();
|
|
424
|
+
|
|
425
|
+
store.setError('Test error');
|
|
426
|
+
const message = store.addMessage(true, 'message1');
|
|
427
|
+
store.setMessageState(message, ChatMessageState.Failed);
|
|
428
|
+
|
|
429
|
+
const p = store.sendMessageRetry(message);
|
|
430
|
+
|
|
431
|
+
expect(message.state).toBe(ChatMessageState.Delivering);
|
|
432
|
+
await p;
|
|
433
|
+
expect(store.isError).toBe(false);
|
|
434
|
+
expect(store.status).toBe(ChatRunState.Started);
|
|
435
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
436
|
+
ChatUiEvent.eventMessageSendRetry,
|
|
437
|
+
expect.any(Function),
|
|
438
|
+
expect.any(Function),
|
|
439
|
+
message
|
|
440
|
+
);
|
|
441
|
+
expect(message.state).toBe(ChatMessageState.Delivering);
|
|
442
|
+
await checkScrollTriggered();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('should sendMessageFileRetry', async () => {
|
|
446
|
+
mockEmit();
|
|
447
|
+
|
|
448
|
+
store.setError('Test error');
|
|
449
|
+
const fileMessage = store.addMessageFile({
|
|
450
|
+
file: new File([], 'fileName'),
|
|
451
|
+
displayName: 'displayName',
|
|
452
|
+
});
|
|
453
|
+
store.setMessageState(fileMessage, ChatMessageState.Failed);
|
|
454
|
+
|
|
455
|
+
const p = store.sendMessageFileRetry(fileMessage);
|
|
456
|
+
|
|
457
|
+
expect(fileMessage.state).toBe(ChatMessageState.Delivering);
|
|
458
|
+
await p;
|
|
459
|
+
expect(store.isError).toBe(false);
|
|
460
|
+
expect(store.status).toBe(ChatRunState.Started);
|
|
461
|
+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
|
462
|
+
ChatUiEvent.eventMessageSendFileRetry,
|
|
463
|
+
expect.any(Function),
|
|
464
|
+
expect.any(Function),
|
|
465
|
+
fileMessage
|
|
466
|
+
);
|
|
467
|
+
expect(fileMessage.state).toBe(ChatMessageState.Delivering);
|
|
468
|
+
await checkScrollTriggered();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('should setTimer', () => {
|
|
473
|
+
store.setTimer({ secondsLeft: 10, secondsTotal: 20 });
|
|
474
|
+
expect(store.timer).toEqual({
|
|
475
|
+
secondsLeft: 10,
|
|
476
|
+
secondsTotal: 20,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('should setAgentTyping', async () => {
|
|
481
|
+
store.setAgentTyping(true);
|
|
482
|
+
expect(store.isAgentTyping).toBe(true);
|
|
483
|
+
await checkScrollTriggered();
|
|
484
|
+
|
|
485
|
+
store.setAgentTyping(false);
|
|
486
|
+
expect(store.isAgentTyping).toBe(false);
|
|
487
|
+
await checkScrollTriggered();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('should setEndReason', async () => {
|
|
491
|
+
store.setEndReason(ChatEndReason.ByTimeout);
|
|
492
|
+
expect(store.endReason).toBe(ChatEndReason.ByTimeout);
|
|
493
|
+
await checkScrollTriggered();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('should setEndedChatState', async () => {
|
|
497
|
+
store.isFilePickerEnabled = true;
|
|
498
|
+
store.setFile(mockFile());
|
|
499
|
+
store.setEndedChatState(ChatEndReason.ByTimeout);
|
|
500
|
+
expect(store.endReason).toBe(ChatEndReason.ByTimeout);
|
|
501
|
+
expect(store.status).toBe(ChatRunState.Ended);
|
|
502
|
+
expect(store.isAgentTyping).toBe(false);
|
|
503
|
+
expect(store.currentFileMessage).toBeUndefined();
|
|
504
|
+
expect(store.file).toBeUndefined();
|
|
505
|
+
expect(store.isFilePickerEnabled).toBe(false);
|
|
506
|
+
await checkScrollTriggered();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
function mockFile() {
|
|
510
|
+
const file: FileDescriptor = {
|
|
511
|
+
file: new File(['content'], 'file.txt', { type: 'text/plain' }),
|
|
512
|
+
displayName: 'file.txt',
|
|
513
|
+
};
|
|
514
|
+
return file;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function mockEmit() {
|
|
518
|
+
mockEventEmitter.listeners.mockImplementation(() => ['1']);
|
|
519
|
+
mockEventEmitter.listenerCount.mockReturnValue(1);
|
|
520
|
+
mockEventEmitter.emit.mockImplementation(async (eventName: string, resolve: () => void) => {
|
|
521
|
+
await Promise.resolve();
|
|
522
|
+
resolve();
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function checkScrollTriggered() {
|
|
527
|
+
const counter = store.scrollCounter;
|
|
528
|
+
await jest.runOnlyPendingTimersAsync();
|
|
529
|
+
expect(counter < store.scrollCounter).toBe(true);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { inject, injectable } from '@servicetitan/react-ioc';
|
|
2
|
+
import {
|
|
3
|
+
ChatMessageModelBase,
|
|
4
|
+
ChatMessageModelText,
|
|
5
|
+
ChatMessageState,
|
|
6
|
+
ChatRunState,
|
|
7
|
+
} from '../models';
|
|
8
|
+
import { IChatUiBackendStore } from './chat-ui-backend.store';
|
|
9
|
+
import {
|
|
10
|
+
CHAT_UI_STORE_TOKEN,
|
|
11
|
+
ChatUiEvent,
|
|
12
|
+
ChatUiEventListener,
|
|
13
|
+
IChatUiStore,
|
|
14
|
+
} from './chat-ui.store';
|
|
15
|
+
|
|
16
|
+
@injectable()
|
|
17
|
+
export class ChatUiBackendEchoStore implements IChatUiBackendStore {
|
|
18
|
+
constructor(@inject(CHAT_UI_STORE_TOKEN) readonly chatUiStore: IChatUiStore) {}
|
|
19
|
+
|
|
20
|
+
subscribe() {
|
|
21
|
+
this.chatUiStore.on(ChatUiEvent.eventRun, this.handleRun);
|
|
22
|
+
this.chatUiStore.on(ChatUiEvent.eventMessageSend, this.handleTextMessageSend);
|
|
23
|
+
this.chatUiStore.on(ChatUiEvent.eventMessageSendRetry, this.handleMessageSendRetry);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
unsubscribe() {
|
|
27
|
+
this.chatUiStore.off(ChatUiEvent.eventRun, this.handleRun);
|
|
28
|
+
this.chatUiStore.off(ChatUiEvent.eventMessageSend, this.handleTextMessageSend);
|
|
29
|
+
this.chatUiStore.off(ChatUiEvent.eventMessageSendRetry, this.handleMessageSendRetry);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private handleRun: ChatUiEventListener = async (resolve, _) => {
|
|
33
|
+
this.chatUiStore.setStatus(ChatRunState.Initializing);
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
35
|
+
this.chatUiStore.addMessageWelcome(
|
|
36
|
+
"Hello! I'm generic echo bot. I can echo your messages. Try it out!"
|
|
37
|
+
);
|
|
38
|
+
this.chatUiStore.setStatus(ChatRunState.Started);
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
private handleTextMessageSend: ChatUiEventListener = async (
|
|
43
|
+
resolve,
|
|
44
|
+
_,
|
|
45
|
+
message: ChatMessageModelText
|
|
46
|
+
) => {
|
|
47
|
+
const isErrorSimulated = message.message.startsWith('[Error]');
|
|
48
|
+
try {
|
|
49
|
+
this.chatUiStore.setAgentTyping(true);
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
51
|
+
if (isErrorSimulated) {
|
|
52
|
+
throw new Error(message.message.split('[Error]')[1]);
|
|
53
|
+
}
|
|
54
|
+
this.chatUiStore.setMessageState(message, ChatMessageState.Delivered);
|
|
55
|
+
this.chatUiStore.addMessage(true, this.getEchoAnswer(message));
|
|
56
|
+
this.chatUiStore.resetError(ChatRunState.Started);
|
|
57
|
+
resolve();
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
this.chatUiStore.setMessageState(message, ChatMessageState.Failed);
|
|
60
|
+
this.chatUiStore.setError(error?.message ?? error, {
|
|
61
|
+
title: 'Custom Error Title',
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
this.chatUiStore.setAgentTyping(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
private handleMessageSendRetry: ChatUiEventListener = async (
|
|
69
|
+
resolve,
|
|
70
|
+
_,
|
|
71
|
+
message: ChatMessageModelBase
|
|
72
|
+
) => {
|
|
73
|
+
try {
|
|
74
|
+
this.chatUiStore.setMessageState(message, ChatMessageState.Delivering);
|
|
75
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
76
|
+
this.chatUiStore.setMessageState(message, ChatMessageState.Delivered);
|
|
77
|
+
this.chatUiStore.addMessage(true, this.getEchoAnswer(message));
|
|
78
|
+
this.chatUiStore.resetError(ChatRunState.Started);
|
|
79
|
+
resolve();
|
|
80
|
+
} catch (error: any) {
|
|
81
|
+
this.chatUiStore.setMessageState(message, ChatMessageState.Failed);
|
|
82
|
+
this.chatUiStore.setError(error?.message ?? error, {
|
|
83
|
+
title: 'Custom Error Title',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private getEchoAnswer(message: ChatMessageModelBase) {
|
|
89
|
+
if (message.type === 'message') {
|
|
90
|
+
let messageText = (message as ChatMessageModelText).message;
|
|
91
|
+
if (messageText.startsWith('[Error]')) {
|
|
92
|
+
messageText = messageText.split('[Error]')[1];
|
|
93
|
+
}
|
|
94
|
+
return `Echo: ${messageText}`;
|
|
95
|
+
}
|
|
96
|
+
return `Echo bot cannot answer to message type ${message.type} yet`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { symbolToken } from '@servicetitan/react-ioc';
|
|
2
|
+
|
|
3
|
+
export const CHAT_UI_BACKEND_STORE_TOKEN = symbolToken<IChatUiBackendStore>(
|
|
4
|
+
'CHAT_UI_BACKEND_STORE_TOKEN'
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
export interface IChatUiBackendStore {
|
|
8
|
+
subscribe(): void;
|
|
9
|
+
unsubscribe(): void;
|
|
10
|
+
}
|