Skip to content

Commit

Permalink
add tests for upload progress
Browse files Browse the repository at this point in the history
  • Loading branch information
jadedevin13 committed Sep 7, 2024
1 parent 3b1a25f commit 8590ec2
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 22 deletions.
59 changes: 37 additions & 22 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class Ky {

const originalBody = this.request.body;
if (originalBody) {
const totalBytes = this._getTotalBytes(originalBody);
const totalBytes = this._getTotalBytes(this._options.body);

Check failure on line 215 in source/core/Ky.ts

View workflow job for this annotation

GitHub Actions / Node.js 18

Argument of type 'BodyInit | null | undefined' is not assignable to parameter of type 'BodyInit | undefined'.
this.request
= new globalThis.Request(this._input, {
...this._options,
Expand Down Expand Up @@ -384,36 +384,21 @@ export class Ky {
);
}

protected _getTotalBytes(body?: globalThis.BodyInit): number {
protected _getTotalBytes(body?: globalThis.BodyInit | undefined): number {
if (!body) {
return 0;
}

if (body instanceof globalThis.Blob) {
return body.size;
}

if (body instanceof globalThis.ArrayBuffer) {
return body.byteLength;
}

if (typeof body === 'string') {
return new globalThis.TextEncoder().encode(body).length;
}

if (body instanceof URLSearchParams) {
return new globalThis.TextEncoder().encode(body.toString()).length;
}

if (body instanceof globalThis.FormData) {
// This is an approximation, as FormData size calculation is not straightforward
let size = 0;
// eslint-disable-next-line unicorn/no-array-for-each -- FormData uses forEach method
body.forEach((value: globalThis.FormDataEntryValue, key: string) => {
if (typeof value === 'string') {
size += new globalThis.TextEncoder().encode(value).length;
} else if (value instanceof globalThis.Blob) {
size += value.size;
} else if (typeof value === 'object' && value !== null && 'size' in value) {
// This catches File objects as well, as File extends Blob
size += (value as Blob).size;
}

// Add some bytes for field name and multipart boundaries
Expand All @@ -423,10 +408,36 @@ export class Ky {
return size;
}

if (body instanceof globalThis.Blob) {
return body.size;
}

if (body instanceof globalThis.ArrayBuffer) {
return body.byteLength;
}

if (typeof body === 'string') {
return new globalThis.TextEncoder().encode(body).length;
}

if (body instanceof URLSearchParams) {
return new globalThis.TextEncoder().encode(body.toString()).length;
}

if ('byteLength' in body) {
return (body).byteLength;
}

if (typeof body === 'object' && body !== null) {
try {
const jsonString = JSON.stringify(body);
return new TextEncoder().encode(jsonString).length;
} catch (error) {
console.warn('Unable to stringify object:', error);
return 0;
}
}

return 0; // Default case, unable to determine size
}

Expand All @@ -451,8 +462,12 @@ export class Ky {
}

transferredBytes += value.byteLength as number;
const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
onUploadProgress({percent, transferredBytes, totalBytes});
let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
if (totalBytes < transferredBytes || percent === 1) {
percent = 0.99;
}

onUploadProgress({percent: Number(percent.toFixed(2)), transferredBytes, totalBytes});

controller.enqueue(value);
await read();
Expand Down
7 changes: 7 additions & 0 deletions test/helpers/create-large-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Helper function to create a large Blob
export function createLargeBlob(sizeInMB: number): Blob {
const chunkSize = 1024 * 1024; // 1MB
// eslint-disable-next-line unicorn/no-new-array
const chunks = new Array(sizeInMB).fill('x'.repeat(chunkSize));
return new Blob(chunks, {type: 'application/octet-stream'});
}
1 change: 1 addition & 0 deletions test/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './create-http-test-server.js';
export * from './parse-body.js';
export * from './with-page.js';
export * from './create-large-file.js';
157 changes: 157 additions & 0 deletions test/upload-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import test from 'ava';
import ky, {type Progress} from '../source/index.js';
import {createLargeBlob} from './helpers/create-large-file.js';
import {createHttpTestServer} from './helpers/create-http-test-server.js';
import {parseRawBody} from './helpers/parse-body.js';

test('POST JSON with upload progress', async t => {
const server = await createHttpTestServer({bodyParser: false});
server.post('/', async (request, response) => {
response.json(await parseRawBody(request));
});

const json = {test: 'test'};
const data: Progress[] = [];
const responseJson = await ky
.post(server.url, {
json,
onUploadProgress(progress) {
data.push(progress);
},
})
.json();

// Check if we have at least two progress updates
t.true(data.length >= 2, 'Should have at least two progress updates');

// Check the first progress update
t.true(
data[0].percent >= 0 && data[0].percent < 1,
'First update should have progress between 0 and 100%',
);
t.true(
data[0].transferredBytes >= 0,
'First update should have non-negative transferred bytes',
);

// Check intermediate updates (if any)
for (let i = 1; i < data.length - 1; i++) {
t.true(
data[i].percent >= data[i - 1].percent,
`Update ${i} should have higher or equal percent than previous`,
);
t.true(
data[i].transferredBytes >= data[i - 1].transferredBytes,
`Update ${i} should have more or equal transferred bytes than previous`,
);
}

// Check the last progress update
const lastUpdate = data.at(-1);
t.is(lastUpdate.percent, 1, 'Last update should have 100% progress');
t.true(
lastUpdate.totalBytes > 0,
'Last update should have positive total bytes',
);
t.is(
lastUpdate.transferredBytes,
lastUpdate.totalBytes,
'Last update should have transferred all bytes',
);

await server.close();
});

test('POST FormData with 10MB file upload progress', async t => {
const server = await createHttpTestServer({bodyParser: false});
server.post('/', async (request, response) => {
let totalBytes = 0;
for await (const chunk of request) {
totalBytes += chunk.length as number;
}

response.json({receivedBytes: totalBytes});
});

const largeBlob = createLargeBlob(10); // 10MB Blob
const formData = new FormData();
formData.append('file', largeBlob, 'large-file.bin');

if (formData instanceof globalThis.FormData) {
// This is an approximation, as FormData size calculation is not straightforward
let size = 0;
// eslint-disable-next-line unicorn/no-array-for-each -- FormData uses forEach method
formData.forEach((value: globalThis.FormDataEntryValue, key: string) => {
if (typeof value === 'string') {
size += new globalThis.TextEncoder().encode(value).length;
t.log(size, 'size is string');
} else if (
typeof value === 'object'
&& value !== null
&& 'size' in value
) {
// This catches File objects as well, as File extends Blob
size += (value as Blob).size;
t.log(size, 'size is file or blob');
}

// Add some bytes for field name and multipart boundaries
size += new TextEncoder().encode(key).length + 40; // 40 is an approximation for multipart overhead
});
// Return size
}

const data: Array<{
percent: number;
transferredBytes: number;
totalBytes: number;
}> = [];
const response = await ky
.post(server.url, {
body: formData,
onUploadProgress(progress) {
data.push(progress);
},
})
.json<{receivedBytes: number}>();

// Check if we have at least two progress updates
t.true(data.length >= 2, 'Should have at least two progress updates');

// Check the first progress update
t.true(
data[0].percent >= 0 && data[0].percent < 1,
'First update should have progress between 0 and 100%',
);
t.true(
data[0].transferredBytes >= 0,
'First update should have non-negative transferred bytes',
);

// Check intermediate updates (if any)
for (let i = 1; i < data.length - 1; i++) {
t.true(
data[i].percent >= data[i - 1].percent,
`Update ${i} should have higher or equal percent than previous`,
);
t.true(
data[i].transferredBytes >= data[i - 1].transferredBytes,
`Update ${i} should have more or equal transferred bytes than previous`,
);
}

// Check the last progress update
const lastUpdate = data.at(-1);
t.is(lastUpdate.percent, 1, 'Last update should have 100% progress');
t.true(
lastUpdate.totalBytes > 0,
'Last update should have positive total bytes',
);
t.is(
lastUpdate.transferredBytes,
lastUpdate.totalBytes,
'Last update should have transferred all bytes',
);

await server.close();
});

0 comments on commit 8590ec2

Please sign in to comment.