@solucx/react-native-solucx-widget 0.2.0 → 0.2.2

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 (115) hide show
  1. package/lib/SoluCXWidget.d.ts +12 -0
  2. package/lib/SoluCXWidget.d.ts.map +1 -0
  3. package/lib/SoluCXWidget.js +110 -0
  4. package/lib/SoluCXWidget.js.map +1 -0
  5. package/lib/components/CloseButton.d.ts +8 -0
  6. package/lib/components/CloseButton.d.ts.map +1 -0
  7. package/lib/components/CloseButton.js +31 -0
  8. package/lib/components/CloseButton.js.map +1 -0
  9. package/lib/components/InlineWidget.d.ts +10 -0
  10. package/lib/components/InlineWidget.d.ts.map +1 -0
  11. package/lib/components/InlineWidget.js +19 -0
  12. package/lib/components/InlineWidget.js.map +1 -0
  13. package/lib/components/ModalWidget.d.ts +10 -0
  14. package/lib/components/ModalWidget.d.ts.map +1 -0
  15. package/lib/components/ModalWidget.js +27 -0
  16. package/lib/components/ModalWidget.js.map +1 -0
  17. package/lib/components/OverlayWidget.d.ts +12 -0
  18. package/lib/components/OverlayWidget.d.ts.map +1 -0
  19. package/lib/components/OverlayWidget.js +55 -0
  20. package/lib/components/OverlayWidget.js.map +1 -0
  21. package/lib/constants/Constants.d.ts +3 -0
  22. package/lib/constants/Constants.d.ts.map +1 -0
  23. package/lib/constants/Constants.js +10 -0
  24. package/lib/constants/Constants.js.map +1 -0
  25. package/lib/constants/webViewConstants.d.ts +12 -0
  26. package/lib/constants/webViewConstants.d.ts.map +1 -0
  27. package/lib/constants/webViewConstants.js +19 -0
  28. package/lib/constants/webViewConstants.js.map +1 -0
  29. package/lib/hooks/index.d.ts +3 -0
  30. package/lib/hooks/index.d.ts.map +1 -0
  31. package/lib/hooks/index.js +8 -0
  32. package/lib/hooks/index.js.map +1 -0
  33. package/lib/hooks/useDeviceInfoCollector.d.ts +14 -0
  34. package/lib/hooks/useDeviceInfoCollector.d.ts.map +1 -0
  35. package/lib/hooks/useDeviceInfoCollector.js +54 -0
  36. package/lib/hooks/useDeviceInfoCollector.js.map +1 -0
  37. package/lib/hooks/useHeightAnimation.d.ts +9 -0
  38. package/lib/hooks/useHeightAnimation.d.ts.map +1 -0
  39. package/lib/hooks/useHeightAnimation.js +19 -0
  40. package/lib/hooks/useHeightAnimation.js.map +1 -0
  41. package/lib/hooks/useWidgetHeight.d.ts +13 -0
  42. package/lib/hooks/useWidgetHeight.d.ts.map +1 -0
  43. package/lib/hooks/useWidgetHeight.js +21 -0
  44. package/lib/hooks/useWidgetHeight.js.map +1 -0
  45. package/lib/hooks/useWidgetState.d.ts +15 -0
  46. package/lib/hooks/useWidgetState.d.ts.map +1 -0
  47. package/lib/hooks/useWidgetState.js +79 -0
  48. package/lib/hooks/useWidgetState.js.map +1 -0
  49. package/lib/index.d.ts +13 -0
  50. package/lib/index.d.ts.map +1 -0
  51. package/lib/index.js +43 -0
  52. package/lib/index.js.map +1 -0
  53. package/lib/interfaces/WidgetCallbacks.d.ts +14 -0
  54. package/lib/interfaces/WidgetCallbacks.d.ts.map +1 -0
  55. package/lib/interfaces/WidgetCallbacks.js +3 -0
  56. package/lib/interfaces/WidgetCallbacks.js.map +1 -0
  57. package/lib/interfaces/WidgetData.d.ts +21 -0
  58. package/lib/interfaces/WidgetData.d.ts.map +1 -0
  59. package/lib/interfaces/WidgetData.js +3 -0
  60. package/lib/interfaces/WidgetData.js.map +1 -0
  61. package/lib/interfaces/WidgetOptions.d.ts +9 -0
  62. package/lib/interfaces/WidgetOptions.d.ts.map +1 -0
  63. package/lib/interfaces/WidgetOptions.js +3 -0
  64. package/lib/interfaces/WidgetOptions.js.map +1 -0
  65. package/lib/interfaces/WidgetResponse.d.ts +10 -0
  66. package/lib/interfaces/WidgetResponse.d.ts.map +1 -0
  67. package/lib/interfaces/WidgetResponse.js +12 -0
  68. package/lib/interfaces/WidgetResponse.js.map +1 -0
  69. package/lib/interfaces/WidgetSamplerLog.d.ts +7 -0
  70. package/lib/interfaces/WidgetSamplerLog.d.ts.map +1 -0
  71. package/lib/interfaces/WidgetSamplerLog.js +3 -0
  72. package/lib/interfaces/WidgetSamplerLog.js.map +1 -0
  73. package/lib/interfaces/index.d.ts +12 -0
  74. package/lib/interfaces/index.d.ts.map +1 -0
  75. package/lib/interfaces/index.js +3 -0
  76. package/lib/interfaces/index.js.map +1 -0
  77. package/lib/services/ClientVersionCollector.d.ts +2 -0
  78. package/lib/services/ClientVersionCollector.d.ts.map +1 -0
  79. package/lib/services/ClientVersionCollector.js +20 -0
  80. package/lib/services/ClientVersionCollector.js.map +1 -0
  81. package/lib/services/storage.d.ts +8 -0
  82. package/lib/services/storage.d.ts.map +1 -0
  83. package/lib/services/storage.js +23 -0
  84. package/lib/services/storage.js.map +1 -0
  85. package/lib/services/widgetBootstrapService.d.ts +5 -0
  86. package/lib/services/widgetBootstrapService.d.ts.map +1 -0
  87. package/lib/services/widgetBootstrapService.js +60 -0
  88. package/lib/services/widgetBootstrapService.js.map +1 -0
  89. package/lib/services/widgetEventService.d.ts +19 -0
  90. package/lib/services/widgetEventService.d.ts.map +1 -0
  91. package/lib/services/widgetEventService.js +79 -0
  92. package/lib/services/widgetEventService.js.map +1 -0
  93. package/lib/services/widgetValidationService.d.ts +18 -0
  94. package/lib/services/widgetValidationService.d.ts.map +1 -0
  95. package/lib/services/widgetValidationService.js +71 -0
  96. package/lib/services/widgetValidationService.js.map +1 -0
  97. package/lib/styles/widgetStyles.d.ts +87 -0
  98. package/lib/styles/widgetStyles.d.ts.map +1 -0
  99. package/lib/styles/widgetStyles.js +59 -0
  100. package/lib/styles/widgetStyles.js.map +1 -0
  101. package/lib/utils/urlUtils.d.ts +3 -0
  102. package/lib/utils/urlUtils.d.ts.map +1 -0
  103. package/lib/utils/urlUtils.js +13 -0
  104. package/lib/utils/urlUtils.js.map +1 -0
  105. package/package.json +5 -3
  106. package/src/SoluCXWidget.tsx +23 -17
  107. package/src/__tests__/SoluCXWidget.rendering.test.tsx +492 -153
  108. package/src/__tests__/e2e/widget-lifecycle.test.tsx +9 -10
  109. package/src/__tests__/integration/webview-communication.test.tsx +9 -9
  110. package/src/__tests__/useWidgetState.test.ts +2 -2
  111. package/src/__tests__/widgetBootstrapService.test.ts +45 -10
  112. package/src/hooks/useWidgetState.ts +3 -3
  113. package/src/interfaces/WidgetCallbacks.ts +0 -1
  114. package/src/services/widgetBootstrapService.ts +13 -4
  115. package/src/services/widgetEventService.ts +1 -1
