@@ -10,6 +10,8 @@ const {
1010 normalizeUrlForAi,
1111 writeAiOutput,
1212 createRateLimiter,
13+ withRetry,
14+ runBatch,
1315 RESULTS_DIR ,
1416} = require ( './utils' ) ;
1517
@@ -260,3 +262,156 @@ describe('utils', () => {
260262 } ) ;
261263 } ) ;
262264} ) ;
265+
266+ // ─── withRetry ───────────────────────────────────────────────────────────────
267+
268+ describe ( 'withRetry' , ( ) => {
269+ const noSleep = ( ) => Promise . resolve ( ) ;
270+
271+ it ( 'returns result on first try' , async ( ) => {
272+ const fn = vi . fn ( ) . mockResolvedValue ( 'ok' ) ;
273+ const result = await withRetry ( fn , { _sleep : noSleep } ) ;
274+ expect ( result ) . toBe ( 'ok' ) ;
275+ expect ( fn ) . toHaveBeenCalledTimes ( 1 ) ;
276+ } ) ;
277+
278+ it ( 'retries once on 429 and returns result' , async ( ) => {
279+ const err = Object . assign ( new Error ( 'rate limit' ) , { statusCode : 429 } ) ;
280+ const fn = vi . fn ( ) . mockRejectedValueOnce ( err ) . mockResolvedValue ( 'ok' ) ;
281+ const result = await withRetry ( fn , { maxRetries : 2 , _sleep : noSleep } ) ;
282+ expect ( result ) . toBe ( 'ok' ) ;
283+ expect ( fn ) . toHaveBeenCalledTimes ( 2 ) ;
284+ } ) ;
285+
286+ it ( 'retries on 5xx errors' , async ( ) => {
287+ const err = Object . assign ( new Error ( 'server error' ) , { statusCode : 503 } ) ;
288+ const fn = vi . fn ( ) . mockRejectedValueOnce ( err ) . mockResolvedValue ( 'ok' ) ;
289+ const result = await withRetry ( fn , { maxRetries : 2 , _sleep : noSleep } ) ;
290+ expect ( result ) . toBe ( 'ok' ) ;
291+ expect ( fn ) . toHaveBeenCalledTimes ( 2 ) ;
292+ } ) ;
293+
294+ it ( 'does not retry on non-429 4xx errors' , async ( ) => {
295+ const err = Object . assign ( new Error ( 'not found' ) , { statusCode : 404 } ) ;
296+ const fn = vi . fn ( ) . mockRejectedValue ( err ) ;
297+ await expect ( withRetry ( fn , { maxRetries : 2 , _sleep : noSleep } ) ) . rejects . toThrow ( 'not found' ) ;
298+ expect ( fn ) . toHaveBeenCalledTimes ( 1 ) ;
299+ } ) ;
300+
301+ it ( 'throws after maxRetries exhausted' , async ( ) => {
302+ const err = Object . assign ( new Error ( 'server error' ) , { statusCode : 500 } ) ;
303+ const fn = vi . fn ( ) . mockRejectedValue ( err ) ;
304+ await expect ( withRetry ( fn , { maxRetries : 2 , _sleep : noSleep } ) ) . rejects . toThrow ( 'server error' ) ;
305+ expect ( fn ) . toHaveBeenCalledTimes ( 3 ) ; // initial attempt + 2 retries
306+ } ) ;
307+
308+ it ( 'doubles the backoff delay on each retry attempt' , async ( ) => {
309+ const err = Object . assign ( new Error ( 'server error' ) , { statusCode : 500 } ) ;
310+ const fn = vi . fn ( ) . mockRejectedValue ( err ) ;
311+ const delays = [ ] ;
312+ const recordSleep = ( ms ) => {
313+ delays . push ( ms ) ; return Promise . resolve ( ) ;
314+ } ;
315+ await withRetry ( fn , { maxRetries : 2 , baseDelayMs : 1000 , _sleep : recordSleep } ) . catch ( ( ) => { } ) ;
316+ expect ( delays ) . toEqual ( [ 1000 , 2000 ] ) ;
317+ } ) ;
318+ } ) ;
319+
320+ // ─── runBatch ────────────────────────────────────────────────────────────────
321+
322+ describe ( 'runBatch' , ( ) => {
323+ const HIGH_RPS = 1000 ; // effectively no rate limit in tests
324+
325+ it ( 'processes all URLs and returns results' , async ( ) => {
326+ const auditFn = vi . fn ( ( url ) => Promise . resolve ( { data : url } ) ) ;
327+ const urls = [ 'https://a.com' , 'https://b.com' , 'https://c.com' ] ;
328+ const results = await runBatch ( urls , auditFn , { maxRequestsPerSecond : HIGH_RPS } ) ;
329+ expect ( results ) . toHaveLength ( 3 ) ;
330+ expect ( auditFn ) . toHaveBeenCalledTimes ( 3 ) ;
331+ expect ( results . every ( ( r ) => r . error === null ) ) . toBe ( true ) ;
332+ } ) ;
333+
334+ it ( 'captures per-URL errors without aborting the batch' , async ( ) => {
335+ const auditFn = vi . fn ( ( url ) => {
336+ if ( url === 'https://bad.com' ) {
337+ return Promise . reject ( new Error ( 'fetch failed' ) ) ;
338+ }
339+ return Promise . resolve ( { data : url } ) ;
340+ } ) ;
341+ const urls = [ 'https://ok.com' , 'https://bad.com' , 'https://ok2.com' ] ;
342+ const results = await runBatch ( urls , auditFn , { maxRequestsPerSecond : HIGH_RPS } ) ;
343+ expect ( results ) . toHaveLength ( 3 ) ;
344+ const bad = results . find ( ( r ) => r . url === 'https://bad.com' ) ;
345+ expect ( bad . error ) . toBe ( 'fetch failed' ) ;
346+ const good = results . filter ( ( r ) => r . url !== 'https://bad.com' ) ;
347+ expect ( good . every ( ( r ) => r . error === null ) ) . toBe ( true ) ;
348+ } ) ;
349+
350+ it ( 'calls onProgress for each URL' , async ( ) => {
351+ const auditFn = vi . fn ( ( url ) => Promise . resolve ( { data : url } ) ) ;
352+ const urls = [ 'https://a.com' , 'https://b.com' ] ;
353+ const progress = [ ] ;
354+ const onProgress = ( completed , total , url , error ) => progress . push ( { completed, total, url, error } ) ;
355+ await runBatch ( urls , auditFn , { maxRequestsPerSecond : HIGH_RPS , onProgress } ) ;
356+ expect ( progress ) . toHaveLength ( 2 ) ;
357+ expect ( progress [ 0 ] . total ) . toBe ( 2 ) ;
358+ expect ( progress . map ( ( p ) => p . url ) . sort ( ) ) . toEqual ( urls . sort ( ) ) ;
359+ } ) ;
360+
361+ it ( 'respects the concurrency limit' , async ( ) => {
362+ let concurrent = 0 ;
363+ let maxConcurrent = 0 ;
364+ const auditFn = async ( ) => {
365+ concurrent ++ ;
366+ maxConcurrent = Math . max ( maxConcurrent , concurrent ) ;
367+ await new Promise ( ( r ) => {
368+ setTimeout ( r , 10 ) ;
369+ } ) ;
370+ concurrent -- ;
371+ return { } ;
372+ } ;
373+ const urls = Array . from ( { length : 10 } , ( _ , i ) => `https://url${ i } .com` ) ;
374+ await runBatch ( urls , auditFn , { maxRequestsPerSecond : HIGH_RPS , concurrency : 3 } ) ;
375+ expect ( maxConcurrent ) . toBeLessThanOrEqual ( 3 ) ;
376+ } ) ;
377+
378+ it ( 'calls writeFn and includes outputPath in result when provided' , async ( ) => {
379+ const auditFn = vi . fn ( ( url ) => Promise . resolve ( { data : url } ) ) ;
380+ const writeFn = vi . fn ( ( url , _data ) => `/results/${ new URL ( url ) . hostname } .json` ) ;
381+ const urls = [ 'https://a.com' , 'https://b.com' ] ;
382+ const results = await runBatch ( urls , auditFn , { maxRequestsPerSecond : HIGH_RPS , writeFn } ) ;
383+ expect ( writeFn ) . toHaveBeenCalledTimes ( 2 ) ;
384+ expect ( results . every ( ( r ) => r . outputPath !== null ) ) . toBe ( true ) ;
385+ expect ( results . find ( ( r ) => r . url === 'https://a.com' ) . outputPath ) . toBe ( '/results/a.com.json' ) ;
386+ } ) ;
387+
388+ it ( 'returns empty array for empty URL list' , async ( ) => {
389+ const auditFn = vi . fn ( ) ;
390+ const results = await runBatch ( [ ] , auditFn , { maxRequestsPerSecond : HIGH_RPS } ) ;
391+ expect ( results ) . toHaveLength ( 0 ) ;
392+ expect ( auditFn ) . not . toHaveBeenCalled ( ) ;
393+ } ) ;
394+
395+ it ( 'accepts object items via urlOf and passes the full item to callbacks' , async ( ) => {
396+ const items = [
397+ { url : 'https://a.com' , strategy : 'mobile' } ,
398+ { url : 'https://a.com' , strategy : 'desktop' } ,
399+ ] ;
400+ const auditFn = vi . fn ( ( item ) => Promise . resolve ( { url : item . url , strategy : item . strategy } ) ) ;
401+ const writeFn = vi . fn ( ( item ) => `/results/${ new URL ( item . url ) . hostname } -${ item . strategy } .json` ) ;
402+ const progress = [ ] ;
403+ const results = await runBatch ( items , auditFn , {
404+ maxRequestsPerSecond : HIGH_RPS ,
405+ writeFn,
406+ urlOf : ( i ) => i . url ,
407+ onProgress : ( c , t , u , e ) => progress . push ( { c, t, u, e } ) ,
408+ } ) ;
409+ expect ( auditFn ) . toHaveBeenCalledTimes ( 2 ) ;
410+ expect ( writeFn ) . toHaveBeenCalledTimes ( 2 ) ;
411+ expect ( results ) . toHaveLength ( 2 ) ;
412+ expect ( results . every ( ( r ) => r . url === 'https://a.com' ) ) . toBe ( true ) ;
413+ expect ( results . map ( ( r ) => r . item . strategy ) . sort ( ) ) . toEqual ( [ 'desktop' , 'mobile' ] ) ;
414+ expect ( results . find ( ( r ) => r . item . strategy === 'mobile' ) . outputPath ) . toBe ( '/results/a.com-mobile.json' ) ;
415+ expect ( progress . every ( ( p ) => p . u === 'https://a.com' ) ) . toBe ( true ) ;
416+ } ) ;
417+ } ) ;
0 commit comments