@prosdevlab/experience-sdk-plugins 0.2.0 → 0.3.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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +120 -0
- package/README.md +141 -79
- package/dist/index.d.ts +206 -35
- package/dist/index.js +1229 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/banner/banner.ts +63 -62
- package/src/exit-intent/exit-intent.ts +2 -3
- package/src/index.ts +2 -0
- package/src/inline/index.ts +3 -0
- package/src/inline/inline.test.ts +620 -0
- package/src/inline/inline.ts +269 -0
- package/src/inline/insertion.ts +66 -0
- package/src/inline/types.ts +52 -0
- package/src/integration.test.ts +356 -297
- package/src/modal/form-rendering.ts +262 -0
- package/src/modal/form-styles.ts +212 -0
- package/src/modal/form-validation.test.ts +413 -0
- package/src/modal/form-validation.ts +126 -0
- package/src/modal/index.ts +3 -0
- package/src/modal/modal-styles.ts +204 -0
- package/src/modal/modal.browser.test.ts +164 -0
- package/src/modal/modal.test.ts +1294 -0
- package/src/modal/modal.ts +685 -0
- package/src/modal/types.ts +114 -0
- package/src/scroll-depth/scroll-depth.test.ts +35 -0
- package/src/scroll-depth/scroll-depth.ts +2 -4
- package/src/time-delay/time-delay.test.ts +2 -2
- package/src/time-delay/time-delay.ts +2 -3
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +4 -1
package/src/integration.test.ts
CHANGED
|
@@ -1,362 +1,421 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration Tests
|
|
2
|
+
* Integration Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - Page Visits
|
|
8
|
-
* - Time Delay
|
|
4
|
+
* Tests the interaction between plugins to ensure they work together correctly.
|
|
5
|
+
*
|
|
6
|
+
* @vitest-environment happy-dom
|
|
9
7
|
*/
|
|
10
|
-
|
|
11
8
|
import { SDK } from '@lytics/sdk-kit';
|
|
12
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
// Type augmentation for plugin APIs
|
|
16
|
-
type SDKWithPlugins = SDK & {
|
|
17
|
-
exitIntent?: any;
|
|
18
|
-
scrollDepth?: any;
|
|
19
|
-
pageVisits?: any;
|
|
20
|
-
timeDelay?: any;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
describe('Display Condition Plugins - Integration', () => {
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
vi.useFakeTimers();
|
|
26
|
-
// Reset document state
|
|
27
|
-
Object.defineProperty(document, 'hidden', {
|
|
28
|
-
writable: true,
|
|
29
|
-
configurable: true,
|
|
30
|
-
value: false,
|
|
31
|
-
});
|
|
32
|
-
// Clear storage
|
|
33
|
-
sessionStorage.clear();
|
|
34
|
-
localStorage.clear();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
vi.restoreAllMocks();
|
|
39
|
-
vi.useRealTimers();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('Plugin Composition', () => {
|
|
43
|
-
it('should load all 4 plugins without conflicts', async () => {
|
|
44
|
-
const sdk = new SDK({
|
|
45
|
-
exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false },
|
|
46
|
-
scrollDepth: { thresholds: [25, 50, 75], throttle: 100 },
|
|
47
|
-
pageVisits: { enabled: true },
|
|
48
|
-
timeDelay: { delay: 5000, pauseWhenHidden: false },
|
|
49
|
-
}) as SDKWithPlugins;
|
|
50
|
-
|
|
51
|
-
sdk.use(exitIntentPlugin);
|
|
52
|
-
sdk.use(scrollDepthPlugin);
|
|
53
|
-
sdk.use(pageVisitsPlugin);
|
|
54
|
-
sdk.use(timeDelayPlugin);
|
|
55
|
-
|
|
56
|
-
await sdk.init();
|
|
57
|
-
|
|
58
|
-
// All plugins should expose their APIs
|
|
59
|
-
expect(sdk.exitIntent).toBeDefined();
|
|
60
|
-
expect(sdk.scrollDepth).toBeDefined();
|
|
61
|
-
expect(sdk.pageVisits).toBeDefined();
|
|
62
|
-
expect(sdk.timeDelay).toBeDefined();
|
|
63
|
-
});
|
|
10
|
+
import { inlinePlugin } from './inline';
|
|
11
|
+
import { modalPlugin } from './modal';
|
|
64
12
|
|
|
65
|
-
|
|
66
|
-
|
|
13
|
+
function initSDK() {
|
|
14
|
+
const sdk = new SDK({ name: 'integration-test' });
|
|
15
|
+
sdk.use(modalPlugin);
|
|
16
|
+
sdk.use(inlinePlugin);
|
|
67
17
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
pageVisits: { enabled: true, autoIncrement: true },
|
|
72
|
-
timeDelay: { delay: 2000, pauseWhenHidden: false },
|
|
73
|
-
}) as SDKWithPlugins;
|
|
18
|
+
if (!document.body) {
|
|
19
|
+
document.body = document.createElement('body');
|
|
20
|
+
}
|
|
74
21
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sdk.use(pageVisitsPlugin);
|
|
78
|
-
sdk.use(timeDelayPlugin);
|
|
22
|
+
return sdk;
|
|
23
|
+
}
|
|
79
24
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
sdk.on('trigger:scrollDepth', (data) => events.push({ type: 'scrollDepth', data }));
|
|
83
|
-
sdk.on('trigger:timeDelay', (data) => events.push({ type: 'timeDelay', data }));
|
|
84
|
-
sdk.on('pageVisits:incremented', (data) => events.push({ type: 'pageVisits', data }));
|
|
25
|
+
describe('Plugin Integration Tests', () => {
|
|
26
|
+
let sdk: SDK & { modal?: any; inline?: any };
|
|
85
27
|
|
|
86
|
-
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
sdk = initSDK();
|
|
30
|
+
await sdk.init();
|
|
31
|
+
});
|
|
87
32
|
|
|
88
|
-
|
|
89
|
-
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
for (const el of document.querySelectorAll('.xp-modal, .xp-inline')) {
|
|
35
|
+
el.remove();
|
|
36
|
+
}
|
|
37
|
+
document.body.innerHTML = '';
|
|
38
|
+
if (sdk) {
|
|
39
|
+
await sdk.destroy();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
90
42
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
43
|
+
describe('Modal + Inline Interaction', () => {
|
|
44
|
+
it('should show modal and inline simultaneously', async () => {
|
|
45
|
+
const shownHandler = vi.fn();
|
|
46
|
+
sdk.on('experiences:shown', shownHandler);
|
|
47
|
+
|
|
48
|
+
const target = document.createElement('div');
|
|
49
|
+
target.id = 'content';
|
|
50
|
+
document.body.appendChild(target);
|
|
51
|
+
|
|
52
|
+
const modalExp = {
|
|
53
|
+
id: 'popup',
|
|
54
|
+
type: 'modal',
|
|
55
|
+
content: {
|
|
56
|
+
title: 'Special Offer',
|
|
57
|
+
message: 'Limited time only!',
|
|
58
|
+
buttons: [{ text: 'Learn More', variant: 'primary' }],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const inlineExp = {
|
|
63
|
+
id: 'inline-banner',
|
|
64
|
+
type: 'inline',
|
|
65
|
+
content: {
|
|
66
|
+
selector: '#content',
|
|
67
|
+
message: '<p>Related: Check out our guide.</p>',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
sdk.modal.show(modalExp);
|
|
72
|
+
sdk.inline.show(inlineExp);
|
|
73
|
+
|
|
74
|
+
await vi.waitFor(() => {
|
|
75
|
+
expect(shownHandler).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
94
77
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
expect(types.size).toBeGreaterThan(1);
|
|
78
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
79
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
98
80
|
});
|
|
99
81
|
|
|
100
|
-
it('should
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
82
|
+
it('should dismiss modal without affecting inline', async () => {
|
|
83
|
+
const dismissedHandler = vi.fn();
|
|
84
|
+
sdk.on('experiences:dismissed', dismissedHandler);
|
|
85
|
+
|
|
86
|
+
const target = document.createElement('div');
|
|
87
|
+
target.id = 'content';
|
|
88
|
+
document.body.appendChild(target);
|
|
89
|
+
|
|
90
|
+
const modalExp = {
|
|
91
|
+
id: 'dismissable-modal',
|
|
92
|
+
type: 'modal',
|
|
93
|
+
content: {
|
|
94
|
+
title: 'Notification',
|
|
95
|
+
message: 'This is a modal.',
|
|
96
|
+
dismissable: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const inlineExp = {
|
|
101
|
+
id: 'persistent-inline',
|
|
102
|
+
type: 'inline',
|
|
103
|
+
content: {
|
|
104
|
+
selector: '#content',
|
|
105
|
+
message: '<p>This stays.</p>',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
sdk.modal.show(modalExp);
|
|
110
|
+
sdk.inline.show(inlineExp);
|
|
111
|
+
|
|
112
|
+
await vi.waitFor(() => {
|
|
113
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
114
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
121
115
|
});
|
|
122
116
|
|
|
123
|
-
|
|
117
|
+
// Dismiss modal
|
|
118
|
+
const closeBtn = document.querySelector('.xp-modal__close') as HTMLElement;
|
|
119
|
+
closeBtn.click();
|
|
124
120
|
|
|
125
|
-
vi.
|
|
121
|
+
await vi.waitFor(() => {
|
|
122
|
+
expect(dismissedHandler).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
experienceId: 'dismissable-modal',
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
126
128
|
|
|
127
|
-
//
|
|
128
|
-
expect(
|
|
129
|
+
// Modal gone, inline remains
|
|
130
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
131
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
129
132
|
});
|
|
130
|
-
});
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
it('should dismiss inline without affecting modal', async () => {
|
|
135
|
+
const target = document.createElement('div');
|
|
136
|
+
target.id = 'content';
|
|
137
|
+
document.body.appendChild(target);
|
|
138
|
+
|
|
139
|
+
const modalExp = {
|
|
140
|
+
id: 'persistent-modal',
|
|
141
|
+
type: 'modal',
|
|
142
|
+
content: {
|
|
143
|
+
title: 'Stay Open',
|
|
144
|
+
message: 'This modal stays.',
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const inlineExp = {
|
|
149
|
+
id: 'dismissable-inline',
|
|
150
|
+
type: 'inline',
|
|
151
|
+
content: {
|
|
152
|
+
selector: '#content',
|
|
153
|
+
message: '<p>Can dismiss</p>',
|
|
154
|
+
dismissable: true,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
sdk.modal.show(modalExp);
|
|
159
|
+
sdk.inline.show(inlineExp);
|
|
160
|
+
|
|
161
|
+
await vi.waitFor(() => {
|
|
162
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
163
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
164
|
+
});
|
|
138
165
|
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
// Dismiss inline
|
|
167
|
+
const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
|
|
168
|
+
closeBtn.click();
|
|
141
169
|
|
|
142
|
-
await
|
|
170
|
+
await vi.waitFor(() => {
|
|
171
|
+
expect(document.querySelector('.xp-inline')).toBeFalsy();
|
|
172
|
+
});
|
|
143
173
|
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
174
|
+
// Inline gone, modal remains
|
|
175
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
148
178
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
describe('Modal Forms', () => {
|
|
180
|
+
it('should render and submit form in modal', async () => {
|
|
181
|
+
const formSubmitHandler = vi.fn();
|
|
182
|
+
sdk.on('experiences:modal:form:submit', formSubmitHandler);
|
|
183
|
+
|
|
184
|
+
const experience = {
|
|
185
|
+
id: 'newsletter',
|
|
186
|
+
type: 'modal',
|
|
187
|
+
content: {
|
|
188
|
+
title: 'Subscribe',
|
|
189
|
+
message: 'Get updates.',
|
|
190
|
+
size: 'sm',
|
|
191
|
+
form: {
|
|
192
|
+
fields: [
|
|
193
|
+
{ name: 'email', type: 'email', required: true, placeholder: 'you@example.com' },
|
|
194
|
+
],
|
|
195
|
+
submitButton: { text: 'Subscribe', variant: 'primary' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
sdk.modal.show(experience);
|
|
201
|
+
|
|
202
|
+
await vi.waitFor(() => {
|
|
203
|
+
expect(document.querySelector('.xp-modal__form')).toBeTruthy();
|
|
204
|
+
});
|
|
152
205
|
|
|
153
|
-
//
|
|
154
|
-
|
|
206
|
+
// Fill and submit form
|
|
207
|
+
const emailInput = document.querySelector('input[name="email"]') as HTMLInputElement;
|
|
208
|
+
emailInput.value = 'test@example.com';
|
|
209
|
+
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
210
|
+
|
|
211
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
212
|
+
form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
213
|
+
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
expect(formSubmitHandler).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
experienceId: 'newsletter',
|
|
218
|
+
formData: { email: 'test@example.com' },
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
});
|
|
155
222
|
});
|
|
156
223
|
|
|
157
|
-
it('should
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
224
|
+
it('should validate form fields', async () => {
|
|
225
|
+
const validationHandler = vi.fn();
|
|
226
|
+
sdk.on('experiences:modal:form:validation', validationHandler);
|
|
227
|
+
|
|
228
|
+
const experience = {
|
|
229
|
+
id: 'form-validation',
|
|
230
|
+
type: 'modal',
|
|
231
|
+
content: {
|
|
232
|
+
form: {
|
|
233
|
+
fields: [{ name: 'email', type: 'email', required: true }],
|
|
234
|
+
submitButton: { text: 'Submit', variant: 'primary' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
sdk.modal.show(experience);
|
|
240
|
+
|
|
241
|
+
await vi.waitFor(() => {
|
|
242
|
+
expect(document.querySelector('.xp-modal__form')).toBeTruthy();
|
|
243
|
+
});
|
|
162
244
|
|
|
163
|
-
|
|
164
|
-
|
|
245
|
+
// Submit empty form (should fail validation)
|
|
246
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
247
|
+
form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
248
|
+
|
|
249
|
+
await vi.waitFor(() => {
|
|
250
|
+
expect(validationHandler).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({
|
|
252
|
+
valid: false,
|
|
253
|
+
errors: expect.any(Object),
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
165
259
|
|
|
166
|
-
|
|
260
|
+
describe('Multiple Instances', () => {
|
|
261
|
+
it('should handle multiple inline experiences in different locations', async () => {
|
|
262
|
+
const target1 = document.createElement('div');
|
|
263
|
+
target1.id = 'sidebar';
|
|
264
|
+
document.body.appendChild(target1);
|
|
265
|
+
|
|
266
|
+
const target2 = document.createElement('div');
|
|
267
|
+
target2.id = 'footer';
|
|
268
|
+
document.body.appendChild(target2);
|
|
269
|
+
|
|
270
|
+
sdk.inline.show({
|
|
271
|
+
id: 'sidebar-promo',
|
|
272
|
+
type: 'inline',
|
|
273
|
+
content: {
|
|
274
|
+
selector: '#sidebar',
|
|
275
|
+
message: '<p>Sidebar content</p>',
|
|
276
|
+
},
|
|
277
|
+
});
|
|
167
278
|
|
|
168
|
-
|
|
169
|
-
|
|
279
|
+
sdk.inline.show({
|
|
280
|
+
id: 'footer-cta',
|
|
281
|
+
type: 'inline',
|
|
282
|
+
content: {
|
|
283
|
+
selector: '#footer',
|
|
284
|
+
message: '<p>Footer content</p>',
|
|
285
|
+
},
|
|
286
|
+
});
|
|
170
287
|
|
|
171
|
-
|
|
172
|
-
|
|
288
|
+
await vi.waitFor(() => {
|
|
289
|
+
expect(document.querySelectorAll('.xp-inline').length).toBe(2);
|
|
290
|
+
});
|
|
173
291
|
|
|
174
|
-
|
|
175
|
-
expect(
|
|
292
|
+
expect(target1.querySelector('.xp-inline')).toBeTruthy();
|
|
293
|
+
expect(target2.querySelector('.xp-inline')).toBeTruthy();
|
|
176
294
|
});
|
|
177
295
|
|
|
178
|
-
it('should
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
}) as SDKWithPlugins;
|
|
296
|
+
it('should replace existing modal when showing a new one', async () => {
|
|
297
|
+
const dismissedHandler = vi.fn();
|
|
298
|
+
sdk.on('experiences:dismissed', dismissedHandler);
|
|
182
299
|
|
|
183
|
-
|
|
300
|
+
// Show first modal
|
|
301
|
+
sdk.modal.show({
|
|
302
|
+
id: 'modal1',
|
|
303
|
+
type: 'modal',
|
|
304
|
+
content: { title: 'First', message: 'Modal 1' },
|
|
305
|
+
});
|
|
184
306
|
|
|
185
|
-
await
|
|
307
|
+
await vi.waitFor(() => {
|
|
308
|
+
expect(sdk.modal.isShowing('modal1')).toBe(true);
|
|
309
|
+
});
|
|
186
310
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
311
|
+
// Show second modal (should replace first)
|
|
312
|
+
sdk.modal.show({
|
|
313
|
+
id: 'modal2',
|
|
314
|
+
type: 'modal',
|
|
315
|
+
content: { title: 'Second', message: 'Modal 2' },
|
|
316
|
+
});
|
|
191
317
|
|
|
192
|
-
|
|
193
|
-
|
|
318
|
+
await vi.waitFor(() => {
|
|
319
|
+
expect(sdk.modal.isShowing('modal2')).toBe(true);
|
|
320
|
+
});
|
|
194
321
|
|
|
195
|
-
//
|
|
196
|
-
sdk.
|
|
197
|
-
|
|
198
|
-
expect(
|
|
322
|
+
// Only second modal should be showing
|
|
323
|
+
expect(sdk.modal.isShowing('modal1')).toBe(false);
|
|
324
|
+
expect(sdk.modal.isShowing('modal2')).toBe(true);
|
|
325
|
+
expect(document.querySelectorAll('.xp-modal').length).toBe(1);
|
|
199
326
|
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe('Performance', () => {
|
|
203
|
-
it('should have minimal overhead with all plugins loaded', async () => {
|
|
204
|
-
const startTime = performance.now();
|
|
205
327
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
pageVisits: { enabled: true },
|
|
210
|
-
timeDelay: { delay: 5000, pauseWhenHidden: false },
|
|
211
|
-
}) as SDKWithPlugins;
|
|
328
|
+
it('should prevent showing the same modal twice', async () => {
|
|
329
|
+
const shownHandler = vi.fn();
|
|
330
|
+
sdk.on('experiences:shown', shownHandler);
|
|
212
331
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
332
|
+
const experience = {
|
|
333
|
+
id: 'duplicate-test',
|
|
334
|
+
type: 'modal',
|
|
335
|
+
content: { title: 'Test', message: 'Cannot show twice' },
|
|
336
|
+
};
|
|
217
337
|
|
|
218
|
-
|
|
338
|
+
sdk.modal.show(experience);
|
|
339
|
+
sdk.modal.show(experience); // Try to show again
|
|
219
340
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// Should initialize in less than 50ms
|
|
224
|
-
expect(duration).toBeLessThan(50);
|
|
225
|
-
});
|
|
341
|
+
await vi.waitFor(() => {
|
|
342
|
+
expect(shownHandler).toHaveBeenCalledTimes(1);
|
|
343
|
+
});
|
|
226
344
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false },
|
|
230
|
-
scrollDepth: { thresholds: [50], throttle: 100 },
|
|
231
|
-
pageVisits: { enabled: true },
|
|
232
|
-
timeDelay: { delay: 1000, pauseWhenHidden: false },
|
|
233
|
-
}) as SDKWithPlugins;
|
|
234
|
-
|
|
235
|
-
sdk.use(exitIntentPlugin);
|
|
236
|
-
sdk.use(scrollDepthPlugin);
|
|
237
|
-
sdk.use(pageVisitsPlugin);
|
|
238
|
-
sdk.use(timeDelayPlugin);
|
|
239
|
-
|
|
240
|
-
await sdk.init();
|
|
241
|
-
|
|
242
|
-
// Reset all plugins multiple times
|
|
243
|
-
for (let i = 0; i < 100; i++) {
|
|
244
|
-
sdk.exitIntent?.reset();
|
|
245
|
-
sdk.scrollDepth?.reset();
|
|
246
|
-
sdk.pageVisits?.reset();
|
|
247
|
-
sdk.timeDelay?.reset();
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Should not throw or hang
|
|
251
|
-
expect(sdk.exitIntent?.isTriggered()).toBe(false);
|
|
252
|
-
expect(sdk.scrollDepth?.getMaxPercent()).toBe(0);
|
|
253
|
-
expect(sdk.timeDelay?.isTriggered()).toBe(false);
|
|
345
|
+
// Only one modal in DOM
|
|
346
|
+
expect(document.querySelectorAll('[data-xp-id="duplicate-test"]').length).toBe(1);
|
|
254
347
|
});
|
|
255
348
|
});
|
|
256
349
|
|
|
257
350
|
describe('Cleanup', () => {
|
|
258
|
-
it('should
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
sdk.use(pageVisitsPlugin);
|
|
269
|
-
sdk.use(timeDelayPlugin);
|
|
270
|
-
|
|
271
|
-
await sdk.init();
|
|
272
|
-
|
|
273
|
-
// Destroy SDK
|
|
274
|
-
sdk.emit('destroy');
|
|
275
|
-
|
|
276
|
-
// Advance time past all delays
|
|
277
|
-
vi.advanceTimersByTime(10000);
|
|
278
|
-
|
|
279
|
-
// Plugins should be cleaned up (no crashes)
|
|
280
|
-
expect(() => {
|
|
281
|
-
vi.advanceTimersByTime(1000);
|
|
282
|
-
}).not.toThrow();
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
describe('Real-World Scenarios', () => {
|
|
287
|
-
it('should handle "engaged user" scenario (scroll + time)', async () => {
|
|
288
|
-
const sdk = new SDK({
|
|
289
|
-
scrollDepth: { thresholds: [50], throttle: 100 },
|
|
290
|
-
timeDelay: { delay: 5000, pauseWhenHidden: false },
|
|
291
|
-
}) as SDKWithPlugins;
|
|
292
|
-
|
|
293
|
-
sdk.use(scrollDepthPlugin);
|
|
294
|
-
sdk.use(timeDelayPlugin);
|
|
295
|
-
|
|
296
|
-
await sdk.init();
|
|
297
|
-
|
|
298
|
-
// User spends 5 seconds (time condition met)
|
|
299
|
-
vi.advanceTimersByTime(5000);
|
|
300
|
-
|
|
301
|
-
const timeElapsed = sdk.timeDelay?.isTriggered() || false;
|
|
302
|
-
const scrolled50 = (sdk.scrollDepth?.getMaxPercent() || 0) >= 50;
|
|
303
|
-
|
|
304
|
-
// Could show "engaged user" offer even without scroll
|
|
305
|
-
expect(timeElapsed).toBe(true);
|
|
306
|
-
expect(timeElapsed || scrolled50).toBe(true); // OR logic
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should handle "returning visitor exit intent" scenario', async () => {
|
|
310
|
-
const sdk = new SDK({
|
|
311
|
-
exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false },
|
|
312
|
-
pageVisits: { enabled: true, autoIncrement: true },
|
|
313
|
-
}) as SDKWithPlugins;
|
|
314
|
-
|
|
315
|
-
sdk.use(exitIntentPlugin);
|
|
316
|
-
sdk.use(pageVisitsPlugin);
|
|
317
|
-
|
|
318
|
-
await sdk.init();
|
|
351
|
+
it('should clean up all experiences on destroy', async () => {
|
|
352
|
+
const target = document.createElement('div');
|
|
353
|
+
target.id = 'content';
|
|
354
|
+
document.body.appendChild(target);
|
|
355
|
+
|
|
356
|
+
sdk.modal.show({
|
|
357
|
+
id: 'modal',
|
|
358
|
+
type: 'modal',
|
|
359
|
+
content: { title: 'Modal', message: 'Content' },
|
|
360
|
+
});
|
|
319
361
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
sdk.inline.show({
|
|
363
|
+
id: 'inline',
|
|
364
|
+
type: 'inline',
|
|
365
|
+
content: { selector: '#content', message: '<p>Inline</p>' },
|
|
366
|
+
});
|
|
323
367
|
|
|
324
|
-
|
|
325
|
-
|
|
368
|
+
await vi.waitFor(() => {
|
|
369
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
370
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
371
|
+
});
|
|
326
372
|
|
|
327
|
-
|
|
328
|
-
expect(isFirstVisit).toBe(false);
|
|
329
|
-
expect(totalVisits).toBeGreaterThan(1);
|
|
373
|
+
await sdk.destroy();
|
|
330
374
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(isReturningVisitor).toBe(true);
|
|
375
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
376
|
+
expect(document.querySelector('.xp-inline')).toBeFalsy();
|
|
334
377
|
});
|
|
378
|
+
});
|
|
335
379
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
380
|
+
describe('Event Flow', () => {
|
|
381
|
+
it('should emit events in correct order for modal', async () => {
|
|
382
|
+
const events: string[] = [];
|
|
383
|
+
|
|
384
|
+
sdk.on('experiences:shown', () => events.push('shown'));
|
|
385
|
+
sdk.on('experiences:action', () => events.push('action'));
|
|
386
|
+
sdk.on('experiences:dismissed', () => events.push('dismissed'));
|
|
387
|
+
|
|
388
|
+
sdk.modal.show({
|
|
389
|
+
id: 'event-test',
|
|
390
|
+
type: 'modal',
|
|
391
|
+
content: {
|
|
392
|
+
title: 'Test',
|
|
393
|
+
message: 'Testing events',
|
|
394
|
+
buttons: [{ text: 'Click Me', variant: 'primary', action: 'test' }],
|
|
395
|
+
dismissable: true,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
341
398
|
|
|
342
|
-
|
|
343
|
-
|
|
399
|
+
await vi.waitFor(() => {
|
|
400
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
401
|
+
});
|
|
344
402
|
|
|
345
|
-
|
|
403
|
+
// Click button
|
|
404
|
+
const button = document.querySelector('.xp-modal__button') as HTMLElement;
|
|
405
|
+
button.click();
|
|
346
406
|
|
|
347
|
-
|
|
407
|
+
await vi.waitFor(() => {
|
|
408
|
+
expect(events).toContain('action');
|
|
409
|
+
});
|
|
348
410
|
|
|
349
|
-
//
|
|
350
|
-
|
|
411
|
+
// Dismiss modal
|
|
412
|
+
sdk.modal.remove('event-test');
|
|
351
413
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
expect(timeElapsed).toBe(true);
|
|
414
|
+
await vi.waitFor(() => {
|
|
415
|
+
expect(events).toContain('dismissed');
|
|
416
|
+
});
|
|
356
417
|
|
|
357
|
-
|
|
358
|
-
const shouldShowWelcome = totalCount <= 1 && timeElapsed;
|
|
359
|
-
expect(shouldShowWelcome).toBe(true);
|
|
418
|
+
expect(events).toEqual(['shown', 'action', 'dismissed']);
|
|
360
419
|
});
|
|
361
420
|
});
|
|
362
421
|
});
|