@nyaruka/temba-components 0.121.7 → 0.123.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.
Files changed (224) hide show
  1. package/.github/copilot-instructions.md +163 -0
  2. package/.github/workflows/build.yml +3 -3
  3. package/.github/workflows/cla.yml +6 -6
  4. package/.github/workflows/copilot-setup-steps.yml +86 -0
  5. package/CHANGELOG.md +41 -0
  6. package/demo/index.html +61 -12
  7. package/dist/locales/es.js +1 -0
  8. package/dist/locales/es.js.map +1 -1
  9. package/dist/locales/fr.js +1 -0
  10. package/dist/locales/fr.js.map +1 -1
  11. package/dist/locales/pt.js +1 -0
  12. package/dist/locales/pt.js.map +1 -1
  13. package/dist/temba-components.js +555 -465
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/chart/TembaChart.js +377 -0
  16. package/out-tsc/src/chart/TembaChart.js.map +1 -0
  17. package/out-tsc/src/list/RunList.js +13 -8
  18. package/out-tsc/src/list/RunList.js.map +1 -1
  19. package/out-tsc/src/locales/es.js +1 -0
  20. package/out-tsc/src/locales/es.js.map +1 -1
  21. package/out-tsc/src/locales/fr.js +1 -0
  22. package/out-tsc/src/locales/fr.js.map +1 -1
  23. package/out-tsc/src/locales/pt.js +1 -0
  24. package/out-tsc/src/locales/pt.js.map +1 -1
  25. package/out-tsc/src/options/Options.js +37 -13
  26. package/out-tsc/src/options/Options.js.map +1 -1
  27. package/out-tsc/src/select/Select.js +28 -5
  28. package/out-tsc/src/select/Select.js.map +1 -1
  29. package/out-tsc/src/store/AppState.js +3 -3
  30. package/out-tsc/src/store/AppState.js.map +1 -1
  31. package/out-tsc/src/utils/index.js +6 -1
  32. package/out-tsc/src/utils/index.js.map +1 -1
  33. package/out-tsc/src/vectoricon/VectorIcon.js +2 -1
  34. package/out-tsc/src/vectoricon/VectorIcon.js.map +1 -1
  35. package/out-tsc/temba-modules.js +2 -2
  36. package/out-tsc/temba-modules.js.map +1 -1
  37. package/out-tsc/test/temba-appstate-language.test.js +176 -0
  38. package/out-tsc/test/temba-appstate-language.test.js.map +1 -0
  39. package/out-tsc/test/temba-chart.test.js +171 -0
  40. package/out-tsc/test/temba-chart.test.js.map +1 -0
  41. package/out-tsc/test/temba-dropdown.test.js +317 -0
  42. package/out-tsc/test/temba-dropdown.test.js.map +1 -0
  43. package/out-tsc/test/temba-run-list.test.js +588 -0
  44. package/out-tsc/test/temba-run-list.test.js.map +1 -0
  45. package/out-tsc/test/temba-select.test.js +16 -0
  46. package/out-tsc/test/temba-select.test.js.map +1 -1
  47. package/out-tsc/test/temba-toast.test.js +299 -0
  48. package/out-tsc/test/temba-toast.test.js.map +1 -0
  49. package/out-tsc/test/temba-utils-index.test.js +1178 -0
  50. package/out-tsc/test/temba-utils-index.test.js.map +1 -0
  51. package/out-tsc/test/temba-webchat.test.js +816 -0
  52. package/out-tsc/test/temba-webchat.test.js.map +1 -0
  53. package/out-tsc/test/utils.test.js +3 -1
  54. package/out-tsc/test/utils.test.js.map +1 -1
  55. package/package.json +8 -8
  56. package/screenshots/truth/alert/error.png +0 -0
  57. package/screenshots/truth/alert/info.png +0 -0
  58. package/screenshots/truth/alert/warning.png +0 -0
  59. package/screenshots/truth/checkbox/checkbox-label-background-hover.png +0 -0
  60. package/screenshots/truth/checkbox/checked.png +0 -0
  61. package/screenshots/truth/checkbox/default.png +0 -0
  62. package/screenshots/truth/colorpicker/default.png +0 -0
  63. package/screenshots/truth/colorpicker/focused.png +0 -0
  64. package/screenshots/truth/colorpicker/initialized.png +0 -0
  65. package/screenshots/truth/colorpicker/selected.png +0 -0
  66. package/screenshots/truth/compose/attachments-tab.png +0 -0
  67. package/screenshots/truth/compose/attachments-with-files-focused.png +0 -0
  68. package/screenshots/truth/compose/attachments-with-files.png +0 -0
  69. package/screenshots/truth/compose/intial-text.png +0 -0
  70. package/screenshots/truth/compose/no-counter.png +0 -0
  71. package/screenshots/truth/compose/wraps-text-and-spaces.png +0 -0
  72. package/screenshots/truth/compose/wraps-text-and-url.png +0 -0
  73. package/screenshots/truth/compose/wraps-text-no-spaces.png +0 -0
  74. package/screenshots/truth/contacts/badges.png +0 -0
  75. package/screenshots/truth/contacts/chat-failure.png +0 -0
  76. package/screenshots/truth/contacts/chat-for-active-contact.png +0 -0
  77. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  78. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  79. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  80. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  81. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  82. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  83. package/screenshots/truth/content-menu/button-no-items.png +0 -0
  84. package/screenshots/truth/content-menu/items-and-buttons.png +0 -0
  85. package/screenshots/truth/counter/summary.png +0 -0
  86. package/screenshots/truth/counter/text.png +0 -0
  87. package/screenshots/truth/counter/unicode-variables.png +0 -0
  88. package/screenshots/truth/counter/unicode.png +0 -0
  89. package/screenshots/truth/counter/variable.png +0 -0
  90. package/screenshots/truth/date/date-inline.png +0 -0
  91. package/screenshots/truth/date/date.png +0 -0
  92. package/screenshots/truth/date/datetime.png +0 -0
  93. package/screenshots/truth/date/duration.png +0 -0
  94. package/screenshots/truth/date/timedate.png +0 -0
  95. package/screenshots/truth/datepicker/date-truncated-time.png +0 -0
  96. package/screenshots/truth/datepicker/date.png +0 -0
  97. package/screenshots/truth/datepicker/initial-timezone.png +0 -0
  98. package/screenshots/truth/datepicker/updated-keyboard-date.png +0 -0
  99. package/screenshots/truth/dialog/focused.png +0 -0
  100. package/screenshots/truth/dropdown/after-blur.png +0 -0
  101. package/screenshots/truth/dropdown/bottom-edge-collision.png +0 -0
  102. package/screenshots/truth/dropdown/custom-arrow-size.png +0 -0
  103. package/screenshots/truth/dropdown/default.png +0 -0
  104. package/screenshots/truth/dropdown/narrow-toggle.png +0 -0
  105. package/screenshots/truth/dropdown/no-mask.png +0 -0
  106. package/screenshots/truth/dropdown/opened.png +0 -0
  107. package/screenshots/truth/dropdown/positioned.png +0 -0
  108. package/screenshots/truth/dropdown/right-edge-collision.png +0 -0
  109. package/screenshots/truth/dropdown/with-mask.png +0 -0
  110. package/screenshots/truth/label/custom.png +0 -0
  111. package/screenshots/truth/label/danger.png +0 -0
  112. package/screenshots/truth/label/dark.png +0 -0
  113. package/screenshots/truth/label/default-icon.png +0 -0
  114. package/screenshots/truth/label/no-icon.png +0 -0
  115. package/screenshots/truth/label/primary.png +0 -0
  116. package/screenshots/truth/label/secondary.png +0 -0
  117. package/screenshots/truth/label/shadow.png +0 -0
  118. package/screenshots/truth/label/tertiary.png +0 -0
  119. package/screenshots/truth/lightbox/img-zoomed.png +0 -0
  120. package/screenshots/truth/list/fields-dragging.png +0 -0
  121. package/screenshots/truth/list/fields-filtered.png +0 -0
  122. package/screenshots/truth/list/fields-hovered.png +0 -0
  123. package/screenshots/truth/list/fields.png +0 -0
  124. package/screenshots/truth/list/items-selected.png +0 -0
  125. package/screenshots/truth/list/items-updated.png +0 -0
  126. package/screenshots/truth/list/items.png +0 -0
  127. package/screenshots/truth/list/sortable-dragging.png +0 -0
  128. package/screenshots/truth/list/sortable-dropped.png +0 -0
  129. package/screenshots/truth/list/sortable.png +0 -0
  130. package/screenshots/truth/menu/menu-focused-with items.png +0 -0
  131. package/screenshots/truth/menu/menu-refresh-1.png +0 -0
  132. package/screenshots/truth/menu/menu-refresh-2.png +0 -0
  133. package/screenshots/truth/menu/menu-root.png +0 -0
  134. package/screenshots/truth/menu/menu-submenu.png +0 -0
  135. package/screenshots/truth/menu/menu-tasks-nextup.png +0 -0
  136. package/screenshots/truth/menu/menu-tasks.png +0 -0
  137. package/screenshots/truth/modax/form.png +0 -0
  138. package/screenshots/truth/modax/simple.png +0 -0
  139. package/screenshots/truth/omnibox/selected.png +0 -0
  140. package/screenshots/truth/options/block.png +0 -0
  141. package/screenshots/truth/run-list/basic.png +0 -0
  142. package/screenshots/truth/select/disabled-multi-selection.png +0 -0
  143. package/screenshots/truth/select/disabled-selection.png +0 -0
  144. package/screenshots/truth/select/disabled.png +0 -0
  145. package/screenshots/truth/select/embedded.png +0 -0
  146. package/screenshots/truth/select/empty-options.png +0 -0
  147. package/screenshots/truth/select/expression-selected.png +0 -0
  148. package/screenshots/truth/select/expressions.png +0 -0
  149. package/screenshots/truth/select/functions.png +0 -0
  150. package/screenshots/truth/select/local-options.png +0 -0
  151. package/screenshots/truth/select/multi-with-endpoint.png +0 -0
  152. package/screenshots/truth/select/multiple-initial-values.png +0 -0
  153. package/screenshots/truth/select/remote-options.png +0 -0
  154. package/screenshots/truth/select/search-enabled.png +0 -0
  155. package/screenshots/truth/select/search-multi-no-matches.png +0 -0
  156. package/screenshots/truth/select/search-selected-focus.png +0 -0
  157. package/screenshots/truth/select/search-selected.png +0 -0
  158. package/screenshots/truth/select/search-with-selected.png +0 -0
  159. package/screenshots/truth/select/searching.png +0 -0
  160. package/screenshots/truth/select/selected-multi-maxitems-reached.png +0 -0
  161. package/screenshots/truth/select/selected-multi.png +0 -0
  162. package/screenshots/truth/select/selected-single.png +0 -0
  163. package/screenshots/truth/select/selection-clearable.png +0 -0
  164. package/screenshots/truth/select/static-initial-value.png +0 -0
  165. package/screenshots/truth/select/static-initial-via-selected.png +0 -0
  166. package/screenshots/truth/select/truncated-selection.png +0 -0
  167. package/screenshots/truth/select/with-placeholder.png +0 -0
  168. package/screenshots/truth/select/without-placeholder.png +0 -0
  169. package/screenshots/truth/slider/custom-min-custom-max-valid-value.png +0 -0
  170. package/screenshots/truth/slider/custom-min-default-max-no-value.png +0 -0
  171. package/screenshots/truth/slider/default-min-custom-max-no-value.png +0 -0
  172. package/screenshots/truth/slider/default-min-default-max-invalid-value.png +0 -0
  173. package/screenshots/truth/slider/default-min-default-max-valid-value.png +0 -0
  174. package/screenshots/truth/slider/update-slider-on-value-change.png +0 -0
  175. package/screenshots/truth/templates/default.png +0 -0
  176. package/screenshots/truth/templates/unapproved.png +0 -0
  177. package/screenshots/truth/textinput/input-disabled.png +0 -0
  178. package/screenshots/truth/textinput/input-focused.png +0 -0
  179. package/screenshots/truth/textinput/input-form.png +0 -0
  180. package/screenshots/truth/textinput/input-inserted.png +0 -0
  181. package/screenshots/truth/textinput/input-placeholder.png +0 -0
  182. package/screenshots/truth/textinput/input-updated.png +0 -0
  183. package/screenshots/truth/textinput/input.png +0 -0
  184. package/screenshots/truth/textinput/textarea-focused.png +0 -0
  185. package/screenshots/truth/textinput/textarea.png +0 -0
  186. package/screenshots/truth/tip/bottom.png +0 -0
  187. package/screenshots/truth/tip/left.png +0 -0
  188. package/screenshots/truth/tip/right.png +0 -0
  189. package/screenshots/truth/tip/top.png +0 -0
  190. package/screenshots/truth/webchat/closed-widget.png +0 -0
  191. package/screenshots/truth/webchat/connected-state.png +0 -0
  192. package/screenshots/truth/webchat/connecting-state.png +0 -0
  193. package/screenshots/truth/webchat/disconnected-state.png +0 -0
  194. package/screenshots/truth/webchat/opened-widget.png +0 -0
  195. package/src/chart/TembaChart.ts +399 -0
  196. package/src/list/RunList.ts +11 -8
  197. package/src/locales/es.ts +1 -0
  198. package/src/locales/fr.ts +1 -0
  199. package/src/locales/pt.ts +1 -0
  200. package/src/options/Options.ts +39 -13
  201. package/src/select/Select.ts +32 -5
  202. package/src/store/AppState.ts +3 -3
  203. package/src/utils/index.ts +17 -5
  204. package/src/vectoricon/VectorIcon.ts +2 -1
  205. package/temba-modules.ts +2 -2
  206. package/test/temba-appstate-language.test.ts +218 -0
  207. package/test/temba-chart.test.ts +215 -0
  208. package/test/temba-dropdown.test.ts +444 -0
  209. package/test/temba-run-list.test.ts +774 -0
  210. package/test/temba-select.test.ts +27 -0
  211. package/test/temba-toast.test.ts +386 -0
  212. package/test/temba-utils-index.test.ts +1547 -0
  213. package/test/temba-webchat.test.ts +1095 -0
  214. package/test/utils.test.ts +4 -2
  215. package/test-assets/list/flow-results.json +17 -0
  216. package/test-assets/list/runs.json +126 -0
  217. package/test-assets/style.css +23 -0
  218. package/web-test-runner.config.mjs +33 -7
  219. package/xliff/es.xlf +3 -0
  220. package/xliff/fr.xlf +3 -0
  221. package/xliff/pt.xlf +3 -0
  222. package/out-tsc/src/outboxmonitor/OutboxMonitor.js +0 -136
  223. package/out-tsc/src/outboxmonitor/OutboxMonitor.js.map +0 -1
  224. package/src/outboxmonitor/OutboxMonitor.ts +0 -148
