onboardme-sdk 0.0.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/ARCHITECTURE-v2.md +225 -0
- package/dist/sdk.iife.js +348 -0
- package/package.json +22 -0
- package/src/__tests__/day1.test.ts +37 -0
- package/src/__tests__/day2.test.ts +447 -0
- package/src/__tests__/day3.test.ts +110 -0
- package/src/__tests__/day4.test.ts +115 -0
- package/src/__tests__/day5.test.ts +102 -0
- package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
- package/src/__tests__/snapshot-sender.test.ts +111 -0
- package/src/__tests__/v2-integration.test.ts +305 -0
- package/src/__tests__/v2-positioner.test.ts +115 -0
- package/src/__tests__/v2-renderer.test.ts +189 -0
- package/src/__tests__/v2-types.test.ts +74 -0
- package/src/__tests__/week2-day1.test.ts +62 -0
- package/src/__tests__/week2-day2.test.ts +128 -0
- package/src/__tests__/week2-day3.test.ts +128 -0
- package/src/__tests__/week2-day4.test.ts +177 -0
- package/src/__tests__/week2-day5.test.ts +294 -0
- package/src/__tests__/week3-day1.test.ts +169 -0
- package/src/__tests__/week3-day2.test.ts +267 -0
- package/src/__tests__/week3-day3.test.ts +213 -0
- package/src/__tests__/week3-day4.test.ts +213 -0
- package/src/__tests__/week3-day5.test.ts +350 -0
- package/src/__tests__/week4-day1.test.ts +277 -0
- package/src/__tests__/week4-day2.test.ts +227 -0
- package/src/__tests__/week4-day3.test.ts +323 -0
- package/src/__tests__/week4-day4.test.ts +210 -0
- package/src/__tests__/week4-day5.test.ts +503 -0
- package/src/__tests__/week5-day1.test.ts +152 -0
- package/src/__tests__/week5-day2.test.ts +222 -0
- package/src/__tests__/week5-day3.test.ts +297 -0
- package/src/__tests__/week5-day4.test.ts +306 -0
- package/src/__tests__/week5-day5.test.ts +345 -0
- package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
- package/src/auto-generate/context-collector.ts +47 -0
- package/src/auto-generate/flow-generator-client.ts +97 -0
- package/src/browser.ts +5 -0
- package/src/components/celebration.ts +44 -0
- package/src/components/checklist-css.ts +159 -0
- package/src/components/checklist.ts +295 -0
- package/src/components/modal-css.ts +96 -0
- package/src/components/modal.ts +171 -0
- package/src/components/shadow-host.ts +30 -0
- package/src/core/api-client.ts +39 -0
- package/src/core/api-flows.ts +204 -0
- package/src/core/config.ts +37 -0
- package/src/core/event-batcher.ts +169 -0
- package/src/core/sdk.ts +301 -0
- package/src/detection/user-detection.ts +55 -0
- package/src/index.ts +95 -0
- package/src/snapshot/dom-collector.ts +193 -0
- package/src/snapshot/sender.ts +105 -0
- package/src/storage/event-listener.ts +59 -0
- package/src/storage/progress-tracker.ts +78 -0
- package/src/styles/checklist-css.ts +159 -0
- package/src/styles/checklist.css +166 -0
- package/src/styles/modal-css.ts +96 -0
- package/src/styles/modal.css +102 -0
- package/src/utils/dom.ts +49 -0
- package/src/utils/fingerprint.ts +20 -0
- package/src/utils/logger.ts +17 -0
- package/src/v2/positioner.ts +105 -0
- package/src/v2/renderer.ts +287 -0
- package/src/v2/styles.ts +89 -0
- package/src/v2/types.ts +53 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +28 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
3
|
+
import { renderModal, showModal, hideModal } from '../components/modal.js';
|
|
4
|
+
import type { FlowStep } from '@onboardme/types';
|
|
5
|
+
|
|
6
|
+
const PRODUCT_ID = 'test-product';
|
|
7
|
+
const SESSION_KEY = `onboardme_modal_shown_${PRODUCT_ID}`;
|
|
8
|
+
|
|
9
|
+
function makeStep(overrides: Partial<FlowStep> = {}): FlowStep {
|
|
10
|
+
return {
|
|
11
|
+
id: 'step-1',
|
|
12
|
+
type: 'modal',
|
|
13
|
+
order: 1,
|
|
14
|
+
title: 'Welcome',
|
|
15
|
+
body: 'Let us show you around.',
|
|
16
|
+
trigger: { type: 'page_load' },
|
|
17
|
+
dismissible: true,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('Week 2 Day 3 — Modal show/hide logic (modal.ts)', () => {
|
|
23
|
+
let shadow: ShadowRoot;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
document.body.innerHTML = '';
|
|
27
|
+
sessionStorage.clear();
|
|
28
|
+
shadow = getShadowRoot();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// --- showModal: session guard ---
|
|
32
|
+
|
|
33
|
+
it('showModal renders the modal for a fresh session', () => {
|
|
34
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
35
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('showModal sets the sessionStorage flag', () => {
|
|
39
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
40
|
+
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('showModal is a no-op if the session flag is already set', () => {
|
|
44
|
+
sessionStorage.setItem(SESSION_KEY, '1');
|
|
45
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
46
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('calling showModal twice only renders one modal', () => {
|
|
50
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
51
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
52
|
+
expect(shadow.querySelectorAll('[data-onboardme="overlay"]').length).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// --- hideModal ---
|
|
56
|
+
|
|
57
|
+
it('hideModal removes the overlay from the shadow root', () => {
|
|
58
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
59
|
+
hideModal(shadow);
|
|
60
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('hideModal does NOT clear the sessionStorage flag', () => {
|
|
64
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
65
|
+
hideModal(shadow);
|
|
66
|
+
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('hideModal is safe to call when no modal is present (no throw)', () => {
|
|
70
|
+
expect(() => hideModal(shadow)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// --- Keyboard: Escape ---
|
|
74
|
+
|
|
75
|
+
it('pressing Escape closes the modal', () => {
|
|
76
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
77
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
78
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('pressing a non-Escape key does not close the modal', () => {
|
|
82
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
83
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
84
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('Escape closes modal even when dismissible is false', () => {
|
|
88
|
+
showModal(makeStep({ dismissible: false }), shadow, PRODUCT_ID, 'test-flow');
|
|
89
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
90
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keyboard listener is removed after hideModal (Escape no longer fires)', () => {
|
|
94
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
95
|
+
hideModal(shadow);
|
|
96
|
+
// Re-render a fresh modal manually (bypassing session guard) to have something to check
|
|
97
|
+
renderModal(makeStep(), shadow);
|
|
98
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
99
|
+
// Listener was removed — overlay should still be present
|
|
100
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// --- Click handlers ---
|
|
104
|
+
|
|
105
|
+
it('clicking the primary CTA closes the modal', () => {
|
|
106
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
107
|
+
const btn = shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]');
|
|
108
|
+
btn?.click();
|
|
109
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('clicking the skip button closes the modal', () => {
|
|
113
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
114
|
+
const btn = shadow.querySelector<HTMLButtonElement>('[data-onboardme="skip-cta"]');
|
|
115
|
+
btn?.click();
|
|
116
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- Focus ---
|
|
120
|
+
|
|
121
|
+
it('showModal moves focus to the primary CTA button', () => {
|
|
122
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
123
|
+
const primaryCta = shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]');
|
|
124
|
+
// Inside a Shadow DOM, document.activeElement returns the shadow HOST.
|
|
125
|
+
// shadow.activeElement returns the focused element within the shadow root.
|
|
126
|
+
expect(shadow.activeElement).toBe(primaryCta);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { runSDK, _resetSDK } from '../core/sdk.js';
|
|
3
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
4
|
+
import type { FlowConfig } from '@onboardme/types';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function makeFlow(overrides: Partial<FlowConfig> = {}): FlowConfig {
|
|
11
|
+
return {
|
|
12
|
+
id: 'flow-1',
|
|
13
|
+
name: 'Onboarding',
|
|
14
|
+
completionGoal: 'goal_reached',
|
|
15
|
+
priority: 1,
|
|
16
|
+
steps: [
|
|
17
|
+
{
|
|
18
|
+
id: 'step-modal',
|
|
19
|
+
type: 'modal',
|
|
20
|
+
order: 1,
|
|
21
|
+
title: 'Welcome',
|
|
22
|
+
body: 'Let us show you around.',
|
|
23
|
+
trigger: { type: 'page_load' },
|
|
24
|
+
dismissible: true,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PRODUCT_ID = 'w2d4-product';
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
localStorage.clear();
|
|
35
|
+
sessionStorage.clear();
|
|
36
|
+
document.body.innerHTML = '';
|
|
37
|
+
_resetSDK();
|
|
38
|
+
_resetIndex();
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tests
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('Week 2 Day 4 — Modal wired into SDK orchestrator (sdk.ts)', () => {
|
|
52
|
+
|
|
53
|
+
// --- New user: modal deferred show ---
|
|
54
|
+
|
|
55
|
+
it('schedules showModal via setTimeout for a new user with a modal step', () => {
|
|
56
|
+
const spy = vi.spyOn(globalThis, 'setTimeout');
|
|
57
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
58
|
+
expect(spy).toHaveBeenCalledWith(expect.any(Function), 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('modal is NOT in DOM immediately after runSDK (deferred)', () => {
|
|
62
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
63
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
64
|
+
// Shadow root may not exist yet — either way no overlay should be present
|
|
65
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('modal appears in DOM after timers flush (new user)', () => {
|
|
69
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
70
|
+
vi.runAllTimers();
|
|
71
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
72
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('modal title matches the first modal step', () => {
|
|
76
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
77
|
+
vi.runAllTimers();
|
|
78
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
79
|
+
expect(shadow?.querySelector('.om-modal__title')?.textContent).toBe('Welcome');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Returning user: no modal ---
|
|
83
|
+
|
|
84
|
+
it('does not schedule showModal for a returning user', () => {
|
|
85
|
+
localStorage.setItem(`onboardme_seen_${PRODUCT_ID}`, '1');
|
|
86
|
+
const spy = vi.spyOn(globalThis, 'setTimeout');
|
|
87
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
88
|
+
// setTimeout may be called by other code; ensure modal never appears
|
|
89
|
+
vi.runAllTimers();
|
|
90
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
91
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- No modal step ---
|
|
95
|
+
|
|
96
|
+
it('logs and skips when no modal step exists in any flow', () => {
|
|
97
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
98
|
+
const flowWithNoModal = makeFlow({
|
|
99
|
+
steps: [
|
|
100
|
+
{
|
|
101
|
+
id: 'step-tooltip',
|
|
102
|
+
type: 'tooltip',
|
|
103
|
+
order: 1,
|
|
104
|
+
title: 'Tip',
|
|
105
|
+
body: 'Some tip.',
|
|
106
|
+
trigger: { type: 'page_load' },
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
runSDK({ productId: PRODUCT_ID, flows: [flowWithNoModal] });
|
|
111
|
+
vi.runAllTimers();
|
|
112
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
113
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not throw when flows array is empty', () => {
|
|
117
|
+
expect(() => {
|
|
118
|
+
runSDK({ productId: PRODUCT_ID, flows: [] });
|
|
119
|
+
vi.runAllTimers();
|
|
120
|
+
}).not.toThrow();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Double-init guard ---
|
|
124
|
+
|
|
125
|
+
it('runSDK returns null on second call (initialised guard)', () => {
|
|
126
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
127
|
+
const second = runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
128
|
+
expect(second).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('modal appears only once when runSDK is called twice', () => {
|
|
132
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
133
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
134
|
+
vi.runAllTimers();
|
|
135
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
136
|
+
const overlays = shadow?.querySelectorAll('[data-onboardme="overlay"]') ?? [];
|
|
137
|
+
expect(overlays.length).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('OnboardMe.init() called twice shows modal only once', () => {
|
|
141
|
+
OnboardMe.init({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
142
|
+
OnboardMe.init({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
143
|
+
vi.runAllTimers();
|
|
144
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
145
|
+
const overlays = shadow?.querySelectorAll('[data-onboardme="overlay"]') ?? [];
|
|
146
|
+
expect(overlays.length).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// --- First modal step selection ---
|
|
150
|
+
|
|
151
|
+
it('picks the step with the lowest order when multiple modal steps exist', () => {
|
|
152
|
+
const flowWithTwo = makeFlow({
|
|
153
|
+
steps: [
|
|
154
|
+
{
|
|
155
|
+
id: 'step-b',
|
|
156
|
+
type: 'modal',
|
|
157
|
+
order: 2,
|
|
158
|
+
title: 'Second Modal',
|
|
159
|
+
body: 'B',
|
|
160
|
+
trigger: { type: 'page_load' },
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'step-a',
|
|
164
|
+
type: 'modal',
|
|
165
|
+
order: 1,
|
|
166
|
+
title: 'First Modal',
|
|
167
|
+
body: 'A',
|
|
168
|
+
trigger: { type: 'page_load' },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
runSDK({ productId: PRODUCT_ID, flows: [flowWithTwo] });
|
|
173
|
+
vi.runAllTimers();
|
|
174
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
175
|
+
expect(shadow?.querySelector('.om-modal__title')?.textContent).toBe('First Modal');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 2 Day 5 — Full integration tests + Week 2 verification checklist
|
|
3
|
+
*
|
|
4
|
+
* Exercises every item on the Week 2 checklist end-to-end:
|
|
5
|
+
* ✓ New user → modal appears (deferred via setTimeout)
|
|
6
|
+
* ✓ Returning user → nothing rendered
|
|
7
|
+
* ✓ CTA click → modal closes, session flag set
|
|
8
|
+
* ✓ Escape key → modal closes
|
|
9
|
+
* ✓ Same-tab reload → modal does not reappear (sessionStorage guard)
|
|
10
|
+
* ✓ New tab → modal appears again (fresh sessionStorage)
|
|
11
|
+
* ✓ dismissible: false → skip button hidden; Escape still works
|
|
12
|
+
* ✓ Host page styles → zero bleed into shadow root
|
|
13
|
+
* ✓ pnpm test → all unit tests pass (this file is the final gate)
|
|
14
|
+
*
|
|
15
|
+
* These tests wire together everything built in Days 1–4 and validate
|
|
16
|
+
* the system as a whole.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import { vi } from 'vitest';
|
|
21
|
+
import { runSDK, _resetSDK } from '../core/sdk.js';
|
|
22
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
23
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
24
|
+
import { renderModal, showModal, hideModal } from '../components/modal.js';
|
|
25
|
+
import type { FlowConfig, FlowStep } from '@onboardme/types';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const PRODUCT_ID = 'w2-checklist';
|
|
32
|
+
const SESSION_KEY = `onboardme_modal_shown_${PRODUCT_ID}`;
|
|
33
|
+
|
|
34
|
+
function makeStep(overrides: Partial<FlowStep> = {}): FlowStep {
|
|
35
|
+
return {
|
|
36
|
+
id: 'step-welcome',
|
|
37
|
+
type: 'modal',
|
|
38
|
+
order: 1,
|
|
39
|
+
title: 'Welcome to the app',
|
|
40
|
+
body: 'Here is a quick tour.',
|
|
41
|
+
trigger: { type: 'page_load' },
|
|
42
|
+
dismissible: true,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeFlow(stepOverrides: Partial<FlowStep> = {}): FlowConfig {
|
|
48
|
+
return {
|
|
49
|
+
id: 'flow-welcome',
|
|
50
|
+
name: 'Welcome flow',
|
|
51
|
+
completionGoal: 'tour_completed',
|
|
52
|
+
priority: 1,
|
|
53
|
+
steps: [makeStep(stepOverrides)],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
localStorage.clear();
|
|
59
|
+
sessionStorage.clear();
|
|
60
|
+
document.body.innerHTML = '';
|
|
61
|
+
_resetSDK();
|
|
62
|
+
_resetIndex();
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.useRealTimers();
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Checklist item 1: New user (fresh localStorage) → modal appears within 500ms
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('Checklist: new user → modal appears', () => {
|
|
76
|
+
it('modal is absent immediately after init (deferred)', () => {
|
|
77
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
78
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
79
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('modal appears after timer flush (well within 500ms)', () => {
|
|
83
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
84
|
+
vi.runAllTimers();
|
|
85
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
86
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('modal displays the correct title and body text', () => {
|
|
90
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
91
|
+
vi.runAllTimers();
|
|
92
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
93
|
+
expect(shadow.querySelector('.om-modal__title')?.textContent).toBe('Welcome to the app');
|
|
94
|
+
expect(shadow.querySelector('.om-modal__body')?.textContent).toBe('Here is a quick tour.');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Checklist item 2: Returning user → nothing rendered
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('Checklist: returning user → no modal', () => {
|
|
103
|
+
it('no modal rendered for a returning user (localStorage seen flag set)', () => {
|
|
104
|
+
localStorage.setItem(`onboardme_seen_${PRODUCT_ID}`, '1');
|
|
105
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
106
|
+
vi.runAllTimers();
|
|
107
|
+
const shadow = document.getElementById('onboardme-root')?.shadowRoot;
|
|
108
|
+
expect(shadow?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Checklist item 3: CTA click → modal closes, session flag set
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe('Checklist: CTA click → modal closes + session flag', () => {
|
|
117
|
+
it('clicking primary CTA removes the modal from the DOM', () => {
|
|
118
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
119
|
+
vi.runAllTimers();
|
|
120
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
121
|
+
shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
122
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('session flag is set after CTA click', () => {
|
|
126
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
127
|
+
vi.runAllTimers();
|
|
128
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
129
|
+
shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
130
|
+
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('clicking skip button also closes the modal', () => {
|
|
134
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
135
|
+
vi.runAllTimers();
|
|
136
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
137
|
+
shadow.querySelector<HTMLButtonElement>('[data-onboardme="skip-cta"]')!.click();
|
|
138
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Checklist item 4: Escape key → modal closes
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe('Checklist: Escape key → modal closes', () => {
|
|
147
|
+
it('pressing Escape removes the modal overlay', () => {
|
|
148
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
149
|
+
vi.runAllTimers();
|
|
150
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
151
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
152
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('pressing a non-Escape key does not close the modal', () => {
|
|
156
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
157
|
+
vi.runAllTimers();
|
|
158
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
|
159
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
160
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Checklist item 5: Same-tab "reload" → modal does not reappear
|
|
166
|
+
// (simulated by calling showModal twice with the session flag already set)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe('Checklist: same-tab session guard → modal does not reappear', () => {
|
|
170
|
+
it('showModal is a no-op when sessionStorage flag already set', () => {
|
|
171
|
+
const shadow = getShadowRoot();
|
|
172
|
+
sessionStorage.setItem(SESSION_KEY, '1');
|
|
173
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
174
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('session flag survives hideModal — second showModal is still blocked', () => {
|
|
178
|
+
const shadow = getShadowRoot();
|
|
179
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
180
|
+
hideModal(shadow);
|
|
181
|
+
// Session flag is still set — re-showing should be blocked
|
|
182
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
183
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Checklist item 6: New tab → modal appears again
|
|
189
|
+
// (sessionStorage is per-tab; simulated by clearing it before showModal)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('Checklist: new tab (fresh session) → modal appears again', () => {
|
|
193
|
+
it('modal shows when sessionStorage is clear (fresh tab)', () => {
|
|
194
|
+
const shadow = getShadowRoot();
|
|
195
|
+
sessionStorage.clear(); // simulates a new browser tab
|
|
196
|
+
showModal(makeStep(), shadow, PRODUCT_ID, 'test-flow');
|
|
197
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Checklist item 7: dismissible: false → skip hidden; Escape still works
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
describe('Checklist: dismissible: false', () => {
|
|
206
|
+
it('skip button is not rendered when dismissible is false', () => {
|
|
207
|
+
const shadow = getShadowRoot();
|
|
208
|
+
showModal(makeStep({ dismissible: false }), shadow, PRODUCT_ID, 'test-flow');
|
|
209
|
+
expect(shadow.querySelector('[data-onboardme="skip-cta"]')).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('Escape still closes the modal when dismissible is false', () => {
|
|
213
|
+
const shadow = getShadowRoot();
|
|
214
|
+
showModal(makeStep({ dismissible: false }), shadow, PRODUCT_ID, 'test-flow');
|
|
215
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
216
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Checklist item 8: Host page styles → zero bleed into shadow root
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
describe('Checklist: style isolation', () => {
|
|
225
|
+
it('modal CSS classes do not exist on the host document body', () => {
|
|
226
|
+
runSDK({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
227
|
+
vi.runAllTimers();
|
|
228
|
+
expect(document.body.querySelector('.om-overlay')).toBeNull();
|
|
229
|
+
expect(document.body.querySelector('.om-modal')).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('modal CSS is injected into shadow root only, not document.head', () => {
|
|
233
|
+
const shadow = getShadowRoot();
|
|
234
|
+
renderModal(makeStep(), shadow);
|
|
235
|
+
const headStyles = Array.from(document.head.querySelectorAll('style'));
|
|
236
|
+
const leaked = headStyles.some((s) => s.textContent?.includes('.om-overlay'));
|
|
237
|
+
expect(leaked).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('shadow root style contains om-overlay and om-modal rules', () => {
|
|
241
|
+
const shadow = getShadowRoot();
|
|
242
|
+
renderModal(makeStep(), shadow);
|
|
243
|
+
const shadowStyle = shadow.querySelector<HTMLStyleElement>('style[data-onboardme="styles"]');
|
|
244
|
+
expect(shadowStyle?.textContent).toContain('.om-overlay');
|
|
245
|
+
expect(shadowStyle?.textContent).toContain('.om-modal');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Checklist item 9: CTA / skip defaults
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe('Checklist: CTA label defaults', () => {
|
|
254
|
+
it('primary CTA defaults to "Get started" when primaryCta is absent', () => {
|
|
255
|
+
const shadow = getShadowRoot();
|
|
256
|
+
renderModal(makeStep(), shadow);
|
|
257
|
+
expect(
|
|
258
|
+
shadow.querySelector('[data-onboardme="primary-cta"]')?.textContent,
|
|
259
|
+
).toBe('Get started');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('skip link defaults to "Skip for now" when secondaryCta is absent', () => {
|
|
263
|
+
const shadow = getShadowRoot();
|
|
264
|
+
renderModal(makeStep(), shadow);
|
|
265
|
+
expect(
|
|
266
|
+
shadow.querySelector('[data-onboardme="skip-cta"]')?.textContent,
|
|
267
|
+
).toBe('Skip for now');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Full end-to-end: OnboardMe.init() → modal shown → dismissed → gone
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe('End-to-end: full init → show → dismiss flow', () => {
|
|
276
|
+
it('complete lifecycle: init, timer flush, CTA click, modal gone', () => {
|
|
277
|
+
OnboardMe.init({ productId: PRODUCT_ID, flows: [makeFlow()] });
|
|
278
|
+
|
|
279
|
+
// After init — not yet visible
|
|
280
|
+
expect(document.getElementById('onboardme-root')?.shadowRoot?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
281
|
+
|
|
282
|
+
// After timer flush — visible
|
|
283
|
+
vi.runAllTimers();
|
|
284
|
+
const shadow = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
285
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
286
|
+
|
|
287
|
+
// CTA click — gone
|
|
288
|
+
shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
289
|
+
expect(shadow.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
290
|
+
|
|
291
|
+
// Session flag remains — would not reappear in same tab
|
|
292
|
+
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
|
293
|
+
});
|
|
294
|
+
});
|