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,169 @@
1
+ /**
2
+ * Week 3 Day 1 — Progress Tracker (storage layer)
3
+ *
4
+ * Verifies all five functions in src/storage/progress-tracker.ts:
5
+ * loadProgress — reads from localStorage, returns default when absent/corrupt
6
+ * saveProgress — persists completedSteps + timestamp
7
+ * markStepComplete — idempotent write; no duplicate step IDs
8
+ * isStepComplete — boolean check against stored completedSteps
9
+ * clearProgress — removes the key entirely
10
+ *
11
+ * Storage key format: onboardme_progress_{productId}_{flowId}
12
+ */
13
+
14
+ import { beforeEach, describe, expect, it } from 'vitest';
15
+ import {
16
+ loadProgress,
17
+ saveProgress,
18
+ markStepComplete,
19
+ isStepComplete,
20
+ clearProgress,
21
+ } from '../storage/progress-tracker.js';
22
+
23
+ const PID = 'test-product';
24
+ const FID = 'flow-1';
25
+ const KEY = `onboardme_progress_${PID}_${FID}`;
26
+
27
+ beforeEach(() => {
28
+ localStorage.clear();
29
+ });
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // loadProgress
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('Week 3 Day 1 — loadProgress', () => {
36
+ it('returns { completedSteps: [], lastUpdated: 0 } when key is absent', () => {
37
+ const result = loadProgress(PID, FID);
38
+ expect(result.completedSteps).toEqual([]);
39
+ expect(result.lastUpdated).toBe(0);
40
+ });
41
+
42
+ it('returns the empty default when stored value is corrupted JSON', () => {
43
+ localStorage.setItem(KEY, '%%not-valid-json%%');
44
+ const result = loadProgress(PID, FID);
45
+ expect(result.completedSteps).toEqual([]);
46
+ expect(result.lastUpdated).toBe(0);
47
+ });
48
+
49
+ it('does not throw when localStorage contains corrupted JSON', () => {
50
+ localStorage.setItem(KEY, '{broken:');
51
+ expect(() => loadProgress(PID, FID)).not.toThrow();
52
+ });
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // saveProgress
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('Week 3 Day 1 — saveProgress', () => {
60
+ it('writes data under the correct localStorage key', () => {
61
+ saveProgress(PID, FID, ['step-1']);
62
+ expect(localStorage.getItem(KEY)).not.toBeNull();
63
+ });
64
+
65
+ it('persists the completedSteps array', () => {
66
+ saveProgress(PID, FID, ['step-1', 'step-2']);
67
+ const result = loadProgress(PID, FID);
68
+ expect(result.completedSteps).toEqual(['step-1', 'step-2']);
69
+ });
70
+
71
+ it('stores a non-zero lastUpdated timestamp', () => {
72
+ const before = Date.now();
73
+ saveProgress(PID, FID, ['step-1']);
74
+ const result = loadProgress(PID, FID);
75
+ expect(result.lastUpdated).toBeGreaterThanOrEqual(before);
76
+ });
77
+
78
+ it('overwrites previous progress for the same productId + flowId', () => {
79
+ saveProgress(PID, FID, ['step-1', 'step-2']);
80
+ saveProgress(PID, FID, ['step-3']);
81
+ const result = loadProgress(PID, FID);
82
+ expect(result.completedSteps).toEqual(['step-3']);
83
+ });
84
+
85
+ it('correctly persists an empty completedSteps array', () => {
86
+ saveProgress(PID, FID, []);
87
+ const result = loadProgress(PID, FID);
88
+ expect(result.completedSteps).toEqual([]);
89
+ });
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // markStepComplete
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe('Week 3 Day 1 — markStepComplete', () => {
97
+ it('adds the stepId to completedSteps', () => {
98
+ markStepComplete(PID, FID, 'step-a');
99
+ const result = loadProgress(PID, FID);
100
+ expect(result.completedSteps).toContain('step-a');
101
+ });
102
+
103
+ it('calling twice with the same stepId does not create a duplicate', () => {
104
+ markStepComplete(PID, FID, 'step-a');
105
+ markStepComplete(PID, FID, 'step-a');
106
+ const result = loadProgress(PID, FID);
107
+ expect(result.completedSteps.filter((id) => id === 'step-a')).toHaveLength(1);
108
+ });
109
+
110
+ it('accumulates multiple different step IDs', () => {
111
+ markStepComplete(PID, FID, 'step-a');
112
+ markStepComplete(PID, FID, 'step-b');
113
+ const result = loadProgress(PID, FID);
114
+ expect(result.completedSteps).toEqual(['step-a', 'step-b']);
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // isStepComplete
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('Week 3 Day 1 — isStepComplete', () => {
123
+ it('returns true for a step that has been marked complete', () => {
124
+ markStepComplete(PID, FID, 'step-x');
125
+ expect(isStepComplete(PID, FID, 'step-x')).toBe(true);
126
+ });
127
+
128
+ it('returns false for a step that has not been marked complete', () => {
129
+ expect(isStepComplete(PID, FID, 'step-x')).toBe(false);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // clearProgress
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe('Week 3 Day 1 — clearProgress', () => {
138
+ it('removes the localStorage key entirely', () => {
139
+ saveProgress(PID, FID, ['step-1']);
140
+ clearProgress(PID, FID);
141
+ expect(localStorage.getItem(KEY)).toBeNull();
142
+ });
143
+
144
+ it('loadProgress returns the empty default after clearProgress', () => {
145
+ saveProgress(PID, FID, ['step-1', 'step-2']);
146
+ clearProgress(PID, FID);
147
+ const result = loadProgress(PID, FID);
148
+ expect(result.completedSteps).toEqual([]);
149
+ expect(result.lastUpdated).toBe(0);
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Isolation
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe('Week 3 Day 1 — isolation', () => {
158
+ it('progress is isolated per productId — saving for product A does not affect product B', () => {
159
+ saveProgress('product-a', FID, ['step-1']);
160
+ const resultB = loadProgress('product-b', FID);
161
+ expect(resultB.completedSteps).toEqual([]);
162
+ });
163
+
164
+ it('progress is isolated per flowId — saving for flow A does not affect flow B', () => {
165
+ saveProgress(PID, 'flow-a', ['step-1']);
166
+ const resultB = loadProgress(PID, 'flow-b');
167
+ expect(resultB.completedSteps).toEqual([]);
168
+ });
169
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Week 3 Day 2 — Checklist HTML Structure (checklist.ts)
3
+ *
4
+ * Verifies renderChecklist() builds the correct DOM inside a Shadow DOM root:
5
+ * ✓ Panel appended to shadow root with correct class
6
+ * ✓ Header shows "Getting started" title + collapse button
7
+ * ✓ Progress label reflects completedSteps count
8
+ * ✓ Progress bar fill width reflects required-item completion ratio
9
+ * ✓ Items rendered in order field sequence (not array order)
10
+ * ✓ Completed items get .om-checklist__item--done
11
+ * ✓ optional items (required: false) get .om-checklist__item--optional + "Optional" badge
12
+ * ✓ Required items have no optional badge
13
+ * ✓ >7 items → console.warn fired, all items still rendered
14
+ * ✓ CSS injected into shadow root style element (not document.head)
15
+ * ✓ Calling renderChecklist twice replaces panel (no duplicate)
16
+ */
17
+
18
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
19
+ import { renderChecklist } from '../components/checklist.js';
20
+ import { getShadowRoot } from '../components/shadow-host.js';
21
+ import type { FlowConfig, FlowStep, ChecklistItem } from '@onboardme/types';
22
+ import type { Progress } from '../storage/progress-tracker.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
29
+ return {
30
+ id: 'item-1',
31
+ label: 'Do the thing',
32
+ order: 1,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function makeFlow(items: ChecklistItem[]): FlowConfig {
38
+ const checklistStep: FlowStep = {
39
+ id: 'cl-step',
40
+ type: 'checklist',
41
+ order: 1,
42
+ title: 'Getting started',
43
+ body: '',
44
+ items,
45
+ trigger: { type: 'page_load' },
46
+ completionGoal: 'onboarding_complete',
47
+ } as unknown as FlowStep;
48
+
49
+ return {
50
+ id: 'flow-1',
51
+ name: 'Onboarding',
52
+ steps: [checklistStep],
53
+ completionGoal: 'onboarding_complete',
54
+ priority: 1,
55
+ };
56
+ }
57
+
58
+ const EMPTY_PROGRESS: Progress = { completedSteps: [], lastUpdated: 0 };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Setup
62
+ // ---------------------------------------------------------------------------
63
+
64
+ let shadowRoot: ShadowRoot;
65
+
66
+ beforeEach(() => {
67
+ document.body.innerHTML = '';
68
+ shadowRoot = getShadowRoot();
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Panel structure
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('Week 3 Day 2 — renderChecklist panel structure', () => {
76
+ it('appends .om-checklist panel to the shadow root', () => {
77
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
78
+ const panel = shadowRoot.querySelector('.om-checklist');
79
+ expect(panel).not.toBeNull();
80
+ });
81
+
82
+ it('panel has data-onboardme="checklist" attribute', () => {
83
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
84
+ const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
85
+ expect(panel).not.toBeNull();
86
+ });
87
+
88
+ it('header contains the title "Getting started"', () => {
89
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
90
+ const title = shadowRoot.querySelector('.om-checklist__title');
91
+ expect(title?.textContent).toBe('Getting started');
92
+ });
93
+
94
+ it('header contains a collapse button', () => {
95
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
96
+ const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
97
+ expect(btn).not.toBeNull();
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Progress label + bar
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe('Week 3 Day 2 — progress label', () => {
106
+ it('shows "0 of N complete" when no items are done', () => {
107
+ const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
108
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
109
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
110
+ expect(label?.textContent).toBe('0 of 2 complete');
111
+ });
112
+
113
+ it('shows "X of N complete" when some items are done', () => {
114
+ const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 }), makeItem({ id: 'c', order: 3 })];
115
+ const progress: Progress = { completedSteps: ['a', 'b'], lastUpdated: 1 };
116
+ renderChecklist(makeFlow(items), progress, shadowRoot);
117
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
118
+ expect(label?.textContent).toBe('2 of 3 complete');
119
+ });
120
+
121
+ it('progress bar fill width reflects required completion ratio', () => {
122
+ const items = [
123
+ makeItem({ id: 'a', order: 1, required: true }),
124
+ makeItem({ id: 'b', order: 2, required: true }),
125
+ makeItem({ id: 'c', order: 3, required: false }),
126
+ ];
127
+ // 1 of 2 required done = 50%
128
+ const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
129
+ renderChecklist(makeFlow(items), progress, shadowRoot);
130
+ const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
131
+ expect(fill?.style.width).toBe('50%');
132
+ });
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Item ordering
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('Week 3 Day 2 — item ordering', () => {
140
+ it('renders items in order field sequence regardless of array order', () => {
141
+ const items = [
142
+ makeItem({ id: 'c', label: 'Third', order: 3 }),
143
+ makeItem({ id: 'a', label: 'First', order: 1 }),
144
+ makeItem({ id: 'b', label: 'Second', order: 2 }),
145
+ ];
146
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
147
+ const rendered = shadowRoot.querySelectorAll('.om-checklist__item');
148
+ expect(rendered[0]?.getAttribute('data-item-id')).toBe('a');
149
+ expect(rendered[1]?.getAttribute('data-item-id')).toBe('b');
150
+ expect(rendered[2]?.getAttribute('data-item-id')).toBe('c');
151
+ });
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Done state
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('Week 3 Day 2 — done state', () => {
159
+ it('completed item has .om-checklist__item--done', () => {
160
+ const items = [makeItem({ id: 'done-item', order: 1 })];
161
+ const progress: Progress = { completedSteps: ['done-item'], lastUpdated: 1 };
162
+ renderChecklist(makeFlow(items), progress, shadowRoot);
163
+ const li = shadowRoot.querySelector('[data-item-id="done-item"]');
164
+ expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
165
+ });
166
+
167
+ it('uncompleted item does NOT have .om-checklist__item--done', () => {
168
+ const items = [makeItem({ id: 'todo-item', order: 1 })];
169
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
170
+ const li = shadowRoot.querySelector('[data-item-id="todo-item"]');
171
+ expect(li?.classList.contains('om-checklist__item--done')).toBe(false);
172
+ });
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Optional badge
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('Week 3 Day 2 — optional badge', () => {
180
+ it('item with required: false has .om-checklist__item--optional class', () => {
181
+ const items = [makeItem({ id: 'opt', order: 1, required: false })];
182
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
183
+ const li = shadowRoot.querySelector('[data-item-id="opt"]');
184
+ expect(li?.classList.contains('om-checklist__item--optional')).toBe(true);
185
+ });
186
+
187
+ it('item with required: false shows "Optional" badge in the DOM', () => {
188
+ const items = [makeItem({ id: 'opt', order: 1, required: false })];
189
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
190
+ const badge = shadowRoot.querySelector('[data-item-id="opt"] .om-badge');
191
+ expect(badge?.textContent).toBe('Optional');
192
+ });
193
+
194
+ it('required item does NOT have the optional badge', () => {
195
+ const items = [makeItem({ id: 'req', order: 1, required: true })];
196
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot);
197
+ const badge = shadowRoot.querySelector('[data-item-id="req"] .om-badge');
198
+ expect(badge).toBeNull();
199
+ });
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // >7 items warning
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe('Week 3 Day 2 — >7 items warning', () => {
207
+ it('logs a console.warn when more than 7 items are present (debug mode)', () => {
208
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
209
+ const items = Array.from({ length: 8 }, (_, i) =>
210
+ makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
211
+ );
212
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
213
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('more than 7 items'));
214
+ warnSpy.mockRestore();
215
+ });
216
+
217
+ it('still renders all items even when >7 are present', () => {
218
+ const items = Array.from({ length: 8 }, (_, i) =>
219
+ makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
220
+ );
221
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
222
+ const rendered = shadowRoot.querySelectorAll('.om-checklist__item');
223
+ expect(rendered).toHaveLength(8);
224
+ });
225
+
226
+ it('does NOT warn when 7 or fewer items are present', () => {
227
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
228
+ const items = Array.from({ length: 7 }, (_, i) =>
229
+ makeItem({ id: `item-${i}`, label: `Item ${i}`, order: i + 1 }),
230
+ );
231
+ renderChecklist(makeFlow(items), EMPTY_PROGRESS, shadowRoot, true);
232
+ expect(warnSpy).not.toHaveBeenCalled();
233
+ warnSpy.mockRestore();
234
+ });
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Style isolation
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe('Week 3 Day 2 — style isolation', () => {
242
+ it('injects CSS into the shadow root style element', () => {
243
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
244
+ const style = shadowRoot.querySelector('style');
245
+ expect(style?.textContent).toContain('.om-checklist');
246
+ });
247
+
248
+ it('does not inject checklist CSS into document.head', () => {
249
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
250
+ const headStyles = document.head.querySelectorAll('style');
251
+ const leaked = Array.from(headStyles).some((s) => s.textContent?.includes('.om-checklist'));
252
+ expect(leaked).toBe(false);
253
+ });
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Re-render deduplication
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('Week 3 Day 2 — re-render deduplication', () => {
261
+ it('calling renderChecklist twice does not create a duplicate panel', () => {
262
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
263
+ renderChecklist(makeFlow([makeItem()]), EMPTY_PROGRESS, shadowRoot);
264
+ const panels = shadowRoot.querySelectorAll('[data-onboardme="checklist"]');
265
+ expect(panels).toHaveLength(1);
266
+ });
267
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Week 3 Day 3 — Checklist Interactivity + Persistence
3
+ *
4
+ * Verifies the click and collapse behaviour added to checklist.ts:
5
+ * ✓ Clicking unchecked item → adds .om-checklist__item--done + check mark
6
+ * ✓ Clicking unchecked item → calls markStepComplete (verified via isStepComplete)
7
+ * ✓ Clicking unchecked item → updates progress label
8
+ * ✓ Clicking unchecked item → updates progress bar fill
9
+ * ✓ Clicking already-done item → no change (progress is additive, not a toggle)
10
+ * ✓ Clicking already-done item → no duplicate in completedSteps
11
+ * ✓ Collapse button → adds .om-checklist--collapsed to the panel
12
+ * ✓ Clicking collapsed panel → removes .om-checklist--collapsed
13
+ * ✓ Collapsed pill shows correct "X / N" count
14
+ * ✓ Collapse state is NOT persisted — renderChecklist always renders expanded
15
+ */
16
+
17
+ import { beforeEach, describe, expect, it } from 'vitest';
18
+ import { renderChecklist } from '../components/checklist.js';
19
+ import { getShadowRoot } from '../components/shadow-host.js';
20
+ import { isStepComplete, clearProgress } from '../storage/progress-tracker.js';
21
+ import type { FlowConfig, FlowStep, ChecklistItem } from '@onboardme/types';
22
+ import type { Progress } from '../storage/progress-tracker.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const PID = 'day3-product';
29
+ const FID = 'day3-flow';
30
+
31
+ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
32
+ return { id: 'item-1', label: 'Do the thing', order: 1, ...overrides };
33
+ }
34
+
35
+ function makeFlow(items: ChecklistItem[]): FlowConfig {
36
+ const step: FlowStep = {
37
+ id: 'cl-step',
38
+ type: 'checklist',
39
+ order: 1,
40
+ title: 'Getting started',
41
+ body: '',
42
+ items,
43
+ trigger: { type: 'page_load' },
44
+ completionGoal: 'done',
45
+ } as unknown as FlowStep;
46
+ return { id: FID, name: 'Onboarding', steps: [step], completionGoal: 'done', priority: 1 };
47
+ }
48
+
49
+ const EMPTY: Progress = { completedSteps: [], lastUpdated: 0 };
50
+
51
+ function click(el: Element | null): void {
52
+ el?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Setup
57
+ // ---------------------------------------------------------------------------
58
+
59
+ let shadowRoot: ShadowRoot;
60
+
61
+ beforeEach(() => {
62
+ document.body.innerHTML = '';
63
+ localStorage.clear();
64
+ shadowRoot = getShadowRoot();
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Item click — visual
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('Week 3 Day 3 — item click (visual)', () => {
72
+ it('clicking an unchecked item adds .om-checklist__item--done', () => {
73
+ renderChecklist(makeFlow([makeItem({ id: 'a' })]), EMPTY, shadowRoot);
74
+ const li = shadowRoot.querySelector('[data-item-id="a"]');
75
+ click(li);
76
+ expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
77
+ });
78
+
79
+ it('clicking an unchecked item shows the check mark inside the circle', () => {
80
+ renderChecklist(makeFlow([makeItem({ id: 'a' })]), EMPTY, shadowRoot);
81
+ const li = shadowRoot.querySelector('[data-item-id="a"]');
82
+ click(li);
83
+ const checkEl = li?.querySelector('.om-checklist__item-check');
84
+ expect(checkEl?.textContent).toBe('✓');
85
+ });
86
+
87
+ it('clicking an already-done item does not change its classes', () => {
88
+ const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
89
+ renderChecklist(makeFlow([makeItem({ id: 'a' })]), progress, shadowRoot);
90
+ const li = shadowRoot.querySelector('[data-item-id="a"]');
91
+ click(li);
92
+ // Still has done class — nothing was removed
93
+ expect(li?.classList.contains('om-checklist__item--done')).toBe(true);
94
+ // classList length is the same as before the click
95
+ const classList = Array.from(li?.classList ?? []);
96
+ expect(classList.filter((c) => c === 'om-checklist__item--done')).toHaveLength(1);
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Item click — persistence
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('Week 3 Day 3 — item click (persistence)', () => {
105
+ it('clicking an unchecked item calls markStepComplete (isStepComplete returns true)', () => {
106
+ renderChecklist(makeFlow([makeItem({ id: 'step-a' })]), EMPTY, shadowRoot, false, PID, FID);
107
+ click(shadowRoot.querySelector('[data-item-id="step-a"]'));
108
+ expect(isStepComplete(PID, FID, 'step-a')).toBe(true);
109
+ });
110
+
111
+ it('clicking the same item twice does not add a duplicate to completedSteps', () => {
112
+ clearProgress(PID, FID);
113
+ renderChecklist(makeFlow([makeItem({ id: 'step-b' })]), EMPTY, shadowRoot, false, PID, FID);
114
+ click(shadowRoot.querySelector('[data-item-id="step-b"]'));
115
+ click(shadowRoot.querySelector('[data-item-id="step-b"]'));
116
+ // Read progress directly from localStorage via loadProgress indirection
117
+ // We can verify via the label — should still say "1 of 1 complete"
118
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
119
+ expect(label?.textContent).toBe('1 of 1 complete');
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Item click — progress bar + label
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe('Week 3 Day 3 — item click (progress updates)', () => {
128
+ it('clicking an item updates the progress label', () => {
129
+ const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
130
+ renderChecklist(makeFlow(items), EMPTY, shadowRoot);
131
+ click(shadowRoot.querySelector('[data-item-id="a"]'));
132
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
133
+ expect(label?.textContent).toBe('1 of 2 complete');
134
+ });
135
+
136
+ it('clicking an item updates the progress bar fill', () => {
137
+ const items = [
138
+ makeItem({ id: 'a', order: 1, required: true }),
139
+ makeItem({ id: 'b', order: 2, required: true }),
140
+ ];
141
+ renderChecklist(makeFlow(items), EMPTY, shadowRoot);
142
+ click(shadowRoot.querySelector('[data-item-id="a"]'));
143
+ const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
144
+ expect(fill?.style.width).toBe('50%');
145
+ });
146
+
147
+ it('optional items count in the label but not in the required bar fill', () => {
148
+ const items = [
149
+ makeItem({ id: 'req', order: 1, required: true }),
150
+ makeItem({ id: 'opt', order: 2, required: false }),
151
+ ];
152
+ // Complete the optional item — bar should stay at 0%, label says 1 of 2
153
+ renderChecklist(makeFlow(items), EMPTY, shadowRoot);
154
+ click(shadowRoot.querySelector('[data-item-id="opt"]'));
155
+ const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
156
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
157
+ expect(fill?.style.width).toBe('0%');
158
+ expect(label?.textContent).toBe('1 of 2 complete');
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Collapse / expand
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('Week 3 Day 3 — collapse / expand', () => {
167
+ it('collapse button adds .om-checklist--collapsed to the panel', () => {
168
+ renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
169
+ const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
170
+ click(btn);
171
+ const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
172
+ expect(panel?.classList.contains('om-checklist--collapsed')).toBe(true);
173
+ });
174
+
175
+ it('clicking the collapsed panel removes .om-checklist--collapsed', () => {
176
+ renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
177
+ const btn = shadowRoot.querySelector('[data-onboardme="checklist-collapse"]');
178
+ const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
179
+ click(btn);
180
+ expect(panel?.classList.contains('om-checklist--collapsed')).toBe(true);
181
+ click(panel);
182
+ expect(panel?.classList.contains('om-checklist--collapsed')).toBe(false);
183
+ });
184
+
185
+ it('collapsed pill shows correct "X / N" count', () => {
186
+ const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
187
+ const progress: Progress = { completedSteps: ['a'], lastUpdated: 1 };
188
+ renderChecklist(makeFlow(items), progress, shadowRoot);
189
+ const countEl = shadowRoot.querySelector('[data-onboardme="collapsed-count"]');
190
+ expect(countEl?.textContent).toBe('1 / 2');
191
+ });
192
+
193
+ it('collapsed count updates when an item is checked then collapsed', () => {
194
+ const items = [makeItem({ id: 'a', order: 1 }), makeItem({ id: 'b', order: 2 })];
195
+ renderChecklist(makeFlow(items), EMPTY, shadowRoot);
196
+ // Check item a
197
+ click(shadowRoot.querySelector('[data-item-id="a"]'));
198
+ // Now collapse
199
+ click(shadowRoot.querySelector('[data-onboardme="checklist-collapse"]'));
200
+ const countEl = shadowRoot.querySelector('[data-onboardme="collapsed-count"]');
201
+ expect(countEl?.textContent).toBe('1 / 2');
202
+ });
203
+
204
+ it('collapse state is not persisted — renderChecklist always renders expanded', () => {
205
+ renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
206
+ // Collapse it
207
+ click(shadowRoot.querySelector('[data-onboardme="checklist-collapse"]'));
208
+ // Re-render (simulates page reload)
209
+ renderChecklist(makeFlow([makeItem()]), EMPTY, shadowRoot);
210
+ const panel = shadowRoot.querySelector('[data-onboardme="checklist"]');
211
+ expect(panel?.classList.contains('om-checklist--collapsed')).toBe(false);
212
+ });
213
+ });