@solucx/react-native-solucx-widget 0.2.0 → 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.
- package/package.json +1 -1
- package/src/SoluCXWidget.tsx +13 -11
- package/src/__tests__/SoluCXWidget.rendering.test.tsx +342 -153
- package/src/__tests__/e2e/widget-lifecycle.test.tsx +9 -10
- package/src/__tests__/integration/webview-communication.test.tsx +9 -9
- package/src/__tests__/useWidgetState.test.ts +2 -2
- package/src/hooks/useWidgetState.ts +3 -3
- package/src/interfaces/WidgetCallbacks.ts +0 -1
- package/src/services/widgetEventService.ts +1 -1
package/package.json
CHANGED
package/src/SoluCXWidget.tsx
CHANGED
|
@@ -6,7 +6,6 @@ import type { SoluCXKey, WidgetData, WidgetOptions, WidgetType, WidgetCallbacks
|
|
|
6
6
|
import { useWidgetState } from "./hooks/useWidgetState";
|
|
7
7
|
import { WidgetEventService } from "./services/widgetEventService";
|
|
8
8
|
import { WidgetValidationService } from "./services/widgetValidationService";
|
|
9
|
-
import { buildWidgetURL } from "./utils/urlUtils";
|
|
10
9
|
import { WEB_VIEW_MESSAGE_LISTENER } from "./constants/webViewConstants";
|
|
11
10
|
import { ModalWidget } from "./components/ModalWidget";
|
|
12
11
|
import { InlineWidget } from "./components/InlineWidget";
|
|
@@ -25,6 +24,11 @@ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, dat
|
|
|
25
24
|
const webviewRef = useRef<WebView>(null);
|
|
26
25
|
const { width } = Dimensions.get("window");
|
|
27
26
|
|
|
27
|
+
if (!data) {
|
|
28
|
+
callbacks?.onError?.("Widget data is required but was not provided");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
const serializedData = JSON.stringify(data);
|
|
29
33
|
const normalizedData = useMemo(() => data, [serializedData]);
|
|
30
34
|
|
|
@@ -38,9 +42,7 @@ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, dat
|
|
|
38
42
|
|
|
39
43
|
const validationService = useMemo(() => new WidgetValidationService(userId), [userId]);
|
|
40
44
|
const isForm = Boolean(normalizedData.form_id);
|
|
41
|
-
const [widgetUri, setWidgetUri] = useState<string | null>(
|
|
42
|
-
isForm ? buildWidgetURL(soluCXKey, normalizedData) : null,
|
|
43
|
-
);
|
|
45
|
+
const [widgetUri, setWidgetUri] = useState<string | null>(null);
|
|
44
46
|
|
|
45
47
|
useEffect(() => {
|
|
46
48
|
loadSavedData();
|
|
@@ -50,12 +52,6 @@ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, dat
|
|
|
50
52
|
let isActive = true;
|
|
51
53
|
|
|
52
54
|
const prepareWidgetURL = async () => {
|
|
53
|
-
if (isForm) {
|
|
54
|
-
setWidgetUri(buildWidgetURL(soluCXKey, normalizedData));
|
|
55
|
-
open(); // Show widget in form mode
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
55
|
setWidgetUri(null);
|
|
60
56
|
|
|
61
57
|
try {
|
|
@@ -78,7 +74,13 @@ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, dat
|
|
|
78
74
|
callbacks?.onOpened?.(userId);
|
|
79
75
|
} catch (error) {
|
|
80
76
|
if (isActive) {
|
|
81
|
-
|
|
77
|
+
let errorMessage = "Unknown error";
|
|
78
|
+
|
|
79
|
+
if (error instanceof Error) errorMessage = error.message;
|
|
80
|
+
else if (typeof error === "string") errorMessage = error;
|
|
81
|
+
else if (error !== null && typeof error === "object") errorMessage = JSON.stringify(error);
|
|
82
|
+
|
|
83
|
+
callbacks?.onError?.(errorMessage);
|
|
82
84
|
setIsWidgetVisible(false);
|
|
83
85
|
}
|
|
84
86
|
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { render, waitFor } from
|
|
3
|
-
import { SoluCXWidget } from
|
|
4
|
-
import { WidgetData, WidgetOptions } from
|
|
5
|
-
import { requestWidgetUrl } from
|
|
6
|
-
|
|
7
|
-
jest.mock(
|
|
8
|
-
const { forwardRef } = require(
|
|
9
|
-
const { View } = require(
|
|
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(
|
|
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:
|
|
31
|
+
userId: "test-user-123",
|
|
34
32
|
}),
|
|
35
33
|
}));
|
|
36
34
|
|
|
37
|
-
jest.mock(
|
|
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(
|
|
41
|
+
jest.mock("../services/widgetValidationService", () => ({
|
|
44
42
|
WidgetValidationService: jest.fn().mockImplementation(() => ({
|
|
45
43
|
shouldDisplayWidget: mockShouldDisplayWidget,
|
|
46
44
|
})),
|
|
47
45
|
}));
|
|
48
46
|
|
|
49
|
-
jest.mock(
|
|
47
|
+
jest.mock("../services/widgetBootstrapService", () => ({
|
|
50
48
|
requestWidgetUrl: jest.fn(),
|
|
51
49
|
}));
|
|
52
50
|
|
|
53
|
-
jest.mock(
|
|
54
|
-
SDK_NAME:
|
|
55
|
-
SDK_VERSION:
|
|
51
|
+
jest.mock("../constants/Constants", () => ({
|
|
52
|
+
SDK_NAME: "rn-widget-sdk",
|
|
53
|
+
SDK_VERSION: "0.1.16",
|
|
56
54
|
}));
|
|
57
55
|
|
|
58
|
-
jest.mock(
|
|
59
|
-
getClientVersion: jest.fn(() =>
|
|
56
|
+
jest.mock("../services/ClientVersionCollector", () => ({
|
|
57
|
+
getClientVersion: jest.fn(() => "1.0.0"),
|
|
60
58
|
}));
|
|
61
59
|
|
|
62
|
-
jest.mock(
|
|
60
|
+
jest.mock("../hooks/useDeviceInfoCollector", () => ({
|
|
63
61
|
getDeviceInfo: jest.fn(() => ({
|
|
64
|
-
platform:
|
|
65
|
-
osVersion:
|
|
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:
|
|
73
|
-
model:
|
|
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 =
|
|
76
|
+
const bootstrappedWidgetUrl = "https://widgets.solucx.com/widget/bootstrap-result";
|
|
79
77
|
|
|
80
78
|
const baseProps = {
|
|
81
|
-
soluCXKey:
|
|
79
|
+
soluCXKey: "test-key-abc",
|
|
82
80
|
data: {
|
|
83
|
-
customer_id:
|
|
84
|
-
transaction_id:
|
|
85
|
-
form_id:
|
|
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,410 @@ beforeEach(() => {
|
|
|
97
95
|
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
98
96
|
});
|
|
99
97
|
|
|
100
|
-
describe(
|
|
101
|
-
it(
|
|
102
|
-
|
|
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
|
|
107
|
-
|
|
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
109
|
|
|
110
|
-
it(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
110
|
+
it("should render close button inside ModalWidget", async () => {
|
|
111
|
+
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
112
|
+
|
|
113
|
+
const { getByText } = render(<SoluCXWidget {...baseProps} type="modal" />);
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(getByText("✕")).toBeTruthy();
|
|
117
|
+
});
|
|
116
118
|
});
|
|
117
119
|
|
|
118
|
-
it(
|
|
119
|
-
|
|
120
|
-
<SoluCXWidget {...baseProps} type="modal" />
|
|
121
|
-
);
|
|
120
|
+
it("should render WebView inside ModalWidget with correct source", async () => {
|
|
121
|
+
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
122
122
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
const { getByTestId } = render(<SoluCXWidget {...baseProps} type="modal" />);
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
const webview = getByTestId("webview");
|
|
127
|
+
expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
|
|
128
|
+
});
|
|
126
129
|
});
|
|
127
130
|
|
|
128
|
-
it(
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(queryByTestId("webview")).toBeTruthy();
|
|
138
|
+
expect(getByText("✕")).toBeTruthy();
|
|
139
|
+
});
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
it(
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
const modal = UNSAFE_queryByType(require("react-native").Modal);
|
|
149
|
+
expect(modal).toBeNull();
|
|
150
|
+
});
|
|
144
151
|
});
|
|
145
152
|
|
|
146
|
-
it(
|
|
147
|
-
|
|
148
|
-
<SoluCXWidget {...baseProps} type="bottom" />
|
|
149
|
-
);
|
|
153
|
+
it("should render OverlayWidget when type is bottom", async () => {
|
|
154
|
+
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
150
155
|
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
const { queryByTestId, getByText } = render(<SoluCXWidget {...baseProps} type="bottom" />);
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(queryByTestId("webview")).toBeTruthy();
|
|
160
|
+
expect(getByText("✕")).toBeTruthy();
|
|
161
|
+
});
|
|
153
162
|
});
|
|
154
163
|
|
|
155
|
-
it(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
164
|
+
it("should render OverlayWidget when type is top", async () => {
|
|
165
|
+
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
166
|
+
|
|
167
|
+
const { queryByTestId, getByText } = render(<SoluCXWidget {...baseProps} type="top" />);
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(queryByTestId("webview")).toBeTruthy();
|
|
171
|
+
expect(getByText("✕")).toBeTruthy();
|
|
172
|
+
});
|
|
162
173
|
});
|
|
163
174
|
|
|
164
|
-
it(
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
const modal = UNSAFE_queryByType(require("react-native").Modal);
|
|
182
|
+
expect(modal).toBeNull();
|
|
183
|
+
});
|
|
171
184
|
});
|
|
172
185
|
|
|
173
|
-
it(
|
|
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(
|
|
181
|
-
it(
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
const webview = getByTestId("webview");
|
|
201
|
+
expect(webview.props.originWhitelist).toEqual(["*"]);
|
|
202
|
+
});
|
|
188
203
|
});
|
|
189
204
|
|
|
190
|
-
it(
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
(s: any) => s.width !== undefined
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
(s: any) => s.height !== undefined
|
|
210
|
-
|
|
211
|
-
|
|
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(
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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);
|
|
244
|
+
|
|
245
|
+
const props = {
|
|
246
|
+
...baseProps,
|
|
247
|
+
type: "modal" as const,
|
|
248
|
+
};
|
|
223
249
|
|
|
224
|
-
|
|
225
|
-
const { getByTestId } = render(
|
|
226
|
-
<SoluCXWidget {...baseProps} type="inline" />
|
|
227
|
-
);
|
|
250
|
+
const { queryByTestId, getByTestId } = render(<SoluCXWidget {...props} />);
|
|
228
251
|
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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:
|
|
242
|
-
journey:
|
|
278
|
+
customer_id: "cust-777",
|
|
279
|
+
journey: "sac",
|
|
243
280
|
},
|
|
244
|
-
type:
|
|
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(
|
|
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(
|
|
292
|
+
expect(queryByTestId("webview")).toBeTruthy();
|
|
258
293
|
});
|
|
259
294
|
|
|
260
|
-
const webview = getByTestId(
|
|
295
|
+
const webview = getByTestId("webview");
|
|
261
296
|
expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl);
|
|
262
297
|
});
|
|
263
298
|
|
|
264
|
-
it(
|
|
265
|
-
mockRequestWidgetUrl.mockRejectedValue(new Error(
|
|
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:
|
|
271
|
-
journey:
|
|
305
|
+
customer_id: "cust-777",
|
|
306
|
+
journey: "sac",
|
|
272
307
|
},
|
|
273
|
-
type:
|
|
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(
|
|
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(
|
|
318
|
+
expect(queryByTestId("webview")).toBeNull();
|
|
286
319
|
});
|
|
287
320
|
});
|
|
288
321
|
|
|
289
|
-
it(
|
|
322
|
+
it("keeps widget hidden when validation fails after bootstrap", async () => {
|
|
290
323
|
mockRequestWidgetUrl.mockResolvedValue(bootstrappedWidgetUrl);
|
|
291
|
-
mockShouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason:
|
|
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:
|
|
297
|
-
journey:
|
|
329
|
+
customer_id: "cust-777",
|
|
330
|
+
journey: "sac",
|
|
298
331
|
},
|
|
299
|
-
type:
|
|
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(
|
|
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(
|
|
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");
|
|
313
502
|
});
|
|
314
503
|
});
|
|
315
504
|
});
|
|
@@ -82,7 +82,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
82
82
|
const callbacks: WidgetCallbacks = {
|
|
83
83
|
onOpened: (userId) => analytics.track('widget_shown', { userId }),
|
|
84
84
|
onQuestionAnswered: () => analytics.track('question_answered'),
|
|
85
|
-
|
|
85
|
+
onCompleted: (userId) => {
|
|
86
86
|
analytics.track('survey_completed', { userId });
|
|
87
87
|
},
|
|
88
88
|
onClosed: () => analytics.track('widget_closed'),
|
|
@@ -117,10 +117,6 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
117
117
|
nativeEvent: { data: 'FORM_RESIZE-400' },
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
await waitFor(() => {
|
|
121
|
-
expect(analytics.track).toHaveBeenCalledWith('question_answered', undefined);
|
|
122
|
-
}, { timeout: 100 }).catch(() => {}); // May not be called yet
|
|
123
|
-
|
|
124
120
|
// 4. Customer answers first question
|
|
125
121
|
fireEvent(webview, 'message', {
|
|
126
122
|
nativeEvent: { data: 'QUESTION_ANSWERED' },
|
|
@@ -130,6 +126,9 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
130
126
|
expect(analytics.track).toHaveBeenCalledWith('question_answered');
|
|
131
127
|
});
|
|
132
128
|
|
|
129
|
+
// Reset counter after first question to track only upcoming interactions
|
|
130
|
+
analytics.track.mockClear();
|
|
131
|
+
|
|
133
132
|
// 5. Customer goes to next page
|
|
134
133
|
fireEvent(webview, 'message', {
|
|
135
134
|
nativeEvent: { data: 'FORM_PAGECHANGED-2' },
|
|
@@ -142,7 +141,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
142
141
|
|
|
143
142
|
await waitFor(() => {
|
|
144
143
|
expect(analytics.track).toHaveBeenCalledWith('question_answered');
|
|
145
|
-
expect(analytics.track).toHaveBeenCalledTimes(
|
|
144
|
+
expect(analytics.track).toHaveBeenCalledTimes(1); // Called once after reset
|
|
146
145
|
});
|
|
147
146
|
|
|
148
147
|
// 7. Customer submits survey
|
|
@@ -165,8 +164,8 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
165
164
|
expect(analytics.track).toHaveBeenCalledWith('widget_closed');
|
|
166
165
|
});
|
|
167
166
|
|
|
168
|
-
// Verify complete tracking flow
|
|
169
|
-
expect(analytics.track).toHaveBeenCalledTimes(
|
|
167
|
+
// Verify complete tracking flow after reset (question_answered + survey_completed + widget_closed)
|
|
168
|
+
expect(analytics.track).toHaveBeenCalledTimes(3);
|
|
170
169
|
});
|
|
171
170
|
});
|
|
172
171
|
|
|
@@ -177,7 +176,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
177
176
|
|
|
178
177
|
const callbacks: WidgetCallbacks = {
|
|
179
178
|
onError: errorHandler,
|
|
180
|
-
|
|
179
|
+
onCompleted: completionHandler,
|
|
181
180
|
};
|
|
182
181
|
|
|
183
182
|
const { getByTestId } = render(
|
|
@@ -298,7 +297,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
298
297
|
callbacks={{
|
|
299
298
|
onPageChanged: pageChangeHandler,
|
|
300
299
|
onQuestionAnswered: questionHandler,
|
|
301
|
-
|
|
300
|
+
onCompleted: completionHandler,
|
|
302
301
|
}}
|
|
303
302
|
/>
|
|
304
303
|
);
|
|
@@ -167,9 +167,9 @@ describe('Integration: WebView Communication', () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
it('should handle FORM_COMPLETED message with userId', async () => {
|
|
170
|
-
const
|
|
170
|
+
const mockOnCompleted = jest.fn();
|
|
171
171
|
const callbacks: WidgetCallbacks = {
|
|
172
|
-
|
|
172
|
+
onCompleted: mockOnCompleted,
|
|
173
173
|
};
|
|
174
174
|
|
|
175
175
|
const { getByTestId } = render(
|
|
@@ -186,7 +186,7 @@ describe('Integration: WebView Communication', () => {
|
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
await waitFor(() => {
|
|
189
|
-
expect(
|
|
189
|
+
expect(mockOnCompleted).toHaveBeenCalledWith(expect.any(String));
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -290,10 +290,10 @@ describe('Integration: WebView Communication', () => {
|
|
|
290
290
|
});
|
|
291
291
|
|
|
292
292
|
it('should handle completeSoluCXWidget survey event', async () => {
|
|
293
|
-
const
|
|
293
|
+
const mockOnCompleted = jest.fn();
|
|
294
294
|
|
|
295
295
|
const { getByTestId } = render(
|
|
296
|
-
<SoluCXWidget {...surveyProps} callbacks={{
|
|
296
|
+
<SoluCXWidget {...surveyProps} callbacks={{ onCompleted: mockOnCompleted }} />
|
|
297
297
|
);
|
|
298
298
|
|
|
299
299
|
await waitFor(() => {
|
|
@@ -305,7 +305,7 @@ describe('Integration: WebView Communication', () => {
|
|
|
305
305
|
});
|
|
306
306
|
|
|
307
307
|
await waitFor(() => {
|
|
308
|
-
expect(
|
|
308
|
+
expect(mockOnCompleted).toHaveBeenCalled();
|
|
309
309
|
});
|
|
310
310
|
});
|
|
311
311
|
});
|
|
@@ -359,14 +359,14 @@ describe('Integration: WebView Communication', () => {
|
|
|
359
359
|
const mockOnPageChanged = jest.fn();
|
|
360
360
|
const mockOnQuestionAnswered = jest.fn();
|
|
361
361
|
const mockOnResize = jest.fn();
|
|
362
|
-
const
|
|
362
|
+
const mockOnCompleted = jest.fn();
|
|
363
363
|
const mockOnClosed = jest.fn();
|
|
364
364
|
|
|
365
365
|
const callbacks: WidgetCallbacks = {
|
|
366
366
|
onPageChanged: mockOnPageChanged,
|
|
367
367
|
onQuestionAnswered: mockOnQuestionAnswered,
|
|
368
368
|
onResize: mockOnResize,
|
|
369
|
-
|
|
369
|
+
onCompleted: mockOnCompleted,
|
|
370
370
|
onClosed: mockOnClosed,
|
|
371
371
|
};
|
|
372
372
|
|
|
@@ -404,7 +404,7 @@ describe('Integration: WebView Communication', () => {
|
|
|
404
404
|
expect(mockOnResize).toHaveBeenCalledTimes(2);
|
|
405
405
|
expect(mockOnPageChanged).toHaveBeenCalledTimes(1);
|
|
406
406
|
expect(mockOnQuestionAnswered).toHaveBeenCalledTimes(1);
|
|
407
|
-
expect(
|
|
407
|
+
expect(mockOnCompleted).toHaveBeenCalledTimes(1);
|
|
408
408
|
expect(mockOnClosed).toHaveBeenCalledTimes(1);
|
|
409
409
|
});
|
|
410
410
|
|
|
@@ -78,7 +78,7 @@ describe('WidgetEventService', () => {
|
|
|
78
78
|
it('should handle FORM_COMPLETED event correctly', async () => {
|
|
79
79
|
const result = await service.handleMessage('FORM_COMPLETED', true);
|
|
80
80
|
|
|
81
|
-
expect(mockCallbacks.
|
|
81
|
+
expect(mockCallbacks.onCompleted).toHaveBeenCalledWith('test-user-123');
|
|
82
82
|
expect(result).toEqual({ status: 'success' });
|
|
83
83
|
});
|
|
84
84
|
|
|
@@ -112,7 +112,7 @@ describe('WidgetEventService', () => {
|
|
|
112
112
|
it('should adapt completeSoluCXWidget to FORM_COMPLETED', async () => {
|
|
113
113
|
const result = await service.handleMessage('completeSoluCXWidget', false);
|
|
114
114
|
|
|
115
|
-
expect(mockCallbacks.
|
|
115
|
+
expect(mockCallbacks.onCompleted).toHaveBeenCalledWith('test-user-123');
|
|
116
116
|
expect(result).toEqual({ status: 'success' });
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -10,9 +10,9 @@ import { StorageService } from '../services/storage';
|
|
|
10
10
|
|
|
11
11
|
function getUserId(widgetData: WidgetData): string {
|
|
12
12
|
return (
|
|
13
|
-
widgetData
|
|
14
|
-
widgetData
|
|
15
|
-
widgetData
|
|
13
|
+
widgetData?.customer_id ??
|
|
14
|
+
widgetData?.document ??
|
|
15
|
+
widgetData?.email ??
|
|
16
16
|
'default_user'
|
|
17
17
|
);
|
|
18
18
|
}
|
|
@@ -4,7 +4,6 @@ export interface WidgetCallbacks {
|
|
|
4
4
|
onPreOpen?: (userId: string) => void;
|
|
5
5
|
onOpened?: (userId: string) => void;
|
|
6
6
|
onBlock?: (blockReason: BlockReason | undefined) => void;
|
|
7
|
-
onPingError?: (error: unknown) => void;
|
|
8
7
|
onClosed?: () => void;
|
|
9
8
|
onError?: (message: string) => void;
|
|
10
9
|
onPageChanged?: (page: string) => void;
|