@nyaruka/temba-components 0.135.9 → 0.136.1

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/demo/components/webchat/example.html +4 -2
  3. package/dist/static/svg/index.svg +1 -1
  4. package/dist/temba-components.js +1351 -322
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/Icons.js +2 -1
  7. package/out-tsc/src/Icons.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +2 -6
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasNode.js +29 -1
  11. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  12. package/out-tsc/src/flow/Editor.js +229 -5
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/Plumber.js +320 -1
  15. package/out-tsc/src/flow/Plumber.js.map +1 -1
  16. package/out-tsc/src/interfaces.js +1 -0
  17. package/out-tsc/src/interfaces.js.map +1 -1
  18. package/out-tsc/src/layout/FloatingWindow.js +30 -8
  19. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  20. package/out-tsc/src/simulator/Simulator.js +1861 -0
  21. package/out-tsc/src/simulator/Simulator.js.map +1 -0
  22. package/out-tsc/src/store/AppState.js +66 -0
  23. package/out-tsc/src/store/AppState.js.map +1 -1
  24. package/out-tsc/src/utils.js +48 -0
  25. package/out-tsc/src/utils.js.map +1 -1
  26. package/out-tsc/temba-modules.js +2 -0
  27. package/out-tsc/temba-modules.js.map +1 -1
  28. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  29. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  30. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  31. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  32. package/out-tsc/test/temba-flow-editor.test.js +262 -1
  33. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  34. package/out-tsc/test/temba-flow-plumber-connections.test.js +3 -1
  35. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  36. package/out-tsc/test/temba-flow-plumber.test.js +3 -1
  37. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  38. package/out-tsc/test/temba-simulator.test.js +642 -0
  39. package/out-tsc/test/temba-simulator.test.js.map +1 -0
  40. package/out-tsc/test/utils.test.js +1 -1
  41. package/out-tsc/test/utils.test.js.map +1 -1
  42. package/package.json +1 -1
  43. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  47. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  57. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  63. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/floating-tab/gray.png +0 -0
  91. package/screenshots/truth/floating-tab/green.png +0 -0
  92. package/screenshots/truth/floating-tab/purple.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  106. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  114. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  115. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  116. package/screenshots/truth/simulator/after-reset.png +0 -0
  117. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  118. package/screenshots/truth/simulator/context-expanded.png +0 -0
  119. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  120. package/screenshots/truth/simulator/event-info.png +0 -0
  121. package/screenshots/truth/simulator/image-attachment.png +0 -0
  122. package/screenshots/truth/simulator/open-initial.png +0 -0
  123. package/screenshots/truth/simulator/quick-replies.png +0 -0
  124. package/src/Icons.ts +2 -1
  125. package/src/display/FloatingTab.ts +2 -7
  126. package/src/flow/CanvasNode.ts +30 -1
  127. package/src/flow/Editor.ts +246 -4
  128. package/src/flow/Plumber.ts +371 -2
  129. package/src/interfaces.ts +2 -1
  130. package/src/layout/FloatingWindow.ts +37 -12
  131. package/src/simulator/Simulator.ts +2061 -0
  132. package/src/store/AppState.ts +109 -0
  133. package/src/utils.ts +53 -0
  134. package/static/svg/index.svg +1 -1
  135. package/static/svg/work/traced/route.svg +1 -0
  136. package/static/svg/work/used/route.svg +3 -0
  137. package/temba-modules.ts +2 -0
  138. package/test/temba-appstate-node-sorting.test.ts +506 -0
  139. package/test/temba-floating-tab.test.ts +0 -11
  140. package/test/temba-flow-editor.test.ts +298 -1
  141. package/test/temba-flow-plumber-connections.test.ts +4 -1
  142. package/test/temba-flow-plumber.test.ts +4 -1
  143. package/test/temba-simulator.test.ts +866 -0
  144. package/test/utils.test.ts +1 -1
