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,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
|
+
});
|