@@ -0,0 +1,816 @@
1
+ import { useFakeTimers, stub } from 'sinon';
2
+ import { WebChat } from '../src/webchat/WebChat';
3
+ import { assertScreenshot, getClip, getComponent, mockNow, mouseClickElement } from '../test/utils.test';
4
+ import { expect, assert } from '@open-wc/testing';
5
+ let clock;
6
+ mockNow('2021-03-31T00:31:00.000-00:00');
7
+ const TAG = 'temba-webchat';
8
+ const getWebChat = async (attrs = {}) => {
9
+ const webChat = (await getComponent(TAG, attrs, '', 400, 600));
10
+ return webChat;
11
+ };
12
+ // Mock WebSocket
13
+ class MockWebSocket {
14
+ constructor(url) {
15
+ this.onopen = null;
16
+ this.onclose = null;
17
+ this.onmessage = null;
18
+ this.onerror = null;
19
+ this.readyState = 0;
20
+ this.sentMessages = [];
21
+ this.autoOpen = true;
22
+ this.url = url;
23
+ // Only auto-open if enabled
24
+ if (this.autoOpen) {
25
+ setTimeout(() => {
26
+ this.readyState = 1; // OPEN
27
+ if (this.onopen) {
28
+ this.onopen(new Event('open'));
29
+ }
30
+ }, 0);
31
+ }
32
+ }
33
+ send(data) {
34
+ this.sentMessages.push(data);
35
+ }
36
+ close() {
37
+ this.readyState = 3; // CLOSED
38
+ if (this.onclose) {
39
+ this.onclose(new CloseEvent('close'));
40
+ }
41
+ }
42
+ // Test helper to manually open connection
43
+ manualOpen() {
44
+ this.readyState = 1; // OPEN
45
+ if (this.onopen) {
46
+ this.onopen(new Event('open'));
47
+ }
48
+ }
49
+ // Test helper to simulate incoming messages
50
+ simulateMessage(data) {
51
+ if (this.onmessage) {
52
+ this.onmessage(new MessageEvent('message', { data: JSON.stringify(data) }));
53
+ }
54
+ }
55
+ // Test helper to simulate errors
56
+ simulateError() {
57
+ if (this.onerror) {
58
+ this.onerror(new Event('error'));
59
+ }
60
+ }
61
+ }
62
+ describe('temba-webchat', () => {
63
+ let originalWebSocket;
64
+ let mockWebSocket;
65
+ let webSocketStub;
66
+ let cookieStub;
67
+ beforeEach(() => {
68
+ // Mock WebSocket
69
+ originalWebSocket = window.WebSocket;
70
+ webSocketStub = stub(window, 'WebSocket').callsFake((url) => {
71
+ mockWebSocket = new MockWebSocket(url);
72
+ mockWebSocket.autoOpen = false; // Disable auto-open by default
73
+ return mockWebSocket;
74
+ });
75
+ // Mock document.cookie
76
+ cookieStub = stub(document, 'cookie').value('');
77
+ clock = useFakeTimers();
78
+ });
79
+ afterEach(() => {
80
+ clock.restore();
81
+ webSocketStub.restore();
82
+ cookieStub.restore();
83
+ window.WebSocket = originalWebSocket;
84
+ });
85
+ describe('Component Initialization', () => {
86
+ it('creates component with default properties', async () => {
87
+ const webChat = await getWebChat();
88
+ assert.instanceOf(webChat, WebChat);
89
+ expect(webChat.open).to.equal(false);
90
+ expect(webChat.status).to.equal('disconnected');
91
+ expect(webChat.hasPendingText).to.equal(false);
92
+ expect(webChat.messageGroups).to.deep.equal([]);
93
+ expect(webChat.blockHistoryFetching).to.equal(false);
94
+ });
95
+ it('accepts channel and urn properties', async () => {
96
+ const webChat = await getWebChat({
97
+ channel: 'test-channel',
98
+ urn: 'test-urn-123'
99
+ });
100
+ expect(webChat.channel).to.equal('test-channel');
101
+ expect(webChat.urn).to.equal('test-urn-123');
102
+ });
103
+ it('accepts host and activeUserAvatar properties', async () => {
104
+ const webChat = await getWebChat({
105
+ host: 'example.com',
106
+ activeUserAvatar: 'https://example.com/avatar.jpg'
107
+ });
108
+ expect(webChat.host).to.equal('example.com');
109
+ expect(webChat.activeUserAvatar).to.equal('https://example.com/avatar.jpg');
110
+ });
111
+ it('initializes chat component on first update', async () => {
112
+ const webChat = await getWebChat({
113
+ channel: 'test-channel'
114
+ });
115
+ // Check that the chat component was initialized
116
+ const chatElement = webChat.shadowRoot.querySelector('temba-chat');
117
+ expect(chatElement).to.exist;
118
+ });
119
+ });
120
+ describe('UI Rendering', () => {
121
+ it('renders closed chat widget by default', async () => {
122
+ const webChat = await getWebChat({
123
+ channel: 'test-channel'
124
+ });
125
+ await assertScreenshot('webchat/closed-widget', getClip(webChat));
126
+ });
127
+ it('renders opened chat widget', async () => {
128
+ const webChat = await getWebChat({
129
+ channel: 'test-channel',
130
+ open: true
131
+ });
132
+ await assertScreenshot('webchat/opened-widget', getClip(webChat));
133
+ });
134
+ it('renders connecting state', async () => {
135
+ const webChat = await getWebChat({
136
+ channel: 'test-channel'
137
+ });
138
+ expect(webChat.open).to.equal(false);
139
+ expect(webChat.status).to.equal('disconnected');
140
+ // Click to open the widget, which should trigger connecting state
141
+ const toggleElement = webChat.shadowRoot.querySelector('.toggle');
142
+ expect(toggleElement).to.exist;
143
+ await mouseClickElement(toggleElement);
144
+ await webChat.updateComplete;
145
+ // Now it should be open and connecting
146
+ expect(webChat.open).to.equal(true);
147
+ expect(webChat.status).to.equal('connecting');
148
+ await assertScreenshot('webchat/connecting-state', getClip(webChat));
149
+ });
150
+ it('renders disconnected state with reconnect option', async () => {
151
+ const webChat = await getWebChat({
152
+ channel: 'test-channel',
153
+ open: true,
154
+ status: 'disconnected'
155
+ });
156
+ await assertScreenshot('webchat/disconnected-state', getClip(webChat));
157
+ });
158
+ it('renders connected state with input field', async () => {
159
+ const webChat = await getWebChat({
160
+ channel: 'test-channel',
161
+ open: true,
162
+ status: 'connected'
163
+ });
164
+ await assertScreenshot('webchat/connected-state', getClip(webChat));
165
+ });
166
+ });
167
+ describe('Chat Toggle Functionality', () => {
168
+ it('toggles chat open/closed', async () => {
169
+ const webChat = await getWebChat({
170
+ channel: 'test-channel'
171
+ });
172
+ expect(webChat.open).to.equal(false);
173
+ // Click the toggle element
174
+ const toggleElement = webChat.shadowRoot.querySelector('.toggle');
175
+ expect(toggleElement).to.exist;
176
+ await mouseClickElement(toggleElement);
177
+ await webChat.updateComplete;
178
+ expect(webChat.open).to.equal(true);
179
+ // Click toggle again
180
+ await mouseClickElement(toggleElement);
181
+ await webChat.updateComplete;
182
+ expect(webChat.open).to.equal(false);
183
+ });
184
+ it('toggles chat via close button', async () => {
185
+ const webChat = await getWebChat({
186
+ channel: 'test-channel',
187
+ open: true
188
+ });
189
+ expect(webChat.open).to.equal(true);
190
+ // Click close button in header
191
+ const closeButton = webChat.shadowRoot.querySelector('.close-button');
192
+ expect(closeButton).to.exist;
193
+ await mouseClickElement(closeButton);
194
+ await webChat.updateComplete;
195
+ expect(webChat.open).to.equal(false);
196
+ });
197
+ it('opens chat programmatically', async () => {
198
+ const webChat = await getWebChat({
199
+ channel: 'test-channel'
200
+ });
201
+ expect(webChat.open).to.equal(false);
202
+ webChat.openChat();
203
+ await webChat.updateComplete;
204
+ expect(webChat.open).to.equal(true);
205
+ });
206
+ });
207
+ describe('Socket Connection Management', () => {
208
+ it('opens socket when chat is opened', async () => {
209
+ const webChat = await getWebChat({
210
+ channel: 'test-channel'
211
+ });
212
+ expect(webChat.status).to.equal('disconnected');
213
+ // Open chat - this should trigger socket connection
214
+ webChat.open = true;
215
+ await webChat.updateComplete;
216
+ expect(webChat.status).to.equal('connecting');
217
+ expect(webSocketStub.called).to.be.true;
218
+ expect(mockWebSocket.url).to.include('test-channel');
219
+ // Now simulate the socket opening
220
+ mockWebSocket.manualOpen();
221
+ await webChat.updateComplete;
222
+ expect(webChat.status).to.equal('connected');
223
+ });
224
+ it('does not open socket if already connecting or connected', async () => {
225
+ const webChat = await getWebChat({
226
+ channel: 'test-channel'
227
+ });
228
+ // First connection
229
+ webChat.open = true;
230
+ await webChat.updateComplete;
231
+ await clock.tick(0);
232
+ const firstCallCount = webSocketStub.callCount;
233
+ // Try to connect again while connecting
234
+ webChat.open = false;
235
+ webChat.open = true;
236
+ await webChat.updateComplete;
237
+ await clock.tick(0);
238
+ // Should not create another socket
239
+ expect(webSocketStub.callCount).to.equal(firstCallCount);
240
+ });
241
+ it('constructs correct WebSocket URL with channel', async () => {
242
+ const webChat = await getWebChat({
243
+ channel: 'my-channel'
244
+ });
245
+ webChat.open = true;
246
+ await webChat.updateComplete;
247
+ await clock.tick(0);
248
+ expect(mockWebSocket.url).to.equal('wss://localhost.textit.com/wc/connect/my-channel/');
249
+ });
250
+ it('includes urn in WebSocket URL when present', async () => {
251
+ const webChat = await getWebChat({
252
+ channel: 'my-channel',
253
+ urn: 'chat-123'
254
+ });
255
+ webChat.open = true;
256
+ await webChat.updateComplete;
257
+ await clock.tick(0);
258
+ expect(mockWebSocket.url).to.equal('wss://localhost.textit.com/wc/connect/my-channel/?chat_id=chat-123');
259
+ });
260
+ it('sends start_chat command on socket open', async () => {
261
+ const webChat = await getWebChat({
262
+ channel: 'test-channel'
263
+ });
264
+ webChat.open = true;
265
+ await webChat.updateComplete;
266
+ await clock.tick(0);
267
+ expect(mockWebSocket.sentMessages).to.have.length(1);
268
+ const sentMessage = JSON.parse(mockWebSocket.sentMessages[0]);
269
+ expect(sentMessage.type).to.equal('start_chat');
270
+ });
271
+ it('includes chat_id in start_chat command when urn is present', async () => {
272
+ // Set the cookie directly in document.cookie
273
+ cookieStub.value('temba-chat-urn=existing-chat-123');
274
+ const webChat = await getWebChat({
275
+ channel: 'test-channel',
276
+ urn: 'existing-chat-123'
277
+ });
278
+ webChat.open = true;
279
+ await webChat.updateComplete;
280
+ mockWebSocket.manualOpen();
281
+ await webChat.updateComplete;
282
+ const sentMessage = JSON.parse(mockWebSocket.sentMessages[0]);
283
+ expect(sentMessage.type).to.equal('start_chat');
284
+ // The chat_id should be 'existing-chat-123' either from the urn property or cookie
285
+ expect(sentMessage.chat_id).to.equal('existing-chat-123');
286
+ });
287
+ it('handles socket close event', async () => {
288
+ const webChat = await getWebChat({
289
+ channel: 'test-channel'
290
+ });
291
+ webChat.open = true;
292
+ await webChat.updateComplete;
293
+ expect(webChat.status).to.equal('connecting');
294
+ mockWebSocket.close();
295
+ await webChat.updateComplete;
296
+ expect(webChat.status).to.equal('disconnected');
297
+ });
298
+ it('handles socket error event', async () => {
299
+ const webChat = await getWebChat({
300
+ channel: 'test-channel'
301
+ });
302
+ webChat.open = true;
303
+ await webChat.updateComplete;
304
+ expect(webChat.status).to.equal('connecting');
305
+ mockWebSocket.simulateError();
306
+ await webChat.updateComplete;
307
+ expect(webChat.status).to.equal('disconnected');
308
+ });
309
+ it('reconnects when reconnect button is clicked', async () => {
310
+ const webChat = await getWebChat({
311
+ channel: 'test-channel',
312
+ open: true,
313
+ status: 'disconnected'
314
+ });
315
+ const reconnectButton = webChat.shadowRoot.querySelector('.reconnect');
316
+ expect(reconnectButton).to.exist;
317
+ await mouseClickElement(reconnectButton);
318
+ await webChat.updateComplete;
319
+ expect(webSocketStub.called).to.be.true;
320
+ expect(webChat.status).to.equal('connecting');
321
+ });
322
+ });
323
+ describe('Message Handling', () => {
324
+ it('handles chat_started message', async () => {
325
+ const webChat = await getWebChat({
326
+ channel: 'test-channel'
327
+ });
328
+ webChat.open = true;
329
+ await webChat.updateComplete;
330
+ mockWebSocket.manualOpen();
331
+ await webChat.updateComplete;
332
+ expect(webChat.status).to.equal('connected');
333
+ // Simulate chat_started message
334
+ mockWebSocket.simulateMessage({
335
+ type: 'chat_started',
336
+ chat_id: 'new-chat-456'
337
+ });
338
+ await webChat.updateComplete;
339
+ expect(webChat.urn).to.equal('new-chat-456');
340
+ expect(webChat.messageGroups).to.deep.equal([]);
341
+ });
342
+ it('handles chat_resumed message and fetches history', async () => {
343
+ const webChat = await getWebChat({
344
+ channel: 'test-channel'
345
+ });
346
+ webChat.open = true;
347
+ await webChat.updateComplete;
348
+ mockWebSocket.manualOpen();
349
+ await webChat.updateComplete;
350
+ // Simulate chat_resumed message
351
+ mockWebSocket.simulateMessage({
352
+ type: 'chat_resumed',
353
+ chat_id: 'resumed-chat-789'
354
+ });
355
+ await webChat.updateComplete;
356
+ expect(webChat.urn).to.equal('resumed-chat-789');
357
+ // Should have sent get_history command
358
+ expect(mockWebSocket.sentMessages.length).to.be.greaterThan(1);
359
+ const lastMessage = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
360
+ expect(lastMessage.type).to.equal('get_history');
361
+ });
362
+ it('handles chat_out message and sends ack', async () => {
363
+ const webChat = await getWebChat({
364
+ channel: 'test-channel'
365
+ });
366
+ webChat.open = true;
367
+ await webChat.updateComplete;
368
+ mockWebSocket.manualOpen();
369
+ await webChat.updateComplete;
370
+ const initialMessageCount = mockWebSocket.sentMessages.length;
371
+ // Simulate incoming message
372
+ mockWebSocket.simulateMessage({
373
+ type: 'chat_out',
374
+ msg_out: {
375
+ id: 'msg-123',
376
+ text: 'Hello from server',
377
+ time: '2021-03-31T00:31:00.000Z',
378
+ user: {
379
+ id: 'user-1',
380
+ name: 'Test User',
381
+ email: 'test@example.com'
382
+ }
383
+ }
384
+ });
385
+ await webChat.updateComplete;
386
+ // Should have sent ack
387
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount + 1);
388
+ const ackMessage = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
389
+ expect(ackMessage.type).to.equal('ack_chat');
390
+ expect(ackMessage.msg_id).to.equal('msg-123');
391
+ });
392
+ it('handles history response', async () => {
393
+ const webChat = await getWebChat({
394
+ channel: 'test-channel'
395
+ });
396
+ webChat.open = true;
397
+ await webChat.updateComplete;
398
+ mockWebSocket.manualOpen();
399
+ await webChat.updateComplete;
400
+ // Simulate history response
401
+ mockWebSocket.simulateMessage({
402
+ type: 'history',
403
+ history: [
404
+ {
405
+ msg_out: {
406
+ id: 'msg-1',
407
+ text: 'First message',
408
+ time: '2021-03-31T00:30:00.000Z',
409
+ user: { id: 'user-1', name: 'User', email: 'user@example.com' }
410
+ }
411
+ },
412
+ {
413
+ msg_in: {
414
+ id: 'msg-2',
415
+ text: 'Second message',
416
+ time: '2021-03-31T00:30:30.000Z'
417
+ }
418
+ }
419
+ ]
420
+ });
421
+ await webChat.updateComplete;
422
+ // Should have updated beforeTime and unblocked history fetching
423
+ expect(webChat.blockHistoryFetching).to.equal(false);
424
+ });
425
+ it('clears messages when chat_id changes', async () => {
426
+ const webChat = await getWebChat({
427
+ channel: 'test-channel',
428
+ urn: 'old-chat-123'
429
+ });
430
+ webChat.messageGroups = [['existing', 'messages']];
431
+ webChat.open = true;
432
+ await webChat.updateComplete;
433
+ mockWebSocket.manualOpen();
434
+ await webChat.updateComplete;
435
+ // Simulate chat_started with different chat_id
436
+ mockWebSocket.simulateMessage({
437
+ type: 'chat_started',
438
+ chat_id: 'new-chat-456'
439
+ });
440
+ await webChat.updateComplete;
441
+ expect(webChat.urn).to.equal('new-chat-456');
442
+ expect(webChat.messageGroups).to.deep.equal([]);
443
+ });
444
+ it('keeps messages when chat_id is the same', async () => {
445
+ const webChat = await getWebChat({
446
+ channel: 'test-channel'
447
+ });
448
+ webChat.open = true;
449
+ await webChat.updateComplete;
450
+ mockWebSocket.manualOpen();
451
+ await webChat.updateComplete;
452
+ // First, simulate a chat_started to set the URN
453
+ mockWebSocket.simulateMessage({
454
+ type: 'chat_started',
455
+ chat_id: 'same-chat-123'
456
+ });
457
+ await webChat.updateComplete;
458
+ // Now set some messages
459
+ webChat.messageGroups = [['existing', 'messages']];
460
+ await webChat.updateComplete;
461
+ // Simulate another chat_started with the same chat_id
462
+ mockWebSocket.simulateMessage({
463
+ type: 'chat_started',
464
+ chat_id: 'same-chat-123'
465
+ });
466
+ await webChat.updateComplete;
467
+ expect(webChat.urn).to.equal('same-chat-123');
468
+ expect(webChat.messageGroups).to.deep.equal([['existing', 'messages']]);
469
+ });
470
+ });
471
+ describe('User Input and Message Sending', () => {
472
+ it('updates hasPendingText on keyup', async () => {
473
+ const webChat = await getWebChat({
474
+ channel: 'test-channel',
475
+ open: true,
476
+ status: 'connected'
477
+ });
478
+ const inputField = webChat.shadowRoot.querySelector('.input');
479
+ expect(inputField).to.exist;
480
+ expect(webChat.hasPendingText).to.equal(false);
481
+ // Simulate typing
482
+ inputField.value = 'Hello';
483
+ inputField.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }));
484
+ await webChat.updateComplete;
485
+ expect(webChat.hasPendingText).to.equal(true);
486
+ // Clear input
487
+ inputField.value = '';
488
+ inputField.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }));
489
+ await webChat.updateComplete;
490
+ expect(webChat.hasPendingText).to.equal(false);
491
+ });
492
+ it('sends message on Enter key', async () => {
493
+ const webChat = await getWebChat({
494
+ channel: 'test-channel'
495
+ });
496
+ webChat.open = true;
497
+ await webChat.updateComplete;
498
+ mockWebSocket.manualOpen();
499
+ await webChat.updateComplete;
500
+ // Ensure the socket is properly set
501
+ expect(webChat.status).to.equal('connected');
502
+ const inputField = webChat.shadowRoot.querySelector('.input');
503
+ inputField.value = 'Test message';
504
+ webChat.hasPendingText = true;
505
+ const initialMessageCount = mockWebSocket.sentMessages.length;
506
+ // Call handleKeyUp directly with Enter key event
507
+ webChat.handleKeyUp({ key: 'Enter', target: inputField });
508
+ await webChat.updateComplete;
509
+ // Should have sent message
510
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount + 1);
511
+ const sentMessage = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
512
+ expect(sentMessage.type).to.equal('send_msg');
513
+ expect(sentMessage.text).to.equal('Test message');
514
+ // input should be cleared and hasPendingText should be false
515
+ expect(inputField.value).to.equal('');
516
+ expect(webChat.hasPendingText).to.equal(false);
517
+ });
518
+ it('sends message via send button click', async () => {
519
+ const webChat = await getWebChat({
520
+ channel: 'test-channel'
521
+ });
522
+ webChat.open = true;
523
+ await webChat.updateComplete;
524
+ mockWebSocket.manualOpen();
525
+ await webChat.updateComplete;
526
+ const inputField = webChat.shadowRoot.querySelector('.input');
527
+ inputField.value = 'Button click message';
528
+ webChat.hasPendingText = true;
529
+ await webChat.updateComplete;
530
+ const initialMessageCount = mockWebSocket.sentMessages.length;
531
+ // call sendPendingMessage directly since it's a private method called by click handler
532
+ webChat.sendPendingMessage();
533
+ await webChat.updateComplete;
534
+ // should have sent message
535
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount + 1);
536
+ const sentMessage = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
537
+ expect(sentMessage.type).to.equal('send_msg');
538
+ expect(sentMessage.text).to.equal('Button click message');
539
+ });
540
+ it('focuses input when input panel is clicked', async () => {
541
+ const webChat = await getWebChat({
542
+ channel: 'test-channel',
543
+ open: true,
544
+ status: 'connected'
545
+ });
546
+ const inputPanel = webChat.shadowRoot.querySelector('.input-panel');
547
+ expect(inputPanel).to.exist;
548
+ // Mock focus method
549
+ const inputField = webChat.shadowRoot.querySelector('.input');
550
+ let focusCalled = false;
551
+ inputField.focus = () => {
552
+ focusCalled = true;
553
+ };
554
+ await mouseClickElement(inputPanel);
555
+ expect(focusCalled).to.be.true;
556
+ });
557
+ it('does not send message when disconnected', async () => {
558
+ const webChat = await getWebChat({
559
+ channel: 'test-channel',
560
+ open: true
561
+ });
562
+ // Keep the status as disconnected - don't connect the socket
563
+ expect(webChat.status).to.equal('disconnected');
564
+ // Find input field - it might not exist in disconnected state
565
+ const inputField = webChat.shadowRoot.querySelector('.input');
566
+ if (inputField) {
567
+ inputField.value = 'Should not send';
568
+ webChat.hasPendingText = true;
569
+ // Try to send message by calling handleKeyUp
570
+ webChat.handleKeyUp({ key: 'Enter', target: inputField });
571
+ await webChat.updateComplete;
572
+ }
573
+ // Should not have created any WebSocket connections
574
+ expect(webSocketStub.called).to.be.false;
575
+ });
576
+ });
577
+ describe('History Fetching', () => {
578
+ it('fetches previous messages when requested', async () => {
579
+ const webChat = await getWebChat({
580
+ channel: 'test-channel'
581
+ });
582
+ webChat.open = true;
583
+ await webChat.updateComplete;
584
+ mockWebSocket.manualOpen();
585
+ await webChat.updateComplete;
586
+ const initialMessageCount = mockWebSocket.sentMessages.length;
587
+ // Manually call fetchPreviousMessages
588
+ webChat.fetchPreviousMessages();
589
+ await webChat.updateComplete;
590
+ expect(webChat.blockHistoryFetching).to.equal(true);
591
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount + 1);
592
+ const historyRequest = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
593
+ expect(historyRequest.type).to.equal('get_history');
594
+ expect(historyRequest.before).to.exist;
595
+ });
596
+ it('does not fetch when already fetching', async () => {
597
+ const webChat = await getWebChat({
598
+ channel: 'test-channel'
599
+ });
600
+ webChat.blockHistoryFetching = true;
601
+ webChat.open = true;
602
+ await webChat.updateComplete;
603
+ mockWebSocket.manualOpen();
604
+ await webChat.updateComplete;
605
+ const initialMessageCount = mockWebSocket.sentMessages.length;
606
+ // Try to fetch - should be blocked
607
+ webChat.fetchPreviousMessages();
608
+ await webChat.updateComplete;
609
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount);
610
+ });
611
+ it('completes fetch and unblocks history fetching', async () => {
612
+ const webChat = await getWebChat({
613
+ channel: 'test-channel'
614
+ });
615
+ webChat.blockHistoryFetching = true;
616
+ webChat.fetchComplete();
617
+ expect(webChat.blockHistoryFetching).to.equal(false);
618
+ });
619
+ });
620
+ describe('Edge Cases and Additional Coverage', () => {
621
+ it('handles keyup events that are not Enter', async () => {
622
+ const webChat = await getWebChat({
623
+ channel: 'test-channel'
624
+ });
625
+ webChat.open = true;
626
+ await webChat.updateComplete;
627
+ mockWebSocket.manualOpen();
628
+ await webChat.updateComplete;
629
+ const inputField = webChat.shadowRoot.querySelector('.input');
630
+ inputField.value = 'Some text';
631
+ // Test with non-Enter key
632
+ webChat.handleKeyUp({ key: 'a', target: inputField });
633
+ await webChat.updateComplete;
634
+ expect(webChat.hasPendingText).to.equal(true);
635
+ // Test with empty value
636
+ inputField.value = '';
637
+ webChat.handleKeyUp({ key: 'Backspace', target: inputField });
638
+ await webChat.updateComplete;
639
+ expect(webChat.hasPendingText).to.equal(false);
640
+ });
641
+ it('does not send message on Enter if no pending text', async () => {
642
+ const webChat = await getWebChat({
643
+ channel: 'test-channel'
644
+ });
645
+ webChat.open = true;
646
+ await webChat.updateComplete;
647
+ mockWebSocket.manualOpen();
648
+ await webChat.updateComplete;
649
+ const inputField = webChat.shadowRoot.querySelector('.input');
650
+ inputField.value = '';
651
+ webChat.hasPendingText = false;
652
+ const initialMessageCount = mockWebSocket.sentMessages.length;
653
+ // Try to send with Enter but no pending text
654
+ webChat.handleKeyUp({ key: 'Enter', target: inputField });
655
+ await webChat.updateComplete;
656
+ // Should not have sent any new messages
657
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount);
658
+ });
659
+ it('updates status when open changes from false to true', async () => {
660
+ const webChat = await getWebChat({
661
+ channel: 'test-channel'
662
+ });
663
+ expect(webChat.status).to.equal('disconnected');
664
+ // Open the chat for the first time
665
+ webChat.open = true;
666
+ await webChat.updateComplete;
667
+ expect(webChat.status).to.equal('connecting');
668
+ expect(webSocketStub.called).to.be.true;
669
+ });
670
+ it('focuses input when status changes to connected', async () => {
671
+ const webChat = await getWebChat({
672
+ channel: 'test-channel'
673
+ });
674
+ // Open chat to trigger connection
675
+ webChat.open = true;
676
+ await webChat.updateComplete;
677
+ expect(webChat.status).to.equal('connecting');
678
+ // Mock focus method before the input is rendered
679
+ let focusCalled = false;
680
+ const originalFocus = HTMLInputElement.prototype.focus;
681
+ HTMLInputElement.prototype.focus = function () {
682
+ focusCalled = true;
683
+ };
684
+ // Manually open socket to trigger status change to connected
685
+ mockWebSocket.manualOpen();
686
+ await webChat.updateComplete;
687
+ // Restore original focus
688
+ HTMLInputElement.prototype.focus = originalFocus;
689
+ // Focus should have been called when status changed to connected
690
+ expect(focusCalled).to.be.true;
691
+ });
692
+ it('handles focus input when input element does not exist', async () => {
693
+ const webChat = await getWebChat({
694
+ channel: 'test-channel',
695
+ open: true,
696
+ status: 'disconnected'
697
+ });
698
+ // Try to focus input when it might not exist
699
+ webChat.focusInput();
700
+ // Should not throw an error
701
+ expect(true).to.be.true;
702
+ });
703
+ it('handles empty history response', async () => {
704
+ const webChat = await getWebChat({
705
+ channel: 'test-channel'
706
+ });
707
+ webChat.open = true;
708
+ await webChat.updateComplete;
709
+ mockWebSocket.manualOpen();
710
+ await webChat.updateComplete;
711
+ // Simulate empty history response
712
+ mockWebSocket.simulateMessage({
713
+ type: 'history',
714
+ history: []
715
+ });
716
+ await webChat.updateComplete;
717
+ expect(webChat.blockHistoryFetching).to.equal(false);
718
+ });
719
+ it('handles message with msg_in in history response', async () => {
720
+ const webChat = await getWebChat({
721
+ channel: 'test-channel'
722
+ });
723
+ webChat.open = true;
724
+ await webChat.updateComplete;
725
+ mockWebSocket.manualOpen();
726
+ await webChat.updateComplete;
727
+ // Simulate history response with msg_in first
728
+ mockWebSocket.simulateMessage({
729
+ type: 'history',
730
+ history: [
731
+ {
732
+ msg_in: {
733
+ id: 'msg-1',
734
+ text: 'Incoming message',
735
+ time: '2021-03-31T00:30:00.000Z'
736
+ }
737
+ }
738
+ ]
739
+ });
740
+ await webChat.updateComplete;
741
+ expect(webChat.blockHistoryFetching).to.equal(false);
742
+ });
743
+ it('prevents event propagation on input panel click', async () => {
744
+ const webChat = await getWebChat({
745
+ channel: 'test-channel',
746
+ open: true,
747
+ status: 'connected'
748
+ });
749
+ const inputPanel = webChat.shadowRoot.querySelector('.input-panel');
750
+ expect(inputPanel).to.exist;
751
+ let preventDefaultCalled = false;
752
+ let stopPropagationCalled = false;
753
+ const mockEvent = {
754
+ preventDefault: () => {
755
+ preventDefaultCalled = true;
756
+ },
757
+ stopPropagation: () => {
758
+ stopPropagationCalled = true;
759
+ }
760
+ };
761
+ webChat.handleClickInputPanel(mockEvent);
762
+ expect(preventDefaultCalled).to.be.true;
763
+ expect(stopPropagationCalled).to.be.true;
764
+ });
765
+ it('handles unknown message types gracefully', async () => {
766
+ const webChat = await getWebChat({
767
+ channel: 'test-channel'
768
+ });
769
+ webChat.open = true;
770
+ await webChat.updateComplete;
771
+ mockWebSocket.manualOpen();
772
+ await webChat.updateComplete;
773
+ // Simulate unknown message type
774
+ mockWebSocket.simulateMessage({
775
+ type: 'unknown_message_type',
776
+ data: 'some data'
777
+ });
778
+ await webChat.updateComplete;
779
+ // Should not throw an error
780
+ expect(true).to.be.true;
781
+ });
782
+ });
783
+ describe('Integration with Chat Component', () => {
784
+ it('listens to scroll threshold event for history fetching', async () => {
785
+ const webChat = await getWebChat({
786
+ channel: 'test-channel'
787
+ });
788
+ webChat.open = true;
789
+ await webChat.updateComplete;
790
+ mockWebSocket.manualOpen();
791
+ await webChat.updateComplete;
792
+ const chatElement = webChat.shadowRoot.querySelector('temba-chat');
793
+ expect(chatElement).to.exist;
794
+ const initialMessageCount = mockWebSocket.sentMessages.length;
795
+ // Simulate scroll threshold event
796
+ chatElement.dispatchEvent(new CustomEvent('temba-scroll-threshold'));
797
+ await webChat.updateComplete;
798
+ // Should have triggered history fetch
799
+ expect(mockWebSocket.sentMessages.length).to.equal(initialMessageCount + 1);
800
+ const historyRequest = JSON.parse(mockWebSocket.sentMessages[mockWebSocket.sentMessages.length - 1]);
801
+ expect(historyRequest.type).to.equal('get_history');
802
+ });
803
+ it('listens to fetch complete event', async () => {
804
+ const webChat = await getWebChat({
805
+ channel: 'test-channel'
806
+ });
807
+ webChat.blockHistoryFetching = true;
808
+ const chatElement = webChat.shadowRoot.querySelector('temba-chat');
809
+ expect(chatElement).to.exist;
810
+ // Simulate fetch complete event
811
+ chatElement.dispatchEvent(new CustomEvent('temba-fetch-complete'));
812
+ expect(webChat.blockHistoryFetching).to.equal(false);
813
+ });
814
+ });
815
+ });
816
+ //# sourceMappingURL=temba-webchat.test.js.map