@solucx/react-native-solucx-widget 0.1.15 → 0.2.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 (46) hide show
  1. package/README.intern.md +513 -513
  2. package/README.md +285 -285
  3. package/package.json +50 -23
  4. package/src/SoluCXWidget.tsx +172 -119
  5. package/src/__mocks__/expo-modules-core-web.js +16 -0
  6. package/src/__mocks__/expo-modules-core.js +33 -0
  7. package/src/__tests__/ClientVersionCollector.test.ts +55 -0
  8. package/src/__tests__/CloseButton.test.tsx +47 -0
  9. package/src/__tests__/Constants.test.ts +17 -0
  10. package/src/__tests__/InlineWidget.rendering.test.tsx +81 -0
  11. package/src/__tests__/ModalWidget.rendering.test.tsx +157 -0
  12. package/src/__tests__/OverlayWidget.rendering.test.tsx +123 -0
  13. package/src/__tests__/SoluCXWidget.rendering.test.tsx +315 -0
  14. package/src/__tests__/e2e/widget-lifecycle.test.tsx +353 -0
  15. package/src/__tests__/integration/webview-communication-simple.test.tsx +147 -0
  16. package/src/__tests__/integration/webview-communication.test.tsx +417 -0
  17. package/src/__tests__/urlUtils.test.ts +56 -56
  18. package/src/__tests__/useDeviceInfoCollector.test.ts +109 -0
  19. package/src/__tests__/useWidgetState.test.ts +181 -189
  20. package/src/__tests__/widgetBootstrapService.test.ts +182 -0
  21. package/src/components/CloseButton.tsx +36 -36
  22. package/src/components/InlineWidget.tsx +36 -36
  23. package/src/components/ModalWidget.tsx +57 -59
  24. package/src/components/OverlayWidget.tsx +88 -88
  25. package/src/constants/Constants.ts +4 -0
  26. package/src/constants/webViewConstants.ts +15 -14
  27. package/src/hooks/index.ts +2 -2
  28. package/src/hooks/useDeviceInfoCollector.ts +67 -0
  29. package/src/hooks/useHeightAnimation.ts +22 -22
  30. package/src/hooks/useWidgetHeight.ts +38 -38
  31. package/src/hooks/useWidgetState.ts +101 -101
  32. package/src/index.ts +12 -8
  33. package/src/interfaces/WidgetCallbacks.ts +15 -0
  34. package/src/interfaces/WidgetData.ts +19 -19
  35. package/src/interfaces/WidgetOptions.ts +7 -7
  36. package/src/interfaces/WidgetResponse.ts +15 -15
  37. package/src/interfaces/WidgetSamplerLog.ts +5 -5
  38. package/src/interfaces/index.ts +25 -24
  39. package/src/services/ClientVersionCollector.ts +15 -0
  40. package/src/services/storage.ts +21 -21
  41. package/src/services/widgetBootstrapService.ts +67 -0
  42. package/src/services/widgetEventService.ts +110 -111
  43. package/src/services/widgetValidationService.ts +102 -86
  44. package/src/setupTests.js +43 -0
  45. package/src/styles/widgetStyles.ts +58 -58
  46. package/src/utils/urlUtils.ts +13 -13
