@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.
- package/docs/README.md +61 -0
- package/package.json +36 -0
- package/src/cms-api.js +764 -0
- package/src/cms-api.test.js +803 -0
- package/src/config.js +288 -0
- package/src/config.test.js +473 -0
- package/src/event-emitter.js +77 -0
- package/src/event-emitter.test.js +432 -0
- package/src/fetch-retry.js +61 -0
- package/src/fetch-retry.test.js +108 -0
- package/src/index.js +6 -0
- package/src/logger.js +237 -0
- package/src/logger.test.js +477 -0
- package/vitest.config.js +8 -0
|
@@ -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';
|