@cdevhub/ngx-chat 1.0.6
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/README.md +382 -0
- package/fesm2022/cdevhub-ngx-chat-testing.mjs +950 -0
- package/fesm2022/cdevhub-ngx-chat-testing.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-chat.mjs +11973 -0
- package/fesm2022/cdevhub-ngx-chat.mjs.map +1 -0
- package/package.json +55 -0
- package/types/cdevhub-ngx-chat-testing.d.ts +362 -0
- package/types/cdevhub-ngx-chat.d.ts +6973 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Test harness for ChatComponent.
|
|
5
|
+
* Provides ergonomic methods for testing chat interactions.
|
|
6
|
+
* @module ngx-chat/testing
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Test harness for ChatComponent.
|
|
10
|
+
*
|
|
11
|
+
* Provides methods for:
|
|
12
|
+
* - Sending messages
|
|
13
|
+
* - Typing in the input
|
|
14
|
+
* - Querying message state
|
|
15
|
+
* - Checking component state
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const harness = await loader.getHarness(ChatHarness);
|
|
20
|
+
* await harness.typeMessage('Hello');
|
|
21
|
+
* await harness.sendMessage('Hello');
|
|
22
|
+
* const count = await harness.getMessageCount();
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
class ChatHarness extends ComponentHarness {
|
|
26
|
+
static hostSelector = 'ngx-chat';
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
// Element Locators
|
|
29
|
+
// ===========================================================================
|
|
30
|
+
_textarea = this.locatorFor('.ngx-chat-sender__textarea');
|
|
31
|
+
_sendButton = this.locatorFor('.ngx-chat-sender__send-btn');
|
|
32
|
+
_messagesList = this.locatorForOptional('.ngx-chat__messages-list');
|
|
33
|
+
_messages = this.locatorForAll('.ngx-chat__message-placeholder');
|
|
34
|
+
_messageTexts = this.locatorForAll('.ngx-chat__message-text');
|
|
35
|
+
_typingIndicator = this.locatorForOptional('.ngx-chat__typing');
|
|
36
|
+
_loadingIndicator = this.locatorForOptional('.ngx-chat__loading');
|
|
37
|
+
_emptyState = this.locatorForOptional('.ngx-chat__empty');
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
// Static Methods
|
|
40
|
+
// ===========================================================================
|
|
41
|
+
/**
|
|
42
|
+
* Gets a `HarnessPredicate` that can be used to search for a chat harness
|
|
43
|
+
* that meets certain criteria.
|
|
44
|
+
* @param options Options for filtering which chat instances are considered a match.
|
|
45
|
+
* @returns A predicate for finding matching chat harnesses.
|
|
46
|
+
*/
|
|
47
|
+
static with(options = {}) {
|
|
48
|
+
return new HarnessPredicate(ChatHarness, options)
|
|
49
|
+
.addOption('disabled', options.disabled, async (harness, disabled) => {
|
|
50
|
+
return (await harness.isDisabled()) === disabled;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// ===========================================================================
|
|
54
|
+
// Action Methods
|
|
55
|
+
// ===========================================================================
|
|
56
|
+
/**
|
|
57
|
+
* Types a message and sends it.
|
|
58
|
+
* Combines typeMessage and clicking send button.
|
|
59
|
+
* @param content The message content to send.
|
|
60
|
+
*/
|
|
61
|
+
async sendMessage(content) {
|
|
62
|
+
await this.typeMessage(content);
|
|
63
|
+
const sendButton = await this._sendButton();
|
|
64
|
+
await sendButton.click();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Types content into the message input.
|
|
68
|
+
* Clears existing content first.
|
|
69
|
+
* @param content The content to type.
|
|
70
|
+
*/
|
|
71
|
+
async typeMessage(content) {
|
|
72
|
+
const textarea = await this._textarea();
|
|
73
|
+
await textarea.clear();
|
|
74
|
+
await textarea.sendKeys(content);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clears the message input.
|
|
78
|
+
*/
|
|
79
|
+
async clearInput() {
|
|
80
|
+
const textarea = await this._textarea();
|
|
81
|
+
await textarea.clear();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Focuses the message input.
|
|
85
|
+
*/
|
|
86
|
+
async focusInput() {
|
|
87
|
+
const textarea = await this._textarea();
|
|
88
|
+
await textarea.focus();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Blurs the message input.
|
|
92
|
+
*/
|
|
93
|
+
async blurInput() {
|
|
94
|
+
const textarea = await this._textarea();
|
|
95
|
+
await textarea.blur();
|
|
96
|
+
}
|
|
97
|
+
// ===========================================================================
|
|
98
|
+
// Query Methods
|
|
99
|
+
// ===========================================================================
|
|
100
|
+
/**
|
|
101
|
+
* Gets the number of messages displayed.
|
|
102
|
+
* @returns The message count.
|
|
103
|
+
*/
|
|
104
|
+
async getMessageCount() {
|
|
105
|
+
const messages = await this._messages();
|
|
106
|
+
return messages.length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Gets all messages with their content and sender info.
|
|
110
|
+
* @returns Array of message data.
|
|
111
|
+
*/
|
|
112
|
+
async getMessages() {
|
|
113
|
+
const messages = await this._messages();
|
|
114
|
+
const messageTexts = await this._messageTexts();
|
|
115
|
+
const result = [];
|
|
116
|
+
for (let i = 0; i < messages.length; i++) {
|
|
117
|
+
const message = messages[i];
|
|
118
|
+
const classes = await message.getAttribute('class');
|
|
119
|
+
let sender = 'other';
|
|
120
|
+
if (classes?.includes('--self')) {
|
|
121
|
+
sender = 'self';
|
|
122
|
+
}
|
|
123
|
+
else if (classes?.includes('--system')) {
|
|
124
|
+
sender = 'system';
|
|
125
|
+
}
|
|
126
|
+
// Get content from corresponding text element
|
|
127
|
+
const content = i < messageTexts.length ? await messageTexts[i].text() : '';
|
|
128
|
+
// Get sender name from full text if present (appears before message content for 'other')
|
|
129
|
+
// The sender name element is separate, so we extract from full message text
|
|
130
|
+
const fullText = await message.text();
|
|
131
|
+
let senderName;
|
|
132
|
+
if (sender === 'other' && fullText !== content && fullText.includes(content)) {
|
|
133
|
+
// Extract sender name (text before content)
|
|
134
|
+
const idx = fullText.indexOf(content);
|
|
135
|
+
if (idx > 0) {
|
|
136
|
+
senderName = fullText.substring(0, idx).trim();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
result.push({ content, sender, senderName });
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Gets the content of the last message.
|
|
145
|
+
* @returns The last message content, or null if no messages.
|
|
146
|
+
*/
|
|
147
|
+
async getLastMessageContent() {
|
|
148
|
+
const messageTexts = await this._messageTexts();
|
|
149
|
+
if (messageTexts.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const lastText = messageTexts[messageTexts.length - 1];
|
|
153
|
+
return await lastText.text();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Gets the current value of the message input.
|
|
157
|
+
* @returns The input value.
|
|
158
|
+
*/
|
|
159
|
+
async getInputValue() {
|
|
160
|
+
const textarea = await this._textarea();
|
|
161
|
+
return await textarea.getProperty('value');
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Checks if the chat is disabled.
|
|
165
|
+
* @returns True if disabled.
|
|
166
|
+
*/
|
|
167
|
+
async isDisabled() {
|
|
168
|
+
const host = await this.host();
|
|
169
|
+
return (await host.hasClass('ngx-chat--disabled'));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Checks if the send button is enabled (can send a message).
|
|
173
|
+
* @returns True if can send.
|
|
174
|
+
*/
|
|
175
|
+
async canSend() {
|
|
176
|
+
const sendButton = await this._sendButton();
|
|
177
|
+
const disabled = await sendButton.getProperty('disabled');
|
|
178
|
+
return !disabled;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Checks if the typing indicator is visible.
|
|
182
|
+
* @returns True if typing indicator is shown.
|
|
183
|
+
*/
|
|
184
|
+
async isTypingIndicatorVisible() {
|
|
185
|
+
const typing = await this._typingIndicator();
|
|
186
|
+
return typing !== null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Checks if the loading indicator is visible.
|
|
190
|
+
* @returns True if loading.
|
|
191
|
+
*/
|
|
192
|
+
async isLoading() {
|
|
193
|
+
const loading = await this._loadingIndicator();
|
|
194
|
+
return loading !== null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Checks if the empty state is visible.
|
|
198
|
+
* @returns True if showing empty state.
|
|
199
|
+
*/
|
|
200
|
+
async isEmpty() {
|
|
201
|
+
const empty = await this._emptyState();
|
|
202
|
+
return empty !== null;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Checks if any messages are displayed.
|
|
206
|
+
* @returns True if messages are visible.
|
|
207
|
+
*/
|
|
208
|
+
async hasMessages() {
|
|
209
|
+
const list = await this._messagesList();
|
|
210
|
+
return list !== null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Gets the placeholder text of the input.
|
|
214
|
+
* @returns The placeholder text.
|
|
215
|
+
*/
|
|
216
|
+
async getPlaceholder() {
|
|
217
|
+
const textarea = await this._textarea();
|
|
218
|
+
return await textarea.getProperty('placeholder');
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Checks if the input has focus.
|
|
222
|
+
* @returns True if input is focused.
|
|
223
|
+
*/
|
|
224
|
+
async isInputFocused() {
|
|
225
|
+
const textarea = await this._textarea();
|
|
226
|
+
return await textarea.isFocused();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @fileoverview Mock data and test scenarios for ngx-chat testing.
|
|
232
|
+
* Provides pre-built scenarios and generator functions for comprehensive testing.
|
|
233
|
+
* @module ngx-chat/testing
|
|
234
|
+
*/
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// Helper Functions
|
|
237
|
+
// ============================================================================
|
|
238
|
+
/**
|
|
239
|
+
* Creates a unique ID with optional prefix.
|
|
240
|
+
*/
|
|
241
|
+
function createId(prefix = 'msg') {
|
|
242
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Creates a timestamp offset from now by the given milliseconds.
|
|
246
|
+
*/
|
|
247
|
+
function createTimestamp(offsetMs = 0) {
|
|
248
|
+
return new Date(Date.now() - offsetMs);
|
|
249
|
+
}
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Sample Actions
|
|
252
|
+
// ============================================================================
|
|
253
|
+
/**
|
|
254
|
+
* Sample confirm action for testing.
|
|
255
|
+
*/
|
|
256
|
+
const SAMPLE_CONFIRM_ACTION = {
|
|
257
|
+
type: 'confirm',
|
|
258
|
+
id: 'confirm-1',
|
|
259
|
+
confirmText: 'Yes',
|
|
260
|
+
cancelText: 'No',
|
|
261
|
+
confirmVariant: 'primary',
|
|
262
|
+
cancelVariant: 'ghost',
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Sample select action for testing.
|
|
266
|
+
*/
|
|
267
|
+
const SAMPLE_SELECT_ACTION = {
|
|
268
|
+
type: 'select',
|
|
269
|
+
id: 'select-1',
|
|
270
|
+
label: 'Choose an option:',
|
|
271
|
+
placeholder: 'Select...',
|
|
272
|
+
options: [
|
|
273
|
+
{ id: 'opt-1', label: 'Option One' },
|
|
274
|
+
{ id: 'opt-2', label: 'Option Two' },
|
|
275
|
+
{ id: 'opt-3', label: 'Option Three' },
|
|
276
|
+
],
|
|
277
|
+
searchable: true,
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Sample multi-select action for testing.
|
|
281
|
+
*/
|
|
282
|
+
const SAMPLE_MULTI_SELECT_ACTION = {
|
|
283
|
+
type: 'multi-select',
|
|
284
|
+
id: 'multi-select-1',
|
|
285
|
+
label: 'Select multiple:',
|
|
286
|
+
options: [
|
|
287
|
+
{ id: 'feature-1', label: 'Feature A' },
|
|
288
|
+
{ id: 'feature-2', label: 'Feature B' },
|
|
289
|
+
{ id: 'feature-3', label: 'Feature C' },
|
|
290
|
+
{ id: 'feature-4', label: 'Feature D' },
|
|
291
|
+
],
|
|
292
|
+
minSelect: 1,
|
|
293
|
+
maxSelect: 3,
|
|
294
|
+
submitText: 'Apply',
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Sample buttons action for testing.
|
|
298
|
+
*/
|
|
299
|
+
const SAMPLE_BUTTONS_ACTION = {
|
|
300
|
+
type: 'buttons',
|
|
301
|
+
id: 'buttons-1',
|
|
302
|
+
layout: 'horizontal',
|
|
303
|
+
buttons: [
|
|
304
|
+
{ id: 'btn-yes', label: 'Yes', variant: 'primary' },
|
|
305
|
+
{ id: 'btn-no', label: 'No', variant: 'secondary' },
|
|
306
|
+
{ id: 'btn-maybe', label: 'Maybe', variant: 'ghost' },
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Sample Attachments
|
|
311
|
+
// ============================================================================
|
|
312
|
+
/**
|
|
313
|
+
* Sample image attachment.
|
|
314
|
+
*/
|
|
315
|
+
const SAMPLE_IMAGE_ATTACHMENT = {
|
|
316
|
+
id: 'att-img-1',
|
|
317
|
+
type: 'image',
|
|
318
|
+
url: 'https://example.com/image.jpg',
|
|
319
|
+
name: 'photo.jpg',
|
|
320
|
+
size: 102400,
|
|
321
|
+
mimeType: 'image/jpeg',
|
|
322
|
+
thumbnail: 'https://example.com/thumb.jpg',
|
|
323
|
+
dimensions: { width: 1920, height: 1080 },
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Sample file attachment.
|
|
327
|
+
*/
|
|
328
|
+
const SAMPLE_FILE_ATTACHMENT = {
|
|
329
|
+
id: 'att-file-1',
|
|
330
|
+
type: 'file',
|
|
331
|
+
url: 'https://example.com/document.pdf',
|
|
332
|
+
name: 'document.pdf',
|
|
333
|
+
size: 2048000,
|
|
334
|
+
mimeType: 'application/pdf',
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* Sample video attachment.
|
|
338
|
+
*/
|
|
339
|
+
const SAMPLE_VIDEO_ATTACHMENT = {
|
|
340
|
+
id: 'att-video-1',
|
|
341
|
+
type: 'video',
|
|
342
|
+
url: 'https://example.com/video.mp4',
|
|
343
|
+
name: 'recording.mp4',
|
|
344
|
+
size: 10485760,
|
|
345
|
+
mimeType: 'video/mp4',
|
|
346
|
+
thumbnail: 'https://example.com/video-thumb.jpg',
|
|
347
|
+
dimensions: { width: 1280, height: 720 },
|
|
348
|
+
duration: 120,
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Sample audio attachment.
|
|
352
|
+
*/
|
|
353
|
+
const SAMPLE_AUDIO_ATTACHMENT = {
|
|
354
|
+
id: 'att-audio-1',
|
|
355
|
+
type: 'audio',
|
|
356
|
+
url: 'https://example.com/audio.mp3',
|
|
357
|
+
name: 'voice-note.mp3',
|
|
358
|
+
size: 1024000,
|
|
359
|
+
mimeType: 'audio/mpeg',
|
|
360
|
+
duration: 45,
|
|
361
|
+
};
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// Sample Errors
|
|
364
|
+
// ============================================================================
|
|
365
|
+
/**
|
|
366
|
+
* Sample network error.
|
|
367
|
+
*/
|
|
368
|
+
const SAMPLE_NETWORK_ERROR = {
|
|
369
|
+
code: 'NETWORK_ERROR',
|
|
370
|
+
message: 'Network connection failed',
|
|
371
|
+
retryable: true,
|
|
372
|
+
retryCount: 0,
|
|
373
|
+
};
|
|
374
|
+
/**
|
|
375
|
+
* Sample timeout error.
|
|
376
|
+
*/
|
|
377
|
+
const SAMPLE_TIMEOUT_ERROR = {
|
|
378
|
+
code: 'TIMEOUT',
|
|
379
|
+
message: 'Request timed out',
|
|
380
|
+
retryable: true,
|
|
381
|
+
retryCount: 1,
|
|
382
|
+
lastRetryAt: new Date(),
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Sample rate limit error.
|
|
386
|
+
*/
|
|
387
|
+
const SAMPLE_RATE_LIMIT_ERROR = {
|
|
388
|
+
code: 'RATE_LIMITED',
|
|
389
|
+
message: 'Too many requests. Please wait.',
|
|
390
|
+
retryable: true,
|
|
391
|
+
retryCount: 0,
|
|
392
|
+
};
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// Mock Scenarios
|
|
395
|
+
// ============================================================================
|
|
396
|
+
/**
|
|
397
|
+
* Pre-built test scenarios for common testing needs.
|
|
398
|
+
* Each scenario provides a complete, valid ChatMessage[] array.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```typescript
|
|
402
|
+
* // In a test
|
|
403
|
+
* const messages = MOCK_SCENARIOS.simpleConversation;
|
|
404
|
+
* fixture.componentRef.setInput('messages', messages);
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
const MOCK_SCENARIOS = {
|
|
408
|
+
/**
|
|
409
|
+
* Empty message list for testing empty states.
|
|
410
|
+
*/
|
|
411
|
+
empty: [],
|
|
412
|
+
/**
|
|
413
|
+
* Single message from self.
|
|
414
|
+
*/
|
|
415
|
+
singleMessage: [
|
|
416
|
+
{
|
|
417
|
+
id: 'msg-single-1',
|
|
418
|
+
sender: 'self',
|
|
419
|
+
content: 'Hello, world!',
|
|
420
|
+
timestamp: createTimestamp(0),
|
|
421
|
+
status: 'sent',
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
/**
|
|
425
|
+
* Simple back-and-forth conversation.
|
|
426
|
+
*/
|
|
427
|
+
simpleConversation: [
|
|
428
|
+
{
|
|
429
|
+
id: 'msg-conv-1',
|
|
430
|
+
sender: 'self',
|
|
431
|
+
content: 'Hi there!',
|
|
432
|
+
timestamp: createTimestamp(300000),
|
|
433
|
+
status: 'read',
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: 'msg-conv-2',
|
|
437
|
+
sender: 'other',
|
|
438
|
+
content: 'Hello! How can I help you today?',
|
|
439
|
+
timestamp: createTimestamp(240000),
|
|
440
|
+
senderName: 'Assistant',
|
|
441
|
+
avatar: 'https://example.com/bot-avatar.png',
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: 'msg-conv-3',
|
|
445
|
+
sender: 'self',
|
|
446
|
+
content: 'I have a question about my order.',
|
|
447
|
+
timestamp: createTimestamp(180000),
|
|
448
|
+
status: 'read',
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
id: 'msg-conv-4',
|
|
452
|
+
sender: 'other',
|
|
453
|
+
content: 'Of course! Please provide your order number and I\'ll look it up for you.',
|
|
454
|
+
timestamp: createTimestamp(120000),
|
|
455
|
+
senderName: 'Assistant',
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: 'msg-conv-5',
|
|
459
|
+
sender: 'self',
|
|
460
|
+
content: 'It\'s #12345',
|
|
461
|
+
timestamp: createTimestamp(60000),
|
|
462
|
+
status: 'delivered',
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
/**
|
|
466
|
+
* Messages with various actions attached.
|
|
467
|
+
*/
|
|
468
|
+
withActions: [
|
|
469
|
+
{
|
|
470
|
+
id: 'msg-action-1',
|
|
471
|
+
sender: 'other',
|
|
472
|
+
content: 'Would you like to proceed with the order?',
|
|
473
|
+
timestamp: createTimestamp(180000),
|
|
474
|
+
senderName: 'Assistant',
|
|
475
|
+
actions: [SAMPLE_CONFIRM_ACTION],
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: 'msg-action-2',
|
|
479
|
+
sender: 'other',
|
|
480
|
+
content: 'Please select your preferred shipping method:',
|
|
481
|
+
timestamp: createTimestamp(120000),
|
|
482
|
+
senderName: 'Assistant',
|
|
483
|
+
actions: [SAMPLE_SELECT_ACTION],
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
/**
|
|
487
|
+
* Message with multi-select action.
|
|
488
|
+
*/
|
|
489
|
+
withMultiSelect: [
|
|
490
|
+
{
|
|
491
|
+
id: 'msg-multi-1',
|
|
492
|
+
sender: 'other',
|
|
493
|
+
content: 'Which features would you like to enable?',
|
|
494
|
+
timestamp: createTimestamp(60000),
|
|
495
|
+
senderName: 'Assistant',
|
|
496
|
+
actions: [SAMPLE_MULTI_SELECT_ACTION],
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
/**
|
|
500
|
+
* Message with buttons action.
|
|
501
|
+
*/
|
|
502
|
+
withButtons: [
|
|
503
|
+
{
|
|
504
|
+
id: 'msg-btn-1',
|
|
505
|
+
sender: 'other',
|
|
506
|
+
content: 'How would you rate your experience?',
|
|
507
|
+
timestamp: createTimestamp(60000),
|
|
508
|
+
senderName: 'Assistant',
|
|
509
|
+
actions: [SAMPLE_BUTTONS_ACTION],
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
/**
|
|
513
|
+
* Messages with error states.
|
|
514
|
+
*/
|
|
515
|
+
withErrors: [
|
|
516
|
+
{
|
|
517
|
+
id: 'msg-err-1',
|
|
518
|
+
sender: 'self',
|
|
519
|
+
content: 'This message failed to send',
|
|
520
|
+
timestamp: createTimestamp(120000),
|
|
521
|
+
status: 'error',
|
|
522
|
+
error: SAMPLE_NETWORK_ERROR,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: 'msg-err-2',
|
|
526
|
+
sender: 'self',
|
|
527
|
+
content: 'This one timed out',
|
|
528
|
+
timestamp: createTimestamp(60000),
|
|
529
|
+
status: 'error',
|
|
530
|
+
error: SAMPLE_TIMEOUT_ERROR,
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: 'msg-err-3',
|
|
534
|
+
sender: 'self',
|
|
535
|
+
content: 'Rate limited',
|
|
536
|
+
timestamp: createTimestamp(0),
|
|
537
|
+
status: 'error',
|
|
538
|
+
error: SAMPLE_RATE_LIMIT_ERROR,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
/**
|
|
542
|
+
* Messages with file attachments.
|
|
543
|
+
*/
|
|
544
|
+
withAttachments: [
|
|
545
|
+
{
|
|
546
|
+
id: 'msg-att-1',
|
|
547
|
+
sender: 'self',
|
|
548
|
+
content: 'Here is the photo you requested',
|
|
549
|
+
timestamp: createTimestamp(180000),
|
|
550
|
+
status: 'sent',
|
|
551
|
+
attachments: [SAMPLE_IMAGE_ATTACHMENT],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: 'msg-att-2',
|
|
555
|
+
sender: 'other',
|
|
556
|
+
content: 'Thanks! Here is the document.',
|
|
557
|
+
timestamp: createTimestamp(120000),
|
|
558
|
+
senderName: 'Assistant',
|
|
559
|
+
attachments: [SAMPLE_FILE_ATTACHMENT],
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
id: 'msg-att-3',
|
|
563
|
+
sender: 'self',
|
|
564
|
+
content: 'Check out this video and audio',
|
|
565
|
+
timestamp: createTimestamp(60000),
|
|
566
|
+
status: 'delivered',
|
|
567
|
+
attachments: [SAMPLE_VIDEO_ATTACHMENT, SAMPLE_AUDIO_ATTACHMENT],
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
/**
|
|
571
|
+
* Long conversation with 100 messages for scroll testing.
|
|
572
|
+
*/
|
|
573
|
+
longConversation: generateConversation(100),
|
|
574
|
+
/**
|
|
575
|
+
* Large dataset with 1000 messages for performance testing.
|
|
576
|
+
*/
|
|
577
|
+
performanceTest: generateConversation(1000),
|
|
578
|
+
/**
|
|
579
|
+
* RTL content for internationalization testing.
|
|
580
|
+
*/
|
|
581
|
+
rtlContent: [
|
|
582
|
+
{
|
|
583
|
+
id: 'msg-rtl-1',
|
|
584
|
+
sender: 'self',
|
|
585
|
+
content: 'مرحبا، كيف حالك؟',
|
|
586
|
+
timestamp: createTimestamp(120000),
|
|
587
|
+
status: 'sent',
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
id: 'msg-rtl-2',
|
|
591
|
+
sender: 'other',
|
|
592
|
+
content: 'أنا بخير، شكرا لك! كيف يمكنني مساعدتك اليوم؟',
|
|
593
|
+
timestamp: createTimestamp(60000),
|
|
594
|
+
senderName: 'المساعد',
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
id: 'msg-rtl-3',
|
|
598
|
+
sender: 'self',
|
|
599
|
+
content: 'שלום! מה נשמע?',
|
|
600
|
+
timestamp: createTimestamp(0),
|
|
601
|
+
status: 'delivered',
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
/**
|
|
605
|
+
* Mixed content with markdown, code, and special characters.
|
|
606
|
+
*/
|
|
607
|
+
mixedContent: [
|
|
608
|
+
{
|
|
609
|
+
id: 'msg-mix-1',
|
|
610
|
+
sender: 'self',
|
|
611
|
+
content: 'Can you show me a code example?',
|
|
612
|
+
timestamp: createTimestamp(180000),
|
|
613
|
+
status: 'read',
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
id: 'msg-mix-2',
|
|
617
|
+
sender: 'other',
|
|
618
|
+
content: `Here's a TypeScript example:
|
|
619
|
+
|
|
620
|
+
\`\`\`typescript
|
|
621
|
+
function greet(name: string): string {
|
|
622
|
+
return \`Hello, \${name}!\`;
|
|
623
|
+
}
|
|
624
|
+
\`\`\`
|
|
625
|
+
|
|
626
|
+
You can also use **bold** and *italic* text.`,
|
|
627
|
+
timestamp: createTimestamp(120000),
|
|
628
|
+
senderName: 'Assistant',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: 'msg-mix-3',
|
|
632
|
+
sender: 'other',
|
|
633
|
+
content: 'Special characters: <>&"\' and emoji: 😀🎉',
|
|
634
|
+
timestamp: createTimestamp(60000),
|
|
635
|
+
senderName: 'Assistant',
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
/**
|
|
639
|
+
* System messages for notifications and status updates.
|
|
640
|
+
*/
|
|
641
|
+
systemMessages: [
|
|
642
|
+
{
|
|
643
|
+
id: 'msg-sys-1',
|
|
644
|
+
sender: 'system',
|
|
645
|
+
content: 'Chat session started',
|
|
646
|
+
timestamp: createTimestamp(300000),
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: 'msg-sys-2',
|
|
650
|
+
sender: 'self',
|
|
651
|
+
content: 'Hello!',
|
|
652
|
+
timestamp: createTimestamp(240000),
|
|
653
|
+
status: 'sent',
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
id: 'msg-sys-3',
|
|
657
|
+
sender: 'system',
|
|
658
|
+
content: 'Assistant joined the conversation',
|
|
659
|
+
timestamp: createTimestamp(180000),
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
id: 'msg-sys-4',
|
|
663
|
+
sender: 'other',
|
|
664
|
+
content: 'Hi there! How can I help?',
|
|
665
|
+
timestamp: createTimestamp(120000),
|
|
666
|
+
senderName: 'Assistant',
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
id: 'msg-sys-5',
|
|
670
|
+
sender: 'system',
|
|
671
|
+
content: 'This conversation will end in 5 minutes',
|
|
672
|
+
timestamp: createTimestamp(60000),
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
};
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// Generator Functions
|
|
678
|
+
// ============================================================================
|
|
679
|
+
/**
|
|
680
|
+
* Generates a conversation with alternating self/other messages.
|
|
681
|
+
*
|
|
682
|
+
* @param count - Number of messages to generate
|
|
683
|
+
* @returns Array of ChatMessage objects
|
|
684
|
+
*
|
|
685
|
+
* @example
|
|
686
|
+
* ```typescript
|
|
687
|
+
* const messages = generateConversation(50);
|
|
688
|
+
* expect(messages.length).toBe(50);
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
function generateConversation(count) {
|
|
692
|
+
if (count < 0) {
|
|
693
|
+
throw new Error('Count must be non-negative');
|
|
694
|
+
}
|
|
695
|
+
const messages = [];
|
|
696
|
+
const baseTime = Date.now();
|
|
697
|
+
for (let i = 0; i < count; i++) {
|
|
698
|
+
const isUser = i % 2 === 0;
|
|
699
|
+
const message = {
|
|
700
|
+
id: `msg-gen-${i}`,
|
|
701
|
+
sender: isUser ? 'self' : 'other',
|
|
702
|
+
content: isUser
|
|
703
|
+
? `User message ${Math.floor(i / 2) + 1}`
|
|
704
|
+
: `Assistant response ${Math.floor(i / 2) + 1}`,
|
|
705
|
+
timestamp: new Date(baseTime - (count - i) * 60000),
|
|
706
|
+
...(isUser
|
|
707
|
+
? { status: 'read' }
|
|
708
|
+
: { senderName: 'Assistant' }),
|
|
709
|
+
};
|
|
710
|
+
messages.push(message);
|
|
711
|
+
}
|
|
712
|
+
return messages;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Generates messages with various action types attached.
|
|
716
|
+
*
|
|
717
|
+
* @param count - Number of messages to generate (each will have an action)
|
|
718
|
+
* @returns Array of ChatMessage objects with actions
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* ```typescript
|
|
722
|
+
* const messages = generateWithActions(10);
|
|
723
|
+
* expect(messages.every(m => m.actions?.length)).toBe(true);
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
function generateWithActions(count) {
|
|
727
|
+
if (count < 0) {
|
|
728
|
+
throw new Error('Count must be non-negative');
|
|
729
|
+
}
|
|
730
|
+
const actions = [
|
|
731
|
+
SAMPLE_CONFIRM_ACTION,
|
|
732
|
+
SAMPLE_SELECT_ACTION,
|
|
733
|
+
SAMPLE_MULTI_SELECT_ACTION,
|
|
734
|
+
SAMPLE_BUTTONS_ACTION,
|
|
735
|
+
];
|
|
736
|
+
const messages = [];
|
|
737
|
+
const baseTime = Date.now();
|
|
738
|
+
for (let i = 0; i < count; i++) {
|
|
739
|
+
const action = actions[i % actions.length];
|
|
740
|
+
const message = {
|
|
741
|
+
id: `msg-action-gen-${i}`,
|
|
742
|
+
sender: 'other',
|
|
743
|
+
content: `Message with ${action.type} action (#${i + 1})`,
|
|
744
|
+
timestamp: new Date(baseTime - (count - i) * 60000),
|
|
745
|
+
senderName: 'Assistant',
|
|
746
|
+
actions: [{ ...action, id: `${action.id}-${i}` }],
|
|
747
|
+
};
|
|
748
|
+
messages.push(message);
|
|
749
|
+
}
|
|
750
|
+
return messages;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Generates messages with various attachment types.
|
|
754
|
+
*
|
|
755
|
+
* @param count - Number of messages to generate
|
|
756
|
+
* @returns Array of ChatMessage objects with attachments
|
|
757
|
+
*/
|
|
758
|
+
function generateWithAttachments(count) {
|
|
759
|
+
if (count < 0) {
|
|
760
|
+
throw new Error('Count must be non-negative');
|
|
761
|
+
}
|
|
762
|
+
const attachments = [
|
|
763
|
+
SAMPLE_IMAGE_ATTACHMENT,
|
|
764
|
+
SAMPLE_FILE_ATTACHMENT,
|
|
765
|
+
SAMPLE_VIDEO_ATTACHMENT,
|
|
766
|
+
SAMPLE_AUDIO_ATTACHMENT,
|
|
767
|
+
];
|
|
768
|
+
const messages = [];
|
|
769
|
+
const baseTime = Date.now();
|
|
770
|
+
for (let i = 0; i < count; i++) {
|
|
771
|
+
const attachment = attachments[i % attachments.length];
|
|
772
|
+
const isUser = i % 2 === 0;
|
|
773
|
+
const message = {
|
|
774
|
+
id: `msg-att-gen-${i}`,
|
|
775
|
+
sender: isUser ? 'self' : 'other',
|
|
776
|
+
content: `Message with ${attachment.type} attachment`,
|
|
777
|
+
timestamp: new Date(baseTime - (count - i) * 60000),
|
|
778
|
+
...(isUser
|
|
779
|
+
? { status: 'sent' }
|
|
780
|
+
: { senderName: 'Assistant' }),
|
|
781
|
+
attachments: [{ ...attachment, id: `${attachment.id}-${i}` }],
|
|
782
|
+
};
|
|
783
|
+
messages.push(message);
|
|
784
|
+
}
|
|
785
|
+
return messages;
|
|
786
|
+
}
|
|
787
|
+
// ============================================================================
|
|
788
|
+
// Wait Utilities
|
|
789
|
+
// ============================================================================
|
|
790
|
+
/**
|
|
791
|
+
* Default timeout for wait operations in milliseconds.
|
|
792
|
+
*/
|
|
793
|
+
const DEFAULT_WAIT_TIMEOUT = 5000;
|
|
794
|
+
/**
|
|
795
|
+
* Default polling interval for wait operations in milliseconds.
|
|
796
|
+
*/
|
|
797
|
+
const DEFAULT_POLL_INTERVAL = 50;
|
|
798
|
+
/**
|
|
799
|
+
* Waits for the chat harness to display a specific number of messages.
|
|
800
|
+
*
|
|
801
|
+
* @param harness - The ChatHarness instance
|
|
802
|
+
* @param count - Expected number of messages
|
|
803
|
+
* @param timeout - Maximum wait time in milliseconds (default: 5000)
|
|
804
|
+
* @returns Promise that resolves when count is reached or rejects on timeout
|
|
805
|
+
*
|
|
806
|
+
* @example
|
|
807
|
+
* ```typescript
|
|
808
|
+
* const harness = await loader.getHarness(ChatHarness);
|
|
809
|
+
* fixture.componentRef.setInput('messages', generateConversation(5));
|
|
810
|
+
* await waitForMessages(harness, 5);
|
|
811
|
+
* ```
|
|
812
|
+
*/
|
|
813
|
+
async function waitForMessages(harness, count, timeout = DEFAULT_WAIT_TIMEOUT) {
|
|
814
|
+
const startTime = Date.now();
|
|
815
|
+
while (Date.now() - startTime < timeout) {
|
|
816
|
+
const currentCount = await harness.getMessageCount();
|
|
817
|
+
if (currentCount >= count) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
await sleep(DEFAULT_POLL_INTERVAL);
|
|
821
|
+
}
|
|
822
|
+
const finalCount = await harness.getMessageCount();
|
|
823
|
+
throw new Error(`Timeout waiting for ${count} messages. Current count: ${finalCount}`);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Waits for the typing indicator to reach a specific visibility state.
|
|
827
|
+
*
|
|
828
|
+
* @param harness - The ChatHarness instance
|
|
829
|
+
* @param visible - Expected visibility state (true = visible, false = hidden)
|
|
830
|
+
* @param timeout - Maximum wait time in milliseconds (default: 5000)
|
|
831
|
+
* @returns Promise that resolves when state is reached or rejects on timeout
|
|
832
|
+
*
|
|
833
|
+
* @example
|
|
834
|
+
* ```typescript
|
|
835
|
+
* const harness = await loader.getHarness(ChatHarness);
|
|
836
|
+
* fixture.componentRef.setInput('isTyping', true);
|
|
837
|
+
* await waitForTypingIndicator(harness, true);
|
|
838
|
+
* ```
|
|
839
|
+
*/
|
|
840
|
+
async function waitForTypingIndicator(harness, visible, timeout = DEFAULT_WAIT_TIMEOUT) {
|
|
841
|
+
const startTime = Date.now();
|
|
842
|
+
while (Date.now() - startTime < timeout) {
|
|
843
|
+
const isVisible = await harness.isTypingIndicatorVisible();
|
|
844
|
+
if (isVisible === visible) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
await sleep(DEFAULT_POLL_INTERVAL);
|
|
848
|
+
}
|
|
849
|
+
const finalState = await harness.isTypingIndicatorVisible();
|
|
850
|
+
throw new Error(`Timeout waiting for typing indicator to be ${visible ? 'visible' : 'hidden'}. Current: ${finalState ? 'visible' : 'hidden'}`);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Waits for the loading indicator to reach a specific visibility state.
|
|
854
|
+
*
|
|
855
|
+
* @param harness - The ChatHarness instance
|
|
856
|
+
* @param visible - Expected visibility state
|
|
857
|
+
* @param timeout - Maximum wait time in milliseconds (default: 5000)
|
|
858
|
+
*/
|
|
859
|
+
async function waitForLoading(harness, visible, timeout = DEFAULT_WAIT_TIMEOUT) {
|
|
860
|
+
const startTime = Date.now();
|
|
861
|
+
while (Date.now() - startTime < timeout) {
|
|
862
|
+
const isLoading = await harness.isLoading();
|
|
863
|
+
if (isLoading === visible) {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
await sleep(DEFAULT_POLL_INTERVAL);
|
|
867
|
+
}
|
|
868
|
+
const finalState = await harness.isLoading();
|
|
869
|
+
throw new Error(`Timeout waiting for loading to be ${visible ? 'visible' : 'hidden'}. Current: ${finalState ? 'visible' : 'hidden'}`);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Waits for the empty state to reach a specific visibility state.
|
|
873
|
+
*
|
|
874
|
+
* @param harness - The ChatHarness instance
|
|
875
|
+
* @param visible - Expected visibility state
|
|
876
|
+
* @param timeout - Maximum wait time in milliseconds (default: 5000)
|
|
877
|
+
*/
|
|
878
|
+
async function waitForEmpty(harness, visible, timeout = DEFAULT_WAIT_TIMEOUT) {
|
|
879
|
+
const startTime = Date.now();
|
|
880
|
+
while (Date.now() - startTime < timeout) {
|
|
881
|
+
const isEmpty = await harness.isEmpty();
|
|
882
|
+
if (isEmpty === visible) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
await sleep(DEFAULT_POLL_INTERVAL);
|
|
886
|
+
}
|
|
887
|
+
const finalState = await harness.isEmpty();
|
|
888
|
+
throw new Error(`Timeout waiting for empty state to be ${visible ? 'visible' : 'hidden'}. Current: ${finalState ? 'visible' : 'hidden'}`);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Waits for the send button to become enabled or disabled.
|
|
892
|
+
*
|
|
893
|
+
* @param harness - The ChatHarness instance
|
|
894
|
+
* @param enabled - Expected enabled state
|
|
895
|
+
* @param timeout - Maximum wait time in milliseconds (default: 5000)
|
|
896
|
+
*/
|
|
897
|
+
async function waitForSendEnabled(harness, enabled, timeout = DEFAULT_WAIT_TIMEOUT) {
|
|
898
|
+
const startTime = Date.now();
|
|
899
|
+
while (Date.now() - startTime < timeout) {
|
|
900
|
+
const canSend = await harness.canSend();
|
|
901
|
+
if (canSend === enabled) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
await sleep(DEFAULT_POLL_INTERVAL);
|
|
905
|
+
}
|
|
906
|
+
const finalState = await harness.canSend();
|
|
907
|
+
throw new Error(`Timeout waiting for send button to be ${enabled ? 'enabled' : 'disabled'}. Current: ${finalState ? 'enabled' : 'disabled'}`);
|
|
908
|
+
}
|
|
909
|
+
// ============================================================================
|
|
910
|
+
// Internal Utilities
|
|
911
|
+
// ============================================================================
|
|
912
|
+
/**
|
|
913
|
+
* Simple sleep utility for polling.
|
|
914
|
+
*/
|
|
915
|
+
function sleep(ms) {
|
|
916
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* @fileoverview Internal testing module exports.
|
|
921
|
+
* @module ngx-chat/testing
|
|
922
|
+
*/
|
|
923
|
+
// Harnesses
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* @fileoverview Public API for ngx-chat/testing secondary entry point.
|
|
927
|
+
*
|
|
928
|
+
* This entry point provides testing utilities for consumers of the ngx-chat library.
|
|
929
|
+
* Import from 'ngx-chat/testing' to access harnesses, mock data, and test utilities.
|
|
930
|
+
*
|
|
931
|
+
* @example
|
|
932
|
+
* ```typescript
|
|
933
|
+
* import { ChatHarness, MOCK_SCENARIOS, waitForMessages } from 'ngx-chat/testing';
|
|
934
|
+
*
|
|
935
|
+
* // In your tests
|
|
936
|
+
* const harness = await loader.getHarness(ChatHarness);
|
|
937
|
+
* fixture.componentRef.setInput('messages', MOCK_SCENARIOS.simpleConversation);
|
|
938
|
+
* await waitForMessages(harness, 5);
|
|
939
|
+
* ```
|
|
940
|
+
*
|
|
941
|
+
* @module ngx-chat/testing
|
|
942
|
+
*/
|
|
943
|
+
// Re-export all testing utilities
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Generated bundle index. Do not edit.
|
|
947
|
+
*/
|
|
948
|
+
|
|
949
|
+
export { ChatHarness, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, MOCK_SCENARIOS, SAMPLE_AUDIO_ATTACHMENT, SAMPLE_BUTTONS_ACTION, SAMPLE_CONFIRM_ACTION, SAMPLE_FILE_ATTACHMENT, SAMPLE_IMAGE_ATTACHMENT, SAMPLE_MULTI_SELECT_ACTION, SAMPLE_NETWORK_ERROR, SAMPLE_RATE_LIMIT_ERROR, SAMPLE_SELECT_ACTION, SAMPLE_TIMEOUT_ERROR, SAMPLE_VIDEO_ATTACHMENT, generateConversation, generateWithActions, generateWithAttachments, waitForEmpty, waitForLoading, waitForMessages, waitForSendEnabled, waitForTypingIndicator };
|
|
950
|
+
//# sourceMappingURL=cdevhub-ngx-chat-testing.mjs.map
|