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,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 4 Day 5 — Full Test Suite + Phase 1 Verification
|
|
3
|
+
*
|
|
4
|
+
* Master integration test that exercises every item on the Phase 1
|
|
5
|
+
* completion checklist end-to-end through the public SDK API.
|
|
6
|
+
*
|
|
7
|
+
* Phase 1 checklist:
|
|
8
|
+
* ✓ New user → welcome modal appears within 500ms of init()
|
|
9
|
+
* ✓ Returning user → no modal rendered
|
|
10
|
+
* ✓ Modal dismiss → session flag set; modal does not reappear this session
|
|
11
|
+
* ✓ Checklist persistence → checked items survive page reload
|
|
12
|
+
* ✓ Auto-check → track(completionEvent) checks matching item without click
|
|
13
|
+
* ✓ Completion goal → track(completionGoal) fires flow_completed + goal_reached
|
|
14
|
+
* ✓ Double goal → track(completionGoal) called twice → goal_reached fires once
|
|
15
|
+
* ✓ Celebration → .om-celebration banner appears and auto-removes after 3s
|
|
16
|
+
* ✓ Event shape → events carry eventId, anonymousId, eventType, pageUrl, timestamp
|
|
17
|
+
* ✓ userId → absent before identify(); present after identify()
|
|
18
|
+
* ✓ Event stream → modal, checklist, goal events all land in the queue with correct types
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
22
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
23
|
+
import { _resetSDK } from '../core/sdk.js';
|
|
24
|
+
import {
|
|
25
|
+
configureBatcher,
|
|
26
|
+
_resetBatcher,
|
|
27
|
+
_getQueue,
|
|
28
|
+
_detachFlushListeners,
|
|
29
|
+
setBatcherUserId,
|
|
30
|
+
pushEvent,
|
|
31
|
+
} from '../core/event-batcher.js';
|
|
32
|
+
import {
|
|
33
|
+
loadProgress,
|
|
34
|
+
markStepComplete,
|
|
35
|
+
isStepComplete,
|
|
36
|
+
clearProgress,
|
|
37
|
+
} from '../storage/progress-tracker.js';
|
|
38
|
+
import { getShadowRoot } from '../components/shadow-host.js';
|
|
39
|
+
import { renderChecklist } from '../components/checklist.js';
|
|
40
|
+
import type { FlowConfig, ChecklistItem } from '@onboardme/types';
|
|
41
|
+
import type { Progress } from '../storage/progress-tracker.js';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Reference fixtures — one flow config used across the whole test file
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const PID = 'phase1-verify';
|
|
48
|
+
const FLOW_MODAL_ID = 'flow-welcome';
|
|
49
|
+
const FLOW_CHECKLIST_ID = 'flow-checklist';
|
|
50
|
+
const COMPLETION_GOAL = 'onboarding_complete';
|
|
51
|
+
|
|
52
|
+
const ITEMS: ChecklistItem[] = [
|
|
53
|
+
{ id: 'profile', label: 'Create your profile', order: 1, required: true, completionEvent: 'profile_created' },
|
|
54
|
+
{ id: 'teammate', label: 'Invite a teammate', order: 2, required: true, completionEvent: 'teammate_invited' },
|
|
55
|
+
{ id: 'project', label: 'Create first project', order: 3, required: true },
|
|
56
|
+
{ id: 'docs', label: 'Explore the docs', order: 4, required: false },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const MODAL_FLOW: FlowConfig = {
|
|
60
|
+
id: FLOW_MODAL_ID,
|
|
61
|
+
name: 'Welcome flow',
|
|
62
|
+
completionGoal: 'modal_complete',
|
|
63
|
+
priority: 1,
|
|
64
|
+
steps: [
|
|
65
|
+
{
|
|
66
|
+
id: 'step-welcome',
|
|
67
|
+
type: 'modal',
|
|
68
|
+
order: 1,
|
|
69
|
+
title: 'Welcome!',
|
|
70
|
+
body: 'Quick tour.',
|
|
71
|
+
trigger: { type: 'page_load' },
|
|
72
|
+
dismissible: true,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const CHECKLIST_FLOW: FlowConfig = {
|
|
78
|
+
id: FLOW_CHECKLIST_ID,
|
|
79
|
+
name: 'Getting started',
|
|
80
|
+
completionGoal: COMPLETION_GOAL,
|
|
81
|
+
priority: 2,
|
|
82
|
+
steps: [
|
|
83
|
+
{
|
|
84
|
+
id: 'cl-step',
|
|
85
|
+
type: 'checklist',
|
|
86
|
+
order: 1,
|
|
87
|
+
title: 'Getting started',
|
|
88
|
+
body: '',
|
|
89
|
+
trigger: { type: 'page_load' },
|
|
90
|
+
items: ITEMS,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const EMPTY: Progress = { completedSteps: [], lastUpdated: 0 };
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Full init for a new user (no returning flag in localStorage). */
|
|
102
|
+
function initNew(flows: FlowConfig[] = [MODAL_FLOW, CHECKLIST_FLOW]) {
|
|
103
|
+
_resetSDK();
|
|
104
|
+
_resetIndex();
|
|
105
|
+
_resetBatcher();
|
|
106
|
+
localStorage.clear();
|
|
107
|
+
sessionStorage.clear();
|
|
108
|
+
document.body.innerHTML = '';
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
OnboardMe.init({ productId: PID, flows });
|
|
111
|
+
vi.runAllTimers();
|
|
112
|
+
vi.useRealTimers();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Full init for a returning user. */
|
|
116
|
+
function initReturning(flows: FlowConfig[] = [MODAL_FLOW, CHECKLIST_FLOW]) {
|
|
117
|
+
_resetSDK();
|
|
118
|
+
_resetIndex();
|
|
119
|
+
_resetBatcher();
|
|
120
|
+
sessionStorage.clear();
|
|
121
|
+
document.body.innerHTML = '';
|
|
122
|
+
localStorage.setItem(`onboardme_seen_${PID}`, '1');
|
|
123
|
+
vi.useFakeTimers();
|
|
124
|
+
OnboardMe.init({ productId: PID, flows });
|
|
125
|
+
vi.runAllTimers();
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shadow(): ShadowRoot {
|
|
130
|
+
return document.getElementById('onboardme-root')!.shadowRoot!;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Setup / teardown
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const originalTrack = OnboardMe.track;
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
141
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
OnboardMe.track = originalTrack;
|
|
146
|
+
_detachFlushListeners();
|
|
147
|
+
_resetBatcher();
|
|
148
|
+
_resetSDK();
|
|
149
|
+
_resetIndex();
|
|
150
|
+
vi.restoreAllMocks();
|
|
151
|
+
document.body.innerHTML = '';
|
|
152
|
+
localStorage.clear();
|
|
153
|
+
sessionStorage.clear();
|
|
154
|
+
clearProgress(PID, FLOW_CHECKLIST_ID);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// 1. New user → welcome modal appears within 500ms
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe('Phase 1 — new user → modal appears', () => {
|
|
162
|
+
it('modal is absent immediately after init() (deferred render)', () => {
|
|
163
|
+
_resetSDK(); _resetIndex(); _resetBatcher();
|
|
164
|
+
localStorage.clear(); sessionStorage.clear(); document.body.innerHTML = '';
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
OnboardMe.init({ productId: PID, flows: [MODAL_FLOW] });
|
|
167
|
+
// Before timer flush — not yet shown
|
|
168
|
+
expect(document.getElementById('onboardme-root')?.shadowRoot?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
169
|
+
vi.useRealTimers();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('modal appears after timer flush — well within 500ms', () => {
|
|
173
|
+
initNew([MODAL_FLOW]);
|
|
174
|
+
expect(shadow().querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('modal shows correct title and body', () => {
|
|
178
|
+
initNew([MODAL_FLOW]);
|
|
179
|
+
expect(shadow().querySelector('.om-modal__title')?.textContent).toBe('Welcome!');
|
|
180
|
+
expect(shadow().querySelector('.om-modal__body')?.textContent).toBe('Quick tour.');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// 2. Returning user → no modal
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe('Phase 1 — returning user → no modal', () => {
|
|
189
|
+
it('no modal rendered when localStorage returning flag is set', () => {
|
|
190
|
+
initReturning([MODAL_FLOW]);
|
|
191
|
+
expect(document.getElementById('onboardme-root')?.shadowRoot?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// 3. Modal dismiss → session flag set; modal does not reappear this session
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe('Phase 1 — modal dismiss + session guard', () => {
|
|
200
|
+
it('clicking CTA removes the modal', () => {
|
|
201
|
+
initNew([MODAL_FLOW]);
|
|
202
|
+
shadow().querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
203
|
+
expect(shadow().querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('clicking CTA sets the session flag', () => {
|
|
207
|
+
initNew([MODAL_FLOW]);
|
|
208
|
+
shadow().querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
209
|
+
expect(sessionStorage.getItem(`onboardme_modal_shown_${PID}`)).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('second init() in same session does not re-show modal (session guard)', () => {
|
|
213
|
+
initNew([MODAL_FLOW]);
|
|
214
|
+
shadow().querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
215
|
+
// Simulate second page-load in same session by running through init again
|
|
216
|
+
_resetSDK(); _resetIndex(); _resetBatcher();
|
|
217
|
+
document.body.innerHTML = '';
|
|
218
|
+
vi.useFakeTimers();
|
|
219
|
+
// Keep sessionStorage as-is (same session), but mark as returning
|
|
220
|
+
localStorage.setItem(`onboardme_seen_${PID}`, '1');
|
|
221
|
+
OnboardMe.init({ productId: PID, flows: [MODAL_FLOW] });
|
|
222
|
+
vi.runAllTimers();
|
|
223
|
+
vi.useRealTimers();
|
|
224
|
+
// Returning user → no modal attempted
|
|
225
|
+
expect(document.getElementById('onboardme-root')?.shadowRoot?.querySelector('[data-onboardme="overlay"]') ?? null).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// 4. Checklist persistence — checked items survive page reload
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('Phase 1 — checklist persistence across reload', () => {
|
|
234
|
+
it('items marked complete persist in localStorage', () => {
|
|
235
|
+
initReturning();
|
|
236
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'profile');
|
|
237
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'project');
|
|
238
|
+
expect(isStepComplete(PID, FLOW_CHECKLIST_ID, 'profile')).toBe(true);
|
|
239
|
+
expect(isStepComplete(PID, FLOW_CHECKLIST_ID, 'project')).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('reload (re-render with saved progress) shows correct checked state', () => {
|
|
243
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'profile');
|
|
244
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'teammate');
|
|
245
|
+
const sr = getShadowRoot();
|
|
246
|
+
const saved = loadProgress(PID, FLOW_CHECKLIST_ID);
|
|
247
|
+
renderChecklist(CHECKLIST_FLOW, saved, sr, false, PID, FLOW_CHECKLIST_ID);
|
|
248
|
+
|
|
249
|
+
expect(sr.querySelector('[data-item-id="profile"]')?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
250
|
+
expect(sr.querySelector('[data-item-id="teammate"]')?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
251
|
+
expect(sr.querySelector('[data-item-id="project"]')?.classList.contains('om-checklist__item--done')).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('progress bar reflects reloaded state correctly', () => {
|
|
255
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'profile');
|
|
256
|
+
markStepComplete(PID, FLOW_CHECKLIST_ID, 'project');
|
|
257
|
+
const sr = getShadowRoot();
|
|
258
|
+
const saved = loadProgress(PID, FLOW_CHECKLIST_ID);
|
|
259
|
+
renderChecklist(CHECKLIST_FLOW, saved, sr, false, PID, FLOW_CHECKLIST_ID);
|
|
260
|
+
// 2 of 3 required → 67%
|
|
261
|
+
const fill = sr.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
|
|
262
|
+
expect(fill?.style.width).toBe('67%');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// 5. Auto-check via track(completionEvent)
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe('Phase 1 — auto-check via track(completionEvent)', () => {
|
|
271
|
+
it('track(completionEvent) checks the matching item without a click', () => {
|
|
272
|
+
const sr = getShadowRoot();
|
|
273
|
+
renderChecklist(CHECKLIST_FLOW, EMPTY, sr, false, PID, FLOW_CHECKLIST_ID);
|
|
274
|
+
OnboardMe.track('profile_created');
|
|
275
|
+
expect(sr.querySelector('[data-item-id="profile"]')?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('auto-check saves the completion to localStorage', () => {
|
|
279
|
+
const sr = getShadowRoot();
|
|
280
|
+
renderChecklist(CHECKLIST_FLOW, EMPTY, sr, false, PID, FLOW_CHECKLIST_ID);
|
|
281
|
+
OnboardMe.track('teammate_invited');
|
|
282
|
+
expect(isStepComplete(PID, FLOW_CHECKLIST_ID, 'teammate')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('unrelated track() call does not check any item', () => {
|
|
286
|
+
const sr = getShadowRoot();
|
|
287
|
+
renderChecklist(CHECKLIST_FLOW, EMPTY, sr, false, PID, FLOW_CHECKLIST_ID);
|
|
288
|
+
OnboardMe.track('some_random_event');
|
|
289
|
+
const doneCls = 'om-checklist__item--done';
|
|
290
|
+
const anyDone = Array.from(sr.querySelectorAll('[data-item-id]')).some(
|
|
291
|
+
(el) => el.classList.contains(doneCls),
|
|
292
|
+
);
|
|
293
|
+
expect(anyDone).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// 6. Completion goal → flow_completed + goal_reached + celebration
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
describe('Phase 1 — completion goal', () => {
|
|
302
|
+
it('track(completionGoal) enqueues flow_completed', () => {
|
|
303
|
+
initReturning();
|
|
304
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
305
|
+
expect(_getQueue().some((e) => e.eventType === 'flow_completed')).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('track(completionGoal) enqueues goal_reached', () => {
|
|
309
|
+
initReturning();
|
|
310
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
311
|
+
expect(_getQueue().some((e) => e.eventType === 'goal_reached')).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('goal_reached carries the correct flowId', () => {
|
|
315
|
+
initReturning();
|
|
316
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
317
|
+
const ev = _getQueue().find((e) => e.eventType === 'goal_reached');
|
|
318
|
+
expect(ev?.flowId).toBe(FLOW_CHECKLIST_ID);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('track(completionGoal) renders the .om-celebration banner', () => {
|
|
322
|
+
initReturning();
|
|
323
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
324
|
+
const sr = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
325
|
+
expect(sr.querySelector('.om-celebration')).not.toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('celebration banner text is correct', () => {
|
|
329
|
+
initReturning();
|
|
330
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
331
|
+
const banner = document.getElementById('onboardme-root')!.shadowRoot!.querySelector('.om-celebration');
|
|
332
|
+
expect(banner?.textContent).toBe("🎉 You're all set!");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('celebration banner auto-removes after 3 seconds', () => {
|
|
336
|
+
initReturning();
|
|
337
|
+
vi.useFakeTimers();
|
|
338
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
339
|
+
vi.advanceTimersByTime(3000);
|
|
340
|
+
const sr = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
341
|
+
expect(sr.querySelector('.om-celebration')).toBeNull();
|
|
342
|
+
vi.useRealTimers();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// 7. Double goal → goal_reached fires only once
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
describe('Phase 1 — double goal guard', () => {
|
|
351
|
+
it('goal_reached fires only once when track(completionGoal) is called twice', () => {
|
|
352
|
+
initReturning();
|
|
353
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
354
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
355
|
+
const goalReached = _getQueue().filter((e) => e.eventType === 'goal_reached');
|
|
356
|
+
expect(goalReached).toHaveLength(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('flow_completed fires only once on repeated calls', () => {
|
|
360
|
+
initReturning();
|
|
361
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
362
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
363
|
+
const flowCompleted = _getQueue().filter((e) => e.eventType === 'flow_completed');
|
|
364
|
+
expect(flowCompleted).toHaveLength(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('localStorage guard key is set and prevents second fire', () => {
|
|
368
|
+
initReturning();
|
|
369
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
370
|
+
expect(localStorage.getItem(`onboardme_flow_done_${PID}_${FLOW_CHECKLIST_ID}`)).toBe('1');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// 8. Event shape — correct fields on every event
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
describe('Phase 1 — event shape', () => {
|
|
379
|
+
beforeEach(() => {
|
|
380
|
+
_resetBatcher();
|
|
381
|
+
configureBatcher('http://localhost:3001/events', 'anon-shape-test');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('every event has a non-empty eventId', () => {
|
|
385
|
+
pushEvent('flow_started', { flowId: 'f1' });
|
|
386
|
+
const ev = _getQueue()[0];
|
|
387
|
+
expect(ev.eventId).toBeTruthy();
|
|
388
|
+
expect(typeof ev.eventId).toBe('string');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('every event has a non-empty anonymousId', () => {
|
|
392
|
+
pushEvent('flow_started', { flowId: 'f1' });
|
|
393
|
+
expect(_getQueue()[0].anonymousId).toBe('anon-shape-test');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('every event has a timestamp (number)', () => {
|
|
397
|
+
pushEvent('step_viewed', { stepId: 's1' });
|
|
398
|
+
expect(typeof _getQueue()[0].timestamp).toBe('number');
|
|
399
|
+
expect(_getQueue()[0].timestamp).toBeGreaterThan(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('every event has a pageUrl string', () => {
|
|
403
|
+
pushEvent('step_viewed', { stepId: 's1' });
|
|
404
|
+
expect(typeof _getQueue()[0].pageUrl).toBe('string');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('every event carries the correct eventType', () => {
|
|
408
|
+
pushEvent('goal_reached', { flowId: 'f1' });
|
|
409
|
+
expect(_getQueue()[0].eventType).toBe('goal_reached');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// 9. userId on events — absent before identify(), present after
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
describe('Phase 1 — userId on events', () => {
|
|
418
|
+
beforeEach(() => {
|
|
419
|
+
_resetBatcher();
|
|
420
|
+
configureBatcher('http://localhost:3001/events', 'anon-user-test');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('userId is absent on events pushed before identify()', () => {
|
|
424
|
+
pushEvent('flow_started');
|
|
425
|
+
expect(_getQueue()[0].userId).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('userId is present on events pushed after setBatcherUserId()', () => {
|
|
429
|
+
setBatcherUserId('user-xyz');
|
|
430
|
+
pushEvent('step_viewed');
|
|
431
|
+
expect(_getQueue()[0].userId).toBe('user-xyz');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('identify() wires through to event queue via OnboardMe.identify()', () => {
|
|
435
|
+
initReturning();
|
|
436
|
+
OnboardMe.identify('real-user-123');
|
|
437
|
+
OnboardMe.track('something');
|
|
438
|
+
const ev = _getQueue().find((e) => e.eventType === 'step_action_taken');
|
|
439
|
+
expect(ev?.userId).toBe('real-user-123');
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// 10. Full Phase 1 end-to-end lifecycle
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('Phase 1 — full end-to-end lifecycle', () => {
|
|
448
|
+
it('complete lifecycle: init → modal → dismiss → checklist → auto-check → goal → celebration', () => {
|
|
449
|
+
// 1. New user — init
|
|
450
|
+
_resetSDK(); _resetIndex(); _resetBatcher();
|
|
451
|
+
localStorage.clear(); sessionStorage.clear(); document.body.innerHTML = '';
|
|
452
|
+
vi.useFakeTimers();
|
|
453
|
+
OnboardMe.init({ productId: PID, flows: [MODAL_FLOW, CHECKLIST_FLOW] });
|
|
454
|
+
vi.runAllTimers();
|
|
455
|
+
vi.useRealTimers();
|
|
456
|
+
|
|
457
|
+
const sr = document.getElementById('onboardme-root')!.shadowRoot!;
|
|
458
|
+
|
|
459
|
+
// 2. Modal appeared
|
|
460
|
+
expect(sr.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
|
|
461
|
+
|
|
462
|
+
// 3. Click CTA → modal closes, step_completed enqueued
|
|
463
|
+
sr.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]')!.click();
|
|
464
|
+
expect(sr.querySelector('[data-onboardme="overlay"]')).toBeNull();
|
|
465
|
+
expect(_getQueue().some((e) => e.eventType === 'step_completed')).toBe(true);
|
|
466
|
+
|
|
467
|
+
// 4. Checklist is rendered (step_viewed enqueued)
|
|
468
|
+
expect(sr.querySelector('[data-onboardme="checklist"]')).not.toBeNull();
|
|
469
|
+
expect(_getQueue().some((e) => e.eventType === 'step_viewed')).toBe(true);
|
|
470
|
+
|
|
471
|
+
// 5. Manual check on item
|
|
472
|
+
sr.querySelector<HTMLLIElement>('[data-item-id="project"]')!.click();
|
|
473
|
+
expect(sr.querySelector('[data-item-id="project"]')?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
474
|
+
expect(_getQueue().some((e) => e.eventType === 'checklist_item_done')).toBe(true);
|
|
475
|
+
|
|
476
|
+
// 6. Auto-check via track
|
|
477
|
+
OnboardMe.track('profile_created');
|
|
478
|
+
expect(sr.querySelector('[data-item-id="profile"]')?.classList.contains('om-checklist__item--done')).toBe(true);
|
|
479
|
+
|
|
480
|
+
// 7. Completion goal
|
|
481
|
+
OnboardMe.track(COMPLETION_GOAL);
|
|
482
|
+
expect(_getQueue().some((e) => e.eventType === 'flow_completed')).toBe(true);
|
|
483
|
+
expect(_getQueue().some((e) => e.eventType === 'goal_reached')).toBe(true);
|
|
484
|
+
|
|
485
|
+
// 8. Celebration banner visible
|
|
486
|
+
expect(sr.querySelector('.om-celebration')).not.toBeNull();
|
|
487
|
+
|
|
488
|
+
// 9. All progress persisted
|
|
489
|
+
expect(isStepComplete(PID, FLOW_CHECKLIST_ID, 'project')).toBe(true);
|
|
490
|
+
expect(isStepComplete(PID, FLOW_CHECKLIST_ID, 'profile')).toBe(true);
|
|
491
|
+
|
|
492
|
+
// 10. Event log has expected event types
|
|
493
|
+
const queue = _getQueue();
|
|
494
|
+
const types = queue.map((e) => e.eventType);
|
|
495
|
+
expect(types).toContain('flow_started');
|
|
496
|
+
expect(types).toContain('step_completed');
|
|
497
|
+
expect(types).toContain('step_viewed');
|
|
498
|
+
expect(types).toContain('checklist_item_done');
|
|
499
|
+
expect(types).toContain('step_action_taken');
|
|
500
|
+
expect(types).toContain('flow_completed');
|
|
501
|
+
expect(types).toContain('goal_reached');
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week 5 Day 1 — AutoGenerateConfig + ProductContext types
|
|
3
|
+
*
|
|
4
|
+
* Tests for:
|
|
5
|
+
* - packages/types/src/config.ts (ProductContext, AutoGenerateConfig)
|
|
6
|
+
* - OnboardMeConfig now accepts optional autoGenerate field
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import type {
|
|
11
|
+
ProductContext,
|
|
12
|
+
AutoGenerateConfig,
|
|
13
|
+
OnboardMeConfig,
|
|
14
|
+
} from '@onboardme/types';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// ProductContext
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('Week 5 Day 1 — ProductContext', () => {
|
|
21
|
+
it('accepts a fully populated object', () => {
|
|
22
|
+
const ctx: ProductContext = {
|
|
23
|
+
title: 'My App — Dashboard',
|
|
24
|
+
headings: ['Welcome to My App', 'Get Started'],
|
|
25
|
+
navLinks: ['Home', 'Settings', 'Help'],
|
|
26
|
+
buttons: ['Create project', 'Invite team', 'Upgrade'],
|
|
27
|
+
};
|
|
28
|
+
expect(ctx.title).toBe('My App — Dashboard');
|
|
29
|
+
expect(ctx.headings).toHaveLength(2);
|
|
30
|
+
expect(ctx.navLinks).toHaveLength(3);
|
|
31
|
+
expect(ctx.buttons).toHaveLength(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('accepts empty arrays for headings, navLinks, and buttons', () => {
|
|
35
|
+
const ctx: ProductContext = {
|
|
36
|
+
title: 'Minimal App',
|
|
37
|
+
headings: [],
|
|
38
|
+
navLinks: [],
|
|
39
|
+
buttons: [],
|
|
40
|
+
};
|
|
41
|
+
expect(ctx.headings).toEqual([]);
|
|
42
|
+
expect(ctx.navLinks).toEqual([]);
|
|
43
|
+
expect(ctx.buttons).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('title is a string', () => {
|
|
47
|
+
const ctx: ProductContext = {
|
|
48
|
+
title: 'Acme SaaS',
|
|
49
|
+
headings: [],
|
|
50
|
+
navLinks: [],
|
|
51
|
+
buttons: [],
|
|
52
|
+
};
|
|
53
|
+
expect(typeof ctx.title).toBe('string');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('headings is an array of strings', () => {
|
|
57
|
+
const ctx: ProductContext = {
|
|
58
|
+
title: '',
|
|
59
|
+
headings: ['h1 text', 'h2 text', 'h3 text'],
|
|
60
|
+
navLinks: [],
|
|
61
|
+
buttons: [],
|
|
62
|
+
};
|
|
63
|
+
ctx.headings.forEach((h) => expect(typeof h).toBe('string'));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// AutoGenerateConfig
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('Week 5 Day 1 — AutoGenerateConfig', () => {
|
|
72
|
+
it('accepts endpoint-only (minimal config)', () => {
|
|
73
|
+
const cfg: AutoGenerateConfig = {
|
|
74
|
+
endpoint: 'http://localhost:3002/generate',
|
|
75
|
+
};
|
|
76
|
+
expect(cfg.endpoint).toBe('http://localhost:3002/generate');
|
|
77
|
+
expect(cfg.productDescription).toBeUndefined();
|
|
78
|
+
expect(cfg.cacheTtlMs).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accepts all optional fields', () => {
|
|
82
|
+
const cfg: AutoGenerateConfig = {
|
|
83
|
+
endpoint: 'https://flows.example.com/generate',
|
|
84
|
+
productDescription: 'A project management tool for remote teams',
|
|
85
|
+
cacheTtlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
86
|
+
};
|
|
87
|
+
expect(cfg.productDescription).toBe('A project management tool for remote teams');
|
|
88
|
+
expect(cfg.cacheTtlMs).toBe(604_800_000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('endpoint is a string', () => {
|
|
92
|
+
const cfg: AutoGenerateConfig = { endpoint: 'http://localhost:3002/generate' };
|
|
93
|
+
expect(typeof cfg.endpoint).toBe('string');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('cacheTtlMs is a number when provided', () => {
|
|
97
|
+
const cfg: AutoGenerateConfig = {
|
|
98
|
+
endpoint: 'http://localhost:3002/generate',
|
|
99
|
+
cacheTtlMs: 3600_000,
|
|
100
|
+
};
|
|
101
|
+
expect(typeof cfg.cacheTtlMs).toBe('number');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// OnboardMeConfig.autoGenerate
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe('Week 5 Day 1 — OnboardMeConfig accepts autoGenerate', () => {
|
|
110
|
+
const baseConfig: OnboardMeConfig = {
|
|
111
|
+
productId: 'test-product',
|
|
112
|
+
flows: [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
it('works without autoGenerate (backwards compatible)', () => {
|
|
116
|
+
expect(baseConfig.autoGenerate).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('accepts autoGenerate with endpoint only', () => {
|
|
120
|
+
const cfg: OnboardMeConfig = {
|
|
121
|
+
...baseConfig,
|
|
122
|
+
autoGenerate: { endpoint: 'http://localhost:3002/generate' },
|
|
123
|
+
};
|
|
124
|
+
expect(cfg.autoGenerate?.endpoint).toBe('http://localhost:3002/generate');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('accepts autoGenerate with all fields', () => {
|
|
128
|
+
const cfg: OnboardMeConfig = {
|
|
129
|
+
...baseConfig,
|
|
130
|
+
autoGenerate: {
|
|
131
|
+
endpoint: 'http://localhost:3002/generate',
|
|
132
|
+
productDescription: 'Analytics dashboard',
|
|
133
|
+
cacheTtlMs: 86_400_000,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
expect(cfg.autoGenerate?.productDescription).toBe('Analytics dashboard');
|
|
137
|
+
expect(cfg.autoGenerate?.cacheTtlMs).toBe(86_400_000);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('autoGenerate can coexist with flows, debug, and eventsEndpoint', () => {
|
|
141
|
+
const cfg: OnboardMeConfig = {
|
|
142
|
+
productId: 'prod-1',
|
|
143
|
+
eventsEndpoint: 'http://localhost:3001/events',
|
|
144
|
+
autoGenerate: { endpoint: 'http://localhost:3002/generate' },
|
|
145
|
+
flows: [],
|
|
146
|
+
debug: true,
|
|
147
|
+
};
|
|
148
|
+
expect(cfg.debug).toBe(true);
|
|
149
|
+
expect(cfg.eventsEndpoint).toBe('http://localhost:3001/events');
|
|
150
|
+
expect(cfg.autoGenerate?.endpoint).toBe('http://localhost:3002/generate');
|
|
151
|
+
});
|
|
152
|
+
});
|