@@ -1,17 +1,15 @@
1
- import React from 'react';
2
- import { render, waitFor } from '@testing-library/react-native';
3
- import { SoluCXWidget } from '../SoluCXWidget';
4
- import { WidgetData, WidgetOptions } from '../interfaces';
5
- import { requestWidgetUrl } from '../services/widgetBootstrapService';
6
-
7
- jest.mock('react-native-webview', () => {
8
- const { forwardRef } = require('react');
9
- const { View } = require('react-native');
1
+ import React from "react";
2
+ import { render, waitFor } from "@testing-library/react-native";
3
+ import { SoluCXWidget } from "../SoluCXWidget";
4
+ import { WidgetData, WidgetOptions } from "../interfaces";
5
+ import { requestWidgetUrl } from "../services/widgetBootstrapService";
6
+
7
+ jest.mock("react-native-webview", () => {
8
+ const { forwardRef } = require("react");
9
+ const { View } = require("react-native");
10
10
  return {
11
11
  __esModule: true,
12
- WebView: forwardRef((props: any, ref: any) => (
13
- <View testID="webview" {...props} ref={ref} />
14
- )),
12
+ WebView: forwardRef((props: any, ref: any) => <View testID="webview" {...props} ref={ref} />),
15
13
  };
16
14
  });
17
15
 
@@ -21,7 +19,7 @@ const mockSetIsWidgetVisible = jest.fn();
21
19
  const mockOpen = jest.fn();
22
20
  const mockShouldDisplayWidget = jest.fn();
23
21
 
24
- jest.mock('../hooks/useWidgetState', () => ({
22
+ jest.mock("../hooks/useWidgetState", () => ({
25
23
  useWidgetState: () => ({
26
24
  widgetHeight: 400,
27
25
  isWidgetVisible: true,
@@ -30,59 +28,59 @@ jest.mock('../hooks/useWidgetState', () => ({
30
28
  resize: jest.fn(),
31
29
  open: mockOpen,
32
30
  close: mockClose,
33
- userId: 'test-user-123',
31
+ userId: "test-user-123",
34
32
  }),
35
33
  }));
36
34
 
37
- jest.mock('../services/widgetEventService', () => ({
35
+ jest.mock("../services/widgetEventService", () => ({
38
36
  WidgetEventService: jest.fn().mockImplementation(() => ({
39
37
  handleMessage: jest.fn(),
40
38
  })),
41
39
  }));
42
40
 
43
- jest.mock('../services/widgetValidationService', () => ({
41
+ jest.mock("../services/widgetValidationService", () => ({
44
42
  WidgetValidationService: jest.fn().mockImplementation(() => ({
45
43
  shouldDisplayWidget: mockShouldDisplayWidget,
46
44
  })),
47
45
  }));
48
46
 
49
- jest.mock('../services/widgetBootstrapService', () => ({
47
+ jest.mock("../services/widgetBootstrapService", () => ({
50
48
  requestWidgetUrl: jest.fn(),
51
49
  }));
52
50
 
53
- jest.mock('../constants/Constants', () => ({
54
- SDK_NAME: 'rn-widget-sdk',
55
- SDK_VERSION: '0.1.16',
51
+ jest.mock("../constants/Constants", () => ({
52
+ SDK_NAME: "rn-widget-sdk",
53
+ SDK_VERSION: "0.1.16",
56
54
  }));
57
55
 
58
- jest.mock('../services/ClientVersionCollector', () => ({
59
- getClientVersion: jest.fn(() => '1.0.0'),
56
+ jest.mock("../services/ClientVersionCollector", () => ({
57
+ getClientVersion: jest.fn(() => "1.0.0"),
60
58
  }));
61
59
 
62
- jest.mock('../hooks/useDeviceInfoCollector', () => ({
60
+ jest.mock("../hooks/useDeviceInfoCollector", () => ({
63
61
  getDeviceInfo: jest.fn(() => ({
64
- platform: 'ios',
65
- osVersion: '16.0',
62
+ platform: "ios",
63
+ osVersion: "16.0",
66
64
  screenWidth: 390,
67
65
  screenHeight: 844,
68
66
  windowWidth: 390,
69
67
  windowHeight: 844,
70
68
  scale: 3,
71
69
  fontScale: 1,
72
- deviceType: 'phone',
73
- model: 'iPhone 14 Pro',
70
+ deviceType: "phone",
71
+ model: "iPhone 14 Pro",
74
72
  })),
75
73
  }));
76
74
 
77
75
  const mockRequestWidgetUrl = requestWidgetUrl as jest.MockedFunction<typeof requestWidgetUrl>;
78
- const bootstrappedWidgetUrl = 'https://widgets.solucx.com/widget/bootstrap-result';
76
+ const bootstrappedWidgetUrl = "https://widgets.solucx.com/widget/bootstrap-result";
79
77
 
80
78
  const baseProps = {
81
- soluCXKey: 'test-key-abc',
79
+ soluCXKey: "test-key-abc",
82
80
  data: {
83
- customer_id: 'cust-001',
84
- transaction_id: 'txn-999',
85
- form_id: 'form-123',
81
+ customer_id: "cust-001",
82
+ transaction_id: "txn-999",
83
+ form_id: "form-123",
86
84
  } as WidgetData,
87
85
  options: {
88
86
  height: 400,
@@ -97,219 +95,560 @@ beforeEach(() => {
97
95
  mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
98
96
  });
99
97
 
100
- describe('SoluCXWidget type routing', () => {
101
- it('should render ModalWidget when type is modal', () => {
102
- const { UNSAFE_getByType } = render(
103
- <SoluCXWidget {...baseProps} type="modal" />
104
- );
98
+ describe("SoluCXWidget type routing", () => {
99
+ it("should render ModalWidget when type is modal", async () => {
100
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
105
101
 
106
- const modal = UNSAFE_getByType(require('react-native').Modal);
107
- expect(modal).toBeTruthy();
108
- });
102
+ const { UNSAFE_getByType } = render(<SoluCXWidget {...baseProps} type="modal" />);
103
+
104
+ await waitFor(() => {
105
+ const modal = UNSAFE_getByType(require("react-native").Modal);
106
+ expect(modal).toBeTruthy();
107
+ });
108
+ }, 10000);
109
+
110
+ it("should render close button inside ModalWidget", async () => {
111
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
109
112
 
110
- it('should render close button inside ModalWidget', () => {
111
- const { getByText } = render(
112
- <SoluCXWidget {...baseProps} type="modal" />
113
- );
113
+ const { getByText } = render(<SoluCXWidget {...baseProps} type="modal" />);
114
114
 
115
- expect(getByText('✕')).toBeTruthy();
115
+ await waitFor(() => {
116
+ expect(getByText("✕")).toBeTruthy();
117
+ });
116
118
  });
117
119
 
118
- it('should render WebView inside ModalWidget with correct source', () => {
119
- const { getByTestId } = render(
120
- <SoluCXWidget {...baseProps} type="modal" />
121
- );
120
+ it("should render WebView inside ModalWidget with correct source", async () => {
121
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
122
+
123
+ const { getByTestId } = render(<SoluCXWidget {...baseProps} type="modal" />);
122
124
 
123
- const webview = getByTestId('webview');
124
- expect(webview.props.source.uri).toContain('test-key-abc');
125
- expect(webview.props.source.uri).toContain('txn-999');
125
+ await waitFor(() => {
126
+ const webview = getByTestId("webview");
127
+ expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
128
+ });
126
129
  });
127
130
 
128
- it('should render InlineWidget when type is inline', () => {
129
- const { queryByTestId, getByText } = render(
130
- <SoluCXWidget {...baseProps} type="inline" />
131
- );
131
+ it("should render InlineWidget when type is inline", async () => {
132
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
133
+
134
+ const { queryByTestId, getByText } = render(<SoluCXWidget {...baseProps} type="inline" />);
132
135
 
133
- expect(queryByTestId('webview')).toBeTruthy();
134
- expect(getByText('✕')).toBeTruthy();
136
+ await waitFor(() => {
137
+ expect(queryByTestId("webview")).toBeTruthy();
138
+ expect(getByText("✕")).toBeTruthy();
139
+ });
135
140
  });
136
141
 
137
- it('should not render Modal when type is inline', () => {
138
- const { UNSAFE_queryByType } = render(
139
- <SoluCXWidget {...baseProps} type="inline" />
140
- );
142
+ it("should not render Modal when type is inline", async () => {
143
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
144
+
145
+ const { UNSAFE_queryByType } = render(<SoluCXWidget {...baseProps} type="inline" />);
141
146
 
142
- const modal = UNSAFE_queryByType(require('react-native').Modal);
143
- expect(modal).toBeNull();
147
+ await waitFor(() => {
148
+ const modal = UNSAFE_queryByType(require("react-native").Modal);
149
+ expect(modal).toBeNull();
150
+ });
144
151
  });
145
152
 
146
- it('should render OverlayWidget when type is bottom', () => {
147
- const { queryByTestId, getByText } = render(
148
- <SoluCXWidget {...baseProps} type="bottom" />
149
- );
153
+ it("should render OverlayWidget when type is bottom", async () => {
154
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
155
+
156
+ const { queryByTestId, getByText } = render(<SoluCXWidget {...baseProps} type="bottom" />);
150
157
 
151
- expect(queryByTestId('webview')).toBeTruthy();
152
- expect(getByText('✕')).toBeTruthy();
158
+ await waitFor(() => {
159
+ expect(queryByTestId("webview")).toBeTruthy();
160
+ expect(getByText("✕")).toBeTruthy();
161
+ });
153
162
  });
154
163
 
155
- it('should render OverlayWidget when type is top', () => {
156
- const { queryByTestId, getByText } = render(
157
- <SoluCXWidget {...baseProps} type="top" />
158
- );
164
+ it("should render OverlayWidget when type is top", async () => {
165
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
159
166
 
160
- expect(queryByTestId('webview')).toBeTruthy();
161
- expect(getByText('✕')).toBeTruthy();
167
+ const { queryByTestId, getByText } = render(<SoluCXWidget {...baseProps} type="top" />);
168
+
169
+ await waitFor(() => {
170
+ expect(queryByTestId("webview")).toBeTruthy();
171
+ expect(getByText("✕")).toBeTruthy();
172
+ });
162
173
  });
163
174
 
164
- it('should not render Modal when type is bottom', () => {
165
- const { UNSAFE_queryByType } = render(
166
- <SoluCXWidget {...baseProps} type="bottom" />
167
- );
175
+ it("should not render Modal when type is bottom", async () => {
176
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
177
+
178
+ const { UNSAFE_queryByType } = render(<SoluCXWidget {...baseProps} type="bottom" />);
168
179
 
169
- const modal = UNSAFE_queryByType(require('react-native').Modal);
170
- expect(modal).toBeNull();
180
+ await waitFor(() => {
181
+ const modal = UNSAFE_queryByType(require("react-native").Modal);
182
+ expect(modal).toBeNull();
183
+ });
171
184
  });
172
185
 
173
- it('should call loadSavedData on mount', () => {
186
+ it("should call loadSavedData on mount", () => {
174
187
  render(<SoluCXWidget {...baseProps} type="modal" />);
175
188
 
176
189
  expect(mockLoadSavedData).toHaveBeenCalledTimes(1);
177
190
  });
178
191
  });
179
192
 
180
- describe('SoluCXWidget WebView configuration', () => {
181
- it('should set originWhitelist to allow all origins', () => {
182
- const { getByTestId } = render(
183
- <SoluCXWidget {...baseProps} type="modal" />
184
- );
193
+ describe("SoluCXWidget WebView configuration", () => {
194
+ it("should set originWhitelist to allow all origins", async () => {
195
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
196
+
197
+ const { getByTestId } = render(<SoluCXWidget {...baseProps} type="modal" />);
185
198
 
186
- const webview = getByTestId('webview');
187
- expect(webview.props.originWhitelist).toEqual(['*']);
199
+ await waitFor(() => {
200
+ const webview = getByTestId("webview");
201
+ expect(webview.props.originWhitelist).toEqual(["*"]);
202
+ });
188
203
  });
189
204
 
190
- it('should set WebView width style to screen width', () => {
191
- const { getByTestId } = render(
192
- <SoluCXWidget {...baseProps} type="modal" />
193
- );
205
+ it("should set WebView width style to screen width", async () => {
206
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
207
+
208
+ const { getByTestId } = render(<SoluCXWidget {...baseProps} type="modal" />);
194
209
 
195
- const webview = getByTestId('webview');
196
- const widthStyle = webview.props.style.find(
197
- (s: any) => s.width !== undefined
198
- );
199
- expect(widthStyle.width).toBeGreaterThan(0);
210
+ await waitFor(() => {
211
+ const webview = getByTestId("webview");
212
+ const widthStyle = webview.props.style.find((s: any) => s.width !== undefined);
213
+ expect(widthStyle.width).toBeGreaterThan(0);
214
+ });
200
215
  });
201
216
 
202
- it('should set WebView height style to widgetHeight', () => {
203
- const { getByTestId } = render(
204
- <SoluCXWidget {...baseProps} type="modal" />
205
- );
217
+ it("should set WebView height style to widgetHeight", async () => {
218
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
219
+
220
+ const { getByTestId } = render(<SoluCXWidget {...baseProps} type="modal" />);
206
221
 
207
- const webview = getByTestId('webview');
208
- const heightStyle = webview.props.style.find(
209
- (s: any) => s.height !== undefined
210
- );
211
- expect(heightStyle.height).toBe(400);
222
+ await waitFor(() => {
223
+ const webview = getByTestId("webview");
224
+ const heightStyle = webview.props.style.find((s: any) => s.height !== undefined);
225
+ expect(heightStyle.height).toBe(400);
226
+ });
212
227
  });
213
228
 
214
- it('should build URL containing soluCXKey and transaction_id', () => {
215
- const { getByTestId } = render(
216
- <SoluCXWidget {...baseProps} type="inline" />
217
- );
229
+ it("should use bootstrapped URL for widgets", async () => {
230
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
231
+
232
+ const { getByTestId } = render(<SoluCXWidget {...baseProps} type="inline" />);
218
233
 
219
- const webview = getByTestId('webview');
220
- expect(webview.props.source.uri).toContain('test-key-abc');
221
- expect(webview.props.source.uri).toContain('transaction_id=txn-999');
234
+ await waitFor(() => {
235
+ const webview = getByTestId("webview");
236
+ expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
237
+ });
222
238
  });
239
+ });
240
+
241
+ describe("SoluCXWidget bootstrap flow", () => {
242
+ it("fetches widget URL for form widgets", async () => {
243
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
223
244
 
224
- it('should build URL with mode=widget parameter', () => {
225
- const { getByTestId } = render(
226
- <SoluCXWidget {...baseProps} type="inline" />
227
- );
245
+ const props = {
246
+ ...baseProps,
247
+ type: "modal" as const,
248
+ };
228
249
 
229
- const webview = getByTestId('webview');
230
- expect(webview.props.source.uri).toContain('mode=widget');
250
+ const { queryByTestId, getByTestId } = render(<SoluCXWidget {...props} />);
251
+
252
+ expect(queryByTestId("webview")).toBeNull();
253
+
254
+ await waitFor(() => {
255
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
256
+ expect(mockRequestWidgetUrl).toHaveBeenCalledWith(
257
+ "test-key-abc",
258
+ expect.objectContaining({
259
+ form_id: "form-123",
260
+ }),
261
+ "test-user-123",
262
+ );
263
+ expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1);
264
+ expect(mockOpen).toHaveBeenCalledTimes(1);
265
+ expect(queryByTestId("webview")).toBeTruthy();
266
+ });
267
+
268
+ const webview = getByTestId("webview");
269
+ expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
231
270
  });
232
- });
233
271
 
234
- describe('SoluCXWidget bootstrap flow', () => {
235
- it('fetches widget URL before rendering non-form widgets', async () => {
272
+ it("fetches widget URL before rendering non-form widgets", async () => {
236
273
  mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
237
274
 
238
275
  const props = {
239
276
  ...baseProps,
240
277
  data: {
241
- customer_id: 'cust-777',
242
- journey: 'sac',
278
+ customer_id: "cust-777",
279
+ journey: "sac",
243
280
  },
244
- type: 'bottom' as const,
281
+ type: "bottom" as const,
245
282
  };
246
283
 
247
- const { queryByTestId, getByTestId } = render(
248
- <SoluCXWidget {...props} />
249
- );
284
+ const { queryByTestId, getByTestId } = render(<SoluCXWidget {...props} />);
250
285
 
251
- expect(queryByTestId('webview')).toBeNull();
286
+ expect(queryByTestId("webview")).toBeNull();
252
287
 
253
288
  await waitFor(() => {
254
289
  expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
255
290
  expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1);
256
291
  expect(mockOpen).toHaveBeenCalledTimes(1);
257
- expect(queryByTestId('webview')).toBeTruthy();
292
+ expect(queryByTestId("webview")).toBeTruthy();
258
293
  });
259
294
 
260
- const webview = getByTestId('webview');
295
+ const webview = getByTestId("webview");
261
296
  expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
262
297
  });
263
298
 
264
- it('keeps widget hidden when identifier fetch fails', async () => {
265
- mockRequestWidgetUrl.mockRejectedValue(new Error('network'));
299
+ it("keeps widget hidden when identifier fetch fails", async () => {
300
+ mockRequestWidgetUrl.mockRejectedValue(new Error("network"));
266
301
 
267
302
  const props = {
268
303
  ...baseProps,
269
304
  data: {
270
- customer_id: 'cust-777',
271
- journey: 'sac',
305
+ customer_id: "cust-777",
306
+ journey: "sac",
272
307
  },
273
- type: 'bottom' as const,
308
+ type: "bottom" as const,
274
309
  };
275
310
 
276
- const { queryByTestId } = render(
277
- <SoluCXWidget {...props} />
278
- );
311
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
279
312
 
280
- expect(queryByTestId('webview')).toBeNull();
313
+ expect(queryByTestId("webview")).toBeNull();
281
314
 
282
315
  await waitFor(() => {
283
316
  expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
284
317
  expect(mockShouldDisplayWidget).not.toHaveBeenCalled();
285
- expect(queryByTestId('webview')).toBeNull();
318
+ expect(queryByTestId("webview")).toBeNull();
286
319
  });
287
320
  });
288
321
 
289
- it('keeps widget hidden when validation fails after bootstrap', async () => {
322
+ it("keeps widget hidden when validation fails after bootstrap", async () => {
290
323
  mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
291
- mockShouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: 'BLOCKED_BY_MAX_RETRY_ATTEMPTS' });
324
+ mockShouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_RETRY_ATTEMPTS" });
292
325
 
293
326
  const props = {
294
327
  ...baseProps,
295
328
  data: {
296
- customer_id: 'cust-777',
297
- journey: 'sac',
329
+ customer_id: "cust-777",
330
+ journey: "sac",
298
331
  },
299
- type: 'bottom' as const,
332
+ type: "bottom" as const,
300
333
  };
301
334
 
302
- const { queryByTestId } = render(
303
- <SoluCXWidget {...props} />
304
- );
335
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
305
336
 
306
- expect(queryByTestId('webview')).toBeNull();
337
+ expect(queryByTestId("webview")).toBeNull();
307
338
 
308
339
  await waitFor(() => {
309
340
  expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
310
341
  expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1);
311
342
  expect(mockOpen).not.toHaveBeenCalled();
312
- expect(queryByTestId('webview')).toBeNull();
343
+ expect(queryByTestId("webview")).toBeNull();
344
+ });
345
+ });
346
+ });
347
+
348
+ describe("SoluCXWidget callbacks", () => {
349
+ it("calls onError when data is not provided", () => {
350
+ const onError = jest.fn();
351
+ const props = {
352
+ ...baseProps,
353
+ data: null as any,
354
+ callbacks: { onError },
355
+ };
356
+
357
+ render(<SoluCXWidget {...props} type="modal" />);
358
+
359
+ expect(onError).toHaveBeenCalledTimes(1);
360
+ expect(onError).toHaveBeenCalledWith("Widget data is required but was not provided");
361
+ });
362
+
363
+ it("calls onError with Error message when bootstrap fails with Error instance", async () => {
364
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Network timeout"));
365
+ const onError = jest.fn();
366
+
367
+ const props = {
368
+ ...baseProps,
369
+ data: {
370
+ customer_id: "cust-777",
371
+ journey: "sac",
372
+ },
373
+ type: "bottom" as const,
374
+ callbacks: { onError },
375
+ };
376
+
377
+ render(<SoluCXWidget {...props} />);
378
+
379
+ await waitFor(() => {
380
+ expect(onError).toHaveBeenCalledTimes(1);
381
+ expect(onError).toHaveBeenCalledWith("Network timeout");
382
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
383
+ });
384
+ });
385
+
386
+ it("calls onError with string when bootstrap fails with string error", async () => {
387
+ mockRequestWidgetUrl.mockRejectedValue("Connection failed");
388
+ const onError = jest.fn();
389
+
390
+ const props = {
391
+ ...baseProps,
392
+ data: {
393
+ customer_id: "cust-777",
394
+ journey: "sac",
395
+ },
396
+ type: "bottom" as const,
397
+ callbacks: { onError },
398
+ };
399
+
400
+ render(<SoluCXWidget {...props} />);
401
+
402
+ await waitFor(() => {
403
+ expect(onError).toHaveBeenCalledTimes(1);
404
+ expect(onError).toHaveBeenCalledWith("Connection failed");
405
+ });
406
+ });
407
+
408
+ it("calls onError with JSON string when bootstrap fails with object error", async () => {
409
+ mockRequestWidgetUrl.mockRejectedValue({ code: 500, message: "Server error" });
410
+ const onError = jest.fn();
411
+
412
+ const props = {
413
+ ...baseProps,
414
+ data: {
415
+ customer_id: "cust-777",
416
+ journey: "sac",
417
+ },
418
+ type: "bottom" as const,
419
+ callbacks: { onError },
420
+ };
421
+
422
+ render(<SoluCXWidget {...props} />);
423
+
424
+ await waitFor(() => {
425
+ expect(onError).toHaveBeenCalledTimes(1);
426
+ expect(onError).toHaveBeenCalledWith('{"code":500,"message":"Server error"}');
427
+ });
428
+ });
429
+
430
+ it('calls onError with "Unknown error" when bootstrap fails with null', async () => {
431
+ mockRequestWidgetUrl.mockRejectedValue(null);
432
+ const onError = jest.fn();
433
+
434
+ const props = {
435
+ ...baseProps,
436
+ data: {
437
+ customer_id: "cust-777",
438
+ journey: "sac",
439
+ },
440
+ type: "bottom" as const,
441
+ callbacks: { onError },
442
+ };
443
+
444
+ render(<SoluCXWidget {...props} />);
445
+
446
+ await waitFor(() => {
447
+ expect(onError).toHaveBeenCalledTimes(1);
448
+ expect(onError).toHaveBeenCalledWith("Unknown error");
449
+ });
450
+ });
451
+
452
+ it("calls onBlock when validation fails", async () => {
453
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
454
+ mockShouldDisplayWidget.mockResolvedValue({
455
+ canDisplay: false,
456
+ blockReason: "BLOCKED_BY_MAX_RETRY_ATTEMPTS",
457
+ });
458
+ const onBlock = jest.fn();
459
+
460
+ const props = {
461
+ ...baseProps,
462
+ data: {
463
+ customer_id: "cust-777",
464
+ journey: "sac",
465
+ },
466
+ type: "bottom" as const,
467
+ callbacks: { onBlock },
468
+ };
469
+
470
+ render(<SoluCXWidget {...props} />);
471
+
472
+ await waitFor(() => {
473
+ expect(onBlock).toHaveBeenCalledTimes(1);
474
+ expect(onBlock).toHaveBeenCalledWith("BLOCKED_BY_MAX_RETRY_ATTEMPTS");
475
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
476
+ });
477
+ });
478
+
479
+ it("calls onPreOpen, onOpened callbacks for successful bootstrap", async () => {
480
+ mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
481
+ mockShouldDisplayWidget.mockResolvedValue({ canDisplay: true });
482
+ const onPreOpen = jest.fn();
483
+ const onOpened = jest.fn();
484
+
485
+ const props = {
486
+ ...baseProps,
487
+ data: {
488
+ customer_id: "cust-777",
489
+ journey: "sac",
490
+ },
491
+ type: "bottom" as const,
492
+ callbacks: { onPreOpen, onOpened },
493
+ };
494
+
495
+ render(<SoluCXWidget {...props} />);
496
+
497
+ await waitFor(() => {
498
+ expect(onPreOpen).toHaveBeenCalledTimes(1);
499
+ expect(onPreOpen).toHaveBeenCalledWith("test-user-123");
500
+ expect(onOpened).toHaveBeenCalledTimes(1);
501
+ expect(onOpened).toHaveBeenCalledWith("test-user-123");
502
+ });
503
+ });
504
+ });
505
+
506
+ describe("SoluCXWidget error handling from requestWidgetUrl", () => {
507
+ it("calls onError when requestWidgetUrl throws error for non-ok response (404)", async () => {
508
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 404 Not Found"));
509
+ const onError = jest.fn();
510
+
511
+ const props = {
512
+ ...baseProps,
513
+ type: "modal" as const,
514
+ callbacks: { onError },
515
+ };
516
+
517
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
518
+
519
+ await waitFor(() => {
520
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
521
+ expect(onError).toHaveBeenCalledTimes(1);
522
+ expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 404 Not Found");
523
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
524
+ expect(queryByTestId("webview")).toBeNull();
525
+ });
526
+ });
527
+
528
+ it("calls onError when requestWidgetUrl throws error for non-ok response (500)", async () => {
529
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 500 Internal Server Error"));
530
+ const onError = jest.fn();
531
+
532
+ const props = {
533
+ ...baseProps,
534
+ type: "inline" as const,
535
+ callbacks: { onError },
536
+ };
537
+
538
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
539
+
540
+ await waitFor(() => {
541
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
542
+ expect(onError).toHaveBeenCalledTimes(1);
543
+ expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 500 Internal Server Error");
544
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
545
+ expect(queryByTestId("webview")).toBeNull();
546
+ });
547
+ });
548
+
549
+ it("calls onError when requestWidgetUrl throws error for fetch not available", async () => {
550
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Fetch is not available"));
551
+ const onError = jest.fn();
552
+
553
+ const props = {
554
+ ...baseProps,
555
+ type: "bottom" as const,
556
+ callbacks: { onError },
557
+ };
558
+
559
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
560
+
561
+ await waitFor(() => {
562
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
563
+ expect(onError).toHaveBeenCalledTimes(1);
564
+ expect(onError).toHaveBeenCalledWith("Fetch is not available");
565
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
566
+ expect(queryByTestId("webview")).toBeNull();
567
+ });
568
+ });
569
+
570
+ it("calls onError when requestWidgetUrl throws error for missing URL in response", async () => {
571
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Widget URL not found in response"));
572
+ const onError = jest.fn();
573
+
574
+ const props = {
575
+ ...baseProps,
576
+ type: "top" as const,
577
+ callbacks: { onError },
578
+ };
579
+
580
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
581
+
582
+ await waitFor(() => {
583
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
584
+ expect(onError).toHaveBeenCalledTimes(1);
585
+ expect(onError).toHaveBeenCalledWith("Widget URL not found in response");
586
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
587
+ expect(queryByTestId("webview")).toBeNull();
588
+ });
589
+ });
590
+
591
+ it("does not call shouldDisplayWidget when requestWidgetUrl fails", async () => {
592
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 403 Forbidden"));
593
+ const onError = jest.fn();
594
+
595
+ const props = {
596
+ ...baseProps,
597
+ type: "modal" as const,
598
+ callbacks: { onError },
599
+ };
600
+
601
+ render(<SoluCXWidget {...props} />);
602
+
603
+ await waitFor(() => {
604
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
605
+ expect(mockShouldDisplayWidget).not.toHaveBeenCalled();
606
+ expect(mockOpen).not.toHaveBeenCalled();
607
+ expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 403 Forbidden");
608
+ });
609
+ });
610
+
611
+ it("does not call onPreOpen or onOpened when requestWidgetUrl fails", async () => {
612
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 401 Unauthorized"));
613
+ const onPreOpen = jest.fn();
614
+ const onOpened = jest.fn();
615
+ const onError = jest.fn();
616
+
617
+ const props = {
618
+ ...baseProps,
619
+ type: "inline" as const,
620
+ callbacks: { onPreOpen, onOpened, onError },
621
+ };
622
+
623
+ render(<SoluCXWidget {...props} />);
624
+
625
+ await waitFor(() => {
626
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
627
+ expect(onPreOpen).not.toHaveBeenCalled();
628
+ expect(onOpened).not.toHaveBeenCalled();
629
+ expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 401 Unauthorized");
630
+ });
631
+ });
632
+
633
+ it("widget remains hidden when requestWidgetUrl fails with 404", async () => {
634
+ mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 404 Not Found"));
635
+
636
+ const props = {
637
+ ...baseProps,
638
+ type: "bottom" as const,
639
+ };
640
+
641
+ const { queryByTestId } = render(<SoluCXWidget {...props} />);
642
+
643
+ // Initially null
644
+ expect(queryByTestId("webview")).toBeNull();
645
+
646
+ await waitFor(() => {
647
+ expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1);
313
648
  });
649
+
650
+ // Should remain null after failure
651
+ expect(queryByTestId("webview")).toBeNull();
652
+ expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
314
653
  });
315
654
  });