110 lines
3.4 KiB
TypeScript
110 lines
3.4 KiB
TypeScript
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' },
|
|
]);
|
|
});
|
|
});
|