11import { describe , it , expect , beforeEach , vi } from 'vitest' ;
22import { fetchRetry } from '../src/fetch-retry' ;
33import { 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+
126const mockFetch = vi . fn ( ) ;
137global . fetch = mockFetch ;
148
15- // Mock logger
169vi . mock ( '../src/logger' , ( ) => ( {
1710 default : {
1811 log : vi . fn ( ) ,
@@ -60,7 +53,7 @@ describe('Retry Plugin', () => {
6053 retryTimes : 2 ,
6154 retryDelay : 10 ,
6255 } ) ,
63- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
56+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
6457
6558 expect ( mockFetch ) . toHaveBeenCalledTimes ( 3 ) ; // 1 initial + 2 retries
6659 } ) ;
@@ -212,7 +205,7 @@ describe('Retry Plugin', () => {
212205
213206 await expect (
214207 retryFunction ( { url : 'https://example.com/script.js' } ) ,
215- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
208+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
216209
217210 expect ( mockRetryFn ) . toHaveBeenCalledTimes ( 2 ) ;
218211 } ) ;
@@ -281,12 +274,8 @@ describe('Retry Plugin', () => {
281274 'http://localhost:2021' ,
282275 ] ;
283276 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' ) ;
290279 sequence . push ( nextUrl ) ;
291280 // always throw to trigger next retry until retryTimes is reached
292281 throw new Error ( 'Script load error' ) ;
@@ -303,27 +292,23 @@ describe('Retry Plugin', () => {
303292
304293 await expect (
305294 retryFunction ( { url : 'http://localhost:2001/remoteEntry.js' } ) ,
306- ) . rejects . toThrow ( 'The request failed and has now been abandoned' ) ;
295+ ) . rejects . toThrow ( ERROR_ABANDONED ) ;
307296
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
310298 expect ( sequence . length ) . toBe ( 3 ) ;
311- // Start from 2001 -> next 2011
299+ // First retry: 2001 -> 2011
312300 expect ( sequence [ 0 ] ) . toContain ( 'http://localhost:2011' ) ;
313- // 2011 -> 2021
301+ // Second retry: 2011 -> 2021
314302 expect ( sequence [ 1 ] ) . toContain ( 'http://localhost:2021' ) ;
315- // 2021 -> wrap to 2001
303+ // Third retry: 2021 -> wrap to 2001
316304 expect ( sequence [ 2 ] ) . toContain ( 'http://localhost:2001' ) ;
317305 } ) ;
318306
319307 it ( 'should append retryCount when addQuery is true for scripts' , async ( ) => {
320308 const sequence : string [ ] = [ ] ;
321309 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' ) ;
327312 sequence . push ( nextUrl ) ;
328313 throw new Error ( 'Script load error' ) ;
329314 } ) ;
@@ -340,211 +325,62 @@ describe('Retry Plugin', () => {
340325
341326 await expect (
342327 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 ) ;
344329
345330 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' ) ;
347333 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' ) ;
349336 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 / ) ;
350339 } ) ;
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- } ) ;
465340
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' ) ;
494348 } ) ;
495349
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 ,
502359 } ) ;
503360
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 ) ;
511364
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 ) ;
520366
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+ ) ;
531371
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' ) ;
540377
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' ) ;
548384 } ) ;
549385 } ) ;
550386} ) ;
0 commit comments