@@ -0,0 +1,353 @@
1
+ /**
2
+ * END-TO-END TEST: Widget Lifecycle
3
+ *
4
+ * Tests complete user journeys from widget mount to close,
5
+ * simulating real-world scenarios that companies would encounter.
6
+ *
7
+ * Strategy: Minimal mocking, test full integration flow
8
+ */
9
+
10
+ import React from 'react';
11
+ import { render, waitFor, fireEvent } from '@testing-library/react-native';
12
+ import { SoluCXWidget } from '../../SoluCXWidget';
13
+ import type { WidgetCallbacks } from '../../interfaces';
14
+
15
+ // Mock storage
16
+ const mockStorage: Record<string, string> = {};
17
+ jest.mock('@react-native-async-storage/async-storage', () => ({
18
+ __esModule: true,
19
+ default: {
20
+ getItem: jest.fn((key: string) => Promise.resolve(mockStorage[key] || null)),
21
+ setItem: jest.fn((key: string, value: string) => {
22
+ mockStorage[key] = value;
23
+ return Promise.resolve();
24
+ }),
25
+ removeItem: jest.fn((key: string) => {
26
+ delete mockStorage[key];
27
+ return Promise.resolve();
28
+ }),
29
+ clear: jest.fn(() => {
30
+ Object.keys(mockStorage).forEach(key => delete mockStorage[key]);
31
+ return Promise.resolve();
32
+ }),
33
+ },
34
+ }));
35
+
36
+ // Mock only what we absolutely must
37
+ jest.mock('react-native-webview', () => {
38
+ const React = require('react');
39
+ const { View, Text } = require('react-native');
40
+ return {
41
+ __esModule: true,
42
+ WebView: React.forwardRef((props: any, ref: any) => {
43
+ React.useImperativeHandle(ref, () => ({
44
+ injectJavaScript: jest.fn(),
45
+ }));
46
+
47
+ return (
48
+ <View testID="webview" {...props}>
49
+ <Text>WebView Content</Text>
50
+ </View>
51
+ );
52
+ }),
53
+ };
54
+ });
55
+
56
+ jest.mock('../../utils/urlUtils', () => ({
57
+ buildWidgetURL: jest.fn().mockReturnValue('https://widget.solucx.com/form/123'),
58
+ }));
59
+
60
+ jest.mock('../../services/widgetBootstrapService', () => ({
61
+ requestWidgetUrl: jest.fn().mockResolvedValue('https://widget.solucx.com/survey/456'),
62
+ }));
63
+
64
+ jest.mock('../../services/widgetValidationService', () => ({
65
+ WidgetValidationService: jest.fn().mockImplementation(() => ({
66
+ shouldDisplayWidget: jest.fn().mockResolvedValue({ canDisplay: true }),
67
+ })),
68
+ }));
69
+
70
+ describe('E2E: Widget Lifecycle', () => {
71
+ beforeEach(async () => {
72
+ jest.clearAllMocks();
73
+ Object.keys(mockStorage).forEach(key => delete mockStorage[key]);
74
+ });
75
+
76
+ describe('E-commerce Post-Purchase Survey Flow', () => {
77
+ it('should complete full flow: mount → display → interact → complete → close', async () => {
78
+ const analytics = {
79
+ track: jest.fn(),
80
+ };
81
+
82
+ const callbacks: WidgetCallbacks = {
83
+ onOpened: (userId) => analytics.track('widget_shown', { userId }),
84
+ onQuestionAnswered: () => analytics.track('question_answered'),
85
+ onPartialCompleted: (userId) => {
86
+ analytics.track('survey_completed', { userId });
87
+ },
88
+ onClosed: () => analytics.track('widget_closed'),
89
+ };
90
+
91
+ // 1. Mount widget after purchase
92
+ const { getByTestId } = render(
93
+ <SoluCXWidget
94
+ soluCXKey="ecommerce-post-purchase"
95
+ type="modal"
96
+ data={{
97
+ form_id: 'post-purchase-123',
98
+ customer_id: 'cust-789',
99
+ transaction_id: 'order-456',
100
+ amount: 199.99,
101
+ }}
102
+ options={{ height: 500 }}
103
+ callbacks={callbacks}
104
+ />
105
+ );
106
+
107
+ // 2. Widget should render
108
+ await waitFor(() => {
109
+ expect(getByTestId('webview')).toBeTruthy();
110
+ });
111
+
112
+ const webview = getByTestId('webview');
113
+
114
+ // 3. First page loads, customer sees question
115
+ // (WebView automatically expands as content loads)
116
+ fireEvent(webview, 'message', {
117
+ nativeEvent: { data: 'FORM_RESIZE-400' },
118
+ });
119
+
120
+ await waitFor(() => {
121
+ expect(analytics.track).toHaveBeenCalledWith('question_answered', undefined);
122
+ }, { timeout: 100 }).catch(() => {}); // May not be called yet
123
+
124
+ // 4. Customer answers first question
125
+ fireEvent(webview, 'message', {
126
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
127
+ });
128
+
129
+ await waitFor(() => {
130
+ expect(analytics.track).toHaveBeenCalledWith('question_answered');
131
+ });
132
+
133
+ // 5. Customer goes to next page
134
+ fireEvent(webview, 'message', {
135
+ nativeEvent: { data: 'FORM_PAGECHANGED-2' },
136
+ });
137
+
138
+ // 6. Customer answers second question
139
+ fireEvent(webview, 'message', {
140
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(analytics.track).toHaveBeenCalledWith('question_answered');
145
+ expect(analytics.track).toHaveBeenCalledTimes(2); // Called twice
146
+ });
147
+
148
+ // 7. Customer submits survey
149
+ fireEvent(webview, 'message', {
150
+ nativeEvent: { data: 'FORM_COMPLETED' },
151
+ });
152
+
153
+ await waitFor(() => {
154
+ expect(analytics.track).toHaveBeenCalledWith('survey_completed', {
155
+ userId: expect.any(String),
156
+ });
157
+ });
158
+
159
+ // 8. Customer closes widget
160
+ fireEvent(webview, 'message', {
161
+ nativeEvent: { data: 'FORM_CLOSE' },
162
+ });
163
+
164
+ await waitFor(() => {
165
+ expect(analytics.track).toHaveBeenCalledWith('widget_closed');
166
+ });
167
+
168
+ // Verify complete tracking flow
169
+ expect(analytics.track).toHaveBeenCalledTimes(4);
170
+ });
171
+ });
172
+
173
+ describe('SaaS NPS Survey Flow with Error Recovery', () => {
174
+ it('should handle network error gracefully and still track partial completion', async () => {
175
+ const errorHandler = jest.fn();
176
+ const completionHandler = jest.fn();
177
+
178
+ const callbacks: WidgetCallbacks = {
179
+ onError: errorHandler,
180
+ onPartialCompleted: completionHandler,
181
+ };
182
+
183
+ const { getByTestId } = render(
184
+ <SoluCXWidget
185
+ soluCXKey="saas-nps"
186
+ type="bottom"
187
+ data={{
188
+ form_id: 'nps-survey',
189
+ customer_id: 'user-123',
190
+ plan: 'premium',
191
+ }}
192
+ options={{ height: 300 }}
193
+ callbacks={callbacks}
194
+ />
195
+ );
196
+
197
+ await waitFor(() => {
198
+ expect(getByTestId('webview')).toBeTruthy();
199
+ });
200
+
201
+ const webview = getByTestId('webview');
202
+
203
+ // User rates NPS
204
+ fireEvent(webview, 'message', {
205
+ nativeEvent: { data: 'FORM_PAGECHANGED-1' },
206
+ });
207
+
208
+ // Network error occurs during submission
209
+ fireEvent(webview, 'message', {
210
+ nativeEvent: { data: 'FORM_ERROR-Network timeout occurred' },
211
+ });
212
+
213
+ await waitFor(() => {
214
+ expect(errorHandler).toHaveBeenCalledWith('Network timeout occurred');
215
+ });
216
+
217
+ // User tries again and succeeds
218
+ fireEvent(webview, 'message', {
219
+ nativeEvent: { data: 'FORM_COMPLETED' },
220
+ });
221
+
222
+ await waitFor(() => {
223
+ expect(completionHandler).toHaveBeenCalledWith(expect.any(String));
224
+ });
225
+ });
226
+ });
227
+
228
+ describe('Banking Compliance Survey with Dynamic Resizing', () => {
229
+ it('should handle multiple resize events as form expands', async () => {
230
+ const resizeHandler = jest.fn();
231
+
232
+ const { getByTestId } = render(
233
+ <SoluCXWidget
234
+ soluCXKey="banking-compliance"
235
+ type="inline"
236
+ data={{
237
+ form_id: 'compliance-check',
238
+ customer_id: 'bank-cust-456',
239
+ }}
240
+ options={{ height: 400 }}
241
+ callbacks={{ onResize: resizeHandler }}
242
+ />
243
+ );
244
+
245
+ await waitFor(() => {
246
+ expect(getByTestId('webview')).toBeTruthy();
247
+ });
248
+
249
+ const webview = getByTestId('webview');
250
+
251
+ // Form starts small
252
+ fireEvent(webview, 'message', {
253
+ nativeEvent: { data: 'FORM_RESIZE-300' },
254
+ });
255
+
256
+ // User expands section, form grows
257
+ fireEvent(webview, 'message', {
258
+ nativeEvent: { data: 'FORM_RESIZE-500' },
259
+ });
260
+
261
+ // User expands another section
262
+ fireEvent(webview, 'message', {
263
+ nativeEvent: { data: 'FORM_RESIZE-700' },
264
+ });
265
+
266
+ // User collapses section
267
+ fireEvent(webview, 'message', {
268
+ nativeEvent: { data: 'FORM_RESIZE-600' },
269
+ });
270
+
271
+ await waitFor(() => {
272
+ expect(resizeHandler).toHaveBeenCalledTimes(4);
273
+ });
274
+
275
+ // Verify resize values
276
+ expect(resizeHandler).toHaveBeenNthCalledWith(1, '300');
277
+ expect(resizeHandler).toHaveBeenNthCalledWith(2, '500');
278
+ expect(resizeHandler).toHaveBeenNthCalledWith(3, '700');
279
+ expect(resizeHandler).toHaveBeenNthCalledWith(4, '600');
280
+ });
281
+ });
282
+
283
+ describe('Multi-Step Survey with Page Tracking', () => {
284
+ it('should track progress through multi-step survey', async () => {
285
+ const pageChangeHandler = jest.fn();
286
+ const questionHandler = jest.fn();
287
+ const completionHandler = jest.fn();
288
+
289
+ const { getByTestId } = render(
290
+ <SoluCXWidget
291
+ soluCXKey="multi-step-survey"
292
+ type="modal"
293
+ data={{
294
+ form_id: 'multi-step',
295
+ customer_id: 'user-999',
296
+ }}
297
+ options={{ height: 450 }}
298
+ callbacks={{
299
+ onPageChanged: pageChangeHandler,
300
+ onQuestionAnswered: questionHandler,
301
+ onPartialCompleted: completionHandler,
302
+ }}
303
+ />
304
+ );
305
+
306
+ await waitFor(() => {
307
+ expect(getByTestId('webview')).toBeTruthy();
308
+ });
309
+
310
+ const webview = getByTestId('webview');
311
+
312
+ // Page 1: Answer question
313
+ fireEvent(webview, 'message', {
314
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
315
+ });
316
+
317
+ // Go to page 2
318
+ fireEvent(webview, 'message', {
319
+ nativeEvent: { data: 'FORM_PAGECHANGED-2' },
320
+ });
321
+
322
+ // Page 2: Answer question
323
+ fireEvent(webview, 'message', {
324
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
325
+ });
326
+
327
+ // Go to page 3
328
+ fireEvent(webview, 'message', {
329
+ nativeEvent: { data: 'FORM_PAGECHANGED-3' },
330
+ });
331
+
332
+ // Page 3: Answer final question
333
+ fireEvent(webview, 'message', {
334
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
335
+ });
336
+
337
+ // Complete survey
338
+ fireEvent(webview, 'message', {
339
+ nativeEvent: { data: 'FORM_COMPLETED' },
340
+ });
341
+
342
+ await waitFor(() => {
343
+ expect(pageChangeHandler).toHaveBeenCalledTimes(2);
344
+ expect(questionHandler).toHaveBeenCalledTimes(3);
345
+ expect(completionHandler).toHaveBeenCalledTimes(1);
346
+ });
347
+
348
+ // Verify page progression
349
+ expect(pageChangeHandler).toHaveBeenNthCalledWith(1, '2');
350
+ expect(pageChangeHandler).toHaveBeenNthCalledWith(2, '3');
351
+ });
352
+ });
353
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * SIMPLIFIED INTEGRATION TEST: WebView Communication
3
+ * Starting with minimal test to understand the flow
4
+ */
5
+
6
+ import React from 'react';
7
+ import { render, waitFor, fireEvent } from '@testing-library/react-native';
8
+ import { SoluCXWidget } from '../../SoluCXWidget';
9
+
10
+ // Mock only external dependencies
11
+ jest.mock('react-native-webview', () => {
12
+ const React = require('react');
13
+ const { View, Text } = require('react-native');
14
+ return {
15
+ __esModule: true,
16
+ WebView: React.forwardRef((props: any, ref: any) => {
17
+ React.useImperativeHandle(ref, () => ({
18
+ injectJavaScript: jest.fn(),
19
+ }));
20
+
21
+ return (
22
+ <View testID="webview" {...props}>
23
+ <Text>WebView Mock</Text>
24
+ </View>
25
+ );
26
+ }),
27
+ };
28
+ });
29
+
30
+ jest.mock('../../utils/urlUtils', () => ({
31
+ buildWidgetURL: jest.fn().mockReturnValue('https://form.url/test'),
32
+ }));
33
+
34
+ jest.mock('../../services/widgetBootstrapService', () => ({
35
+ requestWidgetUrl: jest.fn().mockResolvedValue('https://survey.url/test'),
36
+ }));
37
+
38
+ jest.mock('../../services/widgetValidationService', () => ({
39
+ WidgetValidationService: jest.fn().mockImplementation(() => ({
40
+ shouldDisplayWidget: jest.fn().mockResolvedValue({ canDisplay: true }),
41
+ })),
42
+ }));
43
+
44
+ const mockStorage: Record<string, string> = {};
45
+ jest.mock('@react-native-async-storage/async-storage', () => ({
46
+ __esModule: true,
47
+ default: {
48
+ getItem: jest.fn((key: string) => Promise.resolve(mockStorage[key] || null)),
49
+ setItem: jest.fn((key: string, value: string) => {
50
+ mockStorage[key] = value;
51
+ return Promise.resolve();
52
+ }),
53
+ },
54
+ }));
55
+
56
+ describe('Integration: WebView Communication - Simplified', () => {
57
+ beforeEach(() => {
58
+ jest.clearAllMocks();
59
+ Object.keys(mockStorage).forEach(key => delete mockStorage[key]);
60
+ });
61
+
62
+ it('should render WebView in form mode', async () => {
63
+ const { getByTestId, debug } = render(
64
+ <SoluCXWidget
65
+ soluCXKey="test-key"
66
+ type="modal"
67
+ data={{ form_id: 'form123', customer_id: 'user1' }}
68
+ options={{ height: 400 }}
69
+ />
70
+ );
71
+
72
+ debug(); // Log component tree
73
+
74
+ await waitFor(() => {
75
+ expect(getByTestId('webview')).toBeTruthy();
76
+ }, { timeout: 3000 });
77
+ });
78
+
79
+ it('should handle FORM_CLOSE message in form mode', async () => {
80
+ const mockOnClosed = jest.fn();
81
+
82
+ const { getByTestId } = render(
83
+ <SoluCXWidget
84
+ soluCXKey="test-key"
85
+ type="modal"
86
+ data={{ form_id: 'form123', customer_id: 'user1' }}
87
+ options={{ height: 400 }}
88
+ callbacks={{ onClosed: mockOnClosed }}
89
+ />
90
+ );
91
+
92
+ await waitFor(() => {
93
+ expect(getByTestId('webview')).toBeTruthy();
94
+ }, { timeout: 3000 });
95
+
96
+ // Simulate message
97
+ const webview = getByTestId('webview');
98
+ fireEvent(webview, 'message', {
99
+ nativeEvent: { data: 'FORM_CLOSE' },
100
+ });
101
+
102
+ await waitFor(() => {
103
+ expect(mockOnClosed).toHaveBeenCalledTimes(1);}, { timeout: 1000 });
104
+ });
105
+
106
+ it('should render WebView in survey mode', async () => {
107
+ const { getByTestId } = render(
108
+ <SoluCXWidget
109
+ soluCXKey="test-key"
110
+ type="modal"
111
+ data={{ customer_id: 'user1' }} // No form_id = survey
112
+ options={{ height: 400 }}
113
+ />
114
+ );
115
+
116
+ await waitFor(() => {
117
+ expect(getByTestId('webview')).toBeTruthy();
118
+ }, { timeout: 3000 });
119
+ });
120
+
121
+ it('should handle closeSoluCXWidget message in survey mode', async () => {
122
+ const mockOnClosed = jest.fn();
123
+
124
+ const { getByTestId } = render(
125
+ <SoluCXWidget
126
+ soluCXKey="test-key"
127
+ type="modal"
128
+ data={{ customer_id: 'user1' }}
129
+ options={{ height: 400 }}
130
+ callbacks={{ onClosed: mockOnClosed }}
131
+ />
132
+ );
133
+
134
+ await waitFor(() => {
135
+ expect(getByTestId('webview')).toBeTruthy();
136
+ }, { timeout: 3000 });
137
+
138
+ const webview = getByTestId('webview');
139
+ fireEvent(webview, 'message', {
140
+ nativeEvent: { data: 'closeSoluCXWidget' },
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(mockOnClosed).toHaveBeenCalledTimes(1);
145
+ }, { timeout: 1000 });
146
+ });
147
+ });