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,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 4 Day 3 — Wire All SDK Actions into Event Stream
|
|
3
|
+
*
|
|
4
|
+
* Verifies that every user-facing SDK action enqueues the correct event:
|
|
5
|
+
*
|
|
6
|
+
* runSDK:
|
|
7
|
+
* ✓ configureBatcher is called (events have the correct anonymousId after init)
|
|
8
|
+
*
|
|
9
|
+
* showModal:
|
|
10
|
+
* ✓ Enqueues flow_started with correct flowId
|
|
11
|
+
* ✓ Enqueues step_viewed with correct stepId + stepIndex: 0
|
|
12
|
+
* ✓ CTA click → step_completed (stepId + flowId correct)
|
|
13
|
+
* ✓ Skip click → step_skipped
|
|
14
|
+
* ✓ Escape key → step_skipped
|
|
15
|
+
* ✓ Session guard prevents duplicate flow_started on second call
|
|
16
|
+
*
|
|
17
|
+
* renderChecklist:
|
|
18
|
+
* ✓ Enqueues step_viewed with checklist stepId + flowId
|
|
19
|
+
* ✓ Item click → checklist_item_done with itemId as stepId
|
|
20
|
+
* ✓ Already-done item click does NOT emit checklist_item_done
|
|
21
|
+
* ✓ Auto-check via track() → checklist_item_done
|
|
22
|
+
*
|
|
23
|
+
* identify() + track():
|
|
24
|
+
* ✓ Events before identify() have no userId
|
|
25
|
+
* ✓ Events after identify() carry the userId
|
|
26
|
+
* ✓ track() enqueues step_action_taken
|
|
27
|
+
* ✓ step_action_taken carries eventName and extra properties
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
31
|
+
import {
|
|
32
|
+
configureBatcher,
|
|
33
|
+
_resetBatcher,
|
|
34
|
+
_getQueue,
|
|
35
|
+
_detachFlushListeners,
|
|
36
|
+
setBatcherUserId,
|
|
37
|
+
pushEvent,
|
|
38
|
+
} from '../core/event-batcher.js';
|
|
39
|
+
import { showModal } from '../components/modal.js';
|
|
40
|
+
import { renderChecklist } from '../components/checklist.js';
|
|
41
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
42
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
43
|
+
import { _resetSDK } from '../core/sdk.js';
|
|
44
|
+
import type { FlowStep, FlowConfig } from '@onboardme/types';
|
|
45
|
+
import type { Progress } from '../storage/progress-tracker.js';
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Fixtures
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const FLOW_ID = 'flow-welcome';
|
|
52
|
+
const MODAL_STEP_ID = 'step-modal-1';
|
|
53
|
+
const CHECKLIST_STEP_ID = 'step-checklist-1';
|
|
54
|
+
const ITEM_ID_1 = 'item-profile';
|
|
55
|
+
const ITEM_ID_2 = 'item-invite';
|
|
56
|
+
|
|
57
|
+
const modalStep: FlowStep = {
|
|
58
|
+
id: MODAL_STEP_ID,
|
|
59
|
+
type: 'modal',
|
|
60
|
+
order: 1,
|
|
61
|
+
title: 'Welcome',
|
|
62
|
+
body: 'Let us show you around.',
|
|
63
|
+
trigger: { type: 'page_load' },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const flow: FlowConfig = {
|
|
67
|
+
id: FLOW_ID,
|
|
68
|
+
name: 'Welcome Flow',
|
|
69
|
+
priority: 1,
|
|
70
|
+
completionGoal: 'setup_complete',
|
|
71
|
+
steps: [
|
|
72
|
+
modalStep,
|
|
73
|
+
{
|
|
74
|
+
id: CHECKLIST_STEP_ID,
|
|
75
|
+
type: 'checklist',
|
|
76
|
+
order: 2,
|
|
77
|
+
title: 'Get started',
|
|
78
|
+
body: '',
|
|
79
|
+
trigger: { type: 'page_load' },
|
|
80
|
+
items: [
|
|
81
|
+
{ id: ITEM_ID_1, label: 'Complete profile', order: 1, completionEvent: 'profile_completed' },
|
|
82
|
+
{ id: ITEM_ID_2, label: 'Invite a teammate', order: 2 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const EMPTY_PROGRESS: Progress = { completedSteps: [], lastUpdated: 0 };
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Helpers
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function freshShadow(): ShadowRoot {
|
|
95
|
+
document.body.innerHTML = '';
|
|
96
|
+
return getShadowRoot();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Setup / teardown
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
// Preserve the original track so tests that patch it can restore it
|
|
104
|
+
const originalTrack = OnboardMe.track;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
_resetBatcher();
|
|
108
|
+
configureBatcher('http://localhost:3001/events', 'anon-test');
|
|
109
|
+
sessionStorage.clear();
|
|
110
|
+
localStorage.clear();
|
|
111
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
112
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
OnboardMe.track = originalTrack;
|
|
117
|
+
_detachFlushListeners();
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
document.body.innerHTML = '';
|
|
120
|
+
sessionStorage.clear();
|
|
121
|
+
localStorage.clear();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// runSDK wires batcher
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe('Week 4 Day 3 — runSDK wires batcher', () => {
|
|
129
|
+
it('after init() events carry the anonymousId set by configureBatcher', () => {
|
|
130
|
+
_resetSDK();
|
|
131
|
+
_resetIndex();
|
|
132
|
+
_resetBatcher();
|
|
133
|
+
|
|
134
|
+
// Returning user — no modal/checklist side effects
|
|
135
|
+
localStorage.setItem('onboardme_seen_wire-test', '1');
|
|
136
|
+
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
OnboardMe.init({ productId: 'wire-test', flows: [] });
|
|
139
|
+
vi.runAllTimers();
|
|
140
|
+
vi.useRealTimers();
|
|
141
|
+
|
|
142
|
+
// Push an event after init — batcher should have been configured with the anon ID
|
|
143
|
+
pushEvent('flow_started');
|
|
144
|
+
const event = _getQueue().find((e) => e.eventType === 'flow_started');
|
|
145
|
+
expect(event?.anonymousId).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// showModal events
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('Week 4 Day 3 — showModal events', () => {
|
|
154
|
+
it('enqueues flow_started when the modal is shown', () => {
|
|
155
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
156
|
+
expect(_getQueue().some((e) => e.eventType === 'flow_started')).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('flow_started carries the correct flowId', () => {
|
|
160
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
161
|
+
const event = _getQueue().find((e) => e.eventType === 'flow_started');
|
|
162
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('enqueues step_viewed when the modal is shown', () => {
|
|
166
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
167
|
+
expect(_getQueue().some((e) => e.eventType === 'step_viewed')).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('step_viewed carries correct stepId, flowId, and stepIndex 0', () => {
|
|
171
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
172
|
+
const event = _getQueue().find((e) => e.eventType === 'step_viewed');
|
|
173
|
+
expect(event?.stepId).toBe(MODAL_STEP_ID);
|
|
174
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
175
|
+
expect(event?.stepIndex).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('clicking the primary CTA enqueues step_completed', () => {
|
|
179
|
+
const sr = freshShadow();
|
|
180
|
+
showModal(modalStep, sr, 'prod-1', FLOW_ID);
|
|
181
|
+
sr.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')?.click();
|
|
182
|
+
expect(_getQueue().some((e) => e.eventType === 'step_completed')).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('step_completed carries correct stepId and flowId', () => {
|
|
186
|
+
const sr = freshShadow();
|
|
187
|
+
showModal(modalStep, sr, 'prod-1', FLOW_ID);
|
|
188
|
+
sr.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')?.click();
|
|
189
|
+
const event = _getQueue().find((e) => e.eventType === 'step_completed');
|
|
190
|
+
expect(event?.stepId).toBe(MODAL_STEP_ID);
|
|
191
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('clicking the skip button enqueues step_skipped', () => {
|
|
195
|
+
const sr = freshShadow();
|
|
196
|
+
showModal(modalStep, sr, 'prod-1', FLOW_ID);
|
|
197
|
+
sr.querySelector<HTMLButtonElement>('[data-onboardme="skip-cta"]')?.click();
|
|
198
|
+
expect(_getQueue().some((e) => e.eventType === 'step_skipped')).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('pressing Escape enqueues step_skipped', () => {
|
|
202
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
203
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
204
|
+
expect(_getQueue().some((e) => e.eventType === 'step_skipped')).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('session guard prevents a second flow_started on duplicate showModal call', () => {
|
|
208
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
209
|
+
// Second call blocked by sessionStorage guard
|
|
210
|
+
showModal(modalStep, freshShadow(), 'prod-1', FLOW_ID);
|
|
211
|
+
const flowStarted = _getQueue().filter((e) => e.eventType === 'flow_started');
|
|
212
|
+
expect(flowStarted).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// renderChecklist events
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe('Week 4 Day 3 — renderChecklist events', () => {
|
|
221
|
+
it('enqueues step_viewed when the checklist is rendered', () => {
|
|
222
|
+
renderChecklist(flow, EMPTY_PROGRESS, freshShadow(), false, 'prod-1', FLOW_ID);
|
|
223
|
+
expect(_getQueue().some((e) => e.eventType === 'step_viewed')).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('step_viewed carries the checklist stepId and flowId', () => {
|
|
227
|
+
renderChecklist(flow, EMPTY_PROGRESS, freshShadow(), false, 'prod-1', FLOW_ID);
|
|
228
|
+
const event = _getQueue().find((e) => e.eventType === 'step_viewed');
|
|
229
|
+
expect(event?.stepId).toBe(CHECKLIST_STEP_ID);
|
|
230
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('clicking an unchecked item enqueues checklist_item_done', () => {
|
|
234
|
+
const sr = freshShadow();
|
|
235
|
+
renderChecklist(flow, EMPTY_PROGRESS, sr, false, 'prod-1', FLOW_ID);
|
|
236
|
+
sr.querySelector<HTMLLIElement>(`[data-item-id="${ITEM_ID_1}"]`)?.click();
|
|
237
|
+
expect(_getQueue().some((e) => e.eventType === 'checklist_item_done')).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('checklist_item_done carries the itemId as stepId and the flowId', () => {
|
|
241
|
+
const sr = freshShadow();
|
|
242
|
+
renderChecklist(flow, EMPTY_PROGRESS, sr, false, 'prod-1', FLOW_ID);
|
|
243
|
+
sr.querySelector<HTMLLIElement>(`[data-item-id="${ITEM_ID_1}"]`)?.click();
|
|
244
|
+
const event = _getQueue().find((e) => e.eventType === 'checklist_item_done');
|
|
245
|
+
expect(event?.stepId).toBe(ITEM_ID_1);
|
|
246
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('clicking an already-done item does NOT emit checklist_item_done', () => {
|
|
250
|
+
const sr = freshShadow();
|
|
251
|
+
const doneProgress: Progress = { completedSteps: [ITEM_ID_1], lastUpdated: Date.now() };
|
|
252
|
+
renderChecklist(flow, doneProgress, sr, false, 'prod-1', FLOW_ID);
|
|
253
|
+
sr.querySelector<HTMLLIElement>(`[data-item-id="${ITEM_ID_1}"]`)?.click();
|
|
254
|
+
const done = _getQueue().filter(
|
|
255
|
+
(e) => e.eventType === 'checklist_item_done' && e.stepId === ITEM_ID_1,
|
|
256
|
+
);
|
|
257
|
+
expect(done).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('auto-check via OnboardMe.track enqueues checklist_item_done', () => {
|
|
261
|
+
const sr = freshShadow();
|
|
262
|
+
renderChecklist(flow, EMPTY_PROGRESS, sr, false, 'prod-1', FLOW_ID);
|
|
263
|
+
// watchCompletionEvents patches OnboardMe.track; firing 'profile_completed' auto-checks ITEM_ID_1
|
|
264
|
+
OnboardMe.track('profile_completed');
|
|
265
|
+
expect(
|
|
266
|
+
_getQueue().some((e) => e.eventType === 'checklist_item_done' && e.stepId === ITEM_ID_1),
|
|
267
|
+
).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// identify() + track() wiring
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe('Week 4 Day 3 — identify() wiring', () => {
|
|
276
|
+
it('events pushed before identify() have no userId', () => {
|
|
277
|
+
pushEvent('flow_started');
|
|
278
|
+
expect(_getQueue().find((e) => e.eventType === 'flow_started')?.userId).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('events pushed after setBatcherUserId (identify wiring) carry the userId', () => {
|
|
282
|
+
setBatcherUserId('user-abc');
|
|
283
|
+
pushEvent('step_viewed');
|
|
284
|
+
expect(_getQueue().find((e) => e.eventType === 'step_viewed')?.userId).toBe('user-abc');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('Week 4 Day 3 — track() wiring', () => {
|
|
289
|
+
const PRODUCT = 'track-wire-test';
|
|
290
|
+
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
localStorage.setItem(`onboardme_seen_${PRODUCT}`, '1'); // returning user
|
|
293
|
+
_resetSDK();
|
|
294
|
+
_resetIndex();
|
|
295
|
+
_resetBatcher();
|
|
296
|
+
vi.useFakeTimers();
|
|
297
|
+
OnboardMe.init({ productId: PRODUCT, flows: [] });
|
|
298
|
+
vi.runAllTimers();
|
|
299
|
+
vi.useRealTimers();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
afterEach(() => {
|
|
303
|
+
localStorage.removeItem(`onboardme_seen_${PRODUCT}`);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('track() enqueues a step_action_taken event', () => {
|
|
307
|
+
OnboardMe.track('button_clicked');
|
|
308
|
+
expect(_getQueue().some((e) => e.eventType === 'step_action_taken')).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('step_action_taken properties contain the eventName', () => {
|
|
312
|
+
OnboardMe.track('button_clicked');
|
|
313
|
+
const event = _getQueue().find((e) => e.eventType === 'step_action_taken');
|
|
314
|
+
expect(event?.properties?.['eventName']).toBe('button_clicked');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('step_action_taken properties include extra properties from the caller', () => {
|
|
318
|
+
OnboardMe.track('button_clicked', { color: 'blue', count: 3 });
|
|
319
|
+
const event = _getQueue().find((e) => e.eventType === 'step_action_taken');
|
|
320
|
+
expect(event?.properties?.['color']).toBe('blue');
|
|
321
|
+
expect(event?.properties?.['count']).toBe(3);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 4 Day 4 — Completion Goal + Celebration Banner
|
|
3
|
+
*
|
|
4
|
+
* Verifies that calling OnboardMe.track(completionGoal) triggers the full
|
|
5
|
+
* goal-completion sequence:
|
|
6
|
+
*
|
|
7
|
+
* goal detection:
|
|
8
|
+
* ✓ track(completionGoal) enqueues flow_completed with correct flowId
|
|
9
|
+
* ✓ track(completionGoal) enqueues goal_reached with correct flowId
|
|
10
|
+
* ✓ track(otherEvent) does NOT enqueue flow_completed or goal_reached
|
|
11
|
+
* ✓ track(completionGoal) called twice → goal_reached fires only once (localStorage guard)
|
|
12
|
+
* ✓ flow_completed fires only once on repeated track(completionGoal) calls
|
|
13
|
+
*
|
|
14
|
+
* celebration banner:
|
|
15
|
+
* ✓ track(completionGoal) renders .om-celebration inside the shadow root
|
|
16
|
+
* ✓ banner text is "🎉 You're all set!"
|
|
17
|
+
* ✓ banner auto-removes after 3 seconds
|
|
18
|
+
* ✓ banner does NOT appear when track(otherEvent) is called
|
|
19
|
+
* ✓ second track(completionGoal) after guard fires does NOT render another banner
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
configureBatcher,
|
|
25
|
+
_resetBatcher,
|
|
26
|
+
_getQueue,
|
|
27
|
+
_detachFlushListeners,
|
|
28
|
+
} from '../core/event-batcher.js';
|
|
29
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
30
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
31
|
+
import { _resetSDK } from '../core/sdk.js';
|
|
32
|
+
import type { FlowConfig } from '@onboardme/types';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Fixtures
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const PRODUCT = 'goal-test-product';
|
|
39
|
+
const FLOW_ID = 'flow-onboarding';
|
|
40
|
+
const COMPLETION_GOAL = 'created_first_project';
|
|
41
|
+
|
|
42
|
+
const flow: FlowConfig = {
|
|
43
|
+
id: FLOW_ID,
|
|
44
|
+
name: 'Onboarding Flow',
|
|
45
|
+
priority: 1,
|
|
46
|
+
completionGoal: COMPLETION_GOAL,
|
|
47
|
+
steps: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function initSDK() {
|
|
55
|
+
_resetSDK();
|
|
56
|
+
_resetIndex();
|
|
57
|
+
_resetBatcher();
|
|
58
|
+
// returning user — no modal/checklist side-effects
|
|
59
|
+
localStorage.setItem(`onboardme_seen_${PRODUCT}`, '1');
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
OnboardMe.init({ productId: PRODUCT, flows: [flow] });
|
|
62
|
+
vi.runAllTimers();
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shadowRoot(): ShadowRoot {
|
|
67
|
+
return getShadowRoot();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Setup / teardown
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
document.body.innerHTML = '';
|
|
76
|
+
_resetBatcher();
|
|
77
|
+
configureBatcher('http://localhost:3001/events', 'anon-test');
|
|
78
|
+
localStorage.clear();
|
|
79
|
+
sessionStorage.clear();
|
|
80
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
81
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
_detachFlushListeners();
|
|
86
|
+
vi.restoreAllMocks();
|
|
87
|
+
document.body.innerHTML = '';
|
|
88
|
+
localStorage.clear();
|
|
89
|
+
sessionStorage.clear();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Goal detection
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('Week 4 Day 4 — goal detection', () => {
|
|
97
|
+
it('track(completionGoal) enqueues flow_completed', () => {
|
|
98
|
+
initSDK();
|
|
99
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
100
|
+
expect(_getQueue().some((e) => e.eventType === 'flow_completed')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('flow_completed carries the correct flowId', () => {
|
|
104
|
+
initSDK();
|
|
105
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
106
|
+
const event = _getQueue().find((e) => e.eventType === 'flow_completed');
|
|
107
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('track(completionGoal) enqueues goal_reached', () => {
|
|
111
|
+
initSDK();
|
|
112
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
113
|
+
expect(_getQueue().some((e) => e.eventType === 'goal_reached')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('goal_reached carries the correct flowId', () => {
|
|
117
|
+
initSDK();
|
|
118
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
119
|
+
const event = _getQueue().find((e) => e.eventType === 'goal_reached');
|
|
120
|
+
expect(event?.flowId).toBe(FLOW_ID);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('track(otherEvent) does NOT enqueue flow_completed', () => {
|
|
124
|
+
initSDK();
|
|
125
|
+
OnboardMe.track('some_other_event');
|
|
126
|
+
expect(_getQueue().some((e) => e.eventType === 'flow_completed')).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('track(otherEvent) does NOT enqueue goal_reached', () => {
|
|
130
|
+
initSDK();
|
|
131
|
+
OnboardMe.track('some_other_event');
|
|
132
|
+
expect(_getQueue().some((e) => e.eventType === 'goal_reached')).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('track(completionGoal) twice → goal_reached fires only once (localStorage guard)', () => {
|
|
136
|
+
initSDK();
|
|
137
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
138
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
139
|
+
const goalReached = _getQueue().filter((e) => e.eventType === 'goal_reached');
|
|
140
|
+
expect(goalReached).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('track(completionGoal) twice → flow_completed fires only once', () => {
|
|
144
|
+
initSDK();
|
|
145
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
146
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
147
|
+
const flowCompleted = _getQueue().filter((e) => e.eventType === 'flow_completed');
|
|
148
|
+
expect(flowCompleted).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('localStorage guard key is set after first track(completionGoal)', () => {
|
|
152
|
+
initSDK();
|
|
153
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
154
|
+
expect(localStorage.getItem(`onboardme_flow_done_${PRODUCT}_${FLOW_ID}`)).toBe('1');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Celebration banner
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
describe('Week 4 Day 4 — celebration banner', () => {
|
|
163
|
+
it('track(completionGoal) renders .om-celebration in the shadow root', () => {
|
|
164
|
+
initSDK();
|
|
165
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
166
|
+
expect(shadowRoot().querySelector('.om-celebration')).not.toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('banner text is "🎉 You\'re all set!"', () => {
|
|
170
|
+
initSDK();
|
|
171
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
172
|
+
const banner = shadowRoot().querySelector('.om-celebration');
|
|
173
|
+
expect(banner?.textContent).toBe("🎉 You're all set!");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('banner auto-removes after 3 seconds', () => {
|
|
177
|
+
initSDK();
|
|
178
|
+
vi.useFakeTimers();
|
|
179
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
180
|
+
expect(shadowRoot().querySelector('.om-celebration')).not.toBeNull();
|
|
181
|
+
vi.advanceTimersByTime(3000);
|
|
182
|
+
expect(shadowRoot().querySelector('.om-celebration')).toBeNull();
|
|
183
|
+
vi.useRealTimers();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('banner is still present before 3 seconds have elapsed', () => {
|
|
187
|
+
initSDK();
|
|
188
|
+
vi.useFakeTimers();
|
|
189
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
190
|
+
vi.advanceTimersByTime(2999);
|
|
191
|
+
expect(shadowRoot().querySelector('.om-celebration')).not.toBeNull();
|
|
192
|
+
vi.useRealTimers();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('track(otherEvent) does NOT render a celebration banner', () => {
|
|
196
|
+
initSDK();
|
|
197
|
+
OnboardMe.track('some_other_event');
|
|
198
|
+
expect(shadowRoot().querySelector('.om-celebration')).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('second track(completionGoal) after guard does NOT render another banner', () => {
|
|
202
|
+
initSDK();
|
|
203
|
+
vi.useFakeTimers();
|
|
204
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
205
|
+
vi.advanceTimersByTime(3000); // first banner removed
|
|
206
|
+
OnboardMe.track(COMPLETION_GOAL); // guard prevents second banner
|
|
207
|
+
expect(shadowRoot().querySelector('.om-celebration')).toBeNull();
|
|
208
|
+
vi.useRealTimers();
|
|
209
|
+
});
|
|
210
|
+
});
|