1
1
import { describe , it , expect , beforeEach , vi } from 'vitest' ;
2
2
import { fetchRetry } from '../src/fetch-retry' ;
3
3
import { scriptRetry } from '../src/script-retry' ;
4
- import { RetryPlugin } from '../src/index' ;
5
- import {
6
- getRetryUrl ,
7
- rewriteWithNextDomain ,
8
- appendRetryCountQuery ,
9
- } from '../src/utils' ;
10
-
11
- // Mock fetch
4
+ import { ERROR_ABANDONED } from '../src/constant' ;
5
+
12
6
const mockFetch = vi . fn ( ) ;
13
7
global . fetch = mockFetch ;
14
8
15
- // Mock logger
16
9
vi . mock ( '../src/logger' , ( ) => ( {
17
10
default : {
18
11
log : vi . fn ( ) ,
@@ -60,7 +53,7 @@ describe('Retry Plugin', () => {
60
53
retryTimes : 2 ,
61
54
retryDelay : 10 ,
62
55
} ) ,
63
- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
56
+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
64
57
65
58
expect ( mockFetch ) . toHaveBeenCalledTimes ( 3 ) ; // 1 initial + 2 retries
66
59
} ) ;
@@ -212,7 +205,7 @@ describe('Retry Plugin', () => {
212
205
213
206
await expect (
214
207
retryFunction ( { url : 'https://example.com/script.js' } ) ,
215
- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
208
+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
216
209
217
210
expect ( mockRetryFn ) . toHaveBeenCalledTimes ( 2 ) ;
218
211
} ) ;
@@ -281,12 +274,8 @@ describe('Retry Plugin', () => {
281
274
'http://localhost:2021' ,
282
275
] ;
283
276
const mockRetryFn = vi . fn ( ) . mockImplementation ( ( { getEntryUrl } : any ) => {
284
- // simulate consumer calling getEntryUrl with the current known url
285
- const prev =
286
- sequence . length === 0
287
- ? 'http://localhost:2001/remoteEntry.js'
288
- : sequence [ sequence . length - 1 ] ;
289
- const nextUrl = getEntryUrl ( prev ) ;
277
+ // Consumer always calls getEntryUrl with the same original URL
278
+ const nextUrl = getEntryUrl ( 'http://localhost:2001/remoteEntry.js' ) ;
290
279
sequence . push ( nextUrl ) ;
291
280
// always throw to trigger next retry until retryTimes is reached
292
281
throw new Error ( 'Script load error' ) ;
@@ -303,27 +292,23 @@ describe('Retry Plugin', () => {
303
292
304
293
await expect (
305
294
retryFunction ( { url : 'http://localhost:2001/remoteEntry.js' } ) ,
306
- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
295
+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
307
296
308
- // With current implementation, first attempt already rotates based on base URL
309
- // and then continues rotating on each retry
297
+ // With the fix, should properly rotate domains across retries
310
298
expect ( sequence . length ) . toBe ( 3 ) ;
311
- // Start from 2001 -> next 2011
299
+ // First retry: 2001 -> 2011
312
300
expect ( sequence [ 0 ] ) . toContain ( 'http://localhost:2011' ) ;
313
- // 2011 -> 2021
301
+ // Second retry: 2011 -> 2021
314
302
expect ( sequence [ 1 ] ) . toContain ( 'http://localhost:2021' ) ;
315
- // 2021 -> wrap to 2001
303
+ // Third retry: 2021 -> wrap to 2001
316
304
expect ( sequence [ 2 ] ) . toContain ( 'http://localhost:2001' ) ;
317
305
} ) ;
318
306
319
307
it ( 'should append retryCount when addQuery is true for scripts' , async ( ) => {
320
308
const sequence : string [ ] = [ ] ;
321
309
const mockRetryFn = vi . fn ( ) . mockImplementation ( ( { getEntryUrl } : any ) => {
322
- const prev =
323
- sequence . length === 0
324
- ? 'https://cdn.example.com/entry.js'
325
- : sequence [ sequence . length - 1 ] ;
326
- const nextUrl = getEntryUrl ( prev ) ;
310
+ // Consumer always calls getEntryUrl with the same original URL
311
+ const nextUrl = getEntryUrl ( 'https://cdn-a.example.com/entry.js' ) ;
327
312
sequence . push ( nextUrl ) ;
328
313
throw new Error ( 'Script load error' ) ;
329
314
} ) ;
@@ -340,211 +325,62 @@ describe('Retry Plugin', () => {
340
325
341
326
await expect (
342
327
retryFunction ( { url : 'https://cdn-a.example.com/entry.js' } ) ,
343
- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
328
+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
344
329
345
330
expect ( sequence . length ) . toBe ( 2 ) ;
346
- // first attempt (per current logic) applies retryIndex=1 and rotates domain
331
+ // First retry: should rotate to cdn-b and have retryCount=1
332
+ expect ( sequence [ 0 ] ) . toContain ( 'https://cdn-b.example.com' ) ;
347
333
expect ( sequence [ 0 ] ) . toMatch ( / r e t r y C o u n t = 1 / ) ;
348
- // second attempt uses retryIndex=2
334
+ // Second retry: should rotate back to cdn-a and have retryCount=2
335
+ expect ( sequence [ 1 ] ) . toContain ( 'https://cdn-a.example.com' ) ;
349
336
expect ( sequence [ 1 ] ) . toMatch ( / r e t r y C o u n t = 2 / ) ;
337
+ // Should not accumulate previous retry parameters
338
+ expect ( sequence [ 1 ] ) . not . toMatch ( / r e t r y C o u n t = 1 / ) ;
350
339
} ) ;
351
- } ) ;
352
-
353
- describe ( 'RetryPlugin' , ( ) => {
354
- it ( 'should create plugin with default options' , ( ) => {
355
- const plugin = RetryPlugin ( { } ) ;
356
- expect ( plugin . name ) . toBe ( 'retry-plugin' ) ;
357
- expect ( plugin . fetch ) . toBeDefined ( ) ;
358
- expect ( plugin . loadEntryError ) . toBeDefined ( ) ;
359
- } ) ;
360
-
361
- it ( 'should handle fetch with retry' , async ( ) => {
362
- const mockResponse = {
363
- ok : true ,
364
- json : ( ) => Promise . resolve ( { data : 'test' } ) ,
365
- clone : ( ) => ( {
366
- ok : true ,
367
- json : ( ) => Promise . resolve ( { data : 'test' } ) ,
368
- } ) ,
369
- } ;
370
- mockFetch . mockResolvedValue ( mockResponse ) ;
371
-
372
- const plugin = RetryPlugin ( {
373
- retryTimes : 0 , // 不重试,第一次就成功
374
- retryDelay : 10 ,
375
- } ) ;
376
-
377
- const result = await plugin . fetch ! ( 'https://example.com/api' , { } ) ;
378
-
379
- expect ( mockFetch ) . toHaveBeenCalledWith ( 'https://example.com/api' , { } ) ;
380
- expect ( result ) . toBe ( mockResponse ) ;
381
- } ) ;
382
-
383
- it ( 'should prefer manifestDomains over domains for manifest fetch retries' , async ( ) => {
384
- // Arrange: fail first, then succeed
385
- const mockResponse = {
386
- ok : true ,
387
- json : ( ) => Promise . resolve ( { data : 'ok' } ) ,
388
- clone : ( ) => ( {
389
- ok : true ,
390
- json : ( ) => Promise . resolve ( { data : 'ok' } ) ,
391
- } ) ,
392
- } ;
393
- mockFetch
394
- . mockRejectedValueOnce ( new Error ( 'Network error 1' ) )
395
- . mockResolvedValueOnce ( mockResponse ) ;
396
-
397
- const plugin = RetryPlugin ( {
398
- retryTimes : 2 ,
399
- retryDelay : 1 ,
400
- // global domains (should be ignored when manifestDomains provided)
401
- domains : [ 'https://global-domain.com' ] ,
402
- // manifestDomains should take precedence in plugin.fetch
403
- manifestDomains : [ 'https://m1.example.com' , 'https://m2.example.com' ] ,
404
- } ) ;
405
-
406
- const result = await plugin . fetch ! (
407
- 'https://origin.example.com/mf-manifest.json' ,
408
- { } as any ,
409
- ) ;
410
-
411
- // Assert: second call (first retry) should use manifestDomains[0]
412
- const calls = mockFetch . mock . calls ;
413
- expect ( calls [ 0 ] [ 0 ] ) . toBe ( 'https://origin.example.com/mf-manifest.json' ) ;
414
- expect ( String ( calls [ 1 ] [ 0 ] ) ) . toContain ( 'm1.example.com' ) ;
415
- expect ( result ) . toBe ( mockResponse as any ) ;
416
- } ) ;
417
- } ) ;
418
-
419
- describe ( 'utils' , ( ) => {
420
- describe ( 'rewriteWithNextDomain' , ( ) => {
421
- it ( 'should return null for empty domains' , ( ) => {
422
- expect ( rewriteWithNextDomain ( 'https://example.com/api' , [ ] ) ) . toBeNull ( ) ;
423
- expect (
424
- rewriteWithNextDomain ( 'https://example.com/api' , undefined ) ,
425
- ) . toBeNull ( ) ;
426
- } ) ;
427
-
428
- it ( 'should rotate to next domain' , ( ) => {
429
- const domains = [
430
- 'https://domain1.com' ,
431
- 'https://domain2.com' ,
432
- 'https://domain3.com' ,
433
- ] ;
434
- const result = rewriteWithNextDomain (
435
- 'https://domain1.com/api' ,
436
- domains ,
437
- ) ;
438
- expect ( result ) . toBe ( 'https://domain2.com/api' ) ;
439
- } ) ;
440
-
441
- it ( 'should wrap around to first domain' , ( ) => {
442
- const domains = [ 'https://domain1.com' , 'https://domain2.com' ] ;
443
- const result = rewriteWithNextDomain (
444
- 'https://domain2.com/api' ,
445
- domains ,
446
- ) ;
447
- expect ( result ) . toBe ( 'https://domain1.com/api' ) ;
448
- } ) ;
449
-
450
- it ( 'should handle domains with different protocols' , ( ) => {
451
- const domains = [ 'https://domain1.com' , 'http://domain2.com' ] ;
452
- const result = rewriteWithNextDomain (
453
- 'https://domain1.com/api' ,
454
- domains ,
455
- ) ;
456
- expect ( result ) . toBe ( 'http://domain2.com/api' ) ;
457
- } ) ;
458
- } ) ;
459
-
460
- describe ( 'appendRetryCountQuery' , ( ) => {
461
- it ( 'should append retry count to URL' , ( ) => {
462
- const result = appendRetryCountQuery ( 'https://example.com/api' , 3 ) ;
463
- expect ( result ) . toBe ( 'https://example.com/api?retryCount=3' ) ;
464
- } ) ;
465
340
466
- it ( 'should append to existing query parameters' , ( ) => {
467
- const result = appendRetryCountQuery (
468
- 'https://example.com/api?foo=bar' ,
469
- 2 ,
470
- ) ;
471
- expect ( result ) . toBe ( 'https://example.com/api?foo=bar&retryCount=2' ) ;
472
- } ) ;
473
-
474
- it ( 'should use custom query key' , ( ) => {
475
- const result = appendRetryCountQuery (
476
- 'https://example.com/api' ,
477
- 1 ,
478
- 'retry' ,
479
- ) ;
480
- expect ( result ) . toBe ( 'https://example.com/api?retry=1' ) ;
481
- } ) ;
482
- } ) ;
483
-
484
- describe ( 'getRetryUrl' , ( ) => {
485
- it ( 'should return original URL when no options provided' , ( ) => {
486
- const result = getRetryUrl ( 'https://example.com/api' ) ;
487
- expect ( result ) . toBe ( 'https://example.com/api' ) ;
488
- } ) ;
489
-
490
- it ( 'should apply domain rotation' , ( ) => {
491
- const domains = [ 'https://domain1.com' , 'https://domain2.com' ] ;
492
- const result = getRetryUrl ( 'https://domain1.com/api' , { domains } ) ;
493
- expect ( result ) . toBe ( 'https://domain2.com/api' ) ;
341
+ it ( 'should prevent query parameter accumulation for scripts with functional addQuery' , async ( ) => {
342
+ const sequence : string [ ] = [ ] ;
343
+ const mockRetryFn = vi . fn ( ) . mockImplementation ( ( { getEntryUrl } : any ) => {
344
+ // Consumer always calls getEntryUrl with the same original URL
345
+ const nextUrl = getEntryUrl ( 'https://m1.example.com/remoteEntry.js' ) ;
346
+ sequence . push ( nextUrl ) ;
347
+ throw new Error ( 'Script load error' ) ;
494
348
} ) ;
495
349
496
- it ( 'should add retry count query when addQuery is true' , ( ) => {
497
- const result = getRetryUrl ( 'https://example.com/api' , {
498
- addQuery : true ,
499
- retryIndex : 2 ,
500
- } ) ;
501
- expect ( result ) . toBe ( 'https://example.com/api?retryCount=2' ) ;
350
+ const retryFunction = scriptRetry ( {
351
+ retryOptions : {
352
+ retryTimes : 3 ,
353
+ retryDelay : 0 ,
354
+ domains : [ 'https://m1.example.com' , 'https://m2.example.com' ] ,
355
+ addQuery : ( { times } ) =>
356
+ `retry=${ times } &retryTimeStamp=${ 1757484964434 + times * 1000 } ` ,
357
+ } ,
358
+ retryFn : mockRetryFn ,
502
359
} ) ;
503
360
504
- it ( 'should not add query when retryIndex is 0' , ( ) => {
505
- const result = getRetryUrl ( 'https://example.com/api' , {
506
- addQuery : true ,
507
- retryIndex : 0 ,
508
- } ) ;
509
- expect ( result ) . toBe ( 'https://example.com/api' ) ;
510
- } ) ;
361
+ await expect (
362
+ retryFunction ( { url : 'https://m1.example.com/remoteEntry.js' } ) ,
363
+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
511
364
512
- it ( 'should use custom query key' , ( ) => {
513
- const result = getRetryUrl ( 'https://example.com/api' , {
514
- addQuery : true ,
515
- retryIndex : 1 ,
516
- queryKey : 'retry' ,
517
- } ) ;
518
- expect ( result ) . toBe ( 'https://example.com/api?retry=1' ) ;
519
- } ) ;
365
+ expect ( sequence . length ) . toBe ( 3 ) ;
520
366
521
- it ( 'should support functional addQuery to replace query string (no original query)' , ( ) => {
522
- const result = getRetryUrl ( 'https://example.com/api' , {
523
- addQuery : ( { times, originalQuery } ) =>
524
- `${ originalQuery } &retry=${ times } &retryTimeStamp=123` ,
525
- retryIndex : 2 ,
526
- } ) ;
527
- expect ( result ) . toBe (
528
- 'https://example.com/api?&retry=2&retryTimeStamp=123' ,
529
- ) ;
530
- } ) ;
367
+ // First retry: m1 -> m2 with retry=1
368
+ expect ( sequence [ 0 ] ) . toBe (
369
+ 'https://m2.example.com/remoteEntry.js?retry=1&retryTimeStamp=1757484965434' ,
370
+ ) ;
531
371
532
- it ( 'should support functional addQuery with existing original query' , ( ) => {
533
- const result = getRetryUrl ( 'https://example.com/api?foo=bar' , {
534
- addQuery : ( { times, originalQuery } ) =>
535
- `${ originalQuery } &retry=${ times } ` ,
536
- retryIndex : 3 ,
537
- } ) ;
538
- expect ( result ) . toBe ( 'https://example.com/api?foo=bar&retry=3' ) ;
539
- } ) ;
372
+ // Second retry: m2 -> m1 with retry=2 (no accumulation)
373
+ expect ( sequence [ 1 ] ) . toBe (
374
+ 'https://m1.example.com/remoteEntry.js?retry=2&retryTimeStamp=1757484966434' ,
375
+ ) ;
376
+ expect ( sequence [ 1 ] ) . not . toContain ( 'retry=1' ) ;
540
377
541
- it ( 'should clear query when functional addQuery returns empty string' , ( ) => {
542
- const result = getRetryUrl ( 'https://example.com/api?foo=bar' , {
543
- addQuery : ( ) => '' ,
544
- retryIndex : 1 ,
545
- } ) ;
546
- expect ( result ) . toBe ( 'https://example.com/api' ) ;
547
- } ) ;
378
+ // Third retry: m1 -> m2 with retry=3 (no accumulation)
379
+ expect ( sequence [ 2 ] ) . toBe (
380
+ 'https://m2.example.com/remoteEntry.js?retry=3&retryTimeStamp=1757484967434' ,
381
+ ) ;
382
+ expect ( sequence [ 2 ] ) . not . toContain ( 'retry=1' ) ;
383
+ expect ( sequence [ 2 ] ) . not . toContain ( 'retry=2' ) ;
548
384
} ) ;
549
385
} ) ;
550
386
} ) ;
0 commit comments