@solucx/react-native-solucx-widget 0.1.16 → 0.2.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 (34) hide show
  1. package/package.json +31 -4
  2. package/src/SoluCXWidget.tsx +108 -53
  3. package/src/__mocks__/expo-modules-core-web.js +16 -0
  4. package/src/__mocks__/expo-modules-core.js +33 -0
  5. package/src/__tests__/ClientVersionCollector.test.ts +55 -0
  6. package/src/__tests__/CloseButton.test.tsx +47 -0
  7. package/src/__tests__/Constants.test.ts +17 -0
  8. package/src/__tests__/InlineWidget.rendering.test.tsx +81 -0
  9. package/src/__tests__/ModalWidget.rendering.test.tsx +157 -0
  10. package/src/__tests__/OverlayWidget.rendering.test.tsx +123 -0
  11. package/src/__tests__/SoluCXWidget.rendering.test.tsx +504 -0
  12. package/src/__tests__/e2e/widget-lifecycle.test.tsx +352 -0
  13. package/src/__tests__/integration/webview-communication-simple.test.tsx +147 -0
  14. package/src/__tests__/integration/webview-communication.test.tsx +417 -0
  15. package/src/__tests__/useDeviceInfoCollector.test.ts +109 -0
  16. package/src/__tests__/useWidgetState.test.ts +76 -84
  17. package/src/__tests__/widgetBootstrapService.test.ts +182 -0
  18. package/src/components/ModalWidget.tsx +3 -5
  19. package/src/components/OverlayWidget.tsx +1 -1
  20. package/src/constants/Constants.ts +4 -0
  21. package/src/constants/webViewConstants.ts +1 -0
  22. package/src/hooks/useDeviceInfoCollector.ts +67 -0
  23. package/src/hooks/useWidgetState.ts +4 -4
  24. package/src/index.ts +4 -0
  25. package/src/interfaces/WidgetCallbacks.ts +14 -0
  26. package/src/interfaces/index.ts +3 -2
  27. package/src/services/ClientVersionCollector.ts +15 -0
  28. package/src/services/storage.ts +2 -2
  29. package/src/services/widgetBootstrapService.ts +67 -0
  30. package/src/services/widgetEventService.ts +14 -30
  31. package/src/services/widgetValidationService.ts +29 -13
  32. package/src/setupTests.js +43 -0
  33. package/src/styles/widgetStyles.ts +1 -1
  34. package/src/utils/urlUtils.ts +2 -2
