import { unzipSync } from 'fflate'; import { describe, expect, it } from 'vitest'; import { createResultZip, saveResultsZip, type BlobSaver, type ExportResult, } from '../../src/export'; function result(id: string, fileName: string, contents: string): ExportResult { const blob = new Blob([contents], { type: 'text/plain' }); return { id, planId: 'plan', outputId: id, fileName, blob, mimeType: 'text/plain', role: 'analysis', size: blob.size, createdAt: '2026-07-24T10:00:00.000Z', }; } describe('deterministic result ZIP creation', () => { it('sorts and deduplicates safe paths with fixed archive metadata', async () => { const entries = [ { fileName: '../../Z.txt', blob: new Blob(['z']) }, { fileName: 'a.txt', blob: new Blob(['first']) }, { fileName: 'A.TXT', blob: new Blob(['second']) }, ]; const first = await createResultZip(entries, { fileName: '../My outputs.any', }); const second = await createResultZip(entries, { fileName: '../My outputs.any', }); expect(first.fileName).toBe('My-outputs.zip'); expect(first.entryNames).toEqual(['A-2.txt', 'Z.txt', 'a.txt']); expect(first.totalInputBytes).toBe(12); expect(new Uint8Array(await first.blob.arrayBuffer())).toEqual( new Uint8Array(await second.blob.arrayBuffer()) ); const extracted = unzipSync(new Uint8Array(await first.blob.arrayBuffer())); expect(Object.keys(extracted)).toEqual(['A-2.txt', 'Z.txt', 'a.txt']); expect(new TextDecoder().decode(extracted['a.txt'])).toBe('first'); expect(new TextDecoder().decode(extracted['A-2.txt'])).toBe('second'); }); it('rejects empty, oversized, and pre-cancelled batches', async () => { await expect(createResultZip([])).rejects.toThrow(/at least one/iu); await expect( createResultZip([{ fileName: 'large.bin', blob: new Blob(['12345']) }], { limits: { maxEntries: 2, maxEntryBytes: 4, maxTotalInputBytes: 4, maxArchiveBytes: 100, }, }) ).rejects.toThrowError( expect.objectContaining({ limit: 'entry-bytes', }) ); const controller = new AbortController(); controller.abort(); await expect( createResultZip([{ fileName: 'one.txt', blob: new Blob(['one']) }], { signal: controller.signal, }) ).rejects.toMatchObject({ name: 'AbortError' }); }); it('packages an optional report and passes the ZIP to the save adapter', async () => { const saverCalls: { fileName: string; type: string }[] = []; const saver: BlobSaver = async (blob, fileName) => { saverCalls.push({ fileName, type: blob.type }); return 'saved'; }; const saved = await saveResultsZip([result('one', 'one.txt', 'contents')], { fileName: 'batch.zip', saver, report: { schemaVersion: 1, generatedAt: '2026-07-24T10:00:00.000Z', plan: { id: 'plan', operation: 'convert' }, outputs: [], diagnostics: [], privacy: { includesSourceMedia: false, includesCommandArguments: false, privatePathsRedacted: true, }, }, }); expect(saved.outcome).toBe('saved'); expect(saved.archive.entryNames).toEqual([ 'convert-export-report.json', 'one.txt', ]); expect(saverCalls).toEqual([ { fileName: 'batch.zip', type: 'application/zip' }, ]); }); });