import { describe, expect, it, vi } from 'vitest'; import { MediaJobQueue, MemoryJobMetadataStore, ObjectUrlRegistry, parseFFmpegProgress, progressUpdateFromFFmpeg, weightedJobProgress, type JobExecutionContext, type JobExecutionResult, type MediaJob, type MediaJobDefinition, type MediaJobExecutor, } from '../../src/jobs'; import { freezeCommandPlan } from '../../src/commands'; function definition(id: string, stepIds = ['encode']): MediaJobDefinition { return { id, operation: 'test', steps: stepIds.map((stepId) => ({ id: stepId, name: stepId, weight: stepId === 'read' ? 1 : 9, plan: freezeCommandPlan({ id: `${id}:${stepId}`, operation: stepId, inputs: [], temporaryFiles: [], args: ['-version'], outputs: [], requiredCapabilities: {}, diagnostics: [], }), })), }; } class ControlledExecutor implements MediaJobExecutor { readonly calls: string[] = []; readonly cancelCurrentJob = vi.fn(async () => undefined); readonly recoverAfterCancellation = vi.fn(async () => undefined); readonly cleanup = vi.fn(async () => undefined); #gates: (() => void)[] = []; failNext?: Error; execute( plan: { readonly id: string }, context: JobExecutionContext ): Promise { this.calls.push(plan.id); context.onProgress({ progress: 0.5 }); if (this.failNext) { const error = this.failNext; this.failNext = undefined; return Promise.reject(error); } return new Promise((resolve, reject) => { const release = () => { context.signal.removeEventListener('abort', abort); resolve({ outputs: new Map() }); }; const abort = () => { const index = this.#gates.indexOf(release); if (index >= 0) { this.#gates.splice(index, 1); } reject(new DOMException('cancelled', 'AbortError')); }; context.signal.addEventListener('abort', abort, { once: true }); this.#gates.push(release); }); } release(): void { this.#gates.shift()?.(); } } describe('progress parsing', () => { it('parses FFmpeg machine progress and caps completion until output read', () => { const parsed = parseFFmpegProgress( 'frame=25\nfps=12.5\nout_time_us=5000000\nspeed=1.5x\nprogress=continue\n' ); expect(parsed).toEqual({ frame: 25, fps: 12.5, outTimeSeconds: 5, speed: 1.5, terminal: false, }); expect(progressUpdateFromFFmpeg(parsed, 10)).toMatchObject({ progress: 0.5, }); expect( progressUpdateFromFFmpeg( parseFFmpegProgress('progress=end\nout_time_us=10000000'), 10 ).progress ).toBe(0.99); }); it('combines weighted multi-step progress below 100 percent', () => { expect( weightedJobProgress([ { id: 'analysis', name: 'Analysis', weight: 1, status: 'completed', progress: 1, planId: 'one', }, { id: 'encode', name: 'Encoding', weight: 9, status: 'running', progress: 0.5, planId: 'two', }, ]) ).toBe(0.55); }); }); describe('sequential durable job queue', () => { it('runs only one command at a time and reaches 100% after cleanup', async () => { const executor = new ControlledExecutor(); const store = new MemoryJobMetadataStore(); const queue = new MediaJobQueue({ executor, store }); await queue.initialize(); await queue.enqueue(definition('one')); await queue.enqueue(definition('two')); await waitUntil(() => executor.calls.length === 1); expect(executor.calls).toEqual(['one:encode']); expect(queue.get('one')?.progress).toBe(0.5); executor.release(); await waitUntil(() => executor.calls.length === 2); expect(queue.get('one')).toMatchObject({ status: 'completed', progress: 1, }); executor.release(); await queue.waitForIdle(); expect(queue.get('two')).toMatchObject({ status: 'completed', progress: 1, }); expect(queue.getResults('two')).toHaveLength(1); expect(queue.releaseResults('two')).toBe(true); expect(queue.getResults('two')).toBeUndefined(); expect((await store.list()).map((job) => job.status)).toEqual([ 'completed', 'completed', ]); }); it('terminates processing, recovers the engine, and runs the next job', async () => { const executor = new ControlledExecutor(); const queue = new MediaJobQueue({ executor, store: new MemoryJobMetadataStore(), }); await queue.initialize(); await queue.enqueue(definition('cancel-me')); await queue.enqueue(definition('after')); await waitUntil(() => queue.get('cancel-me')?.status === 'running'); await queue.cancel('cancel-me'); expect(executor.cancelCurrentJob).toHaveBeenCalledOnce(); expect(executor.recoverAfterCancellation).toHaveBeenCalledOnce(); expect(queue.get('cancel-me')?.status).toBe('cancelled'); await waitUntil(() => executor.calls.includes('after:encode')); executor.release(); await queue.waitForIdle(); expect(queue.get('after')?.status).toBe('completed'); }); it('marks failures, cleans up, and retries with the retained definition', async () => { const executor = new ControlledExecutor(); executor.failNext = new Error('encoder failed'); const queue = new MediaJobQueue({ executor, store: new MemoryJobMetadataStore(), }); await queue.initialize(); await queue.enqueue(definition('retry')); await queue.waitForIdle(); expect(queue.get('retry')).toMatchObject({ status: 'failed', error: { code: 'execution-failed', recoverable: true }, }); await queue.retry('retry'); await waitUntil(() => executor.calls.length === 2); executor.release(); await queue.waitForIdle(); expect(queue.get('retry')).toMatchObject({ status: 'completed', retryCount: 1, }); }); it('recovers interrupted durable metadata as an explicit failed job', async () => { const store = new MemoryJobMetadataStore(); const interrupted: MediaJob = { id: 'old', operation: 'convert', status: 'running', steps: [ { id: 'encode', name: 'Encode', weight: 1, status: 'running', progress: 0.4, planId: 'old:encode', }, ], progress: 0.4, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:01:00.000Z', startedAt: '2026-01-01T00:00:01.000Z', retryCount: 0, }; await store.put(interrupted); const queue = new MediaJobQueue({ executor: new ControlledExecutor(), store, }); await queue.initialize(); expect(queue.get('old')).toMatchObject({ status: 'failed', recoveryNote: expect.stringContaining('previous browser session'), error: { code: 'session-interrupted', recoverable: true }, }); }); }); describe('object URL cleanup', () => { it('revokes replaced and cleared result URLs', () => { let index = 0; const revoke = vi.fn(); const registry = new ObjectUrlRegistry({ createObjectURL: () => `blob:${index++}`, revokeObjectURL: revoke, }); expect(registry.replace('output', new Blob(['one']))).toBe('blob:0'); expect(registry.replace('output', new Blob(['two']))).toBe('blob:1'); registry.replace('preview', new Blob(['three'])); expect(revoke).toHaveBeenCalledWith('blob:0'); registry.clear(); expect(revoke).toHaveBeenCalledWith('blob:1'); expect(revoke).toHaveBeenCalledWith('blob:2'); expect(registry.size).toBe(0); }); }); async function waitUntil( predicate: () => boolean, timeoutMs = 2_000 ): Promise { const started = Date.now(); while (!predicate()) { if (Date.now() - started > timeoutMs) { throw new Error('Timed out waiting for queue state'); } await new Promise((resolve) => setTimeout(resolve, 0)); } }