Skip to content

Commit 18c447a

Browse files
committed
feat(tests): add tests for withRetry and runBatch functions
1 parent 0a8476c commit 18c447a

2 files changed

Lines changed: 155 additions & 156 deletions

File tree

lib/utils.test.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

tests/utils.test.js

Lines changed: 0 additions & 156 deletions
This file was deleted.

0 commit comments

Comments
 (0)