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.
Files changed (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. 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
+ });