tus-js-client-stall-detection
Version:
A pure JavaScript client for the tus resumable upload protocol (fork with stall detection)
3,901 lines • 126 kB
JavaScript
(function (tusJsClient) {
'use strict';
/**
* Helper function to create a Blob from a string.
*/
function getBlob(str) {
return new Blob(str.split(''))
}
/**
* Create a promise and obtain the resolve/reject functions
* outside of the Promise callback.
*/
function flatPromise() {
let resolveFn;
let rejectFn;
const p = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
return [p, resolveFn, rejectFn]
}
/**
* Create a spy-able function which resolves a Promise
* once it is called.
*/
function waitableFunction(name = 'func') {
const [promise, resolve] = flatPromise();
const fn = jasmine.createSpy(name, resolve).and.callThrough();
fn.toBeCalled = () => promise;
return fn
}
/**
* Create a Promise that resolves after the specified duration.
*/
function wait(delay) {
return new Promise((resolve) => {
setTimeout(resolve, delay, 'timed out');
})
}
/**
* TestHttpStack implements the HTTP stack interface for tus-js-client
* and can be used to assert outgoing requests and respond with mock data.
*/
class TestHttpStack {
constructor() {
this._pendingRequests = [];
this._pendingWaits = [];
}
createRequest(method, url) {
return new TestRequest(method, url, (req) => {
if (this._pendingWaits.length >= 1) {
const handler = this._pendingWaits.shift();
handler(req);
return
}
this._pendingRequests.push(req);
})
}
nextRequest() {
if (this._pendingRequests.length >= 1) {
return Promise.resolve(this._pendingRequests.shift())
}
return new Promise((resolve) => {
this._pendingWaits.push(resolve);
})
}
supportsProgressEvents() {
// Test HTTP stack supports progress events for testing purposes
return true
}
}
class TestRequest {
constructor(method, url, onRequestSend) {
this.method = method;
this.url = url;
this.requestHeaders = {};
this.body = null;
this.bodySize = null;
this._onRequestSend = onRequestSend;
this._onProgress = () => {}
;[this._requestPromise, this._resolveRequest, this._rejectRequest] = flatPromise();
}
getMethod() {
return this.method
}
getURL() {
return this.url
}
setHeader(header, value) {
this.requestHeaders[header] = value;
}
getHeader(header) {
return this.requestHeaders[header] || null
}
setProgressHandler(progressHandler) {
this._onProgress = progressHandler;
}
async send(body = null) {
this.body = body;
if (body) {
this.bodySize = await getBodySize$1(body);
this._onProgress(0);
this._onProgress(this.bodySize);
}
this._onRequestSend(this);
return this._requestPromise
}
abort() {
this._rejectRequest(new Error('request aborted'));
}
getUnderlyingObject() {
throw new Error('not implemented')
}
respondWith(resData) {
resData.responseHeaders = resData.responseHeaders || {};
const res = new TestResponse(resData);
this._resolveRequest(res);
}
responseError(err) {
this._rejectRequest(err);
}
}
async function getBodySize$1(body) {
if (body == null) {
return null
}
if (
body instanceof ArrayBuffer ||
(typeof SharedArrayBuffer !== 'undefined' && body instanceof SharedArrayBuffer) ||
ArrayBuffer.isView(body)
) {
return body.byteLength
}
if (body instanceof Blob) {
return body.size
}
if (body.length != null) {
return body.length
}
return new Promise((resolve) => {
body.on('readable', () => {
while (true) {
const chunk = body.read();
if (chunk == null) break
resolve(chunk.length);
}
});
})
}
class TestResponse {
constructor(res) {
this._response = res;
}
getStatus() {
return this._response.status
}
getHeader(header) {
return this._response.responseHeaders[header]
}
getBody() {
return this._response.responseText
}
getUnderlyingObject() {
throw new Error('not implemented')
}
}
// Uncomment to enable debug log from tus-js-client
// tus.enableDebugLog();
describe('tus', () => {
describe('#isSupported', () => {
it('should be true', () => {
expect(tusJsClient.isSupported).toBe(true);
});
});
describe('#Upload', () => {
it('should throw if no error handler is available', () => {
const upload = new tusJsClient.Upload(null);
expect(upload.start.bind(upload)).toThrowError('tus: no file or stream to upload provided');
});
it('should throw if no endpoint and upload URL is provided', () => {
const file = getBlob('hello world');
const upload = new tusJsClient.Upload(file);
expect(upload.start.bind(upload)).toThrowError(
'tus: neither an endpoint or an upload URL is provided',
);
});
it('should upload a file', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'https://tus.io/uploads',
headers: {
Custom: 'blargh',
},
metadata: {
foo: 'hello',
bar: 'world',
nonlatin: 'słońce',
number: 100,
},
onProgress() {},
onUploadUrlAvailable: waitableFunction('onUploadUrlAvailable'),
onSuccess: waitableFunction('onSuccess'),
};
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Upload-Metadata']).toBe(
'foo aGVsbG8=,bar d29ybGQ=,nonlatin c8WCb8WEY2U=,number MTAw',
);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(options.onUploadUrlAvailable).toHaveBeenCalled();
expect(req.url).toBe('https://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('https://tus.io/uploads/blargh');
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
});
it('should create an upload if resuming fails', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadUrl: 'http://tus.io/uploads/resuming',
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 404,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
// The upload URL should be cleared when tus-js.client tries to create a new upload.
expect(upload.url).toBe(null);
});
it('should create an upload using the creation-with-data extension', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadDataDuringCreation: true,
onProgress() {},
onChunkComplete() {},
onSuccess: waitableFunction('onSuccess'),
};
spyOn(options, 'onProgress');
spyOn(options, 'onChunkComplete');
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(options.onChunkComplete).toHaveBeenCalledWith(11, 11, 11);
expect(options.onSuccess).toHaveBeenCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
});
it('should create an upload with partial data and continue', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadDataDuringCreation: true,
chunkSize: 6,
onProgress() {},
onChunkComplete() {},
onSuccess: waitableFunction('onSuccess'),
};
spyOn(options, 'onProgress');
spyOn(options, 'onChunkComplete');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(6);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
'Upload-Offset': '6',
},
});
req = await testStack.nextRequest();
// Once the second request has been sent, the progress handler must have been invoked.
expect(options.onProgress).toHaveBeenCalledWith(6, 11);
expect(options.onChunkComplete).toHaveBeenCalledWith(6, 6, 11);
expect(options.onSuccess).not.toHaveBeenCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('6');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(5);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(options.onChunkComplete).toHaveBeenCalledWith(5, 11, 11);
expect(options.onSuccess).toHaveBeenCalled();
});
it("should add the request's body and ID to errors", async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
addRequestId: true,
retryDelays: null,
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
const reqId = req.requestHeaders['X-Request-ID'];
expect(typeof reqId).toBe('string');
expect(reqId.length).toBe(36);
req.respondWith({
status: 500,
responseText: 'server_error',
});
const err = await options.onError.toBeCalled();
expect(err.message).toBe(
`tus: unexpected response while creating upload, originated from request (method: POST, url: http://tus.io/uploads, response code: 500, response text: server_error, request id: ${reqId})`,
);
expect(err.originalRequest).toBeDefined();
expect(err.originalResponse).toBeDefined();
});
it('should invoke the request and response callbacks', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
uploadUrl: 'http://tus.io/uploads/foo',
onBeforeRequest(req) {
expect(req.getURL()).toBe('http://tus.io/uploads/foo');
expect(req.getMethod()).toBe('HEAD');
},
onAfterResponse(req, res) {
expect(req.getURL()).toBe('http://tus.io/uploads/foo');
expect(req.getMethod()).toBe('HEAD');
expect(res.getStatus()).toBe(204);
expect(res.getHeader('Upload-Offset')).toBe(11);
},
onSuccess: waitableFunction('onSuccess'),
};
spyOn(options, 'onBeforeRequest');
spyOn(options, 'onAfterResponse');
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
'Upload-Length': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onBeforeRequest).toHaveBeenCalled();
expect(options.onAfterResponse).toHaveBeenCalled();
});
it('should invoke the onSuccess callback with event payload', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
uploadUrl: 'http://tus.io/uploads/foo',
onSuccess: waitableFunction('onSuccess'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
'Upload-Length': '11',
'Custom-Header': 'hello',
},
});
const { lastResponse } = await options.onSuccess.toBeCalled();
expect(lastResponse).toBeInstanceOf(TestResponse);
expect(lastResponse.getHeader('Custom-Header')).toBe('hello');
});
it('should throw an error if resuming fails and no endpoint is provided', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
uploadUrl: 'http://tus.io/uploads/resuming',
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 404,
});
const err = await options.onError.toBeCalled();
expect(err.message).toBe(
'tus: unable to resume upload (new upload cannot be created without an endpoint), originated from request (method: HEAD, url: http://tus.io/uploads/resuming, response code: 404, response text: , request id: n/a)',
);
});
it('should resolve relative URLs', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io:1080/files/',
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io:1080/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '//localhost/uploads/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://localhost/uploads/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
expect(upload.url).toBe('http://localhost/uploads/foo');
});
it('should upload a file in chunks', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
chunkSize: 7,
onSuccess: waitableFunction('onSuccess'),
onProgress() {},
onChunkComplete() {},
};
spyOn(options, 'onProgress');
spyOn(options, 'onChunkComplete');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(7);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '7',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('7');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(4);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(options.onChunkComplete).toHaveBeenCalledWith(7, 7, 11);
expect(options.onChunkComplete).toHaveBeenCalledWith(4, 11, 11);
});
it('should add the original request to errors', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
retryDelays: null,
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
req.respondWith({
status: 500,
responseHeaders: {
Custom: 'blargh',
},
});
const err = await options.onError.toBeCalled();
expect(upload.url).toBe(null);
expect(err.message).toBe(
'tus: unexpected response while creating upload, originated from request (method: POST, url: http://tus.io/uploads, response code: 500, response text: , request id: n/a)',
);
expect(err.originalRequest).toBeDefined();
expect(err.originalResponse).toBeDefined();
expect(err.originalResponse.getHeader('Custom')).toBe('blargh');
});
it('should only create an upload for empty files', async () => {
const testStack = new TestHttpStack();
const file = getBlob('');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onSuccess: waitableFunction('onSuccess'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('0');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/empty',
},
});
await options.onSuccess.toBeCalled();
expect(options.onSuccess).toHaveBeenCalled();
});
it('should not resume a finished upload', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onProgress() {},
onSuccess: waitableFunction('onSuccess'),
uploadUrl: 'http://tus.io/uploads/resuming',
};
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(options.onSuccess).toHaveBeenCalled();
});
it('should resume an upload from a specified url', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadUrl: 'http://tus.io/files/upload',
onProgress() {},
onUploadUrlAvailable: waitableFunction('onUploadUrlAvailable'),
onSuccess: waitableFunction('onSuccess'),
fingerprint() {},
};
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.start();
expect(options.fingerprint).toHaveBeenCalled();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/upload');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '3',
},
});
req = await testStack.nextRequest();
expect(options.onUploadUrlAvailable).toHaveBeenCalled();
expect(req.url).toBe('http://tus.io/files/upload');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('3');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11 - 3);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(upload.url).toBe('http://tus.io/files/upload');
});
it('should resume a previously started upload', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onSuccess: waitableFunction('onSuccess'),
onError() {},
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
upload.abort();
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
upload.start();
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onSuccess).toHaveBeenCalled();
});
it('should override the PATCH method', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadUrl: 'http://tus.io/files/upload',
overridePatchMethod: true,
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/upload');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '3',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/upload');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('3');
expect(req.requestHeaders['X-HTTP-Method-Override']).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
});
it('should emit an error if an upload is locked', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadUrl: 'http://tus.io/files/upload',
onError: waitableFunction('onError'),
retryDelays: null,
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/upload');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 423, // Locked
});
await options.onError.toBeCalled();
expect(options.onError).toHaveBeenCalledWith(
new Error(
'tus: upload is currently locked; retry later, originated from request (method: HEAD, url: http://tus.io/files/upload, response code: 423, response text: , request id: n/a)',
),
);
});
it('should emit an error if no Location header is presented', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onError: waitableFunction('onError'),
retryDelays: null,
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
// The Location header is omitted on purpose here
req.respondWith({
status: 201,
});
await options.onError.toBeCalled();
expect(options.onError).toHaveBeenCalledWith(
new Error(
'tus: invalid or missing Location header, originated from request (method: POST, url: http://tus.io/uploads, response code: 201, response text: , request id: n/a)',
),
);
});
it('should throw an error if the source provides less data than uploadSize', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
uploadSize: 100,
endpoint: 'http://tus.io/uploads',
retryDelays: [],
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
Location: 'http://tus.io/uploads/foo',
},
});
const err = await options.onError.toBeCalled();
expect(err.message).toBe(
'tus: failed to upload chunk at offset 0, caused by Error: upload was configured with a size of 100 bytes, but the source is done after 11 bytes, originated from request (method: PATCH, url: http://tus.io/uploads/foo, response code: n/a, response text: n/a, request id: n/a)',
);
});
it('should throw if retryDelays is not an array', () => {
const file = getBlob('hello world');
const upload = new tusJsClient.Upload(file, {
endpoint: 'http://endpoint/',
retryDelays: 44,
});
expect(upload.start.bind(upload)).toThrowError(
'tus: the `retryDelays` option must either be an array or null',
);
});
// This tests ensures that tus-js-client correctly retries if the
// response has the code 500 Internal Error, 423 Locked or 409 Conflict.
it('should retry the upload', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 423,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 201,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 409,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 201,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onSuccess).toHaveBeenCalled();
});
// This tests ensures that tus-js-client correctly retries if the
// return value of onShouldRetry is true.
it('should retry the upload when onShouldRetry specified and returns true', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
onShouldRetry: () => true,
};
spyOn(options, 'onShouldRetry').and.callThrough();
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 423,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 201,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 409,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 201,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onSuccess).toHaveBeenCalled();
expect(options.onShouldRetry).toHaveBeenCalled();
const args1 = options.onShouldRetry.calls.argsFor(0);
expect(args1[0].message).toEqual(
'tus: unexpected response while creating upload, originated from request (method: POST, url: http://tus.io/files/, response code: 500, response text: , request id: n/a)',
);
expect(args1[1]).toEqual(0);
expect(args1[2]).toEqual(upload.options);
const args2 = options.onShouldRetry.calls.argsFor(1);
expect(args2[0].message).toEqual(
'tus: unexpected response while uploading chunk, originated from request (method: PATCH, url: http://tus.io/files/foo, response code: 423, response text: , request id: n/a)',
);
expect(args2[1]).toEqual(1);
expect(args2[2]).toEqual(upload.options);
});
// This tests ensures that tus-js-client correctly aborts if the
// return value of onShouldRetry is false.
it('should not retry the upload when callback specified and returns false', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
onError: waitableFunction('onError'),
onShouldRetry: () => false,
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
// The error callback should not be invoked for the first error response.
expect(options.onError).not.toHaveBeenCalled();
req.respondWith({
status: 500,
});
await options.onError.toBeCalled();
expect(options.onSuccess).not.toHaveBeenCalled();
expect(options.onError).toHaveBeenCalledTimes(1);
});
it('should not retry if the error has not been caused by a request', async () => {
const file = getBlob('hello world');
const options = {
httpStack: new TestHttpStack(),
endpoint: 'http://tus.io/files/',
retryDelays: [10, 10, 10],
onSuccess() {},
onError() {},
};
spyOn(options, 'onSuccess');
spyOn(options, 'onError');
const upload = new tusJsClient.Upload(file, options);
spyOn(upload, '_createUpload');
upload.start();
await wait(200);
const error = new Error('custom error');
upload._emitError(error);
expect(upload._createUpload).toHaveBeenCalledTimes(1);
expect(options.onError).toHaveBeenCalledWith(error);
expect(options.onSuccess).not.toHaveBeenCalled();
});
it('should stop retrying after all delays have been used', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10],
onSuccess() {},
onError: waitableFunction('onError'),
};
spyOn(options, 'onSuccess');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
// The error callback should not be invoked for the first error response.
expect(options.onError).not.toHaveBeenCalled();
req.respondWith({
status: 500,
});
await options.onError.toBeCalled();
expect(options.onSuccess).not.toHaveBeenCalled();
expect(options.onError).toHaveBeenCalledTimes(1);
});
it('should stop retrying when the abort function is called', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10],
onError() {},
};
spyOn(options, 'onError');
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
spyOn(upload, 'start').and.callThrough();
upload.abort();
req.respondWith({
status: 500,
});
const result = await Promise.race([testStack.nextRequest(), wait(100)]);
expect(result).toBe('timed out');
});
it('should stop upload when the abort function is called during a callback', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 5,
onChunkComplete() {
upload.abort();
},
};
spyOn(options, 'onChunkComplete').and.callThrough();
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
const result = await Promise.race([testStack.nextRequest(), wait(200)]);
expect(options.onChunkComplete).toHaveBeenCalled();
expect(result).toBe('timed out');
});
it('should stop upload when the abort function is called during the POST request', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
onError() {},
};
spyOn(options, 'onError').and.callThrough();
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
upload.abort();
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
const result = await Promise.race([testStack.nextRequest(), wait(200)]);
expect(options.onError).not.toHaveBeenCalled();
expect(result).toBe('timed out');
});
it('should reset the attempt counter if an upload proceeds', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
retryDelays: [10],
onError() {},
onSuccess: waitableFunction('onSuccess'),
};
spyOn(options, 'onError');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
'Upload-Length': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(options.onError).not.toHaveBeenCalled();
expect(options.onSuccess).toHaveBeenCalled();
});
it('should send upload length on the last request when length is deferred and we know the total size', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
uploadUrl: 'http://tus.io/uploads/resuming',
chunkSize: 4,
// No `uploadLengthDeferred: true` here, but the client learns
// about the deferred length from the HEAD response.
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Defer-Length': '1',
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Offset']).toBe('5');
expect(req.requestHeaders['Upload-Length']).toBe(undefined);
expect(req.body.size).toBe(4);
expect(await req.body.text()).toBe(' wor');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '9',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Offset']).toBe('9');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.body.size).toBe(2);
expect(await req.body.text()).toBe('ld');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
});
});
});
async function assertUrlStorage(urlStorage) {
// In the beginning of the test, the storage should be empty.
let result = await urlStorage.findAllUploads();
expect(result).toEqual([]);
// Add a few uploads into the storage
const key1 = await urlStorage.addUpload('fingerprintA', { id: 1 });
const key2 = await urlStorage.addUpload('fingerprintA', { id: 2 });
const key3 = await urlStorage.addUpload('fingerprintB', { id: 3 });
expect(/^tus::fingerprintA::/.test(key1)).toBe(true);
expect(/^tus::fingerprintA::/.test(key2)).toBe(true);
expect(/^tus::fingerprintB::/.test(key3)).toBe(true);
// Query the just stored uploads individually
result = await urlStorage.findUploadsByFingerprint('fingerprintA');
sort(result);
expect(result).toEqual([
{ id: 1, urlStorageKey: key1 },
{ id: 2, urlStorageKey: key2 },
]);
result = await urlStorage.findUploadsByFingerprint('fingerprintB');
sort(result);
expect(result).toEqual([{ id: 3, urlStorageKey: key3 }]);
// Check that we can retrieve all stored uploads
result = await urlStorage.findAllUploads();
sort(result);
expect(result).toEqual([
{ id: 1, urlStorageKey: key1 },
{ id: 2, urlStorageKey: key2 },
{ id: 3, urlStorageKey: key3 },
]);
// Check that it can remove an upload and will not return it back
await urlStorage.removeUpload(key2);
await urlStorage.removeUpload(key3);
result = await urlStorage.findUploadsByFingerprint('fingerprintA');
expect(result).toEqual([{ id: 1, urlStorageKey: key1 }]);
result = await urlStorage.findUploadsByFingerprint('fingerprintB');
expect(result).toEqual([]);
}
// Sort the results from the URL storage since the order in not deterministic.
function sort(result) {
result.sort((a, b) => a.id - b.id);
}
describe('tus', () => {
beforeEach(() => {
localStorage.clear();
});
describe('#Upload', () => {
it('should resume an upload from a stored url', async () => {
localStorage.setItem(
'tus::fingerprinted::1337',
JSON.stringify({
uploadUrl: 'http://tus.io/uploads/resuming',
}),
);
const testStack = new TestHttpStack();
const file = new Blob('hello world'.split(''));
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onProgress() {},
fingerprint() {},
};
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
const previousUploads = await upload.findPreviousUploads();
expect(previousUploads).toEqual([
{
uploadUrl: 'http://tus.io/uploads/resuming',
urlStorageKey: 'tus::fingerprinted::1337',
},
]);
upload.resumeFromPreviousUpload(previousUploads[0]);
upload.start();
expect(options.fingerprint).toHaveBeenCalledWith(file, upload.options);
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '3',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('3');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11 - 3);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
expect(upload.url).toBe('http://tus.io/uploads/resuming');
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
});
describe('storing of upload urls', () => {
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
fingerprint() {},
};
async function startUpload() {
const file = new Blob('hello world'.split(''));
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
options.onSuccess = waitableFunction('onSuccess');
const upload = new tusJsClient.Upload(file, options);
upload.start();
expect(options.fingerprint).toHaveBeenCalled();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/uploads/blargh',
},
});
// Wait a short delay to allow the Promises to settle
await wait(10);
}
async function finishUpload() {
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
}
it('should store and retain with default options', async () => {
options.removeFingerprintOnSuccess = false;
await startUpload();
const key = localStorage.key(0);
expect(key.indexOf('tus::fingerprinted::')).toBe(0);
const storedUpload = JSON.parse(localStorage.getItem(key));
expect(storedUpload.uploadUrl).toBe('http://tus.io/uploads/blargh');
expect(storedUpload.size).toBe(11);
await finishUpload();
expect(localStorage.getItem(key)).toBe(JSON.stringify(storedUpload));
});
it('should store and remove with option removeFingerprintOnSuccess set', async () => {
options.removeFingerprintOnSuccess = true;
await startUpload();
const key = localStorage.key(0);
expect(key.indexOf('tus::fingerprinted::')).toBe(0);
const storedUpload = JSON.parse(localStorage.getItem(key));
expect(storedUpload.uploadUrl).toBe('http://tus.io/uploads/blargh');
expect(storedUpload.size).toBe(11);
await finishUpload();
expect(localStorage.getItem(key)).toBe(null);
});
it('should store URLs passed in using the uploadUrl option', async () => {
const file = new Blob('hello world'.split(''));
const options2 = {
httpStack: testStack,
uploadUrl: 'http://tus.io/uploads/storedUrl',
fingerprint() {},
onSuccess: waitableFunction('onSuccess'),
removeFingerprintOnSuccess: true,
};
spyOn(options2, 'fingerprint').and.resolveTo('fingerprinted');
const upload = new tusJsClient.Upload(file, options2);
upload.start();
expect(options2.fingerprint).toHaveBeenCalled();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/storedUrl');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '3',
},
});
// Wait a short delay to allow the Promises to settle
await wait(10);
const key = localStorage.key(0);
expect(key.indexOf('tus::fingerprinted::')).toBe(0);
const storedUpload = JSON.parse(localStorage.getItem(key));
expect(storedUpload.uploadUrl).toBe('http://tus.io/uploads/storedUrl');
expect(storedUpload.size).toBe(11);
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/storedUrl');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('3');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11 - 3);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options2.onSuccess.toBeCalled();
// Entry in localStorage should be removed after successful upload
expect(localStorage.getItem(key)).toBe(null);
});
});
it('should delete upload urls on a 4XX', async () => {
const testStack = new TestHttpStack();
const file = new Blob('hello world'.split(''));
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
fingerprint() {},
};
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
const upload = new tusJsClient.Upload(file, options);
upload.resumeFromPreviousUpload({
uploadUrl: 'http://tus.io/uploads/resuming',
urlStorageKey: 'tus::fingerprinted::1337',
});
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/resuming');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 400,
});
await wait(10);
expect(localStorage.getItem('tus::fingerprinted::1337')).toBe(null);
});
describe('resolving of URIs', () => {
// Disable these tests for IE 10 and 11 because it's not possible to overwrite
// the navigator.product property.
const isIE = navigator.userAgent.indexOf('Trident/') > 0;
if (isIE) {
console.log('Skipping tests for React Native in Internet Explorer');
return
}
const originalProduct = navigator.product;
beforeEach(() => {
jasmine.Ajax.install();
// Simulate React Native environment to enable URIs as input objects.
Object.defineProperty(navigator, 'product', {
value: 'ReactNative',
configurable: true,
});
});
afterEach(() => {
jasmine.Ajax.uninstall();
Object.defineProperty(navigator, 'product', {
value: originalProduct,
configurable: true,
});
});
it('should upload a file from an URI', async () => {
const file = {
uri: 'file:///my/file.dat',
};
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
onSuccess: waitableFunction('onSuccess'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
// Wait a short interval to make sure that the XHR has been sent.
await wait(0);
let req = jasmine.Ajax.requests.mostRecent();
expect(req.url).toBe('file:///my/file.dat');
expect(req.method).toBe('GET');
expect(req.responseType).toBe('blob');
req.respondWith({
status: 200,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '3',
},
response: new Blob('hello world'.split('')),
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Length']).toBe('11');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
});
it("should emit an error if it can't resolve the URI", async () => {
const file = {
uri: 'file:///my/file.dat',
};
const options = {
endpoint: 'http://tus.io/uploads',
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
// Wait a short interval to make sure that the XHR has been sent.
await wait(0);
const req = jasmine.Ajax.requests.mostRecent();
expect(req.url).toBe('file:///my/file.dat');
expect(req.method).toBe('GET');
expect(req.responseType).toBe('blob');
req.responseError();
await options.onError.toBeCalled();
expect(options.onError).toHaveBeenCalledWith(
new Error(
'tus: cannot fetch `file.uri` as Blob, make sure the uri is correct and accessible. [object Object]',
),
);
});
});
});
describe('#LocalStorageUrlStorage', () => {
it('should allow storing and retrieving uploads', async () => {
await assertUrlStorage(tusJsClient.defaultOptions.urlStorage);
});
});
});
describe('tus', () => {
describe('parallel uploading', () => {
it('should throw if incompatible options are used', () => {
const file = getBlob('hello world');
const upload = new tusJsClient.Upload(file, {
endpoint: 'https://tus.io/uploads',
parallelUploads: 2,
uploadUrl: 'foo',
});
expect(upload.start.bind(upload)).toThrowError(
'tus: cannot use the `uploadUrl` option when parallelUploads is enabled',
);
});
it('should throw if `parallelUploadBoundaries` is passed without `parallelUploads`', () => {
const file = getBlob('hello world');
const upload = new tusJsClient.Upload(file, {
endpoint: 'https://tus.io/uploads',
parallelUploadBoundaries: [{ start: 0, end: 2 }],
});
expect(upload.start.bind(upload)).toThrowError(
'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled',
);
});
it('should throw if `parallelUploadBoundaries` is not the same length as the value of `parallelUploads`', () => {
const file = getBlob('hello world');
const upload = new tusJsClient.Upload(file, {
endpoint: 'https://tus.io/uploads',
parallelUploads: 3,
parallelUploadBoundaries: [{ start: 0, end: 2 }],
});
expect(upload.start.bind(upload)).toThrowError(
'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`',
);
});
it('should split a file into multiple parts and create an upload for each', async () => {
const testStack = new TestHttpStack();
const testUrlStorage = {
addUpload: (fingerprint, upload) => {
expect(fingerprint).toBe('fingerprinted');
expect(upload.uploadUrl).toBeUndefined();
expect(upload.size).toBe(11);
expect(upload.parallelUploadUrls).toEqual([
'https://tus.io/uploads/upload1',
'https://tus.io/uploads/upload2',
]);
return Promise.resolve('tus::fingerprinted::1337')
},
removeUpload: (urlStorageKey) => {
expect(urlStorageKey).toBe('tus::fingerprinted::1337');
return Promise.resolve()
},
};
spyOn(testUrlStorage, 'removeUpload').and.callThrough();
spyOn(testUrlStorage, 'addUpload').and.callThrough();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
urlStorage: testUrlStorage,
storeFingerprintForResuming: true,
removeFingerprintOnSuccess: true,
parallelUploads: 2,
retryDelays: [10],
endpoint: 'https://tus.io/uploads',
headers: {
Custom: 'blargh',
},
metadata: {
foo: 'hello',
},
metadataForPartialUploads: {
test: 'world',
},
onProgress() {},
onSuccess: waitableFunction(),
fingerprint: () => Promise.resolve('fingerprinted'),
};
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('5');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
expect(req.requestHeaders['Upload-Metadata']).toBe('test d29ybGQ='); // world
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('6');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
expect(req.requestHeaders['Upload-Metadata']).toBe('test d29ybGQ='); // world
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload2',
},
});
req = await testStack.nextRequest();
// Assert that the URLs have been stored.
expect(testUrlStorage.addUpload).toHaveBeenCalled();
expect(req.url).toBe('https://tus.io/uploads/upload1');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(5);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(6);
// Return an error to ensure that the individual partial upload is properly retried.
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '0',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(6);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders.Custom).toBe('blargh');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBeUndefined();
expect(req.requestHeaders['Upload-Concat']).toBe(
'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2',
);
expect(req.requestHeaders['Upload-Metadata']).toBe('foo aGVsbG8='); // hello
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload3',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('https://tus.io/uploads/upload3');
expect(options.onProgress).toHaveBeenCalledWith(5, 11);
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
expect(testUrlStorage.removeUpload).toHaveBeenCalled();
});
it('should split a file into multiple parts based on custom `parallelUploadBoundaries`', async () => {
const testStack = new TestHttpStack();
const parallelUploadBoundaries = [
{ start: 0, end: 1 },
{ start: 1, end: 11 },
];
const file = getBlob('hello world');
const options = {
httpStack: testStack,
parallelUploads: 2,
parallelUploadBoundaries,
endpoint: 'https://tus.io/uploads',
onSuccess: waitableFunction(),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('1');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('10');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload2',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload1');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(1);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(10);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '11',
'Upload-Offset': '0',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(10);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '10',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBeUndefined();
expect(req.requestHeaders['Upload-Concat']).toBe(
'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2',
);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload3',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('https://tus.io/uploads/upload3');
});
it('should emit error from a partial upload', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
parallelUploads: 2,
retryDelays: null,
endpoint: 'https://tus.io/uploads',
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('5');
req.respondWith({
status: 500,
});
const err = await options.onError.toBeCalled();
expect(err.message).toBe(
'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: , request id: n/a)',
);
expect(err.originalRequest).toBe(req);
});
it('should resume the partial uploads', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
// The client should resume the parallel uploads, even if it is not
// configured for new uploads.
parallelUploads: 1,
endpoint: 'https://tus.io/uploads',
onProgress() {},
onSuccess: waitableFunction(),
};
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.resumeFromPreviousUpload({
urlStorageKey: 'tus::fingerprint::1337',
parallelUploadUrls: ['https://tus.io/uploads/upload1', 'https://tus.io/uploads/upload2'],
});
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload1');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '5',
'Upload-Offset': '2',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '6',
'Upload-Offset': '0',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload1');
expect(req.method).toBe('PATCH');
expect(req.bodySize).toBe(3);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('PATCH');
expect(req.bodySize).toBe(6);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Concat']).toBe(
'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2',
);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload3',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('https://tus.io/uploads/upload3');
expect(options.onProgress).toHaveBeenCalledWith(5, 11);
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
});
it('should abort all partial uploads and resume from them', async () => {
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
parallelUploads: 2,
endpoint: 'https://tus.io/uploads',
onProgress() {},
onSuccess: waitableFunction(),
fingerprint: () => Promise.resolve('fingerprinted'),
};
spyOn(options, 'onProgress');
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('5');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
expect(req.requestHeaders['Upload-Metadata']).toBeUndefined();
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('6');
expect(req.requestHeaders['Upload-Concat']).toBe('partial');
expect(req.requestHeaders['Upload-Metadata']).toBeUndefined();
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload2',
},
});
const req1 = await testStack.nextRequest();
expect(req1.url).toBe('https://tus.io/uploads/upload1');
expect(req1.method).toBe('PATCH');
expect(req1.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req1.requestHeaders['Upload-Offset']).toBe('0');
expect(req1.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req1.bodySize).toBe(5);
const req2 = await testStack.nextRequest();
expect(req2.url).toBe('https://tus.io/uploads/upload2');
expect(req2.method).toBe('PATCH');
expect(req2.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req2.requestHeaders['Upload-Offset']).toBe('0');
expect(req2.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req2.bodySize).toBe(6);
upload.abort();
req1.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req2.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
},
});
// No further requests should be sent.
const reqPromise = testStack.nextRequest();
const result = await Promise.race([reqPromise, wait(100)]);
expect(result).toBe('timed out');
// Restart the upload
upload.start();
// Reuse the promise from before as it is not cancelled.
req = await reqPromise;
expect(req.url).toBe('https://tus.io/uploads/upload1');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '5',
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads/upload2');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Length': '6',
'Upload-Offset': '6',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBeUndefined();
expect(req.requestHeaders['Upload-Concat']).toBe(
'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2',
);
req.respondWith({
status: 201,
responseHeaders: {
Location: 'https://tus.io/uploads/upload3',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('https://tus.io/uploads/upload3');
expect(options.onProgress).toHaveBeenCalledWith(5, 11);
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
});
});
});
describe('tus', () => {
describe('terminate upload', () => {
it('should terminate upload when abort is called with true', async () => {
let abortPromise;
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 5,
onChunkComplete() {
abortPromise = upload.abort(true);
},
};
spyOn(options, 'onChunkComplete').and.callThrough();
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 204,
});
expect(options.onChunkComplete).toHaveBeenCalled();
await abortPromise;
});
it('should retry terminate when an error is returned on first try', async () => {
let abortPromise;
const testStack = new TestHttpStack();
const file = getBlob('hello world');
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 5,
retryDelays: [10, 10, 10],
onChunkComplete() {
abortPromise = upload.abort(true);
},
};
spyOn(options, 'onChunkComplete').and.callThrough();
const upload = new tusJsClient.Upload(file, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '5',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 423,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 204,
});
await abortPromise;
expect(options.onChunkComplete).toHaveBeenCalled();
});
it('should stop retrying when all delays are used up', async () => {
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
retryDelays: [10, 10],
};
const terminatePromise = tusJsClient.Upload.terminate('http://tus.io/files/foo', options);
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 500,
});
await expectAsync(terminatePromise).toBeRejectedWithError(
/tus: unexpected response while terminating upload/,
);
});
it('should invoke the request and response Promises', async () => {
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
onBeforeRequest(req) {
return new Promise((resolve) => {
expect(req.getURL()).toBe('http://tus.io/uploads/foo');
expect(req.getMethod()).toBe('DELETE');
resolve();
})
},
onAfterResponse(req, res) {
return new Promise((resolve) => {
expect(req.getURL()).toBe('http://tus.io/uploads/foo');
expect(req.getMethod()).toBe('DELETE');
expect(res.getStatus()).toBe(204);
resolve();
})
},
};
spyOn(options, 'onBeforeRequest');
spyOn(options, 'onAfterResponse');
const terminatePromise = tusJsClient.Upload.terminate('http://tus.io/uploads/foo', options);
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/foo');
expect(req.method).toBe('DELETE');
req.respondWith({
status: 204,
});
await expectAsync(terminatePromise).toBeResolved();
expect(options.onBeforeRequest).toHaveBeenCalled();
expect(options.onAfterResponse).toHaveBeenCalled();
});
});
});
describe('tus', () => {
describe('#Upload', () => {
describe('uploading data from a Web Stream ReadableStream', () => {
function makeReader(content, readSize = content.length) {
let remainingData = new TextEncoder().encode(content);
return new ReadableStream({
pull(controller) {
if (remainingData.length > 0) {
const chunk = remainingData.subarray(0, readSize);
remainingData = remainingData.subarray(readSize);
controller.enqueue(chunk);
} else {
controller.close();
}
},
})
}
async function assertReaderUpload({ readSize, chunkSize }) {
const reader = makeReader('hello world', readSize);
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
chunkSize,
onProgress: waitableFunction('onProgress'),
onSuccess: waitableFunction('onSuccess'),
fingerprint() {},
uploadLengthDeferred: true,
};
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
const upload = new tusJsClient.Upload(reader, options);
upload.start();
expect(options.fingerprint).toHaveBeenCalledWith(reader, upload.options);
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Length']).toBe(undefined);
expect(req.requestHeaders['Upload-Defer-Length']).toBe('1');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(11);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onProgress.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(0, 11);
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
}
it('should upload data', async () => {
await assertReaderUpload({ chunkSize: 100, readSize: 100 });
});
it('should read multiple times from the reader', async () => {
await assertReaderUpload({ chunkSize: 100, readSize: 6 });
});
it('should use multiple PATCH requests', async () => {
const reader = makeReader('hello world', 1);
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
chunkSize: 6,
onProgress: waitableFunction('onProgress'),
onSuccess: waitableFunction('onSuccess'),
fingerprint() {},
uploadLengthDeferred: true,
};
spyOn(options, 'fingerprint').and.resolveTo('fingerprinted');
const upload = new tusJsClient.Upload(reader, options);
upload.start();
expect(options.fingerprint).toHaveBeenCalledWith(reader, upload.options);
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Length']).toBe(undefined);
expect(req.requestHeaders['Upload-Defer-Length']).toBe('1');
req.respondWith({
status: 201,
responseHeaders: {
Location: 'http://tus.io/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(6);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
},
});
await options.onProgress.toBeCalled();
expect(options.onProgress).toHaveBeenCalledWith(6, null);
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('6');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(5);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
expect(options.onProgress).toHaveBeenCalledWith(11, 11);
});
it('should retry the POST request', async () => {
const reader = makeReader('hello world', 1);
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 11,
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
uploadLengthDeferred: true,
};
const upload = new tusJsClient.Upload(reader, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Length']).toBe('11');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
});
it('should retry the first PATCH request', async () => {
const reader = makeReader('hello world', 1);
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 11,
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
uploadLengthDeferred: true,
};
const upload = new tusJsClient.Upload(reader, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Defer-Length']).toBe('1');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Defer-Length': '1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Length']).toBe('11');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
});
it('should retry following PATCH requests', async () => {
const reader = makeReader('hello world there!');
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/files/',
chunkSize: 6,
retryDelays: [10, 10, 10],
onSuccess: waitableFunction('onSuccess'),
uploadLengthDeferred: true,
};
const upload = new tusJsClient.Upload(reader, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Upload-Defer-Length']).toBe('1');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/files/foo',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 500,
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('HEAD');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '6',
'Upload-Defer-Length': '1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '12',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '18',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/files/foo');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Length']).toBe('18');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '18',
},
});
await options.onSuccess.toBeCalled();
});
it('should throw an error if the source provides less data than uploadSize', async () => {
const reader = makeReader('hello world');
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
uploadSize: 100,
chunkSize: 100,
endpoint: 'http://tus.io/uploads',
retryDelays: [],
onError: waitableFunction('onError'),
};
const upload = new tusJsClient.Upload(reader, options);
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
req.respondWith({
status: 204,
responseHeaders: {
Location: 'http://tus.io/uploads/foo',
},
});
const err = await options.onError.toBeCalled();
expect(err.message).toBe(
'tus: failed to upload chunk at offset 0, caused by Error: upload was configured with a size of 100 bytes, but the source is done after 11 bytes, originated from request (method: PATCH, url: http://tus.io/uploads/foo, response code: n/a, response text: n/a, request id: n/a)',
);
});
it('should upload data correctly when using a non zero starting offset', async () => {
const reader = makeReader('hello world', 1);
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
uploadUrl: 'http://tus.io/uploads/fileid',
retryDelays: [],
chunkSize: 6,
uploadLengthDeferred: true,
onSuccess: waitableFunction('onSuccess'),
};
const upload = new tusJsClient.Upload(reader, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/fileid');
expect(req.method).toBe('HEAD');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
// Respond with a non zero offset to test that the stream that is created
// for the reader returns the correct data and ignores the data in the stream
// before the offset.
req.respondWith({
status: 200,
responseHeaders: {
'Upload-Offset': '6',
'Upload-Defer-Length': '1',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/fileid');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Upload-Length']).toBe('11');
expect(req.requestHeaders['Upload-Offset']).toBe('6');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.body.length).toBe(5);
const bodyText = new TextDecoder().decode(req.body);
expect(bodyText).toBe('world');
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '11',
},
});
await options.onSuccess.toBeCalled();
});
});
});
});
describe('tus', () => {
describe('#Upload', () => {
const cases = [
{
name: 'ArrayBuffer',
get: () => new TextEncoder().encode('Hello, world!').buffer,
check: (val) => val instanceof ArrayBuffer,
},
{
name: 'Uint8Array',
get: () => new TextEncoder().encode('Hello, world!'),
check: (val) => val instanceof Uint8Array,
},
{
name: 'DataView',
// DataView does not cover the whole buffer and tus-js-client should respect that.
get: () => new DataView(new TextEncoder().encode('XXXHello, world!XXX').buffer, 3, 13),
check: (val) => ArrayBuffer.isView(val),
},
{
name: 'Blob',
get: () => new Blob(['Hello, world!'], { type: 'text/plain' }),
check: (val) => val instanceof Blob,
},
];
for (const { name, get, check } of cases) {
it(`should upload from a(n) ${name}`, async () => {
const value = get();
if (!check(value)) {
throw new Error(`Value is not a(n) ${name}, but ${value} instead`)
}
const testStack = new TestHttpStack();
const options = {
httpStack: testStack,
endpoint: 'http://tus.io/uploads',
chunkSize: 7,
onSuccess: waitableFunction('onSuccess'),
onProgress() {},
onChunkComplete() {},
};
spyOn(options, 'onProgress');
spyOn(options, 'onChunkComplete');
const upload = new tusJsClient.Upload(value, options);
upload.start();
let req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads');
expect(req.method).toBe('POST');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Length']).toBe('13');
req.respondWith({
status: 201,
responseHeaders: {
Location: '/uploads/blargh',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('0');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(7);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '7',
},
});
req = await testStack.nextRequest();
expect(req.url).toBe('http://tus.io/uploads/blargh');
expect(req.method).toBe('PATCH');
expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0');
expect(req.requestHeaders['Upload-Offset']).toBe('7');
expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream');
expect(req.bodySize).toBe(6);
req.respondWith({
status: 204,
responseHeaders: {
'Upload-Offset': '13',
},
});
await options.onSuccess.toBeCalled();
expect(upload.url).toBe('http://tus.io/uploads/blargh');
expect(options.onProgress).toHaveBeenCalledWith(13, 13);
expect(options.onChunkComplete).toHaveBeenCalledWith(7, 7, 13);
expect(options.onChunkComplete).toHaveBeenCalledWith(6, 13, 13);
});
}
});
});
// Test timeout for end-to-end tests when uploading to real server.
const END_TO_END_TIMEOUT = 20 * 1000;
describe('tus', () => {
describe('end-to-end', () => {
it(
'should upload to a real tus server',
async () => {
return new Promise((resolve, reject) => {
const file = getBlob('hello world');
const options = {
endpoint: 'https://tusd.tusdemo.net/files/',
metadata: {
nonlatin: 'słońce',
number: 100,
filename: 'hello.txt',
filetype: 'text/plain',
},
onSuccess() {
expect(upload.url).toMatch(/^https:\/\/tusd\.tusdemo\.net\/files\//);
console.log('Upload URL:', upload.url);
resolve(upload);
},
onError(err) {
reject(err);
},
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
})
.then(validateUploadContent)
.then((upload) => {
return upload.abort(true).then(() => upload)
})
.then(validateUploadDeletion)
},
END_TO_END_TIMEOUT,
);
it(
'should upload to a real tus server with creation-with-upload',
async () => {
return new Promise((resolve, reject) => {
const file = getBlob('hello world');
const options = {
endpoint: 'https://tusd.tusdemo.net/files/',
metadata: {
nonlatin: 'słońce',
number: 100,
filename: 'hello.txt',
filetype: 'text/plain',
},
onSuccess() {
expect(upload.url).toMatch(/^https:\/\/tusd\.tusdemo\.net\/files\//);
console.log('Upload URL:', upload.url);
resolve(upload);
},
onError(err) {
reject(err);
},
};
const upload = new tusJsClient.Upload(file, options);
upload.start();
}).then(validateUploadContent)
},
END_TO_END_TIMEOUT,
);
});
});
function validateUploadContent(upload) {
return fetch(upload.url)
.then((res) => {
expect(res.status).toBe(200);
return res.text()
})
.then((data) => {
expect(data).toBe('hello world');
return validateUploadMetadata(upload)
})
}
function validateUploadMetadata(upload) {
return fetch(upload.url, {
method: 'HEAD',
headers: {
'Tus-Resumable': '1.0.0',
},
})
.then((res) => {
expect(res.status).toBe(200);
expect(res.headers.get('tus-resumable')).toBe('1.0.0');
expect(res.headers.get('upload-offset')).toBe('11');
expect(res.headers.get('upload-length')).toBe('11');
// The values in the Upload-Metadata header may not be in the same
// order as we submitted them (the specification does not require
// that). Therefore, we split the values and verify that each one
// is present.
const metadataStr = res.headers.get('upload-metadata');
expect(metadataStr).toBeTruthy();
const metadata = metadataStr.split(',');
expect(metadata).toContain('filename aGVsbG8udHh0');
expect(metadata).toContain('filetype dGV4dC9wbGFpbg==');
expect(metadata).toContain('nonlatin c8WCb8WEY2U=');
expect(metadata).toContain('number MTAw');
expect(metadata.length).toBe(4);
return res.text()
})
.then((data) => {
expect(data).toBe('');
return upload
})
}
function validateUploadDeletion(upload) {
return fetch(upload.url).then((res) => {
expect(res.status).toBe(404);
return upload
})
}
/**
* Helper to get body size for various input types
*/
function getBodySize(body) {
if (body == null) return null
if (body instanceof Blob) return body.size
if (body.length != null) return body.length
return 0
}
/**
* Enhanced HTTP stack for testing stall detection scenarios
* Supports both complete stalls and custom progress sequences
*/
class StallTestHttpStack extends TestHttpStack {
constructor() {
super();
this.stallOnNextPatch = false;
this.progressSequences = new Map();
this.progressPromises = new Map();
this.nextProgressSequence = null;
}
/**
* Configure the stack to stall on the next PATCH request
*/
simulateStallOnNextPatch() {
this.stallOnNextPatch = true;
}
/**
* Set a custom progress sequence for the next PATCH request
* @param {Array} sequence - Array of {bytes: number, delay: number} objects
*/
setNextProgressSequence(sequence) {
this.nextProgressSequence = sequence;
}
supportsProgressEvents() {
return true
}
createRequest(method, url) {
const req = super.createRequest(method, url);
if (method === 'PATCH') {
this._setupPatchRequest(req);
}
return req
}
_setupPatchRequest(req) {
const self = this;
// Handle complete stalls
if (this.stallOnNextPatch) {
this.stallOnNextPatch = false;
req.send = async function (body) {
this.body = body;
if (body) {
this.bodySize = await getBodySize(body);
// Don't call progress handler to simulate a complete stall
}
this._onRequestSend(this);
return this._requestPromise
};
return
}
// Handle progress sequences
if (this.nextProgressSequence) {
this.progressSequences.set(req, this.nextProgressSequence);
this.nextProgressSequence = null;
}
// Override respondWith to wait for progress events
const originalRespondWith = req.respondWith.bind(req);
req.respondWith = async (resData) => {
const progressPromise = self.progressPromises.get(req);
if (progressPromise) {
await progressPromise;
self.progressPromises.delete(req);
}
originalRespondWith(resData);
};
// Override send to handle progress sequences
req.send = async function (body) {
this.body = body;
if (body) {
this.bodySize = await getBodySize(body);
}
const progressSequence = self.progressSequences.get(req);
if (progressSequence && this._onProgress) {
self._scheduleProgressSequence(req, progressSequence, this._onProgress);
} else if (this._onProgress) {
self._scheduleDefaultProgress(req, this._onProgress, this.bodySize);
}
this._onRequestSend(this);
return this._requestPromise
};
}
_scheduleProgressSequence(req, sequence, progressHandler) {
const progressPromise = new Promise((resolve) => {
setTimeout(async () => {
for (const event of sequence) {
await new Promise((resolve) => setTimeout(resolve, event.delay || 0));
progressHandler(event.bytes);
}
resolve();
}, 10); // Small delay to ensure stall detector is started
});
this.progressPromises.set(req, progressPromise);
}
_scheduleDefaultProgress(req, progressHandler, bodySize) {
const progressPromise = new Promise((resolve) => {
setTimeout(() => {
progressHandler(0);
progressHandler(bodySize);
resolve();
}, 10); // Small delay to ensure stall detector is started
});
this.progressPromises.set(req, progressPromise);
}
}
/**
* Common test setup helper
*/
function createTestUpload(options = {}) {
const defaultOptions = {
httpStack: new StallTestHttpStack(),
endpoint: 'https://tus.io/uploads',
onError: waitableFunction('onError'),
onSuccess: waitableFunction('onSuccess'),
onProgress: waitableFunction('onProgress'),
};
const file = options.file || getBlob('hello world');
const uploadOptions = { ...defaultOptions, ...options };
const upload = new tusJsClient.Upload(file, uploadOptions);
return { upload, options: uploadOptions, testStack: uploadOptions.httpStack }
}
/**
* Helper to handle standard upload creation flow
*/
async function handleUploadCreation(testStack, location = '/uploads/12345') {
const req = await testStack.nextRequest();
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: {
Location: location,
},
});
return req
}
describe('tus-stall-detection', () => {
describe('integration tests', () => {
it("should not enable stall detection if HTTP stack doesn't support progress events", async () => {
const { enableDebugLog } = await import('tus-js-client');
enableDebugLog();
const testStack = new TestHttpStack();
testStack.supportsProgressEvents = () => false;
const { upload } = createTestUpload({
httpStack: testStack,
stallDetection: { enabled: true },
});
// Capture console output
const originalLog = console.log;
let loggedMessage = '';
console.log = (message) => {
loggedMessage += message;
};
upload.start();
const req = await testStack.nextRequest();
expect(req.url).toBe('https://tus.io/uploads');
expect(req.method).toBe('POST');
req.respondWith({
status: 201,
responseHeaders: { Location: '/uploads/12345' },
});
await wait(50);
console.log = originalLog;
expect(loggedMessage).toContain(
'tus: stall detection is enabled but the HTTP stack does not support progress events',
);
upload.abort();
});
it('should upload a file with stall detection enabled', async () => {
const { upload, options, testStack } = createTestUpload({
stallDetection: {
enabled: true,
checkInterval: 1000,
stallTimeout: 2000,
},
});
upload.start();
await handleUploadCreation(testStack);
const patchReq = await testStack.nextRequest();
expect(patchReq.url).toBe('https://tus.io/uploads/12345');
expect(patchReq.method).toBe('PATCH');
patchReq.respondWith({
status: 204,
responseHeaders: { 'Upload-Offset': '11' },
});
await options.onSuccess.toBeCalled();
expect(options.onError.calls.count()).toBe(0);
});
it('should detect stalls and emit error when no retries configured', async () => {
const { upload, options, testStack } = createTestUpload({
stallDetection: {
enabled: true,
checkInterval: 100,
stallTimeout: 200,
},
retryDelays: null,
});
testStack.simulateStallOnNextPatch();
upload.start();
await handleUploadCreation(testStack);
const error = await options.onError.toBeCalled();
expect(error.message).toContain('stalled:');
});
it('should retry when stall is detected', async () => {
const { upload, options, testStack } = createTestUpload({
stallDetection: {
enabled: true,
checkInterval: 100,
stallTimeout: 200,
},
retryDelays: [100],
});
testStack.simulateStallOnNextPatch();
upload.start();
let requestCount = 0;
while (true) {
const req = await testStack.nextRequest();
requestCount++;
if (req.method === 'POST') {
req.respondWith({
status: 201,
responseHeaders: { Location: '/uploads/12345' },
});
} else if (req.method === 'HEAD') {
req.respondWith({
status: 200,
responseHeaders: {
'Upload-Offset': '0',
'Upload-Length': '11',
},
});
} else if (req.method === 'PATCH') {
req.respondWith({
status: 204,
responseHeaders: { 'Upload-Offset': '11' },
});
break
}
if (requestCount > 10) {
throw new Error('Too many requests')
}
}
await options.onSuccess.toBeCalled();
expect(options.onError.calls.count()).toBe(0);
expect(requestCount).toBeGreaterThan(1);
});
it('should not incorrectly detect stalls during onBeforeRequest delays', async () => {
const { upload, options, testStack } = createTestUpload({
stallDetection: {
enabled: true,
checkInterval: 100,
stallTimeout: 200,
},
onBeforeRequest: async (_req) => {
await wait(300); // Longer than stall timeout
},
});
upload.start();
await handleUploadCreation(testStack);
const patchReq = await testStack.nextRequest();
expect(patchReq.url).toBe('https://tus.io/uploads/12345');
expect(patchReq.method).toBe('PATCH');
patchReq.respondWith({
status: 204,
responseHeaders: { 'Upload-Offset': '11' },
});
await options.onSuccess.toBeCalled();
expect(options.onError.calls.count()).toBe(0);
});
it('should detect stalls when progress events stop mid-upload', async () => {
const file = getBlob('hello world'.repeat(100));
const { upload, options, testStack } = createTestUpload({
file,
stallDetection: {
enabled: true,
checkInterval: 100,
stallTimeout: 200,
},
retryDelays: null,
});
// Create a progress sequence that stops at 30% of the file
const fileSize = file.size;
const progressSequence = [
{ bytes: 0, delay: 10 },
{ bytes: Math.floor(fileSize * 0.1), delay: 50 },
{ bytes: Math.floor(fileSize * 0.2), delay: 50 },
{ bytes: Math.floor(fileSize * 0.3), delay: 50 },
// No more progress events after 30%
];
testStack.setNextProgressSequence(progressSequence);
upload.start();
await handleUploadCreation(testStack);
const error = await options.onError.toBeCalled();
expect(error.message).toContain('stalled:');
expect(options.onProgress.calls.count()).toBeGreaterThan(0);
});
it('should detect stalls when progress value does not change', async () => {
const { upload, options, testStack } = createTestUpload({
stallDetection: {
enabled: true,
checkInterval: 50,
stallTimeout: 500,
},
retryDelays: null,
});
// Create a progress sequence that gets stuck at 300 bytes
const progressSequence = [
{ bytes: 0, delay: 10 },
{ bytes: 100, delay: 10 },
{ bytes: 200, delay: 10 },
{ bytes: 300, delay: 10 },
// Repeat the same value to trigger value-based stall detection
...Array(12).fill({ bytes: 300, delay: 30 }),
];
testStack.setNextProgressSequence(progressSequence);
upload.start();
await handleUploadCreation(testStack);
const patchReq = await testStack.nextRequest();
expect(patchReq.method).toBe('PATCH');
const error = await options.onError.toBeCalled();
expect(error.message).toContain('stalled: no progress');
expect(options.onProgress.calls.count()).toBeGreaterThan(0);
});
});
});
beforeEach(() => {
// Clear localStorage before every test to prevent stored URLs to
// interfere with our setup.
localStorage.clear();
});
})(tusJsClient);
//# sourceMappingURL=browser-test-bundle.js.map