@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.
@@ -0,0 +1,620 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { SDK } from '@lytics/sdk-kit';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { inlinePlugin } from './inline';
7
+
8
+ // Helper to initialize SDK with inline plugin
9
+ function initPlugin(config = {}) {
10
+ const sdk = new SDK({
11
+ name: 'test-sdk',
12
+ ...config,
13
+ });
14
+
15
+ sdk.use(inlinePlugin);
16
+
17
+ // Ensure body exists
18
+ if (!document.body) {
19
+ document.body = document.createElement('body');
20
+ }
21
+
22
+ return sdk;
23
+ }
24
+
25
+ describe('Inline Plugin', () => {
26
+ let sdk: SDK & { inline?: any };
27
+
28
+ beforeEach(async () => {
29
+ vi.useFakeTimers();
30
+ sdk = initPlugin();
31
+ await sdk.init();
32
+ });
33
+
34
+ afterEach(async () => {
35
+ // Clean up any inline content
36
+ document.querySelectorAll('.xp-inline').forEach((el) => {
37
+ el.remove();
38
+ });
39
+
40
+ // Clean up any target elements
41
+ document.body.innerHTML = '';
42
+
43
+ if (sdk) {
44
+ await sdk.destroy();
45
+ }
46
+
47
+ vi.restoreAllMocks();
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ it('should register the inline plugin', () => {
52
+ expect(sdk.inline).toBeDefined();
53
+ expect(sdk.inline.show).toBeInstanceOf(Function);
54
+ expect(sdk.inline.remove).toBeInstanceOf(Function);
55
+ expect(sdk.inline.isShowing).toBeInstanceOf(Function);
56
+ });
57
+
58
+ it('should set default configuration', () => {
59
+ // Check that defaults were set by verifying plugin registered
60
+ expect(sdk.inline).toBeDefined();
61
+ });
62
+
63
+ describe('Insertion Methods', () => {
64
+ it('should insert content with "replace" position', () => {
65
+ // Create target element
66
+ const target = document.createElement('div');
67
+ target.id = 'test-target';
68
+ target.innerHTML = '<p>Original content</p>';
69
+ document.body.appendChild(target);
70
+
71
+ const experience = {
72
+ id: 'replace-test',
73
+ type: 'inline',
74
+ content: {
75
+ selector: '#test-target',
76
+ position: 'replace',
77
+ message: '<p>New content</p>',
78
+ },
79
+ };
80
+
81
+ sdk.inline.show(experience);
82
+
83
+ const inline = document.querySelector('.xp-inline');
84
+ expect(inline).toBeTruthy();
85
+ expect(inline?.innerHTML).toBe('<p>New content</p>');
86
+ expect(target.querySelector('p')?.textContent).toBe('New content'); // Content replaced
87
+ });
88
+
89
+ it('should insert content with "append" position', () => {
90
+ const target = document.createElement('div');
91
+ target.id = 'test-target';
92
+ target.innerHTML = '<p>Original</p>';
93
+ document.body.appendChild(target);
94
+
95
+ const experience = {
96
+ id: 'append-test',
97
+ type: 'inline',
98
+ content: {
99
+ selector: '#test-target',
100
+ position: 'append',
101
+ message: '<span>Appended</span>',
102
+ },
103
+ };
104
+
105
+ sdk.inline.show(experience);
106
+
107
+ const inline = document.querySelector('.xp-inline');
108
+ expect(inline).toBeTruthy();
109
+ expect(target.children.length).toBe(2); // Original + appended
110
+ expect(target.children[0].tagName).toBe('P'); // Original first
111
+ expect(target.children[1].className).toBe('xp-inline'); // Appended second
112
+ });
113
+
114
+ it('should insert content with "prepend" position', () => {
115
+ const target = document.createElement('div');
116
+ target.id = 'test-target';
117
+ target.innerHTML = '<p>Original</p>';
118
+ document.body.appendChild(target);
119
+
120
+ const experience = {
121
+ id: 'prepend-test',
122
+ type: 'inline',
123
+ content: {
124
+ selector: '#test-target',
125
+ position: 'prepend',
126
+ message: '<span>Prepended</span>',
127
+ },
128
+ };
129
+
130
+ sdk.inline.show(experience);
131
+
132
+ const inline = document.querySelector('.xp-inline');
133
+ expect(inline).toBeTruthy();
134
+ expect(target.children.length).toBe(2); // Prepended + original
135
+ expect(target.children[0].className).toBe('xp-inline'); // Prepended first
136
+ expect(target.children[1].tagName).toBe('P'); // Original second
137
+ });
138
+
139
+ it('should insert content with "before" position', () => {
140
+ const container = document.createElement('div');
141
+ const target = document.createElement('p');
142
+ target.id = 'test-target';
143
+ target.textContent = 'Target';
144
+ container.appendChild(target);
145
+ document.body.appendChild(container);
146
+
147
+ const experience = {
148
+ id: 'before-test',
149
+ type: 'inline',
150
+ content: {
151
+ selector: '#test-target',
152
+ position: 'before',
153
+ message: '<span>Before</span>',
154
+ },
155
+ };
156
+
157
+ sdk.inline.show(experience);
158
+
159
+ const inline = document.querySelector('.xp-inline');
160
+ expect(inline).toBeTruthy();
161
+ expect(container.children.length).toBe(2); // Inline + target
162
+ expect(container.children[0].className).toBe('xp-inline'); // Inline first
163
+ expect(container.children[1].id).toBe('test-target'); // Target second
164
+ });
165
+
166
+ it('should insert content with "after" position', () => {
167
+ const container = document.createElement('div');
168
+ const target = document.createElement('p');
169
+ target.id = 'test-target';
170
+ target.textContent = 'Target';
171
+ container.appendChild(target);
172
+ document.body.appendChild(container);
173
+
174
+ const experience = {
175
+ id: 'after-test',
176
+ type: 'inline',
177
+ content: {
178
+ selector: '#test-target',
179
+ position: 'after',
180
+ message: '<span>After</span>',
181
+ },
182
+ };
183
+
184
+ sdk.inline.show(experience);
185
+
186
+ const inline = document.querySelector('.xp-inline');
187
+ expect(inline).toBeTruthy();
188
+ expect(container.children.length).toBe(2); // Target + inline
189
+ expect(container.children[0].id).toBe('test-target'); // Target first
190
+ expect(container.children[1].className).toBe('xp-inline'); // Inline second
191
+ });
192
+
193
+ it('should default to "replace" when position not specified', () => {
194
+ const target = document.createElement('div');
195
+ target.id = 'test-target';
196
+ target.innerHTML = '<p>Original</p>';
197
+ document.body.appendChild(target);
198
+
199
+ const experience = {
200
+ id: 'default-position',
201
+ type: 'inline',
202
+ content: {
203
+ selector: '#test-target',
204
+ message: '<h2>Replaced</h2>',
205
+ },
206
+ };
207
+
208
+ sdk.inline.show(experience);
209
+
210
+ const inline = document.querySelector('.xp-inline');
211
+ expect(inline).toBeTruthy();
212
+ expect(target.querySelector('p')).toBeFalsy(); // Original replaced
213
+ });
214
+ });
215
+
216
+ describe('Error Handling', () => {
217
+ it('should emit error event when selector not found', async () => {
218
+ const errorHandler = vi.fn();
219
+ sdk.on('experiences:inline:error', errorHandler);
220
+
221
+ const experience = {
222
+ id: 'not-found',
223
+ type: 'inline',
224
+ content: {
225
+ selector: '#does-not-exist',
226
+ message: '<p>Content</p>',
227
+ },
228
+ };
229
+
230
+ sdk.inline.show(experience);
231
+
232
+ await vi.waitFor(() => {
233
+ expect(errorHandler).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ experienceId: 'not-found',
236
+ error: 'selector-not-found',
237
+ selector: '#does-not-exist',
238
+ })
239
+ );
240
+ });
241
+ });
242
+
243
+ it('should not throw error when removing non-existent inline', () => {
244
+ expect(() => {
245
+ sdk.inline.remove('does-not-exist');
246
+ }).not.toThrow();
247
+ });
248
+ });
249
+
250
+ describe('Dismissal', () => {
251
+ it('should render close button when dismissable is true', () => {
252
+ const target = document.createElement('div');
253
+ target.id = 'test-target';
254
+ document.body.appendChild(target);
255
+
256
+ const experience = {
257
+ id: 'dismissable-test',
258
+ type: 'inline',
259
+ content: {
260
+ selector: '#test-target',
261
+ message: '<p>Content</p>',
262
+ dismissable: true,
263
+ },
264
+ };
265
+
266
+ sdk.inline.show(experience);
267
+
268
+ const closeBtn = document.querySelector('.xp-inline__close');
269
+ expect(closeBtn).toBeTruthy();
270
+ expect(closeBtn?.getAttribute('aria-label')).toBe('Close');
271
+ });
272
+
273
+ it('should not render close button when dismissable is false', () => {
274
+ const target = document.createElement('div');
275
+ target.id = 'test-target';
276
+ document.body.appendChild(target);
277
+
278
+ const experience = {
279
+ id: 'not-dismissable',
280
+ type: 'inline',
281
+ content: {
282
+ selector: '#test-target',
283
+ message: '<p>Content</p>',
284
+ dismissable: false,
285
+ },
286
+ };
287
+
288
+ sdk.inline.show(experience);
289
+
290
+ const closeBtn = document.querySelector('.xp-inline__close');
291
+ expect(closeBtn).toBeFalsy();
292
+ });
293
+
294
+ it('should remove inline when close button is clicked', async () => {
295
+ const target = document.createElement('div');
296
+ target.id = 'test-target';
297
+ document.body.appendChild(target);
298
+
299
+ const dismissHandler = vi.fn();
300
+ sdk.on('experiences:dismissed', dismissHandler);
301
+
302
+ const experience = {
303
+ id: 'dismiss-test',
304
+ type: 'inline',
305
+ content: {
306
+ selector: '#test-target',
307
+ message: '<p>Content</p>',
308
+ dismissable: true,
309
+ },
310
+ };
311
+
312
+ sdk.inline.show(experience);
313
+
314
+ const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
315
+ closeBtn.click();
316
+
317
+ await vi.waitFor(() => {
318
+ expect(dismissHandler).toHaveBeenCalledWith(
319
+ expect.objectContaining({
320
+ experienceId: 'dismiss-test',
321
+ })
322
+ );
323
+ });
324
+
325
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
326
+ });
327
+
328
+ it('should persist dismissal in localStorage when persist is true', async () => {
329
+ const target = document.createElement('div');
330
+ target.id = 'test-target';
331
+ document.body.appendChild(target);
332
+
333
+ const experience = {
334
+ id: 'persist-test',
335
+ type: 'inline',
336
+ content: {
337
+ selector: '#test-target',
338
+ message: '<p>Content</p>',
339
+ dismissable: true,
340
+ persist: true,
341
+ },
342
+ };
343
+
344
+ sdk.inline.show(experience);
345
+
346
+ const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
347
+ closeBtn.click();
348
+
349
+ // Try to show again
350
+ const dismissedHandler = vi.fn();
351
+ sdk.on('experiences:inline:dismissed', dismissedHandler);
352
+
353
+ sdk.inline.show(experience);
354
+
355
+ await vi.waitFor(() => {
356
+ expect(dismissedHandler).toHaveBeenCalledWith(
357
+ expect.objectContaining({
358
+ experienceId: 'persist-test',
359
+ reason: 'previously-dismissed',
360
+ })
361
+ );
362
+ });
363
+
364
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
365
+ });
366
+ });
367
+
368
+ describe('Custom Styling', () => {
369
+ it('should apply custom className', () => {
370
+ const target = document.createElement('div');
371
+ target.id = 'test-target';
372
+ document.body.appendChild(target);
373
+
374
+ const experience = {
375
+ id: 'class-test',
376
+ type: 'inline',
377
+ content: {
378
+ selector: '#test-target',
379
+ message: '<p>Content</p>',
380
+ className: 'custom-class',
381
+ },
382
+ };
383
+
384
+ sdk.inline.show(experience);
385
+
386
+ const inline = document.querySelector('.xp-inline');
387
+ expect(inline?.classList.contains('custom-class')).toBe(true);
388
+ });
389
+
390
+ it('should apply custom inline styles', () => {
391
+ const target = document.createElement('div');
392
+ target.id = 'test-target';
393
+ document.body.appendChild(target);
394
+
395
+ const experience = {
396
+ id: 'style-test',
397
+ type: 'inline',
398
+ content: {
399
+ selector: '#test-target',
400
+ message: '<p>Content</p>',
401
+ style: {
402
+ padding: '20px',
403
+ backgroundColor: 'red',
404
+ },
405
+ },
406
+ };
407
+
408
+ sdk.inline.show(experience);
409
+
410
+ const inline = document.querySelector('.xp-inline') as HTMLElement;
411
+ expect(inline?.style.padding).toBe('20px');
412
+ expect(inline?.style.backgroundColor).toBe('red');
413
+ });
414
+ });
415
+
416
+ describe('Multi-instance', () => {
417
+ it('should support multiple inline experiences', () => {
418
+ const target1 = document.createElement('div');
419
+ target1.id = 'target-1';
420
+ document.body.appendChild(target1);
421
+
422
+ const target2 = document.createElement('div');
423
+ target2.id = 'target-2';
424
+ document.body.appendChild(target2);
425
+
426
+ sdk.inline.show({
427
+ id: 'inline-1',
428
+ type: 'inline',
429
+ content: {
430
+ selector: '#target-1',
431
+ message: '<p>Content 1</p>',
432
+ },
433
+ });
434
+
435
+ sdk.inline.show({
436
+ id: 'inline-2',
437
+ type: 'inline',
438
+ content: {
439
+ selector: '#target-2',
440
+ message: '<p>Content 2</p>',
441
+ },
442
+ });
443
+
444
+ const inlines = document.querySelectorAll('.xp-inline');
445
+ expect(inlines.length).toBe(2);
446
+ });
447
+
448
+ it('should prevent duplicate inline experiences', () => {
449
+ const target = document.createElement('div');
450
+ target.id = 'test-target';
451
+ document.body.appendChild(target);
452
+
453
+ const experience = {
454
+ id: 'duplicate-test',
455
+ type: 'inline' as const,
456
+ content: {
457
+ selector: '#test-target',
458
+ message: '<p>Content</p>',
459
+ },
460
+ };
461
+
462
+ // Show the same experience twice
463
+ sdk.inline.show(experience);
464
+ sdk.inline.show(experience);
465
+
466
+ // Should only insert once
467
+ const inlines = document.querySelectorAll('[data-xp-id="duplicate-test"]');
468
+ expect(inlines.length).toBe(1);
469
+ expect(sdk.inline.isShowing('duplicate-test')).toBe(true);
470
+ });
471
+
472
+ it('should check if specific inline is showing', () => {
473
+ const target = document.createElement('div');
474
+ target.id = 'test-target';
475
+ document.body.appendChild(target);
476
+
477
+ sdk.inline.show({
478
+ id: 'check-test',
479
+ type: 'inline',
480
+ content: {
481
+ selector: '#test-target',
482
+ message: '<p>Content</p>',
483
+ },
484
+ });
485
+
486
+ expect(sdk.inline.isShowing('check-test')).toBe(true);
487
+ expect(sdk.inline.isShowing('does-not-exist')).toBe(false);
488
+ });
489
+
490
+ it('should check if any inline is showing', () => {
491
+ expect(sdk.inline.isShowing()).toBe(false);
492
+
493
+ const target = document.createElement('div');
494
+ target.id = 'test-target';
495
+ document.body.appendChild(target);
496
+
497
+ sdk.inline.show({
498
+ id: 'any-test',
499
+ type: 'inline',
500
+ content: {
501
+ selector: '#test-target',
502
+ message: '<p>Content</p>',
503
+ },
504
+ });
505
+
506
+ expect(sdk.inline.isShowing()).toBe(true);
507
+ });
508
+ });
509
+
510
+ describe('Events', () => {
511
+ it('should emit shown event when inline is displayed', async () => {
512
+ const target = document.createElement('div');
513
+ target.id = 'test-target';
514
+ document.body.appendChild(target);
515
+
516
+ const shownHandler = vi.fn();
517
+ sdk.on('experiences:shown', shownHandler);
518
+
519
+ sdk.inline.show({
520
+ id: 'shown-test',
521
+ type: 'inline',
522
+ content: {
523
+ selector: '#test-target',
524
+ message: '<p>Content</p>',
525
+ },
526
+ });
527
+
528
+ await vi.waitFor(() => {
529
+ expect(shownHandler).toHaveBeenCalledWith(
530
+ expect.objectContaining({
531
+ experienceId: 'shown-test',
532
+ type: 'inline',
533
+ selector: '#test-target',
534
+ position: 'replace',
535
+ })
536
+ );
537
+ });
538
+ });
539
+ });
540
+
541
+ describe('Cleanup', () => {
542
+ it('should remove inline on explicit remove call', () => {
543
+ const target = document.createElement('div');
544
+ target.id = 'test-target';
545
+ document.body.appendChild(target);
546
+
547
+ sdk.inline.show({
548
+ id: 'remove-test',
549
+ type: 'inline',
550
+ content: {
551
+ selector: '#test-target',
552
+ message: '<p>Content</p>',
553
+ },
554
+ });
555
+
556
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
557
+
558
+ sdk.inline.remove('remove-test');
559
+
560
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
561
+ });
562
+
563
+ it('should remove all inlines on destroy', async () => {
564
+ const target1 = document.createElement('div');
565
+ target1.id = 'target-1';
566
+ document.body.appendChild(target1);
567
+
568
+ const target2 = document.createElement('div');
569
+ target2.id = 'target-2';
570
+ document.body.appendChild(target2);
571
+
572
+ sdk.inline.show({
573
+ id: 'destroy-1',
574
+ type: 'inline',
575
+ content: {
576
+ selector: '#target-1',
577
+ message: '<p>Content 1</p>',
578
+ },
579
+ });
580
+
581
+ sdk.inline.show({
582
+ id: 'destroy-2',
583
+ type: 'inline',
584
+ content: {
585
+ selector: '#target-2',
586
+ message: '<p>Content 2</p>',
587
+ },
588
+ });
589
+
590
+ expect(document.querySelectorAll('.xp-inline').length).toBe(2);
591
+
592
+ await sdk.destroy();
593
+
594
+ expect(document.querySelectorAll('.xp-inline').length).toBe(0);
595
+ });
596
+ });
597
+
598
+ describe('HTML Sanitization', () => {
599
+ it('should sanitize HTML content', () => {
600
+ const target = document.createElement('div');
601
+ target.id = 'test-target';
602
+ document.body.appendChild(target);
603
+
604
+ const experience = {
605
+ id: 'sanitize-test',
606
+ type: 'inline',
607
+ content: {
608
+ selector: '#test-target',
609
+ message: '<p>Safe content</p><script>alert("xss")</script>',
610
+ },
611
+ };
612
+
613
+ sdk.inline.show(experience);
614
+
615
+ const inline = document.querySelector('.xp-inline');
616
+ expect(inline?.innerHTML).not.toContain('<script>');
617
+ expect(inline?.innerHTML).toContain('<p>Safe content</p>');
618
+ });
619
+ });
620
+ });