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,277 @@
1
+ /**
2
+ * Week 4 Day 1 — Event Queue + API Client
3
+ *
4
+ * Tests for:
5
+ * - src/core/event-batcher.ts (pushEvent, flushEvents, configureBatcher, queue limits)
6
+ * - src/core/api-client.ts (postEvents: success, HTTP error, network error, timeout)
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import {
11
+ configureBatcher,
12
+ pushEvent,
13
+ flushEvents,
14
+ _resetBatcher,
15
+ _getQueue,
16
+ } from '../core/event-batcher.js';
17
+ import { postEvents } from '../core/api-client.js';
18
+ import { setDebug } from '../utils/logger.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Shared setup
22
+ // ---------------------------------------------------------------------------
23
+
24
+ beforeEach(() => {
25
+ _resetBatcher();
26
+ vi.useFakeTimers();
27
+ vi.spyOn(console, 'log').mockImplementation(() => {});
28
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // configureBatcher
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe('Week 4 Day 1 — configureBatcher', () => {
41
+ it('sets the endpoint used during flush', async () => {
42
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
43
+ vi.stubGlobal('fetch', fetchMock);
44
+
45
+ configureBatcher('http://localhost:3001/events', 'anon-123');
46
+ pushEvent('flow_started');
47
+ await flushEvents();
48
+
49
+ expect(fetchMock).toHaveBeenCalledWith(
50
+ 'http://localhost:3001/events',
51
+ expect.objectContaining({ method: 'POST' }),
52
+ );
53
+ });
54
+
55
+ it('warns and does not send if flush is called before configureBatcher', async () => {
56
+ setDebug(true);
57
+ pushEvent('flow_started');
58
+ await flushEvents();
59
+ expect(console.warn).toHaveBeenCalled();
60
+ setDebug(false);
61
+ });
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // pushEvent
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe('Week 4 Day 1 — pushEvent', () => {
69
+ it('adds an event to the queue', () => {
70
+ configureBatcher('http://localhost:3001/events', 'anon-1');
71
+ pushEvent('flow_started');
72
+ expect(_getQueue()).toHaveLength(1);
73
+ });
74
+
75
+ it('auto-fills eventId as a UUID', () => {
76
+ configureBatcher('http://localhost:3001/events', 'anon-1');
77
+ pushEvent('step_viewed');
78
+ const [event] = _getQueue();
79
+ expect(event.eventId).toMatch(
80
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
81
+ );
82
+ });
83
+
84
+ it('auto-fills anonymousId from configureBatcher', () => {
85
+ configureBatcher('http://localhost:3001/events', 'anon-xyz');
86
+ pushEvent('step_viewed');
87
+ expect(_getQueue()[0].anonymousId).toBe('anon-xyz');
88
+ });
89
+
90
+ it('auto-fills timestamp as a non-zero number', () => {
91
+ configureBatcher('http://localhost:3001/events', 'anon-1');
92
+ pushEvent('step_viewed');
93
+ expect(_getQueue()[0].timestamp).toBeGreaterThan(0);
94
+ });
95
+
96
+ it('sets the provided eventType', () => {
97
+ configureBatcher('http://localhost:3001/events', 'anon-1');
98
+ pushEvent('checklist_item_done', { flowId: 'f1', stepId: 's1' });
99
+ expect(_getQueue()[0].eventType).toBe('checklist_item_done');
100
+ });
101
+
102
+ it('preserves optional fields passed in partial', () => {
103
+ configureBatcher('http://localhost:3001/events', 'anon-1');
104
+ pushEvent('step_completed', { flowId: 'flow-1', stepId: 'step-1' });
105
+ const event = _getQueue()[0];
106
+ expect(event.flowId).toBe('flow-1');
107
+ expect(event.stepId).toBe('step-1');
108
+ });
109
+
110
+ it('accumulates multiple events', () => {
111
+ configureBatcher('http://localhost:3001/events', 'anon-1');
112
+ pushEvent('flow_started');
113
+ pushEvent('step_viewed');
114
+ pushEvent('step_completed');
115
+ expect(_getQueue()).toHaveLength(3);
116
+ });
117
+
118
+ it('drops the oldest event when queue exceeds 100 entries', () => {
119
+ configureBatcher('http://localhost:3001/events', 'anon-1');
120
+ for (let i = 0; i < 100; i++) pushEvent('step_viewed', { properties: { i } });
121
+ const firstId = _getQueue()[0].eventId;
122
+ pushEvent('flow_started'); // triggers drop
123
+ expect(_getQueue()).toHaveLength(100);
124
+ expect(_getQueue()[0].eventId).not.toBe(firstId); // oldest was dropped
125
+ expect(_getQueue()[99].eventType).toBe('flow_started');
126
+ });
127
+
128
+ it('warns when queue is full and an event is dropped', () => {
129
+ setDebug(true);
130
+ configureBatcher('http://localhost:3001/events', 'anon-1');
131
+ for (let i = 0; i < 101; i++) pushEvent('step_viewed');
132
+ expect(console.warn).toHaveBeenCalled();
133
+ setDebug(false);
134
+ });
135
+
136
+ it('starts the 5s auto-flush timer on the first event', async () => {
137
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
138
+ vi.stubGlobal('fetch', fetchMock);
139
+
140
+ configureBatcher('http://localhost:3001/events', 'anon-1');
141
+ pushEvent('flow_started');
142
+
143
+ expect(fetchMock).not.toHaveBeenCalled();
144
+ await vi.advanceTimersByTimeAsync(5_000);
145
+ expect(fetchMock).toHaveBeenCalled();
146
+ });
147
+
148
+ it('does not start a second timer when more events are pushed', async () => {
149
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
150
+ vi.stubGlobal('fetch', fetchMock);
151
+
152
+ configureBatcher('http://localhost:3001/events', 'anon-1');
153
+ pushEvent('flow_started');
154
+ pushEvent('step_viewed');
155
+ pushEvent('step_completed');
156
+
157
+ await vi.advanceTimersByTimeAsync(5_000);
158
+ expect(fetchMock).toHaveBeenCalledTimes(1); // only one flush
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // flushEvents
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('Week 4 Day 1 — flushEvents', () => {
167
+ it('does nothing when the queue is empty', async () => {
168
+ const fetchMock = vi.fn();
169
+ vi.stubGlobal('fetch', fetchMock);
170
+ configureBatcher('http://localhost:3001/events', 'anon-1');
171
+ await flushEvents();
172
+ expect(fetchMock).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('sends all queued events in a single POST', async () => {
176
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
177
+ vi.stubGlobal('fetch', fetchMock);
178
+
179
+ configureBatcher('http://localhost:3001/events', 'anon-1');
180
+ pushEvent('flow_started');
181
+ pushEvent('step_viewed');
182
+ await flushEvents();
183
+
184
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
185
+ expect(body.events).toHaveLength(2);
186
+ });
187
+
188
+ it('clears the queue on success', async () => {
189
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
190
+ configureBatcher('http://localhost:3001/events', 'anon-1');
191
+ pushEvent('flow_started');
192
+ await flushEvents();
193
+ expect(_getQueue()).toHaveLength(0);
194
+ });
195
+
196
+ it('retains the queue on server error (non-2xx)', async () => {
197
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
198
+ configureBatcher('http://localhost:3001/events', 'anon-1');
199
+ pushEvent('flow_started');
200
+ await flushEvents();
201
+ expect(_getQueue()).toHaveLength(1); // retained for retry
202
+ });
203
+
204
+ it('retains the queue on network error', async () => {
205
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
206
+ configureBatcher('http://localhost:3001/events', 'anon-1');
207
+ pushEvent('flow_started');
208
+ await flushEvents();
209
+ expect(_getQueue()).toHaveLength(1);
210
+ });
211
+
212
+ it('cancels the auto-flush timer when called manually', async () => {
213
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
214
+ vi.stubGlobal('fetch', fetchMock);
215
+
216
+ configureBatcher('http://localhost:3001/events', 'anon-1');
217
+ pushEvent('flow_started');
218
+ await flushEvents(); // manual flush
219
+ fetchMock.mockClear();
220
+
221
+ await vi.advanceTimersByTimeAsync(5_000); // timer should be cancelled
222
+ expect(fetchMock).not.toHaveBeenCalled();
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // postEvents (api-client)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('Week 4 Day 1 — postEvents (api-client)', () => {
231
+ it('returns true on a 2xx response', async () => {
232
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
233
+ const result = await postEvents('http://localhost:3001/events', []);
234
+ expect(result).toBe(true);
235
+ });
236
+
237
+ it('returns false on a non-2xx response', async () => {
238
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
239
+ const result = await postEvents('http://localhost:3001/events', []);
240
+ expect(result).toBe(false);
241
+ });
242
+
243
+ it('returns false on a network error', async () => {
244
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Failed to fetch')));
245
+ const result = await postEvents('http://localhost:3001/events', []);
246
+ expect(result).toBe(false);
247
+ });
248
+
249
+ it('never throws — resolves even on network failure', async () => {
250
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('timeout')));
251
+ await expect(postEvents('http://localhost:3001/events', [])).resolves.toBeDefined();
252
+ });
253
+
254
+ it('sends events as JSON body under the "events" key', async () => {
255
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
256
+ vi.stubGlobal('fetch', fetchMock);
257
+
258
+ const mockEvent = {
259
+ eventId: 'e1',
260
+ anonymousId: 'a1',
261
+ eventType: 'flow_started' as const,
262
+ pageUrl: 'http://localhost',
263
+ timestamp: 1000,
264
+ };
265
+ await postEvents('http://localhost:3001/events', [mockEvent]);
266
+
267
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
268
+ expect(body.events[0].eventId).toBe('e1');
269
+ });
270
+
271
+ it('uses POST method', async () => {
272
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
273
+ vi.stubGlobal('fetch', fetchMock);
274
+ await postEvents('http://localhost:3001/events', []);
275
+ expect(fetchMock.mock.calls[0][1].method).toBe('POST');
276
+ });
277
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Week 4 Day 2 — Flush Mechanisms
3
+ *
4
+ * Tests for attachFlushListeners() in src/core/event-batcher.ts:
5
+ * - visibilitychange → flush via fetch when tab hidden
6
+ * - beforeunload → flush via sendBeacon (fire-and-forget)
7
+ * - duplicate registration guard (listeners attached only once)
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import {
12
+ configureBatcher,
13
+ pushEvent,
14
+ flushEvents,
15
+ attachFlushListeners,
16
+ _resetBatcher,
17
+ _detachFlushListeners,
18
+ _getQueue,
19
+ } from '../core/event-batcher.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function fireVisibilityChange(state: 'hidden' | 'visible') {
26
+ Object.defineProperty(document, 'visibilityState', {
27
+ value: state,
28
+ configurable: true,
29
+ });
30
+ document.dispatchEvent(new Event('visibilitychange'));
31
+ }
32
+
33
+ function fireBeforeUnload() {
34
+ window.dispatchEvent(new Event('beforeunload'));
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Setup
39
+ // ---------------------------------------------------------------------------
40
+
41
+ beforeEach(() => {
42
+ _resetBatcher();
43
+ vi.useFakeTimers();
44
+ vi.spyOn(console, 'log').mockImplementation(() => {});
45
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
46
+ });
47
+
48
+ afterEach(() => {
49
+ _detachFlushListeners(); // remove jsdom listeners so they don't accumulate across tests
50
+ vi.useRealTimers();
51
+ vi.restoreAllMocks();
52
+ Object.defineProperty(document, 'visibilityState', {
53
+ value: 'visible',
54
+ configurable: true,
55
+ });
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // visibilitychange
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('Week 4 Day 2 — visibilitychange flush', () => {
63
+ it('flushes events when the tab becomes hidden', async () => {
64
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
65
+ vi.stubGlobal('fetch', fetchMock);
66
+
67
+ configureBatcher('http://localhost:3001/events', 'anon-1');
68
+ attachFlushListeners();
69
+ pushEvent('flow_started');
70
+
71
+ fireVisibilityChange('hidden');
72
+ await vi.runAllTimersAsync();
73
+
74
+ expect(fetchMock).toHaveBeenCalled();
75
+ });
76
+
77
+ it('clears the queue after a successful visibility flush', async () => {
78
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
79
+
80
+ configureBatcher('http://localhost:3001/events', 'anon-1');
81
+ attachFlushListeners();
82
+ pushEvent('flow_started');
83
+ pushEvent('step_viewed');
84
+
85
+ fireVisibilityChange('hidden');
86
+ await vi.runAllTimersAsync();
87
+
88
+ expect(_getQueue()).toHaveLength(0);
89
+ });
90
+
91
+ it('does NOT flush when tab becomes visible (only on hidden)', async () => {
92
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
93
+ vi.stubGlobal('fetch', fetchMock);
94
+
95
+ configureBatcher('http://localhost:3001/events', 'anon-1');
96
+ attachFlushListeners();
97
+ pushEvent('flow_started');
98
+
99
+ fireVisibilityChange('visible');
100
+ // Use Promise.resolve() to flush microtasks only — does NOT advance the 5s auto-flush timer
101
+ await Promise.resolve();
102
+
103
+ expect(fetchMock).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('does nothing when the queue is empty on visibilitychange', async () => {
107
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
108
+ vi.stubGlobal('fetch', fetchMock);
109
+
110
+ configureBatcher('http://localhost:3001/events', 'anon-1');
111
+ attachFlushListeners();
112
+ // no events pushed
113
+
114
+ fireVisibilityChange('hidden');
115
+ await vi.runAllTimersAsync();
116
+
117
+ expect(fetchMock).not.toHaveBeenCalled();
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // beforeunload / sendBeacon
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('Week 4 Day 2 — beforeunload sendBeacon flush', () => {
126
+ it('calls sendBeacon on beforeunload when there are queued events', () => {
127
+ const beaconMock = vi.fn().mockReturnValue(true);
128
+ vi.stubGlobal('navigator', { sendBeacon: beaconMock });
129
+
130
+ configureBatcher('http://localhost:3001/events', 'anon-1');
131
+ attachFlushListeners();
132
+ pushEvent('flow_started');
133
+
134
+ fireBeforeUnload();
135
+
136
+ expect(beaconMock).toHaveBeenCalledWith(
137
+ 'http://localhost:3001/events',
138
+ expect.any(Blob),
139
+ );
140
+ });
141
+
142
+ it('sends events JSON inside the Blob payload', () => {
143
+ const beaconMock = vi.fn().mockReturnValue(true);
144
+ vi.stubGlobal('navigator', { sendBeacon: beaconMock });
145
+
146
+ configureBatcher('http://localhost:3001/events', 'anon-1');
147
+ attachFlushListeners();
148
+ pushEvent('flow_started');
149
+
150
+ fireBeforeUnload();
151
+
152
+ const blob: Blob = beaconMock.mock.calls[0][1];
153
+ expect(blob.type).toBe('application/json');
154
+ });
155
+
156
+ it('clears the queue after sendBeacon returns true', () => {
157
+ vi.stubGlobal('navigator', { sendBeacon: vi.fn().mockReturnValue(true) });
158
+
159
+ configureBatcher('http://localhost:3001/events', 'anon-1');
160
+ attachFlushListeners();
161
+ pushEvent('flow_started');
162
+ pushEvent('step_viewed');
163
+
164
+ fireBeforeUnload();
165
+
166
+ expect(_getQueue()).toHaveLength(0);
167
+ });
168
+
169
+ it('retains the queue if sendBeacon returns false', () => {
170
+ vi.stubGlobal('navigator', { sendBeacon: vi.fn().mockReturnValue(false) });
171
+
172
+ configureBatcher('http://localhost:3001/events', 'anon-1');
173
+ attachFlushListeners();
174
+ pushEvent('flow_started');
175
+
176
+ fireBeforeUnload();
177
+
178
+ expect(_getQueue()).toHaveLength(1);
179
+ });
180
+
181
+ it('does not call sendBeacon when queue is empty', () => {
182
+ const beaconMock = vi.fn();
183
+ vi.stubGlobal('navigator', { sendBeacon: beaconMock });
184
+
185
+ configureBatcher('http://localhost:3001/events', 'anon-1');
186
+ attachFlushListeners();
187
+ // no events pushed
188
+
189
+ fireBeforeUnload();
190
+
191
+ expect(beaconMock).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('does not call sendBeacon when endpoint is not configured', () => {
195
+ const beaconMock = vi.fn();
196
+ vi.stubGlobal('navigator', { sendBeacon: beaconMock });
197
+
198
+ attachFlushListeners(); // no configureBatcher
199
+ pushEvent('flow_started');
200
+
201
+ fireBeforeUnload();
202
+
203
+ expect(beaconMock).not.toHaveBeenCalled();
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Duplicate registration guard
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('Week 4 Day 2 — duplicate listener guard', () => {
212
+ it('calling attachFlushListeners twice only registers listeners once', async () => {
213
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
214
+ vi.stubGlobal('fetch', fetchMock);
215
+
216
+ configureBatcher('http://localhost:3001/events', 'anon-1');
217
+ attachFlushListeners();
218
+ attachFlushListeners(); // second call should be a no-op
219
+ pushEvent('flow_started');
220
+
221
+ fireVisibilityChange('hidden');
222
+ await vi.runAllTimersAsync();
223
+
224
+ // fetch called exactly once — not twice due to duplicate listener
225
+ expect(fetchMock).toHaveBeenCalledTimes(1);
226
+ });
227
+ });