170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
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<FFmpegCommandPlan, 'id' | 'outputs'> = {
|
|
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<string, Blob | Uint8Array>([
|
|
['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<BlobSaver>(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);
|
|
});
|
|
});
|