import { JobQueue } from '../src/index.js'; import { cleanupDir, createDbPath, createTempDir, waitFor } from './helpers.js'; describe('JobQueue', () => { it('runs multi-phase jobs to completion', async () => { const dir = createTempDir(); const queue = new JobQueue<{ url: string }>({ dbPath: createDbPath(dir), phases: ['download', 'process'], concurrency: 1, }); const events: string[] = []; queue.on('job:started', () => events.push('started')); queue.on('job:phase:completed', (_, phase) => events.push(`phase:${phase.name}`)); queue.on('job:completed', () => events.push('completed')); queue.handle('download', async (_job, ctx) => { await ctx.progress(50, 'downloading'); return { filePath: '/tmp/video.mp4' }; }); queue.handle('process', async (_job, ctx) => { expect(ctx.phaseResult<{ filePath: string }>('download')?.filePath).toBe('/tmp/video.mp4'); await ctx.progress(25, 'processing'); return { outputPath: '/tmp/video.txt' }; }); try { const jobId = await queue.enqueue({ url: 'https://example.com/video' }); await waitFor(() => queue.getJob(jobId)?.status === 'completed'); const job = queue.getJob(jobId); expect(job?.status).toBe('completed'); expect(job?.phaseResults.download).toEqual({ filePath: '/tmp/video.mp4' }); expect(job?.phaseResults.process).toEqual({ outputPath: '/tmp/video.txt' }); expect(events).toEqual(['started', 'phase:download', 'phase:process', 'completed']); } finally { await queue.shutdown(); cleanupDir(dir); } }); it('retries recoverable failures and eventually completes', async () => { const dir = createTempDir(); const queue = new JobQueue<{ url: string }>({ dbPath: createDbPath(dir), phases: ['run'], concurrency: 1, retry: { maxAttempts: 3, baseDelayMs: 10, classifyError: async (error) => error instanceof Error && error.message === 'recoverable' ? 'recoverable' : 'fatal', }, }); let attempts = 0; let retries = 0; queue.on('job:retrying', () => { retries += 1; }); queue.handle('run', async () => { attempts += 1; if (attempts === 1) { throw new Error('recoverable'); } return { ok: true }; }); try { const jobId = await queue.enqueue({ url: 'https://example.com/video' }); await waitFor(() => queue.getJob(jobId)?.status === 'completed', { timeoutMs: 4_000 }); const job = queue.getJob(jobId); expect(job?.status).toBe('completed'); expect(job?.retryCount).toBe(1); expect(retries).toBe(1); } finally { await queue.shutdown(); cleanupDir(dir); } }); it('streams queue events as SSE', async () => { const dir = createTempDir(); const queue = new JobQueue<{ url: string }>({ dbPath: createDbPath(dir), phases: ['run'], concurrency: 1, }); const stream = queue.createEventStream({ includeSnapshot: false }); const reader = stream.getReader(); const chunks: string[] = []; const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) { return; } if (value) { chunks.push(new TextDecoder().decode(value)); if (chunks.some((chunk) => chunk.includes('event: job:completed'))) { return; } } } })(); queue.handle('run', async (_job, ctx) => { await ctx.progress(100, 'done'); return { ok: true }; }); try { await queue.enqueue({ url: 'https://example.com/video' }); await Promise.race([ readPromise, waitFor(() => chunks.some((chunk) => chunk.includes('event: job:completed')), { timeoutMs: 4_000, }), ]); } finally { await reader.cancel(); await queue.shutdown(); cleanupDir(dir); } expect(chunks.join('\n')).toContain('event: job:completed'); expect(chunks.join('\n')).toContain('event: job:progress'); }); });