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,447 @@
1
+ /**
2
+ * Day 2 Tests — Shared Types
3
+ *
4
+ * These tests verify that every type defined in @onboardme/types has the right
5
+ * shape. They create example objects and check their fields at runtime so we
6
+ * know the types actually work — not just that TypeScript is happy at compile time.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import type {
11
+ FlowConfig,
12
+ FlowStep,
13
+ ChecklistItem,
14
+ Trigger,
15
+ Condition,
16
+ UserIdentity,
17
+ OnboardingEvent,
18
+ OnboardMeConfig,
19
+ EventType,
20
+ StepType,
21
+ TriggerType,
22
+ ConditionOperator,
23
+ } from '@onboardme/types';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers — reusable minimal objects used across tests
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const minimalTrigger: Trigger = { type: 'page_load' };
30
+
31
+ const minimalStep: FlowStep = {
32
+ id: 'step-1',
33
+ type: 'modal',
34
+ order: 1,
35
+ title: 'Welcome!',
36
+ body: 'Let us show you around.',
37
+ trigger: minimalTrigger,
38
+ };
39
+
40
+ const minimalFlow: FlowConfig = {
41
+ id: 'flow-1',
42
+ name: 'Welcome Flow',
43
+ steps: [minimalStep],
44
+ completionGoal: 'first_project_created',
45
+ priority: 1,
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // FlowStep
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe('Day 2 — FlowStep', () => {
53
+ it('a step has an id, type, order, title, body, and trigger', () => {
54
+ expect(minimalStep.id).toBe('step-1');
55
+ expect(minimalStep.type).toBe('modal');
56
+ expect(minimalStep.order).toBe(1);
57
+ expect(minimalStep.title).toBe('Welcome!');
58
+ expect(minimalStep.body).toBeDefined();
59
+ expect(minimalStep.trigger).toBeDefined();
60
+ });
61
+
62
+ it('order is a number you set yourself — two steps can have order 1 and 3, skipping 2', () => {
63
+ const stepA: FlowStep = { ...minimalStep, id: 'a', order: 1 };
64
+ const stepB: FlowStep = { ...minimalStep, id: 'b', order: 3 }; // gap is intentional
65
+ expect(stepA.order).toBe(1);
66
+ expect(stepB.order).toBe(3);
67
+ // The order values are independent — not derived from position in any array
68
+ expect(stepB.order - stepA.order).toBe(2);
69
+ });
70
+
71
+ it('a step can have optional media (image or video)', () => {
72
+ const stepWithImage: FlowStep = {
73
+ ...minimalStep,
74
+ media: { type: 'image', url: 'https://example.com/screenshot.png' },
75
+ };
76
+ expect(stepWithImage.media?.type).toBe('image');
77
+ expect(stepWithImage.media?.url).toContain('example.com');
78
+ });
79
+
80
+ it('a step can have custom CTA button labels — they are optional', () => {
81
+ const stepWithCtaLabels: FlowStep = {
82
+ ...minimalStep,
83
+ primaryCta: 'Get Started',
84
+ secondaryCta: 'Maybe Later',
85
+ };
86
+ expect(stepWithCtaLabels.primaryCta).toBe('Get Started');
87
+ expect(stepWithCtaLabels.secondaryCta).toBe('Maybe Later');
88
+ // A step without them is also valid
89
+ expect(minimalStep.primaryCta).toBeUndefined();
90
+ });
91
+
92
+ it('a checklist step can have a list of items', () => {
93
+ const item: ChecklistItem = {
94
+ id: 'item-1',
95
+ label: 'Connect your data source',
96
+ order: 1,
97
+ };
98
+ const checklistStep: FlowStep = {
99
+ ...minimalStep,
100
+ type: 'checklist',
101
+ items: [item],
102
+ };
103
+ expect(checklistStep.type).toBe('checklist');
104
+ expect(checklistStep.items).toHaveLength(1);
105
+ expect(checklistStep.items![0].label).toBe('Connect your data source');
106
+ });
107
+
108
+ it('a checklist item can auto-complete when a specific event fires', () => {
109
+ const item: ChecklistItem = {
110
+ id: 'item-2',
111
+ label: 'Invite a teammate',
112
+ order: 2,
113
+ completionEvent: 'teammate_invited',
114
+ };
115
+ expect(item.completionEvent).toBe('teammate_invited');
116
+ });
117
+
118
+ it('a step can have conditions — rules that decide whether to show the step', () => {
119
+ const condition: Condition = {
120
+ property: 'user.traits.plan',
121
+ operator: 'equals',
122
+ value: 'free',
123
+ };
124
+ const conditionalStep: FlowStep = {
125
+ ...minimalStep,
126
+ conditions: [condition],
127
+ };
128
+ expect(conditionalStep.conditions).toHaveLength(1);
129
+ expect(conditionalStep.conditions![0].operator).toBe('equals');
130
+ expect(conditionalStep.conditions![0].value).toBe('free');
131
+ });
132
+
133
+ it('a step can point to the next step by id', () => {
134
+ const step: FlowStep = { ...minimalStep, nextStep: 'step-2' };
135
+ expect(step.nextStep).toBe('step-2');
136
+ });
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Trigger
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe('Day 2 — Trigger', () => {
144
+ it('a trigger can fire on page load (no extra value needed)', () => {
145
+ const t: Trigger = { type: 'page_load' };
146
+ expect(t.type).toBe('page_load');
147
+ expect(t.value).toBeUndefined();
148
+ });
149
+
150
+ it('a trigger can fire when the user clicks a specific element', () => {
151
+ const t: Trigger = { type: 'element_click', value: '#upgrade-button' };
152
+ expect(t.type).toBe('element_click');
153
+ expect(t.value).toBe('#upgrade-button');
154
+ });
155
+
156
+ it('a trigger can fire on a custom event sent by the host app', () => {
157
+ const t: Trigger = { type: 'custom_event', value: 'project_created' };
158
+ expect(t.type).toBe('custom_event');
159
+ expect(t.value).toBe('project_created');
160
+ });
161
+
162
+ it('a trigger can be manual — the step only shows when the app explicitly calls show()', () => {
163
+ const t: Trigger = { type: 'manual' };
164
+ expect(t.type).toBe('manual');
165
+ });
166
+
167
+ it('a trigger can have a delay in milliseconds before the step appears', () => {
168
+ const t: Trigger = { type: 'page_load', delay: 500 };
169
+ expect(t.delay).toBe(500);
170
+ });
171
+
172
+ it('all four trigger types are valid', () => {
173
+ const validTypes: TriggerType[] = ['page_load', 'element_click', 'custom_event', 'manual'];
174
+ validTypes.forEach((type) => {
175
+ const t: Trigger = { type };
176
+ expect(t.type).toBe(type);
177
+ });
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // FlowConfig
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe('Day 2 — FlowConfig', () => {
186
+ it('a flow has an id, name, steps array, completionGoal, and priority', () => {
187
+ expect(minimalFlow.id).toBe('flow-1');
188
+ expect(minimalFlow.name).toBe('Welcome Flow');
189
+ expect(Array.isArray(minimalFlow.steps)).toBe(true);
190
+ expect(minimalFlow.completionGoal).toBe('first_project_created');
191
+ expect(typeof minimalFlow.priority).toBe('number');
192
+ });
193
+
194
+ it('a flow can have zero steps — the SDK must never crash on an empty flow', () => {
195
+ const emptyFlow: FlowConfig = {
196
+ id: 'empty',
197
+ name: 'Empty Flow',
198
+ steps: [],
199
+ completionGoal: 'anything',
200
+ priority: 0,
201
+ };
202
+ expect(emptyFlow.steps).toHaveLength(0);
203
+ });
204
+
205
+ it('steps are stored in an array but their display order is controlled by the order field, not array position', () => {
206
+ const stepFirst: FlowStep = { ...minimalStep, id: 's1', order: 1 };
207
+ const stepSecond: FlowStep = { ...minimalStep, id: 's2', order: 2 };
208
+ // Even if we put stepSecond before stepFirst in the array, order field wins
209
+ const flow: FlowConfig = {
210
+ ...minimalFlow,
211
+ steps: [stepSecond, stepFirst],
212
+ };
213
+ const sorted = [...flow.steps].sort((a, b) => a.order - b.order);
214
+ expect(sorted[0].id).toBe('s1');
215
+ expect(sorted[1].id).toBe('s2');
216
+ });
217
+
218
+ it('a flow can target a specific user segment', () => {
219
+ const segmentedFlow: FlowConfig = { ...minimalFlow, targetSegment: 'free-tier-users' };
220
+ expect(segmentedFlow.targetSegment).toBe('free-tier-users');
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // UserIdentity
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe('Day 2 — UserIdentity', () => {
229
+ it('a user always has an anonymousId — even before they log in', () => {
230
+ const user: UserIdentity = { anonymousId: 'anon-abc-123' };
231
+ expect(user.anonymousId).toBe('anon-abc-123');
232
+ });
233
+
234
+ it('userId is optional — new visitors do not have one yet', () => {
235
+ const anonymous: UserIdentity = { anonymousId: 'anon-xyz' };
236
+ expect(anonymous.userId).toBeUndefined();
237
+
238
+ const loggedIn: UserIdentity = { anonymousId: 'anon-xyz', userId: 'user-001' };
239
+ expect(loggedIn.userId).toBe('user-001');
240
+ });
241
+
242
+ it('createdAt is optional — it holds the ISO date string of when the account was made', () => {
243
+ const user: UserIdentity = {
244
+ anonymousId: 'anon-1',
245
+ createdAt: '2024-01-15T10:00:00.000Z',
246
+ };
247
+ expect(user.createdAt).toBe('2024-01-15T10:00:00.000Z');
248
+ });
249
+
250
+ it('traits is a flexible key-value store — any custom property goes here', () => {
251
+ const user: UserIdentity = {
252
+ anonymousId: 'anon-2',
253
+ traits: {
254
+ plan: 'pro',
255
+ company: 'Acme Corp',
256
+ teamSize: 42,
257
+ betaUser: true,
258
+ },
259
+ };
260
+ expect(user.traits?.plan).toBe('pro');
261
+ expect(user.traits?.teamSize).toBe(42);
262
+ expect(user.traits?.betaUser).toBe(true);
263
+ });
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // OnboardingEvent
268
+ // ---------------------------------------------------------------------------
269
+
270
+ describe('Day 2 — OnboardingEvent', () => {
271
+ it('every event has an eventId, anonymousId, eventType, pageUrl, and timestamp', () => {
272
+ const event: OnboardingEvent = {
273
+ eventId: 'evt-001',
274
+ anonymousId: 'anon-abc',
275
+ eventType: 'flow_started',
276
+ pageUrl: 'https://app.example.com/dashboard',
277
+ timestamp: 1700000000000,
278
+ };
279
+ expect(event.eventId).toBe('evt-001');
280
+ expect(event.anonymousId).toBe('anon-abc');
281
+ expect(event.eventType).toBe('flow_started');
282
+ expect(event.pageUrl).toContain('dashboard');
283
+ expect(typeof event.timestamp).toBe('number');
284
+ });
285
+
286
+ it('timestamp is stored as a number (Unix milliseconds) — not a date string', () => {
287
+ const now = Date.now();
288
+ const event: OnboardingEvent = {
289
+ eventId: 'evt-002',
290
+ anonymousId: 'anon-abc',
291
+ eventType: 'step_viewed',
292
+ pageUrl: 'https://app.example.com/',
293
+ timestamp: now,
294
+ };
295
+ expect(typeof event.timestamp).toBe('number');
296
+ expect(event.timestamp).toBeGreaterThan(0);
297
+ });
298
+
299
+ it('all 11 event types are valid and accounted for', () => {
300
+ const allEventTypes: EventType[] = [
301
+ 'flow_started',
302
+ 'step_viewed',
303
+ 'step_completed',
304
+ 'step_skipped',
305
+ 'step_action_taken',
306
+ 'flow_completed',
307
+ 'flow_abandoned',
308
+ 'survey_answered',
309
+ 'goal_reached',
310
+ 'checklist_item_done',
311
+ 'celebration_shown',
312
+ ];
313
+ expect(allEventTypes).toHaveLength(11);
314
+ allEventTypes.forEach((type) => {
315
+ expect(typeof type).toBe('string');
316
+ });
317
+ });
318
+
319
+ it('an event can optionally record which flow and step it came from', () => {
320
+ const event: OnboardingEvent = {
321
+ eventId: 'evt-003',
322
+ anonymousId: 'anon-abc',
323
+ eventType: 'step_completed',
324
+ pageUrl: 'https://app.example.com/',
325
+ timestamp: Date.now(),
326
+ flowId: 'flow-1',
327
+ stepId: 'step-2',
328
+ stepIndex: 1,
329
+ };
330
+ expect(event.flowId).toBe('flow-1');
331
+ expect(event.stepId).toBe('step-2');
332
+ expect(event.stepIndex).toBe(1);
333
+ });
334
+
335
+ it('an event can carry extra custom properties in a flexible key-value bag', () => {
336
+ const event: OnboardingEvent = {
337
+ eventId: 'evt-004',
338
+ anonymousId: 'anon-abc',
339
+ eventType: 'survey_answered',
340
+ pageUrl: 'https://app.example.com/',
341
+ timestamp: Date.now(),
342
+ properties: { answer: 'option_b', questionId: 'q-1' },
343
+ };
344
+ expect(event.properties?.answer).toBe('option_b');
345
+ });
346
+
347
+ it('a goal_reached event links a user reaching their Aha! moment', () => {
348
+ const event: OnboardingEvent = {
349
+ eventId: 'evt-005',
350
+ anonymousId: 'anon-abc',
351
+ eventType: 'goal_reached',
352
+ pageUrl: 'https://app.example.com/projects/new',
353
+ timestamp: Date.now(),
354
+ flowId: 'flow-1',
355
+ };
356
+ expect(event.eventType).toBe('goal_reached');
357
+ });
358
+ });
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // OnboardMeConfig
362
+ // ---------------------------------------------------------------------------
363
+
364
+ describe('Day 2 — OnboardMeConfig', () => {
365
+ it('config only requires a productId and a flows array — everything else is optional', () => {
366
+ const config: OnboardMeConfig = {
367
+ productId: 'my-saas',
368
+ flows: [],
369
+ };
370
+ expect(config.productId).toBe('my-saas');
371
+ expect(Array.isArray(config.flows)).toBe(true);
372
+ expect(config.user).toBeUndefined();
373
+ expect(config.debug).toBeUndefined();
374
+ });
375
+
376
+ it('flows can be an empty array — the SDK handles it gracefully', () => {
377
+ const config: OnboardMeConfig = { productId: 'p1', flows: [] };
378
+ expect(config.flows).toHaveLength(0);
379
+ });
380
+
381
+ it('a user can be passed in config with just an id', () => {
382
+ const config: OnboardMeConfig = {
383
+ productId: 'p1',
384
+ flows: [],
385
+ user: { id: 'user-42' },
386
+ };
387
+ expect(config.user?.id).toBe('user-42');
388
+ });
389
+
390
+ it('a user in config can also include a createdAt date and custom traits', () => {
391
+ const config: OnboardMeConfig = {
392
+ productId: 'p1',
393
+ flows: [],
394
+ user: {
395
+ id: 'user-99',
396
+ createdAt: '2024-06-01T00:00:00.000Z',
397
+ traits: { plan: 'enterprise', seats: 200 },
398
+ },
399
+ };
400
+ expect(config.user?.createdAt).toBe('2024-06-01T00:00:00.000Z');
401
+ expect(config.user?.traits?.plan).toBe('enterprise');
402
+ });
403
+
404
+ it('debug mode is an optional boolean — off by default', () => {
405
+ const defaultConfig: OnboardMeConfig = { productId: 'p1', flows: [] };
406
+ const debugConfig: OnboardMeConfig = { productId: 'p1', flows: [], debug: true };
407
+ expect(defaultConfig.debug).toBeUndefined();
408
+ expect(debugConfig.debug).toBe(true);
409
+ });
410
+
411
+ it('a config can hold multiple flows at once', () => {
412
+ const config: OnboardMeConfig = {
413
+ productId: 'p1',
414
+ flows: [minimalFlow, { ...minimalFlow, id: 'flow-2', name: 'Upgrade Flow' }],
415
+ };
416
+ expect(config.flows).toHaveLength(2);
417
+ expect(config.flows[1].name).toBe('Upgrade Flow');
418
+ });
419
+ });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // StepType and ConditionOperator — enum-like string unions
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe('Day 2 — StepType', () => {
426
+ it('all 6 step types are valid', () => {
427
+ const validTypes: StepType[] = ['modal', 'tooltip', 'checklist', 'hotspot', 'survey', 'spotlight'];
428
+ expect(validTypes).toHaveLength(6);
429
+ });
430
+ });
431
+
432
+ describe('Day 2 — ConditionOperator', () => {
433
+ it('all 5 condition operators are valid', () => {
434
+ const operators: ConditionOperator[] = ['equals', 'not_equals', 'contains', 'greater_than', 'less_than'];
435
+ expect(operators).toHaveLength(5);
436
+ });
437
+
438
+ it('conditions can compare a user property against any value type', () => {
439
+ const stringCondition: Condition = { property: 'user.traits.plan', operator: 'equals', value: 'free' };
440
+ const numberCondition: Condition = { property: 'user.traits.seats', operator: 'greater_than', value: 10 };
441
+ const boolCondition: Condition = { property: 'user.traits.betaUser', operator: 'equals', value: true };
442
+
443
+ expect(stringCondition.value).toBe('free');
444
+ expect(numberCondition.value).toBe(10);
445
+ expect(boolCondition.value).toBe(true);
446
+ });
447
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Day 3 Tests — SDK Initialisation
3
+ *
4
+ * Tests for: logger, config validator, OnboardMe.init() behaviour.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Logger
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe('Day 3 — logger', () => {
14
+ beforeEach(() => {
15
+ vi.spyOn(console, 'log').mockImplementation(() => {});
16
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
17
+ vi.spyOn(console, 'error').mockImplementation(() => {});
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ it('suppresses all output when debug is off', async () => {
25
+ const { setDebug, logger } = await import('../utils/logger.js');
26
+ setDebug(false);
27
+ logger.log('hello');
28
+ logger.warn('warning');
29
+ logger.error('oops');
30
+ expect(console.log).not.toHaveBeenCalled();
31
+ expect(console.warn).not.toHaveBeenCalled();
32
+ expect(console.error).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it('outputs when debug is on', async () => {
36
+ const { setDebug, logger } = await import('../utils/logger.js');
37
+ setDebug(true);
38
+ logger.log('hello');
39
+ expect(console.log).toHaveBeenCalledWith('[OnboardMe] hello');
40
+ setDebug(false); // reset
41
+ });
42
+
43
+ it('prefixes every message with [OnboardMe]', async () => {
44
+ const { setDebug, logger } = await import('../utils/logger.js');
45
+ setDebug(true);
46
+ logger.warn('something happened');
47
+ expect(console.warn).toHaveBeenCalledWith('[OnboardMe] something happened');
48
+ setDebug(false);
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // validateConfig
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('Day 3 — validateConfig', () => {
57
+ beforeEach(() => {
58
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
59
+ });
60
+ afterEach(() => { vi.restoreAllMocks(); });
61
+
62
+ it('returns null if no config is passed', async () => {
63
+ const { validateConfig } = await import('../core/config.js');
64
+ expect(validateConfig(null)).toBeNull();
65
+ expect(validateConfig(undefined)).toBeNull();
66
+ });
67
+
68
+ it('returns null if productId is missing', async () => {
69
+ const { validateConfig } = await import('../core/config.js');
70
+ expect(validateConfig({ flows: [] })).toBeNull();
71
+ });
72
+
73
+ it('returns null if productId is not a string', async () => {
74
+ const { validateConfig } = await import('../core/config.js');
75
+ expect(validateConfig({ productId: 123, flows: [] })).toBeNull();
76
+ });
77
+
78
+ it('returns a normalised config with defaults when valid', async () => {
79
+ const { validateConfig } = await import('../core/config.js');
80
+ const result = validateConfig({ productId: 'my-app', flows: [] });
81
+ expect(result).not.toBeNull();
82
+ expect(result?.productId).toBe('my-app');
83
+ expect(result?.flows).toEqual([]);
84
+ expect(result?.debug).toBe(false);
85
+ });
86
+
87
+ it('defaults flows to [] if omitted', async () => {
88
+ const { validateConfig } = await import('../core/config.js');
89
+ const result = validateConfig({ productId: 'my-app' });
90
+ expect(result?.flows).toEqual([]);
91
+ });
92
+
93
+ it('defaults debug to false if omitted', async () => {
94
+ const { validateConfig } = await import('../core/config.js');
95
+ const result = validateConfig({ productId: 'my-app', flows: [] });
96
+ expect(result?.debug).toBe(false);
97
+ });
98
+
99
+ it('preserves debug: true when passed', async () => {
100
+ const { validateConfig } = await import('../core/config.js');
101
+ const result = validateConfig({ productId: 'my-app', flows: [], debug: true });
102
+ expect(result?.debug).toBe(true);
103
+ });
104
+
105
+ it('resets flows to [] and warns if flows is not an array', async () => {
106
+ const { validateConfig } = await import('../core/config.js');
107
+ const result = validateConfig({ productId: 'my-app', flows: 'bad' });
108
+ expect(result?.flows).toEqual([]);
109
+ });
110
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Day 4 Tests — First-Time User Detection + Anonymous ID
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { detectUser } from '../detection/user-detection.js';
7
+ import { getAnonymousId } from '../utils/fingerprint.js';
8
+ import type { OnboardMeConfig } from '@onboardme/types';
9
+
10
+ const baseConfig: OnboardMeConfig = {
11
+ productId: 'test-app',
12
+ flows: [],
13
+ };
14
+
15
+ // Clear localStorage before each test so tests are independent
16
+ beforeEach(() => {
17
+ localStorage.clear();
18
+ });
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // detectUser
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('Day 4 — detectUser: Layer 1 (localStorage flag)', () => {
25
+ it('returns isNew: true with reason first_visit when no flag exists', () => {
26
+ const result = detectUser(baseConfig);
27
+ expect(result.isNew).toBe(true);
28
+ expect(result.reason).toBe('first_visit');
29
+ });
30
+
31
+ it('sets the seen flag in localStorage on first visit', () => {
32
+ detectUser(baseConfig);
33
+ expect(localStorage.getItem('onboardme_seen_test-app')).toBe('1');
34
+ });
35
+
36
+ it('returns isNew: false when the seen flag already exists', () => {
37
+ localStorage.setItem('onboardme_seen_test-app', '1');
38
+ const result = detectUser(baseConfig);
39
+ expect(result.isNew).toBe(false);
40
+ expect(result.reason).toBe('returning');
41
+ });
42
+ });
43
+
44
+ describe('Day 4 — detectUser: Layer 2 (account age)', () => {
45
+ it('returns isNew: true with reason new_account when account is less than 48h old', () => {
46
+ const config: OnboardMeConfig = {
47
+ ...baseConfig,
48
+ user: { createdAt: new Date().toISOString() },
49
+ };
50
+ const result = detectUser(config);
51
+ expect(result.isNew).toBe(true);
52
+ expect(result.reason).toBe('new_account');
53
+ });
54
+
55
+ it('returns isNew: false when account is older than 48 hours', () => {
56
+ const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
57
+ const config: OnboardMeConfig = {
58
+ ...baseConfig,
59
+ user: { createdAt: threeDaysAgo },
60
+ };
61
+ const result = detectUser(config);
62
+ expect(result.isNew).toBe(false);
63
+ expect(result.reason).toBe('returning');
64
+ });
65
+ });
66
+
67
+ describe('Day 4 — detectUser: productId isolation', () => {
68
+ it('seen flags are independent per productId', () => {
69
+ const configA: OnboardMeConfig = { ...baseConfig, productId: 'app-a' };
70
+ const configB: OnboardMeConfig = { ...baseConfig, productId: 'app-b' };
71
+
72
+ // Mark app-a as seen
73
+ detectUser(configA);
74
+ localStorage.setItem('onboardme_seen_app-a', '1');
75
+
76
+ // app-b should still be new
77
+ const resultB = detectUser(configB);
78
+ expect(resultB.isNew).toBe(true);
79
+ });
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // getAnonymousId
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('Day 4 — getAnonymousId', () => {
87
+ it('returns a non-empty string', () => {
88
+ const id = getAnonymousId('test-app');
89
+ expect(typeof id).toBe('string');
90
+ expect(id.length).toBeGreaterThan(0);
91
+ });
92
+
93
+ it('returns the same ID on repeated calls (stable across page loads)', () => {
94
+ const id1 = getAnonymousId('test-app');
95
+ const id2 = getAnonymousId('test-app');
96
+ expect(id1).toBe(id2);
97
+ });
98
+
99
+ it('stores the ID in localStorage', () => {
100
+ const id = getAnonymousId('test-app');
101
+ expect(localStorage.getItem('onboardme_anon_test-app')).toBe(id);
102
+ });
103
+
104
+ it('returns different IDs for different productIds', () => {
105
+ const idA = getAnonymousId('app-a');
106
+ const idB = getAnonymousId('app-b');
107
+ expect(idA).not.toBe(idB);
108
+ });
109
+
110
+ it('generates a valid UUID format', () => {
111
+ const id = getAnonymousId('test-app');
112
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
113
+ expect(id).toMatch(uuidRegex);
114
+ });
115
+ });