import { describe, expect, it, vi } from 'vitest'; import { collectCommandOutputs, DEFAULT_RESULT_COLLECTION_LIMITS, DEFAULT_RESULT_ZIP_LIMITS, ExportResultCollection, ExportResultLimitError, MissingCommandOutputError, saveCollectionResult, type BlobSaver, type ExportResultInput, } from '../../src/export'; import type { FFmpegCommandPlan } from '../../src/commands'; function input( id: string, fileName = 'result.mp4', contents = 'result' ): ExportResultInput { return { id, planId: 'plan', outputId: id, fileName, blob: new Blob([contents]), mimeType: 'video/mp4', role: 'media', }; } describe('export result collection', () => { it('reserves one ZIP entry for the optional report', () => { expect(DEFAULT_RESULT_COLLECTION_LIMITS.maxResults).toBe(255); expect(DEFAULT_RESULT_ZIP_LIMITS.maxEntries).toBe( DEFAULT_RESULT_COLLECTION_LIMITS.maxResults + 1 ); }); it('owns result Blobs and revokes every replaced or released object URL', () => { let urlIndex = 0; const revokeObjectURL = vi.fn(); const collection = new ExportResultCollection({ objectUrlApi: { createObjectURL: () => `blob:result-${urlIndex++}`, revokeObjectURL, }, now: () => new Date('2026-07-24T10:00:00.000Z'), }); const first = collection.add(input('one')); expect(first).toMatchObject({ fileName: 'result.mp4', mimeType: 'video/mp4', size: 6, createdAt: '2026-07-24T10:00:00.000Z', }); expect(collection.objectUrl('one')).toBe('blob:result-0'); expect(collection.objectUrl('one')).toBe('blob:result-0'); collection.add(input('two', 'RESULT.mp4')); expect(collection.get('two')?.fileName).toBe('RESULT-2.mp4'); collection.replace(input('one', 'replacement.mp4', 'new')); expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-0'); expect(collection.totalBytes).toBe(9); collection.objectUrl('two'); expect(collection.remove('two')).toBe(true); expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-1'); collection.dispose(); expect(collection.totalBytes).toBe(0); expect(() => collection.list()).toThrow(/disposed/iu); expect(() => collection.dispose()).not.toThrow(); }); it('adds a batch atomically and enforces count and byte limits', () => { const collection = new ExportResultCollection({ limits: { maxResults: 2, maxSingleResultBytes: 5, maxTotalBytes: 8, }, }); expect(() => collection.addMany([ input('one', 'one.bin', '1234'), input('bad', 'bad.bin', '123456'), ]) ).toThrowError(ExportResultLimitError); expect(collection.size).toBe(0); collection.add(input('one', 'one.bin', '1234')); expect(() => collection.add(input('two', 'two.bin', '12345'))).toThrowError( expect.objectContaining({ limit: 'total-result-bytes' }) ); expect(collection.size).toBe(1); }); it('collects all planned outputs atomically from IDs or virtual paths', () => { const collection = new ExportResultCollection(); const plan: Pick = { id: 'split-plan', outputs: [ { id: 'one', path: '/work/job/one.mp4', fileName: 'clip-001.mp4', mediaType: 'video/mp4', role: 'media', timeRange: { startSeconds: 0, endSeconds: 5 }, }, { id: 'two', path: '/work/job/two.mp4', fileName: 'clip-002.mp4', mediaType: 'video/mp4', role: 'media', }, ], }; const results = collectCommandOutputs( plan, new Map([ ['one', Uint8Array.of(1, 2)], ['/work/job/two.mp4', new Blob(['two'])], ]), collection ); expect(results.map((result) => result.outputId)).toEqual(['one', 'two']); expect(results[0]?.blob.type).toBe('video/mp4'); expect(results[0]?.timeRange).toEqual({ startSeconds: 0, endSeconds: 5, }); const empty = new ExportResultCollection(); expect(() => collectCommandOutputs(plan, new Map([['one', new Blob(['one'])]]), empty) ).toThrowError(MissingCommandOutputError); expect(empty.size).toBe(0); }); }); describe('browser result saving', () => { it('sanitizes the download name and releases a successfully saved result', async () => { const collection = new ExportResultCollection(); collection.add(input('one', 'result.mp4')); const saver = vi.fn(async () => 'downloaded'); await expect( saveCollectionResult(collection, 'one', { fileName: '../../My result.mp4', saver, }) ).resolves.toBe('downloaded'); expect(saver).toHaveBeenCalledWith( expect.any(Blob), 'My-result.mp4', 'video/mp4' ); expect(collection.size).toBe(0); }); it('retains the result when the save picker is cancelled', async () => { const collection = new ExportResultCollection(); collection.add(input('one')); const saver: BlobSaver = async () => 'cancelled'; await saveCollectionResult(collection, 'one', { saver }); expect(collection.size).toBe(1); }); });