@@ -0,0 +1,352 @@
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
+ onCompleted: (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
+ // 4. Customer answers first question
121
+ fireEvent(webview, 'message', {
122
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
123
+ });
124
+
125
+ await waitFor(() => {
126
+ expect(analytics.track).toHaveBeenCalledWith('question_answered');
127
+ });
128
+
129
+ // Reset counter after first question to track only upcoming interactions
130
+ analytics.track.mockClear();
131
+
132
+ // 5. Customer goes to next page
133
+ fireEvent(webview, 'message', {
134
+ nativeEvent: { data: 'FORM_PAGECHANGED-2' },
135
+ });
136
+
137
+ // 6. Customer answers second question
138
+ fireEvent(webview, 'message', {
139
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
140
+ });
141
+
142
+ await waitFor(() => {
143
+ expect(analytics.track).toHaveBeenCalledWith('question_answered');
144
+ expect(analytics.track).toHaveBeenCalledTimes(1); // Called once after reset
145
+ });
146
+
147
+ // 7. Customer submits survey
148
+ fireEvent(webview, 'message', {
149
+ nativeEvent: { data: 'FORM_COMPLETED' },
150
+ });
151
+
152
+ await waitFor(() => {
153
+ expect(analytics.track).toHaveBeenCalledWith('survey_completed', {
154
+ userId: expect.any(String),
155
+ });
156
+ });
157
+
158
+ // 8. Customer closes widget
159
+ fireEvent(webview, 'message', {
160
+ nativeEvent: { data: 'FORM_CLOSE' },
161
+ });
162
+
163
+ await waitFor(() => {
164
+ expect(analytics.track).toHaveBeenCalledWith('widget_closed');
165
+ });
166
+
167
+ // Verify complete tracking flow after reset (question_answered + survey_completed + widget_closed)
168
+ expect(analytics.track).toHaveBeenCalledTimes(3);
169
+ });
170
+ });
171
+
172
+ describe('SaaS NPS Survey Flow with Error Recovery', () => {
173
+ it('should handle network error gracefully and still track partial completion', async () => {
174
+ const errorHandler = jest.fn();
175
+ const completionHandler = jest.fn();
176
+
177
+ const callbacks: WidgetCallbacks = {
178
+ onError: errorHandler,
179
+ onCompleted: completionHandler,
180
+ };
181
+
182
+ const { getByTestId } = render(
183
+ <SoluCXWidget
184
+ soluCXKey="saas-nps"
185
+ type="bottom"
186
+ data={{
187
+ form_id: 'nps-survey',
188
+ customer_id: 'user-123',
189
+ plan: 'premium',
190
+ }}
191
+ options={{ height: 300 }}
192
+ callbacks={callbacks}
193
+ />
194
+ );
195
+
196
+ await waitFor(() => {
197
+ expect(getByTestId('webview')).toBeTruthy();
198
+ });
199
+
200
+ const webview = getByTestId('webview');
201
+
202
+ // User rates NPS
203
+ fireEvent(webview, 'message', {
204
+ nativeEvent: { data: 'FORM_PAGECHANGED-1' },
205
+ });
206
+
207
+ // Network error occurs during submission
208
+ fireEvent(webview, 'message', {
209
+ nativeEvent: { data: 'FORM_ERROR-Network timeout occurred' },
210
+ });
211
+
212
+ await waitFor(() => {
213
+ expect(errorHandler).toHaveBeenCalledWith('Network timeout occurred');
214
+ });
215
+
216
+ // User tries again and succeeds
217
+ fireEvent(webview, 'message', {
218
+ nativeEvent: { data: 'FORM_COMPLETED' },
219
+ });
220
+
221
+ await waitFor(() => {
222
+ expect(completionHandler).toHaveBeenCalledWith(expect.any(String));
223
+ });
224
+ });
225
+ });
226
+
227
+ describe('Banking Compliance Survey with Dynamic Resizing', () => {
228
+ it('should handle multiple resize events as form expands', async () => {
229
+ const resizeHandler = jest.fn();
230
+
231
+ const { getByTestId } = render(
232
+ <SoluCXWidget
233
+ soluCXKey="banking-compliance"
234
+ type="inline"
235
+ data={{
236
+ form_id: 'compliance-check',
237
+ customer_id: 'bank-cust-456',
238
+ }}
239
+ options={{ height: 400 }}
240
+ callbacks={{ onResize: resizeHandler }}
241
+ />
242
+ );
243
+
244
+ await waitFor(() => {
245
+ expect(getByTestId('webview')).toBeTruthy();
246
+ });
247
+
248
+ const webview = getByTestId('webview');
249
+
250
+ // Form starts small
251
+ fireEvent(webview, 'message', {
252
+ nativeEvent: { data: 'FORM_RESIZE-300' },
253
+ });
254
+
255
+ // User expands section, form grows
256
+ fireEvent(webview, 'message', {
257
+ nativeEvent: { data: 'FORM_RESIZE-500' },
258
+ });
259
+
260
+ // User expands another section
261
+ fireEvent(webview, 'message', {
262
+ nativeEvent: { data: 'FORM_RESIZE-700' },
263
+ });
264
+
265
+ // User collapses section
266
+ fireEvent(webview, 'message', {
267
+ nativeEvent: { data: 'FORM_RESIZE-600' },
268
+ });
269
+
270
+ await waitFor(() => {
271
+ expect(resizeHandler).toHaveBeenCalledTimes(4);
272
+ });
273
+
274
+ // Verify resize values
275
+ expect(resizeHandler).toHaveBeenNthCalledWith(1, '300');
276
+ expect(resizeHandler).toHaveBeenNthCalledWith(2, '500');
277
+ expect(resizeHandler).toHaveBeenNthCalledWith(3, '700');
278
+ expect(resizeHandler).toHaveBeenNthCalledWith(4, '600');
279
+ });
280
+ });
281
+
282
+ describe('Multi-Step Survey with Page Tracking', () => {
283
+ it('should track progress through multi-step survey', async () => {
284
+ const pageChangeHandler = jest.fn();
285
+ const questionHandler = jest.fn();
286
+ const completionHandler = jest.fn();
287
+
288
+ const { getByTestId } = render(
289
+ <SoluCXWidget
290
+ soluCXKey="multi-step-survey"
291
+ type="modal"
292
+ data={{
293
+ form_id: 'multi-step',
294
+ customer_id: 'user-999',
295
+ }}
296
+ options={{ height: 450 }}
297
+ callbacks={{
298
+ onPageChanged: pageChangeHandler,
299
+ onQuestionAnswered: questionHandler,
300
+ onCompleted: completionHandler,
301
+ }}
302
+ />
303
+ );
304
+
305
+ await waitFor(() => {
306
+ expect(getByTestId('webview')).toBeTruthy();
307
+ });
308
+
309
+ const webview = getByTestId('webview');
310
+
311
+ // Page 1: Answer question
312
+ fireEvent(webview, 'message', {
313
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
314
+ });
315
+
316
+ // Go to page 2
317
+ fireEvent(webview, 'message', {
318
+ nativeEvent: { data: 'FORM_PAGECHANGED-2' },
319
+ });
320
+
321
+ // Page 2: Answer question
322
+ fireEvent(webview, 'message', {
323
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
324
+ });
325
+
326
+ // Go to page 3
327
+ fireEvent(webview, 'message', {
328
+ nativeEvent: { data: 'FORM_PAGECHANGED-3' },
329
+ });
330
+
331
+ // Page 3: Answer final question
332
+ fireEvent(webview, 'message', {
333
+ nativeEvent: { data: 'QUESTION_ANSWERED' },
334
+ });
335
+
336
+ // Complete survey
337
+ fireEvent(webview, 'message', {
338
+ nativeEvent: { data: 'FORM_COMPLETED' },
339
+ });
340
+
341
+ await waitFor(() => {
342
+ expect(pageChangeHandler).toHaveBeenCalledTimes(2);
343
+ expect(questionHandler).toHaveBeenCalledTimes(3);
344
+ expect(completionHandler).toHaveBeenCalledTimes(1);
345
+ });
346
+
347
+ // Verify page progression
348
+ expect(pageChangeHandler).toHaveBeenNthCalledWith(1, '2');
349
+ expect(pageChangeHandler).toHaveBeenNthCalledWith(2, '3');
350
+ });
351
+ });
352
+ });
@@ -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
+ });