@@ -0,0 +1,866 @@
1
+ import { fixture, expect, assert } from '@open-wc/testing';
2
+ import { Simulator } from '../src/simulator/Simulator';
3
+ import {
4
+ assertScreenshot,
5
+ getClip,
6
+ mockPOST,
7
+ clearMockPosts,
8
+ delay,
9
+ waitForCondition,
10
+ loadStore
11
+ } from './utils.test';
12
+
13
+ const FLOW_UUID = 'test-flow-123';
14
+
15
+ const createSimulator = async (attrs: any = {}) => {
16
+ // load store first since simulator depends on it
17
+ await loadStore();
18
+
19
+ const defaults = {
20
+ flow: FLOW_UUID,
21
+ animationTime: '0' // disable animations for deterministic tests
22
+ };
23
+ const mergedAttrs = { ...defaults, ...attrs };
24
+
25
+ const attrString = Object.entries(mergedAttrs)
26
+ .map(([key, value]) => `${key}="${value}"`)
27
+ .join(' ');
28
+
29
+ const simulator: Simulator = await fixture(
30
+ `<temba-simulator ${attrString}></temba-simulator>`
31
+ );
32
+
33
+ // reset cookie-based properties for deterministic tests
34
+ simulator.size = 'medium';
35
+ (simulator as any).following = true;
36
+ (simulator as any).contextExplorerOpen = false;
37
+ await simulator.updateComplete;
38
+
39
+ return simulator;
40
+ };
41
+
42
+ // helper to open the simulator
43
+ const openSimulator = async (simulator: Simulator) => {
44
+ const tab = simulator.shadowRoot.querySelector('temba-floating-tab') as any;
45
+ expect(tab).to.exist;
46
+
47
+ // trigger the button clicked event on the tab
48
+ tab.dispatchEvent(new CustomEvent('temba-button-clicked', { bubbles: true }));
49
+
50
+ await simulator.updateComplete;
51
+ // brief delay for async API mock processing
52
+ await delay(50);
53
+ };
54
+
55
+ // helper to get clip for the simulator window (fixed positioning)
56
+ const getSimulatorClip = (
57
+ simulator: Simulator,
58
+ includeContext: boolean = false
59
+ ) => {
60
+ const phoneWindow = simulator.shadowRoot.querySelector(
61
+ 'temba-floating-window'
62
+ ) as any;
63
+
64
+ if (!phoneWindow) {
65
+ // if window not open, use default clip
66
+ return getClip(simulator);
67
+ }
68
+
69
+ const windowElement = phoneWindow.shadowRoot?.querySelector(
70
+ '.window'
71
+ ) as HTMLElement;
72
+ if (!windowElement) {
73
+ return getClip(simulator);
74
+ }
75
+
76
+ const windowBounds = windowElement.getBoundingClientRect();
77
+
78
+ if (includeContext) {
79
+ // get the context explorer and phone to clip just those areas
80
+ const phoneSimulator = phoneWindow.querySelector(
81
+ '.phone-simulator'
82
+ ) as HTMLElement;
83
+ if (!phoneSimulator) {
84
+ return getClip(simulator);
85
+ }
86
+
87
+ const contextExplorer = phoneSimulator.querySelector(
88
+ '.context-explorer'
89
+ ) as HTMLElement;
90
+ const phoneFrame = phoneSimulator.querySelector(
91
+ '.phone-frame'
92
+ ) as HTMLElement;
93
+
94
+ if (!contextExplorer || !phoneFrame) {
95
+ return {
96
+ x: windowBounds.x,
97
+ y: windowBounds.y,
98
+ width: windowBounds.width,
99
+ height: windowBounds.height
100
+ };
101
+ }
102
+
103
+ const contextBounds = contextExplorer.getBoundingClientRect();
104
+ const phoneBounds = phoneFrame.getBoundingClientRect();
105
+
106
+ // clip from the left edge of context explorer to the right edge of phone frame only
107
+ // do not include the option-pane which is to the right of the phone
108
+ // keep padding within the phone bounds to avoid capturing the gap to the option pane
109
+ const padding = 10;
110
+ const leftX = contextBounds.x - padding;
111
+
112
+ // don't extend past the phone frame right edge - the option pane is close by
113
+ const rightX = phoneBounds.right;
114
+
115
+ const topY = Math.min(contextBounds.y, phoneBounds.y) - padding;
116
+ const bottomY =
117
+ Math.max(contextBounds.bottom, phoneBounds.bottom) + padding;
118
+
119
+ return {
120
+ x: leftX,
121
+ y: topY,
122
+ width: rightX - leftX,
123
+ height: bottomY - topY
124
+ };
125
+ }
126
+
127
+ // the phone-simulator is in the light DOM of the phoneWindow (slotted content)
128
+ const phoneSimulator = phoneWindow.querySelector(
129
+ '.phone-simulator'
130
+ ) as HTMLElement;
131
+ if (!phoneSimulator) {
132
+ return getClip(simulator);
133
+ }
134
+
135
+ // get the phone-frame from within the phone-simulator
136
+ const phoneFrame = phoneSimulator.querySelector(
137
+ '.phone-frame'
138
+ ) as HTMLElement;
139
+ if (!phoneFrame) {
140
+ // fallback to window bounds if phone-frame not found
141
+ return {
142
+ x: windowBounds.x,
143
+ y: windowBounds.y,
144
+ width: windowBounds.width,
145
+ height: windowBounds.height
146
+ };
147
+ }
148
+
149
+ const frameBounds = phoneFrame.getBoundingClientRect();
150
+
151
+ // add padding around the phone frame
152
+ const padding = 10;
153
+ return {
154
+ x: frameBounds.x - padding,
155
+ y: frameBounds.y - padding,
156
+ width: frameBounds.width + padding * 2,
157
+ height: frameBounds.height + padding * 2
158
+ };
159
+ };
160
+
161
+ // mock responses for simulation endpoints
162
+ const mockSimulatorStart = () => {
163
+ const response = {
164
+ session: {
165
+ status: 'waiting',
166
+ trigger: {
167
+ type: 'manual',
168
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' }
169
+ },
170
+ runs: [
171
+ {
172
+ uuid: 'run-1',
173
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' },
174
+ status: 'waiting',
175
+ path: [
176
+ {
177
+ uuid: 'step-1',
178
+ node_uuid: 'node-1',
179
+ arrived_on: new Date().toISOString(),
180
+ exit_uuid: null
181
+ }
182
+ ]
183
+ }
184
+ ],
185
+ environment: {
186
+ date_format: 'YYYY-MM-DD',
187
+ time_format: 'HH:mm',
188
+ timezone: 'America/New_York',
189
+ allowed_languages: ['eng'],
190
+ default_country: 'US'
191
+ }
192
+ },
193
+ events: [
194
+ {
195
+ type: 'msg_created',
196
+ created_on: new Date().toISOString(),
197
+ msg: {
198
+ uuid: 'msg-1',
199
+ text: 'Hello! How can I help you today?',
200
+ urn: 'tel:+12065551212'
201
+ }
202
+ }
203
+ ],
204
+ contact: {
205
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
206
+ urns: ['tel:+12065551212'],
207
+ fields: {},
208
+ groups: [],
209
+ language: 'eng',
210
+ status: 'active',
211
+ created_on: new Date().toISOString()
212
+ },
213
+ context: {
214
+ contact: {
215
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
216
+ name: 'Test User',
217
+ urns: {
218
+ tel: ['+12065551212'],
219
+ __default__: '+12065551212'
220
+ },
221
+ fields: {
222
+ age: '25',
223
+ city: 'Seattle'
224
+ }
225
+ },
226
+ trigger: {
227
+ type: 'manual',
228
+ __default__: 'manual'
229
+ }
230
+ }
231
+ };
232
+
233
+ mockPOST(/\/flow\/simulate\/.*\//, response);
234
+ };
235
+
236
+ const mockSimulatorResume = (responseText: string, quickReplies?: string[]) => {
237
+ const msg: any = {
238
+ uuid: 'msg-response',
239
+ text: responseText,
240
+ urn: 'tel:+12065551212'
241
+ };
242
+
243
+ if (quickReplies) {
244
+ msg.quick_replies = quickReplies.map((text) => ({ text }));
245
+ }
246
+
247
+ const response = {
248
+ session: {
249
+ status: 'waiting',
250
+ trigger: {
251
+ type: 'manual',
252
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' }
253
+ },
254
+ runs: [
255
+ {
256
+ uuid: 'run-1',
257
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' },
258
+ status: 'waiting',
259
+ path: [
260
+ {
261
+ uuid: 'step-1',
262
+ node_uuid: 'node-1',
263
+ arrived_on: new Date().toISOString(),
264
+ exit_uuid: 'exit-1'
265
+ },
266
+ {
267
+ uuid: 'step-2',
268
+ node_uuid: 'node-2',
269
+ arrived_on: new Date().toISOString(),
270
+ exit_uuid: null
271
+ }
272
+ ]
273
+ }
274
+ ],
275
+ environment: {
276
+ date_format: 'YYYY-MM-DD',
277
+ time_format: 'HH:mm',
278
+ timezone: 'America/New_York',
279
+ allowed_languages: ['eng'],
280
+ default_country: 'US'
281
+ }
282
+ },
283
+ events: [
284
+ {
285
+ type: 'msg_created',
286
+ created_on: new Date().toISOString(),
287
+ msg
288
+ }
289
+ ],
290
+ contact: {
291
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
292
+ urns: ['tel:+12065551212'],
293
+ fields: {
294
+ age: '25',
295
+ city: 'Seattle'
296
+ },
297
+ groups: [],
298
+ language: 'eng',
299
+ status: 'active',
300
+ created_on: new Date().toISOString()
301
+ },
302
+ context: {
303
+ contact: {
304
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
305
+ name: 'Test User',
306
+ urns: {
307
+ tel: ['+12065551212'],
308
+ __default__: '+12065551212'
309
+ },
310
+ fields: {
311
+ age: '25',
312
+ city: 'Seattle'
313
+ }
314
+ },
315
+ results: {
316
+ user_response: {
317
+ value: responseText,
318
+ __default__: responseText
319
+ }
320
+ }
321
+ }
322
+ };
323
+
324
+ mockPOST(/\/flow\/simulate\/.*\//, response);
325
+ };
326
+
327
+ describe('temba-simulator', () => {
328
+ beforeEach(() => {
329
+ clearMockPosts();
330
+ });
331
+
332
+ it('can be created', async () => {
333
+ const simulator: Simulator = await createSimulator();
334
+ assert.instanceOf(simulator, Simulator);
335
+ expect(simulator.flow).to.equal(FLOW_UUID);
336
+ expect(simulator.endpoint).to.equal(`/flow/simulate/${FLOW_UUID}/`);
337
+ });
338
+
339
+ it('opens simulator window and starts flow', async () => {
340
+ mockSimulatorStart();
341
+
342
+ const simulator: Simulator = await createSimulator();
343
+ // ensure consistent size for screenshot
344
+ simulator.size = 'medium';
345
+ await simulator.updateComplete;
346
+ await openSimulator(simulator);
347
+
348
+ const phoneWindow = simulator.shadowRoot.querySelector(
349
+ 'temba-floating-window'
350
+ ) as any;
351
+ expect(phoneWindow).to.exist;
352
+
353
+ // verify phone screen is visible
354
+ const phoneScreen = simulator.shadowRoot.querySelector('.phone-screen');
355
+ expect(phoneScreen).to.exist;
356
+
357
+ // verify initial message is displayed
358
+ const messages = simulator.shadowRoot.querySelectorAll('.message');
359
+ expect(messages.length).to.be.greaterThan(0);
360
+
361
+ await assertScreenshot(
362
+ 'simulator/open-initial',
363
+ getSimulatorClip(simulator)
364
+ );
365
+ });
366
+
367
+ it('sends a text message', async () => {
368
+ mockSimulatorStart();
369
+
370
+ const simulator: Simulator = await createSimulator();
371
+ simulator.size = 'medium';
372
+ await simulator.updateComplete;
373
+ await openSimulator(simulator);
374
+
375
+ // count initial messages
376
+ let messages = simulator.shadowRoot.querySelectorAll('.message');
377
+ const initialCount = messages.length;
378
+
379
+ // mock the resume response
380
+ mockSimulatorResume('Thanks for your message!');
381
+
382
+ // type a message
383
+ const input = simulator.shadowRoot.querySelector(
384
+ '.message-input input'
385
+ ) as HTMLInputElement;
386
+ expect(input).to.exist;
387
+
388
+ input.value = 'Hello from test';
389
+ input.dispatchEvent(new Event('input'));
390
+ await simulator.updateComplete;
391
+
392
+ // press enter to send
393
+ const enterEvent = new KeyboardEvent('keyup', {
394
+ key: 'Enter',
395
+ bubbles: true
396
+ });
397
+ input.dispatchEvent(enterEvent);
398
+
399
+ await simulator.updateComplete;
400
+ // brief delay for async API mock processing
401
+ await delay(100);
402
+
403
+ // verify we have more messages than before
404
+ messages = simulator.shadowRoot.querySelectorAll('.message');
405
+ expect(messages.length).to.be.greaterThan(initialCount);
406
+
407
+ // ensure DOM is settled
408
+ await simulator.updateComplete;
409
+
410
+ await assertScreenshot(
411
+ 'simulator/after-message-sent',
412
+ getSimulatorClip(simulator)
413
+ );
414
+ });
415
+
416
+ it('tests message flow and takes screenshot', async () => {
417
+ mockSimulatorStart();
418
+
419
+ const simulator: Simulator = await createSimulator();
420
+ simulator.size = 'medium';
421
+ await simulator.updateComplete;
422
+ await openSimulator(simulator);
423
+
424
+ // clear previous mocks and set up new mock for a response
425
+ clearMockPosts();
426
+ mockSimulatorResume('Thank you for your message!', ['Yes', 'No', 'Maybe']);
427
+
428
+ // send a message
429
+ const input = simulator.shadowRoot.querySelector(
430
+ '.message-input input'
431
+ ) as HTMLInputElement;
432
+ input.value = 'Test message';
433
+ input.dispatchEvent(new Event('input'));
434
+
435
+ const enterEvent = new KeyboardEvent('keyup', {
436
+ key: 'Enter',
437
+ bubbles: true
438
+ });
439
+ input.dispatchEvent(enterEvent);
440
+
441
+ // wait for quick replies to appear
442
+ await waitForCondition(
443
+ () =>
444
+ simulator.shadowRoot.querySelectorAll('.quick-reply-btn').length > 0,
445
+ 2000
446
+ );
447
+ await simulator.updateComplete;
448
+
449
+ // take screenshot with quick replies
450
+ await assertScreenshot(
451
+ 'simulator/quick-replies',
452
+ getSimulatorClip(simulator)
453
+ );
454
+ });
455
+
456
+ it('opens attachment menu', async () => {
457
+ mockSimulatorStart();
458
+
459
+ const simulator: Simulator = await createSimulator();
460
+ simulator.size = 'medium';
461
+ await simulator.updateComplete;
462
+ await openSimulator(simulator);
463
+
464
+ // click the attachment button
465
+ const attachmentButton = simulator.shadowRoot.querySelector(
466
+ '.attachment-button'
467
+ ) as HTMLElement;
468
+ expect(attachmentButton).to.exist;
469
+ attachmentButton.click();
470
+
471
+ await simulator.updateComplete;
472
+
473
+ // verify attachment menu is displayed
474
+ const attachmentMenu =
475
+ simulator.shadowRoot.querySelector('.attachment-menu');
476
+ expect(attachmentMenu).to.exist;
477
+ expect(attachmentMenu.classList.contains('open')).to.be.true;
478
+
479
+ await assertScreenshot(
480
+ 'simulator/attachment-menu',
481
+ getSimulatorClip(simulator)
482
+ );
483
+ });
484
+
485
+ it('sends an image attachment', async () => {
486
+ mockSimulatorStart();
487
+
488
+ const simulator: Simulator = await createSimulator();
489
+ simulator.size = 'medium';
490
+ await simulator.updateComplete;
491
+ // reset attachment indices for deterministic testing
492
+ simulator.resetAttachmentIndices();
493
+ await openSimulator(simulator);
494
+
495
+ // mock the response for image attachment
496
+ mockSimulatorResume('Nice picture!');
497
+
498
+ // open attachment menu and click image option
499
+ const attachmentButton = simulator.shadowRoot.querySelector(
500
+ '.attachment-button'
501
+ ) as HTMLElement;
502
+ attachmentButton.click();
503
+ await simulator.updateComplete;
504
+ await delay(200);
505
+
506
+ const imageMenuItem = Array.from(
507
+ simulator.shadowRoot.querySelectorAll('.attachment-menu-item')
508
+ ).find((el) => el.textContent?.includes('Image')) as HTMLElement;
509
+ expect(imageMenuItem).to.exist;
510
+ imageMenuItem.click();
511
+
512
+ await delay(100);
513
+ await simulator.updateComplete;
514
+
515
+ // verify attachment wrapper is displayed (image attachments show in attachments not messages)
516
+ const attachmentWrappers = simulator.shadowRoot.querySelectorAll(
517
+ '.attachment-wrapper'
518
+ );
519
+ expect(attachmentWrappers.length).to.be.greaterThan(0);
520
+
521
+ await assertScreenshot(
522
+ 'simulator/image-attachment',
523
+ getSimulatorClip(simulator)
524
+ );
525
+ });
526
+
527
+ it('opens context explorer', async () => {
528
+ mockSimulatorStart();
529
+
530
+ const simulator: Simulator = await createSimulator();
531
+ await openSimulator(simulator);
532
+
533
+ // find and click the context explorer button (has expressions icon)
534
+ const optionButtons = Array.from(
535
+ simulator.shadowRoot.querySelectorAll('.option-btn')
536
+ );
537
+ const contextButton = optionButtons.find((btn) =>
538
+ btn.querySelector('temba-icon[name="expressions"]')
539
+ ) as HTMLElement;
540
+ expect(contextButton).to.exist;
541
+ contextButton.click();
542
+
543
+ await simulator.updateComplete;
544
+ await delay(100);
545
+
546
+ // verify context explorer is displayed
547
+ const contextExplorer =
548
+ simulator.shadowRoot.querySelector('.context-explorer');
549
+ expect(contextExplorer).to.exist;
550
+ expect(contextExplorer.classList.contains('open')).to.be.true;
551
+
552
+ // delay for context explorer to fully render
553
+ await delay(300);
554
+ await simulator.updateComplete;
555
+ await document.fonts.ready;
556
+
557
+ await assertScreenshot(
558
+ 'simulator/context-explorer-open',
559
+ getSimulatorClip(simulator, true)
560
+ );
561
+ });
562
+
563
+ it('expands context tree items', async () => {
564
+ mockSimulatorStart();
565
+
566
+ const simulator: Simulator = await createSimulator();
567
+ await openSimulator(simulator);
568
+
569
+ // ensure context explorer starts closed
570
+ if ((simulator as any).contextExplorerOpen) {
571
+ // click to close it first
572
+ const optionButtons = Array.from(
573
+ simulator.shadowRoot.querySelectorAll('.option-btn')
574
+ );
575
+ const contextButton = optionButtons.find((btn) =>
576
+ btn.querySelector('temba-icon[name="expressions"]')
577
+ ) as HTMLElement;
578
+ contextButton.click();
579
+ await simulator.updateComplete;
580
+ await delay(100);
581
+ }
582
+
583
+ // now open context explorer
584
+ const optionButtons = Array.from(
585
+ simulator.shadowRoot.querySelectorAll('.option-btn')
586
+ );
587
+ const contextButton = optionButtons.find((btn) =>
588
+ btn.querySelector('temba-icon[name="expressions"]')
589
+ ) as HTMLElement;
590
+ expect(contextButton).to.exist;
591
+ contextButton.click();
592
+
593
+ await simulator.updateComplete;
594
+ await delay(100);
595
+
596
+ // verify context explorer is now open
597
+ expect((simulator as any).contextExplorerOpen).to.be.true;
598
+ const contextExplorer =
599
+ simulator.shadowRoot.querySelector('.context-explorer');
600
+ expect(contextExplorer).to.exist;
601
+ expect(contextExplorer.classList.contains('open')).to.be.true;
602
+
603
+ // find and click on an expandable item (should have context-item-expandable class)
604
+ const expandableItems = simulator.shadowRoot.querySelectorAll(
605
+ '.context-item-expandable'
606
+ );
607
+ expect(expandableItems.length).to.be.greaterThan(0);
608
+
609
+ const firstExpandable = expandableItems[0] as HTMLElement;
610
+ firstExpandable.click();
611
+
612
+ // wait for children to be displayed with specific content
613
+ await waitForCondition(() => {
614
+ const children =
615
+ simulator.shadowRoot.querySelectorAll('.context-children');
616
+ if (children.length === 0) return false;
617
+ // also check that the children have rendered content
618
+ const items = simulator.shadowRoot.querySelectorAll('.context-item');
619
+ return items.length > expandableItems.length;
620
+ }, 2000);
621
+
622
+ // verify children are displayed
623
+ const contextChildren =
624
+ simulator.shadowRoot.querySelectorAll('.context-children');
625
+ expect(contextChildren.length).to.be.greaterThan(0);
626
+
627
+ await simulator.updateComplete;
628
+ // delay for DOM to fully render expanded content (context rendering is complex)
629
+ await delay(300);
630
+ await simulator.updateComplete;
631
+
632
+ // ensure fonts are loaded and give extra time for rendering
633
+ await document.fonts.ready;
634
+
635
+ // wait for any pending animation frames
636
+ await new Promise((resolve) =>
637
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
638
+ );
639
+ await delay(200);
640
+
641
+ await assertScreenshot(
642
+ 'simulator/context-expanded',
643
+ getSimulatorClip(simulator, true)
644
+ );
645
+ });
646
+
647
+ it('cycles simulator size', async () => {
648
+ mockSimulatorStart();
649
+
650
+ const simulator: Simulator = await createSimulator();
651
+ await openSimulator(simulator);
652
+
653
+ // initially should be medium (set in createSimulator)
654
+ expect(simulator.size).to.equal('medium');
655
+
656
+ // find and click the size button (shows current size as text)
657
+ const optionButtons = Array.from(
658
+ simulator.shadowRoot.querySelectorAll('.option-btn')
659
+ );
660
+ const sizeButton = optionButtons.find((btn) => {
661
+ const text = btn.textContent?.trim();
662
+ return text === 'S' || text === 'M' || text === 'L';
663
+ }) as HTMLElement;
664
+ expect(sizeButton).to.exist;
665
+ sizeButton.click();
666
+
667
+ await simulator.updateComplete;
668
+
669
+ // should now be large (medium -> large)
670
+ expect(simulator.size).to.equal('large');
671
+ });
672
+
673
+ it('resets simulation', async () => {
674
+ mockSimulatorStart();
675
+
676
+ const simulator: Simulator = await createSimulator();
677
+ simulator.size = 'medium';
678
+ await simulator.updateComplete;
679
+ await openSimulator(simulator);
680
+
681
+ // send a message first
682
+ mockSimulatorResume('Response to test message');
683
+ const input = simulator.shadowRoot.querySelector(
684
+ '.message-input input'
685
+ ) as HTMLInputElement;
686
+ input.value = 'Test message';
687
+ input.dispatchEvent(new Event('input'));
688
+
689
+ const enterEvent = new KeyboardEvent('keyup', {
690
+ key: 'Enter',
691
+ bubbles: true
692
+ });
693
+ input.dispatchEvent(enterEvent);
694
+
695
+ await delay(1000);
696
+ await simulator.updateComplete;
697
+
698
+ // verify we have multiple messages
699
+ let messages = simulator.shadowRoot.querySelectorAll('.message');
700
+ const messageCountBefore = messages.length;
701
+ expect(messageCountBefore).to.be.greaterThan(1);
702
+
703
+ // mock the start response for reset
704
+ mockSimulatorStart();
705
+
706
+ // click the reset button (has delete icon)
707
+ const optionButtons = Array.from(
708
+ simulator.shadowRoot.querySelectorAll('.option-btn')
709
+ );
710
+ const resetButton = optionButtons.find((btn) =>
711
+ btn.querySelector('temba-icon[name="delete"]')
712
+ ) as HTMLElement;
713
+ expect(resetButton).to.exist;
714
+ resetButton.click();
715
+
716
+ await delay(100);
717
+ await simulator.updateComplete;
718
+
719
+ // verify messages are reset - should go back to just initial message
720
+ messages = simulator.shadowRoot.querySelectorAll('.message');
721
+ expect(messages.length).to.be.lessThan(messageCountBefore);
722
+
723
+ await assertScreenshot(
724
+ 'simulator/after-reset',
725
+ getSimulatorClip(simulator)
726
+ );
727
+ });
728
+
729
+ it('displays event info messages', async () => {
730
+ const responseWithEvents = {
731
+ session: {
732
+ status: 'waiting',
733
+ trigger: {
734
+ type: 'manual',
735
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' }
736
+ },
737
+ runs: [
738
+ {
739
+ uuid: 'run-1',
740
+ flow: { uuid: FLOW_UUID, name: 'Test Flow' },
741
+ status: 'waiting',
742
+ path: [
743
+ {
744
+ uuid: 'step-1',
745
+ node_uuid: 'node-1',
746
+ arrived_on: new Date().toISOString(),
747
+ exit_uuid: null
748
+ }
749
+ ]
750
+ }
751
+ ],
752
+ environment: {
753
+ date_format: 'YYYY-MM-DD',
754
+ time_format: 'HH:mm',
755
+ timezone: 'America/New_York',
756
+ allowed_languages: ['eng'],
757
+ default_country: 'US'
758
+ }
759
+ },
760
+ events: [
761
+ {
762
+ type: 'contact_field_changed',
763
+ created_on: new Date().toISOString(),
764
+ field: { key: 'name', name: 'Name' },
765
+ value: { text: 'John Doe' }
766
+ },
767
+ {
768
+ type: 'msg_created',
769
+ created_on: new Date().toISOString(),
770
+ msg: {
771
+ uuid: 'msg-1',
772
+ text: 'Your name has been updated!',
773
+ urn: 'tel:+12065551212'
774
+ }
775
+ }
776
+ ],
777
+ contact: {
778
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
779
+ urns: ['tel:+12065551212'],
780
+ fields: {
781
+ name: 'John Doe'
782
+ },
783
+ groups: [],
784
+ language: 'eng',
785
+ status: 'active',
786
+ created_on: new Date().toISOString()
787
+ },
788
+ context: {
789
+ contact: {
790
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
791
+ name: 'John Doe',
792
+ fields: {
793
+ name: 'John Doe'
794
+ }
795
+ }
796
+ }
797
+ };
798
+
799
+ mockPOST(/\/flow\/simulate\/.*\//, responseWithEvents);
800
+
801
+ const simulator: Simulator = await createSimulator();
802
+ simulator.size = 'medium';
803
+ await simulator.updateComplete;
804
+ await openSimulator(simulator);
805
+
806
+ // verify event info is displayed
807
+ const eventInfo = simulator.shadowRoot.querySelectorAll('.event-info');
808
+ expect(eventInfo.length).to.be.greaterThan(0);
809
+
810
+ await assertScreenshot('simulator/event-info', getSimulatorClip(simulator));
811
+ });
812
+
813
+ it('displays different simulator sizes', async () => {
814
+ mockSimulatorStart();
815
+
816
+ const simulator: Simulator = await createSimulator();
817
+ await openSimulator(simulator);
818
+
819
+ // get size button - find it by checking if textContent is a size indicator
820
+ let optionButtons = Array.from(
821
+ simulator.shadowRoot.querySelectorAll('.option-btn')
822
+ );
823
+ let sizeButton = optionButtons.find((btn) => {
824
+ const text = btn.textContent?.trim();
825
+ return text === 'S' || text === 'M' || text === 'L';
826
+ }) as HTMLElement;
827
+ expect(sizeButton).to.exist;
828
+
829
+ // cycle to next size
830
+ sizeButton.click();
831
+ await simulator.updateComplete;
832
+ await delay(200);
833
+
834
+ // verify size changed
835
+ expect(simulator.size).to.equal('large');
836
+
837
+ // re-query the button after it updated
838
+ optionButtons = Array.from(
839
+ simulator.shadowRoot.querySelectorAll('.option-btn')
840
+ );
841
+ sizeButton = optionButtons.find((btn) => {
842
+ const text = btn.textContent?.trim();
843
+ return text === 'S' || text === 'M' || text === 'L';
844
+ }) as HTMLElement;
845
+
846
+ // cycle to next size
847
+ sizeButton.click();
848
+ await simulator.updateComplete;
849
+ await delay(200);
850
+
851
+ expect(simulator.size).to.equal('small');
852
+ });
853
+
854
+ it('verifies simulator endpoint configuration', async () => {
855
+ const simulator: Simulator = await createSimulator();
856
+
857
+ // verify endpoint is set correctly from flow prop
858
+ expect(simulator.endpoint).to.equal(`/flow/simulate/${FLOW_UUID}/`);
859
+
860
+ // change flow prop and verify endpoint updates
861
+ simulator.flow = 'different-flow-456';
862
+ await simulator.updateComplete;
863
+
864
+ expect(simulator.endpoint).to.equal('/flow/simulate/different-flow-456/');
865
+ });
866
+ });