@xiboplayer/utils 0.1.0

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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * EventEmitter Tests
3
+ *
4
+ * Contract-based testing for EventEmitter module
5
+ * Tests all pre/post conditions, invariants, and edge cases
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
9
+ import { EventEmitter } from './event-emitter.js';
10
+
11
+ /** Simple spy factory — wraps vi.fn() for readability */
12
+ const createSpy = () => vi.fn();
13
+
14
+ describe('EventEmitter', () => {
15
+ let emitter;
16
+
17
+ beforeEach(() => {
18
+ emitter = new EventEmitter();
19
+ });
20
+
21
+ describe('on(event, callback)', () => {
22
+ it('should satisfy contract: registers callback for event', () => {
23
+ const callback = createSpy();
24
+
25
+ // Pre-condition: emitter has no listeners
26
+ expect(emitter.events.size).toBe(0);
27
+
28
+ // Execute
29
+ emitter.on('test', callback);
30
+
31
+ // Post-condition: callback registered
32
+ expect(emitter.events.has('test')).toBe(true);
33
+ expect(emitter.events.get('test')).toContain(callback);
34
+ });
35
+
36
+ it('should allow same callback to be registered multiple times', () => {
37
+ const callback = createSpy();
38
+
39
+ emitter.on('test', callback);
40
+ emitter.on('test', callback);
41
+ emitter.on('test', callback);
42
+
43
+ // Invariant: Same callback can be registered multiple times
44
+ expect(emitter.events.get('test').length).toBe(3);
45
+
46
+ // When emitted, called for each registration
47
+ emitter.emit('test');
48
+ expect(callback).toHaveBeenCalledTimes(3);
49
+ });
50
+
51
+ it('should create event array if not exists', () => {
52
+ const callback = createSpy();
53
+
54
+ emitter.on('new-event', callback);
55
+
56
+ expect(emitter.events.has('new-event')).toBe(true);
57
+ expect(Array.isArray(emitter.events.get('new-event'))).toBe(true);
58
+ });
59
+
60
+ it('should support multiple callbacks for same event', () => {
61
+ const callback1 = createSpy();
62
+ const callback2 = createSpy();
63
+ const callback3 = createSpy();
64
+
65
+ emitter.on('test', callback1);
66
+ emitter.on('test', callback2);
67
+ emitter.on('test', callback3);
68
+
69
+ expect(emitter.events.get('test').length).toBe(3);
70
+ });
71
+ });
72
+
73
+ describe('once(event, callback)', () => {
74
+ it('should satisfy contract: callback called once then removed', () => {
75
+ const callback = createSpy();
76
+
77
+ // Pre-condition: no listeners
78
+ expect(emitter.events.size).toBe(0);
79
+
80
+ // Execute
81
+ emitter.once('test', callback);
82
+
83
+ // Post-condition: wrapper registered
84
+ expect(emitter.events.has('test')).toBe(true);
85
+ expect(emitter.events.get('test').length).toBe(1);
86
+
87
+ // Emit first time
88
+ emitter.emit('test', 'arg1');
89
+
90
+ // Invariant: callback called
91
+ expect(callback).toHaveBeenCalledTimes(1);
92
+ expect(callback).toHaveBeenCalledWith('arg1');
93
+
94
+ // Invariant: callback removed after emission
95
+ expect(emitter.events.get('test').length).toBe(0);
96
+
97
+ // Emit second time
98
+ emitter.emit('test', 'arg2');
99
+
100
+ // Invariant: callback NOT called again
101
+ expect(callback).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('should remove wrapper correctly', () => {
105
+ const callback = createSpy();
106
+
107
+ emitter.once('test', callback);
108
+
109
+ // Before emission: 1 listener (wrapper)
110
+ const listenersBeforeEmit = emitter.events.get('test');
111
+ expect(listenersBeforeEmit.length).toBe(1);
112
+
113
+ // Emit
114
+ emitter.emit('test');
115
+
116
+ // After emission: 0 listeners
117
+ expect(emitter.events.get('test').length).toBe(0);
118
+ });
119
+
120
+ it('should support multiple once() listeners', () => {
121
+ const callback1 = createSpy();
122
+ const callback2 = createSpy();
123
+
124
+ emitter.once('test', callback1);
125
+ emitter.once('test', callback2);
126
+
127
+ // Both wrappers registered
128
+ expect(emitter.events.get('test').length).toBe(2);
129
+
130
+ // Emit
131
+ emitter.emit('test', 'data');
132
+
133
+ // Both called (array is copied before iteration)
134
+ expect(callback1).toHaveBeenCalledWith('data');
135
+ expect(callback2).toHaveBeenCalledWith('data');
136
+
137
+ // Both removed
138
+ expect(emitter.events.get('test').length).toBe(0);
139
+ });
140
+ });
141
+
142
+ describe('emit(event, ...args)', () => {
143
+ it('should satisfy contract: calls all registered callbacks with args', () => {
144
+ const callback1 = createSpy();
145
+ const callback2 = createSpy();
146
+
147
+ emitter.on('test', callback1);
148
+ emitter.on('test', callback2);
149
+
150
+ // Execute
151
+ emitter.emit('test', 'arg1', 'arg2', 'arg3');
152
+
153
+ // Post-condition: All callbacks called with args
154
+ expect(callback1).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
155
+ expect(callback2).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
156
+ });
157
+
158
+ it('should maintain invariant: callbacks invoked in registration order', () => {
159
+ const callOrder = [];
160
+
161
+ emitter.on('test', () => callOrder.push('first'));
162
+ emitter.on('test', () => callOrder.push('second'));
163
+ emitter.on('test', () => callOrder.push('third'));
164
+
165
+ emitter.emit('test');
166
+
167
+ expect(callOrder).toEqual(['first', 'second', 'third']);
168
+ });
169
+
170
+ it('should handle emit with no listeners (no error)', () => {
171
+ // Edge case: emitting event with no listeners should not throw
172
+ expect(() => {
173
+ emitter.emit('non-existent-event', 'data');
174
+ }).not.toThrow();
175
+ });
176
+
177
+ it('should pass multiple arguments correctly', () => {
178
+ const callback = createSpy();
179
+ emitter.on('test', callback);
180
+
181
+ emitter.emit('test', 1, 'two', { three: 3 }, [4, 5]);
182
+
183
+ expect(callback).toHaveBeenCalledWith(1, 'two', { three: 3 }, [4, 5]);
184
+ });
185
+
186
+ it('should handle zero arguments', () => {
187
+ const callback = createSpy();
188
+ emitter.on('test', callback);
189
+
190
+ emitter.emit('test');
191
+
192
+ expect(callback).toHaveBeenCalledWith();
193
+ });
194
+ });
195
+
196
+ describe('off(event, callback)', () => {
197
+ it('should satisfy contract: removes specific callback', () => {
198
+ const callback1 = createSpy();
199
+ const callback2 = createSpy();
200
+ const callback3 = createSpy();
201
+
202
+ emitter.on('test', callback1);
203
+ emitter.on('test', callback2);
204
+ emitter.on('test', callback3);
205
+
206
+ // Pre-condition: 3 callbacks
207
+ expect(emitter.events.get('test').length).toBe(3);
208
+
209
+ // Execute: remove callback2
210
+ emitter.off('test', callback2);
211
+
212
+ // Post-condition: only callback2 removed
213
+ const listeners = emitter.events.get('test');
214
+ expect(listeners.length).toBe(2);
215
+ expect(listeners).toContain(callback1);
216
+ expect(listeners).toContain(callback3);
217
+ expect(listeners).not.toContain(callback2);
218
+ });
219
+
220
+ it('should maintain invariant: other callbacks unaffected', () => {
221
+ const callback1 = createSpy();
222
+ const callback2 = createSpy();
223
+
224
+ emitter.on('test', callback1);
225
+ emitter.on('test', callback2);
226
+
227
+ // Remove callback1
228
+ emitter.off('test', callback1);
229
+
230
+ // Emit
231
+ emitter.emit('test', 'data');
232
+
233
+ // Invariant: only callback2 called
234
+ expect(callback1).not.toHaveBeenCalled();
235
+ expect(callback2).toHaveBeenCalledWith('data');
236
+ });
237
+
238
+ it('should handle removing non-existent callback (no error)', () => {
239
+ const callback = createSpy();
240
+
241
+ // Edge case: removing callback that was never added
242
+ expect(() => {
243
+ emitter.off('test', callback);
244
+ }).not.toThrow();
245
+ });
246
+
247
+ it('should handle removing from non-existent event (no error)', () => {
248
+ const callback = createSpy();
249
+
250
+ // Edge case: event doesn't exist
251
+ expect(() => {
252
+ emitter.off('non-existent', callback);
253
+ }).not.toThrow();
254
+ });
255
+
256
+ it('should remove only first occurrence when callback registered multiple times', () => {
257
+ const callback = createSpy();
258
+
259
+ emitter.on('test', callback);
260
+ emitter.on('test', callback);
261
+ emitter.on('test', callback);
262
+
263
+ // 3 registrations
264
+ expect(emitter.events.get('test').length).toBe(3);
265
+
266
+ // Remove once
267
+ emitter.off('test', callback);
268
+
269
+ // 2 remaining
270
+ expect(emitter.events.get('test').length).toBe(2);
271
+
272
+ // Emit: called 2 times
273
+ emitter.emit('test');
274
+ expect(callback).toHaveBeenCalledTimes(2);
275
+ });
276
+ });
277
+
278
+ describe('removeAllListeners(event?)', () => {
279
+ it('should satisfy contract: removes all listeners for specific event', () => {
280
+ const callback1 = createSpy();
281
+ const callback2 = createSpy();
282
+
283
+ emitter.on('test1', callback1);
284
+ emitter.on('test1', callback2);
285
+ emitter.on('test2', callback1);
286
+
287
+ // Pre-condition: 2 events with listeners
288
+ expect(emitter.events.size).toBe(2);
289
+ expect(emitter.events.get('test1').length).toBe(2);
290
+
291
+ // Execute: remove all for 'test1'
292
+ emitter.removeAllListeners('test1');
293
+
294
+ // Post-condition: test1 removed, test2 intact
295
+ expect(emitter.events.has('test1')).toBe(false);
296
+ expect(emitter.events.has('test2')).toBe(true);
297
+ expect(emitter.events.get('test2').length).toBe(1);
298
+ });
299
+
300
+ it('should satisfy contract: removes ALL listeners when event not specified', () => {
301
+ const callback1 = createSpy();
302
+ const callback2 = createSpy();
303
+
304
+ emitter.on('test1', callback1);
305
+ emitter.on('test2', callback2);
306
+ emitter.on('test3', callback1);
307
+
308
+ // Pre-condition: 3 events
309
+ expect(emitter.events.size).toBe(3);
310
+
311
+ // Execute: remove all
312
+ emitter.removeAllListeners();
313
+
314
+ // Post-condition: all events removed
315
+ expect(emitter.events.size).toBe(0);
316
+ });
317
+
318
+ it('should maintain invariant: events Map structure maintained', () => {
319
+ emitter.on('test', createSpy());
320
+
321
+ emitter.removeAllListeners('test');
322
+
323
+ // Map still exists, just empty for that event
324
+ expect(emitter.events).toBeInstanceOf(Map);
325
+ });
326
+
327
+ it('should handle removing listeners from non-existent event (no error)', () => {
328
+ expect(() => {
329
+ emitter.removeAllListeners('non-existent');
330
+ }).not.toThrow();
331
+ });
332
+ });
333
+
334
+ describe('Edge Cases', () => {
335
+ it('should handle callback removal during emission', () => {
336
+ const callback1 = createSpy();
337
+ const callback2 = vi.fn(() => {
338
+ // Remove itself during execution
339
+ emitter.off('test', callback2);
340
+ });
341
+ const callback3 = createSpy();
342
+
343
+ emitter.on('test', callback1);
344
+ emitter.on('test', callback2);
345
+ emitter.on('test', callback3);
346
+
347
+ // Emit
348
+ emitter.emit('test');
349
+
350
+ // All 3 called (array is copied before iteration)
351
+ expect(callback1).toHaveBeenCalledTimes(1);
352
+ expect(callback2).toHaveBeenCalledTimes(1);
353
+ expect(callback3).toHaveBeenCalledTimes(1);
354
+
355
+ // But callback2 removed for future emissions
356
+ emitter.emit('test');
357
+ expect(callback1).toHaveBeenCalledTimes(2);
358
+ expect(callback2).toHaveBeenCalledTimes(1); // Not called again
359
+ expect(callback3).toHaveBeenCalledTimes(2);
360
+ });
361
+
362
+ it('should handle errors in callbacks gracefully', () => {
363
+ const callback1 = createSpy();
364
+ const callback2 = vi.fn(() => {
365
+ throw new Error('Callback error');
366
+ });
367
+ const callback3 = createSpy();
368
+
369
+ emitter.on('test', callback1);
370
+ emitter.on('test', callback2);
371
+ emitter.on('test', callback3);
372
+
373
+ // Error in callback2 should propagate
374
+ expect(() => {
375
+ emitter.emit('test');
376
+ }).toThrow('Callback error');
377
+
378
+ // callback1 was called (before error)
379
+ expect(callback1).toHaveBeenCalledTimes(1);
380
+ // callback3 NOT called (error stopped iteration)
381
+ expect(callback3).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it('should handle multiple events independently', () => {
385
+ const callback1 = createSpy();
386
+ const callback2 = createSpy();
387
+
388
+ emitter.on('event1', callback1);
389
+ emitter.on('event2', callback2);
390
+
391
+ emitter.emit('event1', 'data1');
392
+
393
+ expect(callback1).toHaveBeenCalledWith('data1');
394
+ expect(callback2).not.toHaveBeenCalled();
395
+
396
+ emitter.emit('event2', 'data2');
397
+
398
+ expect(callback1).toHaveBeenCalledTimes(1);
399
+ expect(callback2).toHaveBeenCalledWith('data2');
400
+ });
401
+ });
402
+
403
+ describe('Memory Management', () => {
404
+ it('should not leak memory when listeners are removed', () => {
405
+ // Register many listeners
406
+ for (let i = 0; i < 1000; i++) {
407
+ emitter.on(`event${i}`, createSpy());
408
+ }
409
+
410
+ expect(emitter.events.size).toBe(1000);
411
+
412
+ // Remove all
413
+ emitter.removeAllListeners();
414
+
415
+ // Map properly cleaned
416
+ expect(emitter.events.size).toBe(0);
417
+ });
418
+
419
+ it('should clean up event arrays when last listener removed', () => {
420
+ const callback = createSpy();
421
+
422
+ emitter.on('test', callback);
423
+ expect(emitter.events.has('test')).toBe(true);
424
+
425
+ emitter.off('test', callback);
426
+
427
+ // Array still exists but empty (implementation detail)
428
+ // This is acceptable - small memory overhead
429
+ expect(emitter.events.get('test').length).toBe(0);
430
+ });
431
+ });
432
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Fetch with retry and exponential backoff
3
+ *
4
+ * Wraps native fetch() with configurable retry logic for transient failures.
5
+ * Only retries on network errors and 5xx server errors (not 4xx client errors).
6
+ * On final attempt, returns the response as-is so the caller can handle errors.
7
+ */
8
+
9
+ import { createLogger } from './logger.js';
10
+
11
+ const log = createLogger('FetchRetry');
12
+
13
+ /**
14
+ * Fetch with automatic retry on failure
15
+ * @param {string|URL} url - URL to fetch
16
+ * @param {RequestInit} [options] - Fetch options
17
+ * @param {Object} [retryOptions] - Retry configuration
18
+ * @param {number} [retryOptions.maxRetries=3] - Maximum retry attempts
19
+ * @param {number} [retryOptions.baseDelayMs=1000] - Base delay between retries (doubles each time)
20
+ * @param {number} [retryOptions.maxDelayMs=30000] - Maximum delay between retries
21
+ * @returns {Promise<Response>}
22
+ */
23
+ export async function fetchWithRetry(url, options = {}, retryOptions = {}) {
24
+ const { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30000 } = retryOptions;
25
+
26
+ let lastError;
27
+ let lastResponse;
28
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
29
+ try {
30
+ const response = await fetch(url, options);
31
+
32
+ // Don't retry client errors (4xx) — they won't change with retries
33
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
34
+ return response;
35
+ }
36
+
37
+ // Server error (5xx) — retryable, but return on last attempt
38
+ lastResponse = response;
39
+ lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
40
+ lastError.status = response.status;
41
+ } catch (error) {
42
+ // Network error — retryable
43
+ lastError = error;
44
+ lastResponse = null;
45
+ }
46
+
47
+ if (attempt < maxRetries) {
48
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
49
+ const jitter = delay * (0.5 + Math.random() * 0.5); // 50-100% of delay
50
+ log.debug(`Retry ${attempt + 1}/${maxRetries} in ${Math.round(jitter)}ms:`, String(url).slice(0, 80));
51
+ await new Promise(resolve => setTimeout(resolve, jitter));
52
+ }
53
+ }
54
+
55
+ // On exhausted retries: return response if we have one (let caller handle),
56
+ // throw if we only have network errors
57
+ if (lastResponse) {
58
+ return lastResponse;
59
+ }
60
+ throw lastError;
61
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for fetchWithRetry utility
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { fetchWithRetry } from './fetch-retry.js';
6
+
7
+ describe('fetchWithRetry', () => {
8
+ let mockFetch;
9
+
10
+ beforeEach(() => {
11
+ mockFetch = vi.fn();
12
+ global.fetch = mockFetch;
13
+ vi.useFakeTimers();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it('should return response on success', async () => {
21
+ mockFetch.mockResolvedValue({ ok: true, status: 200 });
22
+
23
+ const response = await fetchWithRetry('https://example.com');
24
+
25
+ expect(response.ok).toBe(true);
26
+ expect(mockFetch).toHaveBeenCalledTimes(1);
27
+ });
28
+
29
+ it('should return 4xx responses without retrying', async () => {
30
+ mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
31
+
32
+ const response = await fetchWithRetry('https://example.com', {}, { maxRetries: 3 });
33
+
34
+ expect(response.status).toBe(404);
35
+ expect(mockFetch).toHaveBeenCalledTimes(1); // No retry for 4xx
36
+ });
37
+
38
+ it('should retry on 5xx and return last response when exhausted', async () => {
39
+ mockFetch.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' });
40
+
41
+ const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
42
+
43
+ // Advance through retry delays
44
+ await vi.advanceTimersByTimeAsync(100);
45
+ await vi.advanceTimersByTimeAsync(200);
46
+
47
+ const response = await promise;
48
+ expect(response.status).toBe(503);
49
+ expect(mockFetch).toHaveBeenCalledTimes(3); // 1 original + 2 retries
50
+ });
51
+
52
+ it('should throw on network error after retries exhausted', async () => {
53
+ mockFetch.mockRejectedValue(new Error('Network error'));
54
+
55
+ const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 1, baseDelayMs: 100 });
56
+ // Attach .catch() early to prevent Node's unhandled rejection detection
57
+ // (the rejection is intentional and will be asserted below)
58
+ const handled = promise.catch(() => {});
59
+ await vi.advanceTimersByTimeAsync(200);
60
+
61
+ await expect(promise).rejects.toThrow('Network error');
62
+ await handled;
63
+ expect(mockFetch).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ it('should succeed on retry after initial failure', async () => {
67
+ mockFetch
68
+ .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Error' })
69
+ .mockResolvedValueOnce({ ok: true, status: 200 });
70
+
71
+ const promise = fetchWithRetry('https://example.com', {}, { maxRetries: 2, baseDelayMs: 100 });
72
+ await vi.advanceTimersByTimeAsync(100);
73
+
74
+ const response = await promise;
75
+ expect(response.ok).toBe(true);
76
+ expect(mockFetch).toHaveBeenCalledTimes(2);
77
+ });
78
+
79
+ it('should not retry with maxRetries=0', async () => {
80
+ mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Error' });
81
+
82
+ const response = await fetchWithRetry('https://example.com', {}, { maxRetries: 0 });
83
+
84
+ expect(response.status).toBe(500);
85
+ expect(mockFetch).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it('should cap delay at maxDelayMs', async () => {
89
+ mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Error' });
90
+
91
+ // With baseDelayMs=1000 and maxRetries=5, delays would be 1s, 2s, 4s, 8s, 16s
92
+ // But maxDelayMs=3000 should cap at 3s
93
+ const promise = fetchWithRetry('https://example.com', {}, {
94
+ maxRetries: 3,
95
+ baseDelayMs: 1000,
96
+ maxDelayMs: 3000
97
+ });
98
+
99
+ // Advance enough time for all retries (jitter makes exact timing unpredictable)
100
+ for (let i = 0; i < 10; i++) {
101
+ await vi.advanceTimersByTimeAsync(3000);
102
+ }
103
+
104
+ const response = await promise;
105
+ expect(response.status).toBe(500);
106
+ expect(mockFetch).toHaveBeenCalledTimes(4);
107
+ });
108
+ });
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @xiboplayer/utils - Shared utilities
2
+ export { createLogger, setLogLevel, getLogLevel, isDebug, applyCmsLogLevel, mapCmsLogLevel, registerLogSink, unregisterLogSink, LOG_LEVELS } from './logger.js';
3
+ export { EventEmitter } from './event-emitter.js';
4
+ export { config } from './config.js';
5
+ export { fetchWithRetry } from './fetch-retry.js';
6
+ export { CmsApiClient, CmsApiError } from './cms-api.js';