Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
SnowLumaAbortError,
|
|
SnowLumaApiError,
|
|
SnowLumaHttpClient,
|
|
SnowLumaParseError,
|
|
} from '../src/index.js';
|
|
import type { JsonObject } from '../src/index.js';
|
|
|
|
const wrap = (data: unknown) => ({ status: 'ok', retcode: 0, stream: 'stream-action', data });
|
|
const wire = (data: unknown) => JSON.stringify(wrap(data)) + '\r\n\r\n';
|
|
const info = {
|
|
type: 'stream',
|
|
data_type: 'file_info',
|
|
file_name: 'test',
|
|
file_size: 2,
|
|
chunk_size: 2,
|
|
};
|
|
const chunk = {
|
|
type: 'stream',
|
|
data_type: 'file_chunk',
|
|
index: 0,
|
|
data: 'YWI=',
|
|
size: 2,
|
|
base64_size: 4,
|
|
progress: 100,
|
|
};
|
|
const complete = {
|
|
type: 'response',
|
|
data_type: 'file_complete',
|
|
file_name: 'test',
|
|
total_chunks: 1,
|
|
total_bytes: 2,
|
|
message: 'done',
|
|
};
|
|
|
|
async function download(frames: unknown[], onInfo?: (value: unknown) => void) {
|
|
const client = new SnowLumaHttpClient({
|
|
fetch: async () => new Response(frames.map(wire).join('')),
|
|
});
|
|
const stream = client.downloadFileStream({ file: 'test' }, { onInfo });
|
|
const chunks: Uint8Array[] = [];
|
|
|
|
while (true) {
|
|
const result = await stream.next();
|
|
|
|
if (result.done) {
|
|
return { chunks, complete: result.value };
|
|
}
|
|
|
|
chunks.push(result.value);
|
|
}
|
|
}
|
|
|
|
describe('file download helper', () => {
|
|
it('yields bytes, exposes metadata and returns verified completion', async () => {
|
|
const onInfo = vi.fn();
|
|
expect(await download([info, chunk, complete], onInfo)).toEqual({
|
|
chunks: [new Uint8Array([97, 98])],
|
|
complete,
|
|
});
|
|
expect(onInfo).toHaveBeenCalledExactlyOnceWith(info);
|
|
});
|
|
|
|
it('accepts an unknown initial size and verifies the final totals', async () => {
|
|
expect(
|
|
(await download([{ ...info, file_size: 0 }, chunk, complete])).complete.total_bytes,
|
|
).toBe(2);
|
|
});
|
|
|
|
it('supports empty files', async () => {
|
|
expect(
|
|
(
|
|
await download([
|
|
{ ...info, file_size: 0 },
|
|
{ ...complete, total_chunks: 0, total_bytes: 0 },
|
|
])
|
|
).chunks,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['missing info', [chunk, complete]],
|
|
['duplicate info', [info, info, chunk, complete]],
|
|
['missing chunk', [info, complete]],
|
|
['out-of-order index', [info, { ...chunk, index: 1 }, complete]],
|
|
['invalid base64', [info, { ...chunk, data: '????' }, complete]],
|
|
['non-canonical base64', [info, { ...chunk, data: 'YWL=' }, complete]],
|
|
['wrong chunk size', [info, { ...chunk, size: 3 }, complete]],
|
|
['wrong base64 size', [info, { ...chunk, base64_size: 5 }, complete]],
|
|
['wrong final size', [info, chunk, { ...complete, total_bytes: 3 }]],
|
|
['wrong final count', [info, chunk, { ...complete, total_chunks: 2 }]],
|
|
['wrong info size', [{ ...info, file_size: 3 }, chunk, complete]],
|
|
['wrong final name', [info, chunk, { ...complete, file_name: 'other' }]],
|
|
['unexpected frame', [info, { type: 'stream', data_type: 'other' }, complete]],
|
|
])('rejects %s', async (_name, frames) => {
|
|
await expect(download(frames as unknown[])).rejects.toBeInstanceOf(SnowLumaParseError);
|
|
});
|
|
});
|
|
|
|
function uploader(transform?: (data: JsonObject, params: JsonObject) => JsonObject) {
|
|
const calls: JsonObject[] = [];
|
|
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
const { params } = JSON.parse(String(init?.body)) as { params: JsonObject };
|
|
calls.push(params);
|
|
const first = calls[0];
|
|
const result: JsonObject = params.is_complete
|
|
? {
|
|
type: 'response',
|
|
status: 'file_complete',
|
|
stream_id: first.stream_id,
|
|
received_chunks: first.total_chunks,
|
|
total_chunks: first.total_chunks,
|
|
file_size: first.file_size,
|
|
file_path: '/remote/file',
|
|
file_name: 'test',
|
|
sha256: 'a'.repeat(64),
|
|
}
|
|
: {
|
|
type: 'stream',
|
|
status: 'chunk_received',
|
|
stream_id: params.stream_id,
|
|
received_chunks: Number(params.chunk_index) + 1,
|
|
total_chunks: params.total_chunks,
|
|
};
|
|
return new Response(wire(transform ? transform(result, params) : result));
|
|
});
|
|
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
|
|
}
|
|
|
|
describe('file upload helper', () => {
|
|
it('cancels a stalled readable source before any request is sent', async () => {
|
|
const { client, fetch } = uploader();
|
|
const cancel = vi.fn();
|
|
const source = new ReadableStream<Uint8Array>({ cancel });
|
|
const controller = new AbortController();
|
|
const upload = client.uploadFileStream(source, { fileSize: 1, signal: controller.signal });
|
|
await vi.waitFor(() => expect(source.locked).toBe(true));
|
|
controller.abort();
|
|
await expect(upload).rejects.toBeInstanceOf(SnowLumaAbortError);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(source.locked).toBe(false);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not send later chunks or finalize after cancellation from a progress callback', async () => {
|
|
const { client, calls } = uploader();
|
|
const controller = new AbortController();
|
|
await expect(
|
|
client.uploadFileStream(new Uint8Array([1, 2]), {
|
|
chunkSize: 1,
|
|
signal: controller.signal,
|
|
onProgress: () => controller.abort(),
|
|
}),
|
|
).rejects.toBeInstanceOf(SnowLumaAbortError);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it('re-chunks async input, awaits progress and separately finalizes after all ACKs', async () => {
|
|
const { client, calls } = uploader();
|
|
const progress = vi.fn();
|
|
let readSecond = false;
|
|
const source = (async function* () {
|
|
yield new Uint8Array([1, 2, 3, 4]);
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
readSecond = true;
|
|
yield new Uint8Array([5]);
|
|
})();
|
|
const result = await client.uploadFileStream(source, {
|
|
fileSize: 5,
|
|
chunkSize: 2,
|
|
filename: 'input',
|
|
streamId: 'test',
|
|
onProgress: progress,
|
|
});
|
|
expect(readSecond).toBe(true);
|
|
expect(calls.map((params) => params.chunk_data)).toEqual([
|
|
'AQI=',
|
|
'AwQ=',
|
|
'BQ==',
|
|
undefined,
|
|
]);
|
|
expect(calls[0]).toMatchObject({ total_chunks: 3, file_size: 5, filename: 'input' });
|
|
expect(calls[3]).toEqual({ stream_id: 'test', is_complete: true });
|
|
expect(progress).toHaveBeenLastCalledWith({
|
|
streamId: 'test',
|
|
uploadedChunks: 3,
|
|
totalChunks: 3,
|
|
uploadedBytes: 5,
|
|
fileSize: 5,
|
|
});
|
|
expect(result.file_path).toBe('/remote/file');
|
|
});
|
|
|
|
it.each([new Uint8Array(), new Blob([]), new Blob(['ab'])])(
|
|
'infers size for byte and Blob sources: %s',
|
|
async (source) => {
|
|
const { client, calls } = uploader();
|
|
const result = await client.uploadFileStream(source);
|
|
expect(result.file_size).toBe(source instanceof Blob ? source.size : source.length);
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[0].total_chunks).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.each([0, 4])(
|
|
'does not finalize a streaming source with mismatched size %i',
|
|
async (fileSize) => {
|
|
const { client, calls } = uploader();
|
|
let closed = false;
|
|
const source = (async function* () {
|
|
try {
|
|
yield new Uint8Array([1, 2]);
|
|
} finally {
|
|
closed = true;
|
|
}
|
|
})();
|
|
await expect(
|
|
client.uploadFileStream(source, { fileSize, chunkSize: 1 }),
|
|
).rejects.toThrow('fileSize');
|
|
expect(closed).toBe(true);
|
|
expect(calls.some((call) => call.is_complete)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('requires a declared size and validates options before making requests', async () => {
|
|
const { client, fetch } = uploader();
|
|
const source = (async function* () {
|
|
yield new Uint8Array([1]);
|
|
})();
|
|
await expect(client.uploadFileStream(source)).rejects.toThrow('fileSize');
|
|
await expect(client.uploadFileStream(new Uint8Array([1]), { fileSize: 2 })).rejects.toThrow(
|
|
'fileSize',
|
|
);
|
|
await expect(
|
|
client.uploadFileStream(new Uint8Array([1]), { chunkSize: 0 }),
|
|
).rejects.toThrow('chunkSize');
|
|
await expect(
|
|
client.uploadFileStream(new Uint8Array([1]), { streamId: '../bad' }),
|
|
).rejects.toThrow('streamId');
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects incorrect ACKs and cancels a readable source on failure', async () => {
|
|
const { client, calls } = uploader((data) => ({ ...data, received_chunks: 99 }));
|
|
const cancel = vi.fn();
|
|
const source = new ReadableStream<Uint8Array>({
|
|
start(c) {
|
|
c.enqueue(new Uint8Array([1, 2]));
|
|
},
|
|
cancel,
|
|
});
|
|
await expect(client.uploadFileStream(source, { fileSize: 2 })).rejects.toBeInstanceOf(
|
|
SnowLumaParseError,
|
|
);
|
|
expect(calls).toHaveLength(1);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(source.locked).toBe(false);
|
|
});
|
|
|
|
it('stops after a failed chunk and preserves the server error', async () => {
|
|
const fetch = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
status: 'failed',
|
|
retcode: 1500,
|
|
data: { type: 'error' },
|
|
wording: 'disk full',
|
|
}),
|
|
),
|
|
);
|
|
const client = new SnowLumaHttpClient({ fetch });
|
|
await expect(
|
|
client.uploadFileStream(new Uint8Array(10), { chunkSize: 2 }),
|
|
).rejects.toBeInstanceOf(SnowLumaApiError);
|
|
expect(fetch).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects inconsistent final metadata', async () => {
|
|
const { client } = uploader((data, params) =>
|
|
params.is_complete ? { ...data, file_size: 99 } : data,
|
|
);
|
|
await expect(client.uploadFileStream(new Uint8Array([1]))).rejects.toThrow(
|
|
'completion metadata',
|
|
);
|
|
});
|
|
});
|