node-fetch/test/main.js

2307 lines
67 KiB
JavaScript

// Test tools
import zlib from 'zlib';
import crypto from 'crypto';
import http from 'http';
import fs from 'fs';
import stream from 'stream';
import path from 'path';
import {lookup} from 'dns';
import vm from 'vm';
import chai from 'chai';
import chaiPromised from 'chai-as-promised';
import chaiIterator from 'chai-iterator';
import chaiString from 'chai-string';
import FormData from 'form-data';
import {FormData as FormDataNode} from 'formdata-node';
import delay from 'delay';
import AbortControllerMysticatea from 'abort-controller';
import abortControllerPolyfill from 'abortcontroller-polyfill/dist/abortcontroller.js';
// Test subjects
import Blob from 'fetch-blob';
import {fileFromSync} from 'fetch-blob/from.js';
import fetch, {
FetchError,
Headers,
Request,
Response
} from '../src/index.js';
import {FetchError as FetchErrorOrig} from '../src/errors/fetch-error.js';
import HeadersOrig, {fromRawHeaders} from '../src/headers.js';
import RequestOrig from '../src/request.js';
import ResponseOrig from '../src/response.js';
import Body, {getTotalBytes, extractContentType} from '../src/body.js';
import TestServer from './utils/server.js';
import chaiTimeout from './utils/chai-timeout.js';
const AbortControllerPolyfill = abortControllerPolyfill.AbortController;
function isNodeLowerThan(version) {
return !~process.version.localeCompare(version, undefined, {numeric: true});
}
const {
Uint8Array: VMUint8Array
} = vm.runInNewContext('this');
chai.use(chaiPromised);
chai.use(chaiIterator);
chai.use(chaiString);
chai.use(chaiTimeout);
const {expect} = chai;
function streamToPromise(stream, dataHandler) {
return new Promise((resolve, reject) => {
stream.on('data', (...args) => {
Promise.resolve()
.then(() => dataHandler(...args))
.catch(reject);
});
stream.on('end', resolve);
stream.on('error', reject);
});
}
describe('node-fetch', () => {
const local = new TestServer();
let base;
before(async () => {
await local.start();
base = `http://${local.hostname}:${local.port}/`;
});
after(async () => {
return local.stop();
});
it('should return a promise', () => {
const url = `${base}hello`;
const p = fetch(url);
expect(p).to.be.an.instanceof(Promise);
expect(p).to.have.property('then');
});
it('should expose Headers, Response and Request constructors', () => {
expect(FetchError).to.equal(FetchErrorOrig);
expect(Headers).to.equal(HeadersOrig);
expect(Response).to.equal(ResponseOrig);
expect(Request).to.equal(RequestOrig);
});
it('should support proper toString output for Headers, Response and Request objects', () => {
expect(new Headers().toString()).to.equal('[object Headers]');
expect(new Response().toString()).to.equal('[object Response]');
expect(new Request(base).toString()).to.equal('[object Request]');
});
it('should reject with error if url is protocol relative', () => {
const url = '//example.com/';
return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /Invalid URL/);
});
it('should reject with error if url is relative path', () => {
const url = '/some/path';
return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /Invalid URL/);
});
it('should reject with error if protocol is unsupported', () => {
const url = 'ftp://example.com/';
return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /URL scheme "ftp" is not supported/);
});
it('should reject with error on network failure', function () {
this.timeout(5000);
const url = 'http://localhost:50000/';
return expect(fetch(url)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.include({type: 'system', code: 'ECONNREFUSED', errno: 'ECONNREFUSED'});
});
it('error should contain system error if one occurred', () => {
const err = new FetchError('a message', 'system', new Error('an error'));
return expect(err).to.have.property('erroredSysCall');
});
it('error should not contain system error if none occurred', () => {
const err = new FetchError('a message', 'a type');
return expect(err).to.not.have.property('erroredSysCall');
});
it('system error is extracted from failed requests', function () {
this.timeout(5000);
const url = 'http://localhost:50000/';
return expect(fetch(url)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('erroredSysCall');
});
it('should resolve into response', () => {
const url = `${base}hello`;
return fetch(url).then(res => {
expect(res).to.be.an.instanceof(Response);
expect(res.headers).to.be.an.instanceof(Headers);
expect(res.body).to.be.an.instanceof(stream.Transform);
expect(res.bodyUsed).to.be.false;
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
expect(res.statusText).to.equal('OK');
});
});
it('Response.redirect should resolve into response', () => {
const res = Response.redirect('http://localhost');
expect(res).to.be.an.instanceof(Response);
expect(res.headers).to.be.an.instanceof(Headers);
expect(res.headers.get('location')).to.equal('http://localhost/');
expect(res.status).to.equal(302);
});
it('Response.redirect /w invalid url should fail', () => {
expect(() => {
Response.redirect('localhost');
}).to.throw();
});
it('Response.redirect /w invalid status should fail', () => {
expect(() => {
Response.redirect('http://localhost', 200);
}).to.throw();
});
it('should accept plain text response', () => {
const url = `${base}plain`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(res.bodyUsed).to.be.true;
expect(result).to.be.a('string');
expect(result).to.equal('text');
});
});
});
it('should accept html response (like plain text)', () => {
const url = `${base}html`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/html');
return res.text().then(result => {
expect(res.bodyUsed).to.be.true;
expect(result).to.be.a('string');
expect(result).to.equal('<html></html>');
});
});
});
it('should accept json response', () => {
const url = `${base}json`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('application/json');
return res.json().then(result => {
expect(res.bodyUsed).to.be.true;
expect(result).to.be.an('object');
expect(result).to.deep.equal({name: 'value'});
});
});
});
it('should send request with custom headers', () => {
const url = `${base}inspect`;
const options = {
headers: {'x-custom-header': 'abc'}
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.headers['x-custom-header']).to.equal('abc');
});
});
it('should accept headers instance', () => {
const url = `${base}inspect`;
const options = {
headers: new Headers({'x-custom-header': 'abc'})
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.headers['x-custom-header']).to.equal('abc');
});
});
it('should accept custom host header', () => {
const url = `${base}inspect`;
const options = {
headers: {
host: 'example.com'
}
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.headers.host).to.equal('example.com');
});
});
it('should accept custom HoSt header', () => {
const url = `${base}inspect`;
const options = {
headers: {
HoSt: 'example.com'
}
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.headers.host).to.equal('example.com');
});
});
it('should follow redirect code 301', () => {
const url = `${base}redirect/301`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
});
});
it('should follow redirect code 302', () => {
const url = `${base}redirect/302`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should follow redirect code 303', () => {
const url = `${base}redirect/303`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should follow redirect code 307', () => {
const url = `${base}redirect/307`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should follow redirect code 308', () => {
const url = `${base}redirect/308`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should follow redirect chain', () => {
const url = `${base}redirect/chain`;
return fetch(url).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should follow POST request redirect code 301 with GET', () => {
const url = `${base}redirect/301`;
const options = {
method: 'POST',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(result => {
expect(result.method).to.equal('GET');
expect(result.body).to.equal('');
});
});
});
it('should follow PATCH request redirect code 301 with PATCH', () => {
const url = `${base}redirect/301`;
const options = {
method: 'PATCH',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(res => {
expect(res.method).to.equal('PATCH');
expect(res.body).to.equal('a=1');
});
});
});
it('should follow POST request redirect code 302 with GET', () => {
const url = `${base}redirect/302`;
const options = {
method: 'POST',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(result => {
expect(result.method).to.equal('GET');
expect(result.body).to.equal('');
});
});
});
it('should follow PATCH request redirect code 302 with PATCH', () => {
const url = `${base}redirect/302`;
const options = {
method: 'PATCH',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(res => {
expect(res.method).to.equal('PATCH');
expect(res.body).to.equal('a=1');
});
});
});
it('should follow redirect code 303 with GET', () => {
const url = `${base}redirect/303`;
const options = {
method: 'PUT',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(result => {
expect(result.method).to.equal('GET');
expect(result.body).to.equal('');
});
});
});
it('should follow PATCH request redirect code 307 with PATCH', () => {
const url = `${base}redirect/307`;
const options = {
method: 'PATCH',
body: 'a=1'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
return res.json().then(result => {
expect(result.method).to.equal('PATCH');
expect(result.body).to.equal('a=1');
});
});
});
it('should not follow non-GET redirect if body is a readable stream', () => {
const url = `${base}redirect/307`;
const options = {
method: 'PATCH',
body: stream.Readable.from('tada')
};
return expect(fetch(url, options)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'unsupported-redirect');
});
it('should obey maximum redirect, reject case', () => {
const url = `${base}redirect/chain`;
const options = {
follow: 1
};
return expect(fetch(url, options)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'max-redirect');
});
it('should obey redirect chain, resolve case', () => {
const url = `${base}redirect/chain`;
const options = {
follow: 2
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
expect(res.status).to.equal(200);
});
});
it('should allow not following redirect', () => {
const url = `${base}redirect/301`;
const options = {
follow: 0
};
return expect(fetch(url, options)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'max-redirect');
});
it('should support redirect mode, manual flag', () => {
const url = `${base}redirect/301`;
const options = {
redirect: 'manual'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(url);
expect(res.status).to.equal(301);
expect(res.headers.get('location')).to.equal(`${base}inspect`);
});
});
it('should support redirect mode, manual flag, broken Location header', () => {
const url = `${base}redirect/bad-location`;
const options = {
redirect: 'manual'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(url);
expect(res.status).to.equal(301);
expect(res.headers.get('location')).to.equal(`${base}redirect/%C3%A2%C2%98%C2%83`);
});
});
it('should support redirect mode, error flag', () => {
const url = `${base}redirect/301`;
const options = {
redirect: 'error'
};
return expect(fetch(url, options)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'no-redirect');
});
it('should support redirect mode, manual flag when there is no redirect', () => {
const url = `${base}hello`;
const options = {
redirect: 'manual'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(url);
expect(res.status).to.equal(200);
expect(res.headers.get('location')).to.be.null;
});
});
it('should follow redirect code 301 and keep existing headers', () => {
const url = `${base}redirect/301`;
const options = {
headers: new Headers({'x-custom-header': 'abc'})
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(`${base}inspect`);
return res.json();
}).then(res => {
expect(res.headers['x-custom-header']).to.equal('abc');
});
});
it('should treat broken redirect as ordinary response (follow)', () => {
const url = `${base}redirect/no-location`;
return fetch(url).then(res => {
expect(res.url).to.equal(url);
expect(res.status).to.equal(301);
expect(res.headers.get('location')).to.be.null;
});
});
it('should treat broken redirect as ordinary response (manual)', () => {
const url = `${base}redirect/no-location`;
const options = {
redirect: 'manual'
};
return fetch(url, options).then(res => {
expect(res.url).to.equal(url);
expect(res.status).to.equal(301);
expect(res.headers.get('location')).to.be.null;
});
});
it('should throw a TypeError on an invalid redirect option', () => {
const url = `${base}redirect/301`;
const options = {
redirect: 'foobar'
};
return fetch(url, options).then(() => {
expect.fail();
}, error => {
expect(error).to.be.an.instanceOf(TypeError);
expect(error.message).to.equal('Redirect option \'foobar\' is not a valid value of RequestRedirect');
});
});
it('should set redirected property on response when redirect', () => {
const url = `${base}redirect/301`;
return fetch(url).then(res => {
expect(res.redirected).to.be.true;
});
});
it('should not set redirected property on response without redirect', () => {
const url = `${base}hello`;
return fetch(url).then(res => {
expect(res.redirected).to.be.false;
});
});
it('should ignore invalid headers', () => {
const headers = fromRawHeaders([
'Invalid-Header ',
'abc\r\n',
'Invalid-Header-Value',
'\u0007k\r\n',
'Cookie',
'\u0007k\r\n',
'Cookie',
'\u0007kk\r\n'
]);
expect(headers).to.be.instanceOf(Headers);
expect(headers.raw()).to.deep.equal({});
});
it('should handle client-error response', () => {
const url = `${base}error/400`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
expect(res.status).to.equal(400);
expect(res.statusText).to.equal('Bad Request');
expect(res.ok).to.be.false;
return res.text().then(result => {
expect(res.bodyUsed).to.be.true;
expect(result).to.be.a('string');
expect(result).to.equal('client error');
});
});
});
it('should handle server-error response', () => {
const url = `${base}error/500`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
expect(res.status).to.equal(500);
expect(res.statusText).to.equal('Internal Server Error');
expect(res.ok).to.be.false;
return res.text().then(result => {
expect(res.bodyUsed).to.be.true;
expect(result).to.be.a('string');
expect(result).to.equal('server error');
});
});
});
it('should handle network-error response', () => {
const url = `${base}error/reset`;
return expect(fetch(url)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('code', 'ECONNRESET');
});
it('should handle network-error partial response', () => {
const url = `${base}error/premature`;
return fetch(url).then(res => {
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
return expect(res.text()).to.eventually.be.rejectedWith(Error)
.and.have.property('message').matches(/Premature close|The operation was aborted|aborted/);
});
});
it('should handle network-error in chunked response', () => {
const url = `${base}error/premature/chunked`;
return fetch(url).then(res => {
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
return expect(new Promise((resolve, reject) => {
res.body.on('error', reject);
res.body.on('close', resolve);
})).to.eventually.be.rejectedWith(Error, 'Premature close')
.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
});
});
it('should handle network-error in chunked response async iterator', () => {
const url = `${base}error/premature/chunked`;
return fetch(url).then(res => {
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
const read = async body => {
const chunks = [];
if (isNodeLowerThan('v14.15.2')) {
// In older Node.js versions, some errors don't come out in the async iterator; we have
// to pick them up from the event-emitter and then throw them after the async iterator
let error;
body.on('error', err => {
error = err;
});
for await (const chunk of body) {
chunks.push(chunk);
}
if (error) {
throw error;
}
return new Promise(resolve => {
body.on('close', () => resolve(chunks));
});
}
for await (const chunk of body) {
chunks.push(chunk);
}
return chunks;
};
return expect(read(res.body))
.to.eventually.be.rejectedWith(Error, 'Premature close')
.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
});
});
it('should handle network-error in chunked response in consumeBody', () => {
const url = `${base}error/premature/chunked`;
return fetch(url).then(res => {
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
return expect(res.text())
.to.eventually.be.rejectedWith(Error, 'Premature close');
});
});
it('should handle DNS-error response', () => {
const url = 'http://domain.invalid';
return expect(fetch(url)).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('code').that.matches(/ENOTFOUND|EAI_AGAIN/);
});
it('should reject invalid json response', () => {
const url = `${base}error/json`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('application/json');
return expect(res.json()).to.eventually.be.rejectedWith(Error);
});
});
it('should handle response with no status text', () => {
const url = `${base}no-status-text`;
return fetch(url).then(res => {
expect(res.statusText).to.equal('');
});
});
it('should handle no content response', () => {
const url = `${base}no-content`;
return fetch(url).then(res => {
expect(res.status).to.equal(204);
expect(res.statusText).to.equal('No Content');
expect(res.ok).to.be.true;
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.be.empty;
});
});
});
it('should reject when trying to parse no content response as json', () => {
const url = `${base}no-content`;
return fetch(url).then(res => {
expect(res.status).to.equal(204);
expect(res.statusText).to.equal('No Content');
expect(res.ok).to.be.true;
return expect(res.json()).to.eventually.be.rejectedWith(Error);
});
});
it('should handle no content response with gzip encoding', () => {
const url = `${base}no-content/gzip`;
return fetch(url).then(res => {
expect(res.status).to.equal(204);
expect(res.statusText).to.equal('No Content');
expect(res.headers.get('content-encoding')).to.equal('gzip');
expect(res.ok).to.be.true;
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.be.empty;
});
});
});
it('should handle not modified response', () => {
const url = `${base}not-modified`;
return fetch(url).then(res => {
expect(res.status).to.equal(304);
expect(res.statusText).to.equal('Not Modified');
expect(res.ok).to.be.false;
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.be.empty;
});
});
});
it('should handle not modified response with gzip encoding', () => {
const url = `${base}not-modified/gzip`;
return fetch(url).then(res => {
expect(res.status).to.equal(304);
expect(res.statusText).to.equal('Not Modified');
expect(res.headers.get('content-encoding')).to.equal('gzip');
expect(res.ok).to.be.false;
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.be.empty;
});
});
});
it('should decompress gzip response', () => {
const url = `${base}gzip`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should decompress slightly invalid gzip response', () => {
const url = `${base}gzip-truncated`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should make capitalised Content-Encoding lowercase', () => {
const url = `${base}gzip-capital`;
return fetch(url).then(res => {
expect(res.headers.get('content-encoding')).to.equal('gzip');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should decompress deflate response', () => {
const url = `${base}deflate`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should decompress deflate raw response from old apache server', () => {
const url = `${base}deflate-raw`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should decompress brotli response', function () {
if (typeof zlib.createBrotliDecompress !== 'function') {
this.skip();
}
const url = `${base}brotli`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('hello world');
});
});
});
it('should handle no content response with brotli encoding', function () {
if (typeof zlib.createBrotliDecompress !== 'function') {
this.skip();
}
const url = `${base}no-content/brotli`;
return fetch(url).then(res => {
expect(res.status).to.equal(204);
expect(res.statusText).to.equal('No Content');
expect(res.headers.get('content-encoding')).to.equal('br');
expect(res.ok).to.be.true;
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.be.empty;
});
});
});
it('should skip decompression if unsupported', () => {
const url = `${base}sdch`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.equal('fake sdch string');
});
});
});
it('should reject if response compression is invalid', () => {
const url = `${base}invalid-content-encoding`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return expect(res.text()).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('code', 'Z_DATA_ERROR');
});
});
it('should handle errors on the body stream even if it is not used', done => {
const url = `${base}invalid-content-encoding`;
fetch(url)
.then(res => {
expect(res.status).to.equal(200);
})
.catch(() => {})
.then(() => {
// Wait a few ms to see if a uncaught error occurs
setTimeout(() => {
done();
}, 20);
});
});
it('should collect handled errors on the body stream to reject if the body is used later', () => {
const url = `${base}invalid-content-encoding`;
return fetch(url).then(delay(20)).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return expect(res.text()).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('code', 'Z_DATA_ERROR');
});
});
it('should allow disabling auto decompression', () => {
const url = `${base}gzip`;
const options = {
compress: false
};
return fetch(url, options).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(result => {
expect(result).to.be.a('string');
expect(result).to.not.equal('hello world');
});
});
});
it('should not overwrite existing accept-encoding header when auto decompression is true', () => {
const url = `${base}inspect`;
const options = {
compress: true,
headers: {
'Accept-Encoding': 'gzip'
}
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.headers['accept-encoding']).to.equal('gzip');
});
});
const testAbortController = (name, buildAbortController, moreTests = null) => {
describe(`AbortController (${name})`, () => {
let controller;
beforeEach(() => {
controller = buildAbortController();
});
it('should support request cancellation with signal', () => {
const fetches = [
fetch(
`${base}timeout`,
{
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
body: JSON.stringify({hello: 'world'})
}
}
)
];
setTimeout(() => {
controller.abort();
}, 100);
return Promise.all(fetches.map(fetched => expect(fetched)
.to.eventually.be.rejected
.and.be.an.instanceOf(Error)
.and.include({
type: 'aborted',
name: 'AbortError'
})
));
});
it('should support multiple request cancellation with signal', () => {
const fetches = [
fetch(`${base}timeout`, {signal: controller.signal}),
fetch(
`${base}timeout`,
{
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
body: JSON.stringify({hello: 'world'})
}
}
)
];
setTimeout(() => {
controller.abort();
}, 100);
return Promise.all(fetches.map(fetched => expect(fetched)
.to.eventually.be.rejected
.and.be.an.instanceOf(Error)
.and.include({
type: 'aborted',
name: 'AbortError'
})
));
});
it('should reject immediately if signal has already been aborted', () => {
const url = `${base}timeout`;
const options = {
signal: controller.signal
};
controller.abort();
const fetched = fetch(url, options);
return expect(fetched).to.eventually.be.rejected
.and.be.an.instanceOf(Error)
.and.include({
type: 'aborted',
name: 'AbortError'
});
});
it('should allow redirects to be aborted', () => {
const request = new Request(`${base}redirect/slow`, {
signal: controller.signal
});
setTimeout(() => {
controller.abort();
}, 20);
return expect(fetch(request)).to.be.eventually.rejected
.and.be.an.instanceOf(Error)
.and.have.property('name', 'AbortError');
});
it('should allow redirected response body to be aborted', () => {
const request = new Request(`${base}redirect/slow-stream`, {
signal: controller.signal
});
return expect(fetch(request).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
const result = res.text();
controller.abort();
return result;
})).to.be.eventually.rejected
.and.be.an.instanceOf(Error)
.and.have.property('name', 'AbortError');
});
it('should reject response body with AbortError when aborted before stream has been read completely', () => {
return expect(fetch(
`${base}slow`,
{signal: controller.signal}
))
.to.eventually.be.fulfilled
.then(res => {
const promise = res.text();
controller.abort();
return expect(promise)
.to.eventually.be.rejected
.and.be.an.instanceof(Error)
.and.have.property('name', 'AbortError');
});
});
it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', () => {
return expect(fetch(
`${base}slow`,
{signal: controller.signal}
))
.to.eventually.be.fulfilled
.then(res => {
controller.abort();
return expect(res.text())
.to.eventually.be.rejected
.and.be.an.instanceof(Error)
.and.have.property('name', 'AbortError');
});
});
it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', done => {
expect(fetch(
`${base}slow`,
{signal: controller.signal}
))
.to.eventually.be.fulfilled
.then(res => {
res.body.once('error', err => {
expect(err)
.to.be.an.instanceof(Error)
.and.have.property('name', 'AbortError');
done();
});
controller.abort();
});
});
it('should cancel request body of type Stream with AbortError when aborted', () => {
const body = new stream.Readable({objectMode: true});
body._read = () => {};
const promise = fetch(
`${base}slow`,
{signal: controller.signal, body, method: 'POST'}
);
const result = Promise.all([
new Promise((resolve, reject) => {
body.on('error', error => {
try {
expect(error).to.be.an.instanceof(Error).and.have.property('name', 'AbortError');
resolve();
} catch (error_) {
reject(error_);
}
});
}),
expect(promise).to.eventually.be.rejected
.and.be.an.instanceof(Error)
.and.have.property('name', 'AbortError')
]);
controller.abort();
return result;
});
if (moreTests) {
moreTests();
}
});
};
testAbortController('polyfill',
() => new AbortControllerPolyfill(),
() => {
it('should remove internal AbortSignal event listener after request is aborted', () => {
const controller = new AbortControllerPolyfill();
const {signal} = controller;
setTimeout(() => {
controller.abort();
}, 20);
return expect(fetch(`${base}timeout`, {signal}))
.to.eventually.be.rejected
.and.be.an.instanceof(Error)
.and.have.property('name', 'AbortError')
.then(() => {
return expect(signal.listeners.abort.length).to.equal(0);
});
});
it('should remove internal AbortSignal event listener after request and response complete without aborting', () => {
const controller = new AbortControllerPolyfill();
const {signal} = controller;
const fetchHtml = fetch(`${base}html`, {signal})
.then(res => res.text());
const fetchResponseError = fetch(`${base}error/reset`, {signal});
const fetchRedirect = fetch(`${base}redirect/301`, {signal}).then(res => res.json());
return Promise.all([
expect(fetchHtml).to.eventually.be.fulfilled.and.equal('<html></html>'),
expect(fetchResponseError).to.be.eventually.rejected,
expect(fetchRedirect).to.eventually.be.fulfilled
]).then(() => {
expect(signal.listeners.abort.length).to.equal(0);
});
});
}
);
testAbortController('mysticatea', () => new AbortControllerMysticatea());
if (process.version > 'v15') {
testAbortController('native', () => new AbortController());
}
it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', () => {
return Promise.all([
expect(fetch(`${base}inspect`, {signal: {}}))
.to.be.eventually.rejected
.and.be.an.instanceof(TypeError)
.and.have.property('message').includes('AbortSignal'),
expect(fetch(`${base}inspect`, {signal: ''}))
.to.be.eventually.rejected
.and.be.an.instanceof(TypeError)
.and.have.property('message').includes('AbortSignal'),
expect(fetch(`${base}inspect`, {signal: Object.create(null)}))
.to.be.eventually.rejected
.and.be.an.instanceof(TypeError)
.and.have.property('message').includes('AbortSignal')
]);
});
it('should gracefully handle a nullish signal', () => {
return Promise.all([
fetch(`${base}hello`, {signal: null}).then(res => {
return expect(res.ok).to.be.true;
}),
fetch(`${base}hello`, {signal: undefined}).then(res => {
return expect(res.ok).to.be.true;
})
]);
});
it('should set default User-Agent', () => {
const url = `${base}inspect`;
return fetch(url).then(res => res.json()).then(res => {
expect(res.headers['user-agent']).to.startWith('node-fetch');
});
});
it('should allow setting User-Agent', () => {
const url = `${base}inspect`;
const options = {
headers: {
'user-agent': 'faked'
}
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.headers['user-agent']).to.equal('faked');
});
});
it('should set default Accept header', () => {
const url = `${base}inspect`;
fetch(url).then(res => res.json()).then(res => {
expect(res.headers.accept).to.equal('*/*');
});
});
it('should allow setting Accept header', () => {
const url = `${base}inspect`;
const options = {
headers: {
accept: 'application/json'
}
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.headers.accept).to.equal('application/json');
});
});
it('should allow POST request', () => {
const url = `${base}inspect`;
const options = {
method: 'POST'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('0');
});
});
it('should allow POST request with string body', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: 'a=1'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow POST request with buffer body', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: Buffer.from('a=1', 'utf-8')
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow POST request with ArrayBuffer body', () => {
const encoder = new TextEncoder();
const url = `${base}inspect`;
const options = {
method: 'POST',
body: encoder.encode('Hello, world!\n').buffer
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('Hello, world!\n');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('14');
});
});
it('should allow POST request with ArrayBuffer body from a VM context', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: new VMUint8Array(Buffer.from('Hello, world!\n')).buffer
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('Hello, world!\n');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('14');
});
});
it('should allow POST request with ArrayBufferView (Uint8Array) body', () => {
const encoder = new TextEncoder();
const url = `${base}inspect`;
const options = {
method: 'POST',
body: encoder.encode('Hello, world!\n')
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('Hello, world!\n');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('14');
});
});
it('should allow POST request with ArrayBufferView (DataView) body', () => {
const encoder = new TextEncoder();
const url = `${base}inspect`;
const options = {
method: 'POST',
body: new DataView(encoder.encode('Hello, world!\n').buffer)
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('Hello, world!\n');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('14');
});
});
it('should allow POST request with ArrayBufferView (Uint8Array) body from a VM context', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: new VMUint8Array(Buffer.from('Hello, world!\n'))
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('Hello, world!\n');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('14');
});
});
it('should allow POST request with ArrayBufferView (Uint8Array, offset, length) body', () => {
const encoder = new TextEncoder();
const url = `${base}inspect`;
const options = {
method: 'POST',
body: encoder.encode('Hello, world!\n').subarray(7, 13)
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('world!');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('6');
});
});
it('should allow POST request with blob body without type', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: new Blob(['a=1'])
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow POST request with blob body with type', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: new Blob(['a=1'], {
type: 'text/plain;charset=UTF-8'
})
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow POST request with readable stream as body', () => {
const url = `${base}inspect`;
const options = {
method: 'POST',
body: stream.Readable.from('a=1')
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.equal('chunked');
expect(res.headers['content-type']).to.be.undefined;
expect(res.headers['content-length']).to.be.undefined;
});
});
it('should allow POST request with form-data as body', () => {
const form = new FormData();
form.append('a', '1');
const url = `${base}multipart`;
const options = {
method: 'POST',
body: form
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.startWith('multipart/form-data;boundary=');
expect(res.headers['content-length']).to.be.a('string');
expect(res.body).to.equal('a=1');
});
});
it('should allow POST request with form-data using stream as body', () => {
const form = new FormData();
form.append('my_field', fs.createReadStream('test/utils/dummy.txt'));
const url = `${base}multipart`;
const options = {
method: 'POST',
body: form
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.startWith('multipart/form-data;boundary=');
expect(res.headers['content-length']).to.be.undefined;
expect(res.body).to.contain('my_field=');
});
});
it('should allow POST request with form-data as body and custom headers', () => {
const form = new FormData();
form.append('a', '1');
const headers = form.getHeaders();
headers.b = '2';
const url = `${base}multipart`;
const options = {
method: 'POST',
body: form,
headers
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.startWith('multipart/form-data; boundary=');
expect(res.headers['content-length']).to.be.a('string');
expect(res.headers.b).to.equal('2');
expect(res.body).to.equal('a=1');
});
});
it('should support spec-compliant form-data as POST body', () => {
const form = new FormDataNode();
const filename = path.join('test', 'utils', 'dummy.txt');
form.set('field', 'some text');
form.set('file', fileFromSync(filename));
const url = `${base}multipart`;
const options = {
method: 'POST',
body: form
};
return fetch(url, options).then(res => res.json()).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.startWith('multipart/form-data');
expect(res.body).to.contain('field=');
expect(res.body).to.contain('file=');
});
});
it('should allow POST request with object body', () => {
const url = `${base}inspect`;
// Note that fetch simply calls tostring on an object
const options = {
method: 'POST',
body: {a: 1}
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('[object Object]');
expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8');
expect(res.headers['content-length']).to.equal('15');
});
});
it('constructing a Response with URLSearchParams as body should have a Content-Type', () => {
const parameters = new URLSearchParams();
const res = new Response(parameters);
res.headers.get('Content-Type');
expect(res.headers.get('Content-Type')).to.equal('application/x-www-form-urlencoded;charset=UTF-8');
});
it('constructing a Request with URLSearchParams as body should have a Content-Type', () => {
const parameters = new URLSearchParams();
const request = new Request(base, {method: 'POST', body: parameters});
expect(request.headers.get('Content-Type')).to.equal('application/x-www-form-urlencoded;charset=UTF-8');
});
it('Reading a body with URLSearchParams should echo back the result', () => {
const parameters = new URLSearchParams();
parameters.append('a', '1');
return new Response(parameters).text().then(text => {
expect(text).to.equal('a=1');
});
});
// Body should been cloned...
it('constructing a Request/Response with URLSearchParams and mutating it should not affected body', () => {
const parameters = new URLSearchParams();
const request = new Request(`${base}inspect`, {method: 'POST', body: parameters});
parameters.append('a', '1');
return request.text().then(text => {
expect(text).to.equal('');
});
});
it('should allow POST request with URLSearchParams as body', () => {
const parameters = new URLSearchParams();
parameters.append('a', '1');
const url = `${base}inspect`;
const options = {
method: 'POST',
body: parameters
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.equal('application/x-www-form-urlencoded;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
expect(res.body).to.equal('a=1');
});
});
it('should still recognize URLSearchParams when extended', () => {
class CustomSearchParameters extends URLSearchParams {}
const parameters = new CustomSearchParameters();
parameters.append('a', '1');
const url = `${base}inspect`;
const options = {
method: 'POST',
body: parameters
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.equal('application/x-www-form-urlencoded;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
expect(res.body).to.equal('a=1');
});
});
/* For 100% code coverage, checks for duck-typing-only detection
* where both constructor.name and brand tests fail */
it('should still recognize URLSearchParams when extended from polyfill', () => {
class CustomPolyfilledSearchParameters extends URLSearchParams {}
const parameters = new CustomPolyfilledSearchParameters();
parameters.append('a', '1');
const url = `${base}inspect`;
const options = {
method: 'POST',
body: parameters
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.headers['content-type']).to.equal('application/x-www-form-urlencoded;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
expect(res.body).to.equal('a=1');
});
});
it('should overwrite Content-Length if possible', () => {
const url = `${base}inspect`;
// Note that fetch simply calls tostring on an object
const options = {
method: 'POST',
headers: {
'Content-Length': '1000'
},
body: 'a=1'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('POST');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8');
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow PUT request', () => {
const url = `${base}inspect`;
const options = {
method: 'PUT',
body: 'a=1'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('PUT');
expect(res.body).to.equal('a=1');
});
});
it('should allow DELETE request', () => {
const url = `${base}inspect`;
const options = {
method: 'DELETE'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('DELETE');
});
});
it('should allow DELETE request with string body', () => {
const url = `${base}inspect`;
const options = {
method: 'DELETE',
body: 'a=1'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('DELETE');
expect(res.body).to.equal('a=1');
expect(res.headers['transfer-encoding']).to.be.undefined;
expect(res.headers['content-length']).to.equal('3');
});
});
it('should allow PATCH request', () => {
const url = `${base}inspect`;
const options = {
method: 'PATCH',
body: 'a=1'
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.method).to.equal('PATCH');
expect(res.body).to.equal('a=1');
});
});
it('should allow HEAD request', () => {
const url = `${base}hello`;
const options = {
method: 'HEAD'
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(200);
expect(res.statusText).to.equal('OK');
expect(res.headers.get('content-type')).to.equal('text/plain');
expect(res.body).to.be.an.instanceof(stream.Transform);
return res.text();
}).then(text => {
expect(text).to.equal('');
});
});
it('should allow HEAD request with content-encoding header', () => {
const url = `${base}error/404`;
const options = {
method: 'HEAD'
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(404);
expect(res.headers.get('content-encoding')).to.equal('gzip');
return res.text();
}).then(text => {
expect(text).to.equal('');
});
});
it('should allow OPTIONS request', () => {
const url = `${base}options`;
const options = {
method: 'OPTIONS'
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(200);
expect(res.statusText).to.equal('OK');
expect(res.headers.get('allow')).to.equal('GET, HEAD, OPTIONS');
expect(res.body).to.be.an.instanceof(stream.Transform);
});
});
it('should reject decoding body twice', () => {
const url = `${base}plain`;
return fetch(url).then(res => {
expect(res.headers.get('content-type')).to.equal('text/plain');
return res.text().then(() => {
expect(res.bodyUsed).to.be.true;
return expect(res.text()).to.eventually.be.rejectedWith(Error);
});
});
});
it('should support maximum response size, multiple chunk', () => {
const url = `${base}size/chunk`;
const options = {
size: 5
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.equal('text/plain');
return expect(res.text()).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'max-size');
});
});
it('should support maximum response size, single chunk', () => {
const url = `${base}size/long`;
const options = {
size: 5
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.equal('text/plain');
return expect(res.text()).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.have.property('type', 'max-size');
});
});
it('should allow piping response body as stream', () => {
const url = `${base}hello`;
return fetch(url).then(res => {
expect(res.body).to.be.an.instanceof(stream.Transform);
return streamToPromise(res.body, chunk => {
if (chunk === null) {
return;
}
expect(chunk.toString()).to.equal('world');
});
});
});
it('should allow cloning a response, and use both as stream', () => {
const url = `${base}hello`;
return fetch(url).then(res => {
const r1 = res.clone();
expect(res.body).to.be.an.instanceof(stream.Transform);
expect(r1.body).to.be.an.instanceof(stream.Transform);
const dataHandler = chunk => {
if (chunk === null) {
return;
}
expect(chunk.toString()).to.equal('world');
};
return Promise.all([
streamToPromise(res.body, dataHandler),
streamToPromise(r1.body, dataHandler)
]);
});
});
it('should allow cloning a json response and log it as text response', () => {
const url = `${base}json`;
return fetch(url).then(res => {
const r1 = res.clone();
return Promise.all([res.json(), r1.text()]).then(results => {
expect(results[0]).to.deep.equal({name: 'value'});
expect(results[1]).to.equal('{"name":"value"}');
});
});
});
it('should allow cloning a json response, and then log it as text response', () => {
const url = `${base}json`;
return fetch(url).then(res => {
const r1 = res.clone();
return res.json().then(result => {
expect(result).to.deep.equal({name: 'value'});
return r1.text().then(result => {
expect(result).to.equal('{"name":"value"}');
});
});
});
});
it('should allow cloning a json response, first log as text response, then return json object', () => {
const url = `${base}json`;
return fetch(url).then(res => {
const r1 = res.clone();
return r1.text().then(result => {
expect(result).to.equal('{"name":"value"}');
return res.json().then(result => {
expect(result).to.deep.equal({name: 'value'});
});
});
});
});
it('should not allow cloning a response after its been used', () => {
const url = `${base}hello`;
return fetch(url).then(res =>
res.text().then(() => {
expect(() => {
res.clone();
}).to.throw(Error);
})
);
});
it('the default highWaterMark should equal 16384', () => {
const url = `${base}hello`;
return fetch(url).then(res => {
expect(res.highWaterMark).to.equal(16384);
});
});
it('should timeout on cloning response without consuming one of the streams when the second packet size is equal default highWaterMark', function () {
this.timeout(300);
const url = local.mockResponse(res => {
// Observed behavior of TCP packets splitting:
// - response body size <= 65438 → single packet sent
// - response body size > 65438 → multiple packets sent
// Max TCP packet size is 64kB (https://stackoverflow.com/a/2614188/5763764),
// but first packet probably transfers more than the response body.
const firstPacketMaxSize = 65438;
const secondPacketSize = 16 * 1024; // = defaultHighWaterMark
res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize));
});
return expect(
fetch(url).then(res => res.clone().buffer())
).to.timeout;
});
it('should timeout on cloning response without consuming one of the streams when the second packet size is equal custom highWaterMark', function () {
this.timeout(300);
const url = local.mockResponse(res => {
const firstPacketMaxSize = 65438;
const secondPacketSize = 10;
res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize));
});
return expect(
fetch(url, {highWaterMark: 10}).then(res => res.clone().buffer())
).to.timeout;
});
it('should not timeout on cloning response without consuming one of the streams when the second packet size is less than default highWaterMark', function () {
// TODO: fix test.
if (!isNodeLowerThan('v16.0.0')) {
this.skip();
}
this.timeout(300);
const url = local.mockResponse(res => {
const firstPacketMaxSize = 65438;
const secondPacketSize = 16 * 1024; // = defaultHighWaterMark
res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize - 1));
});
return expect(
fetch(url).then(res => res.clone().buffer())
).not.to.timeout;
});
it('should not timeout on cloning response without consuming one of the streams when the second packet size is less than custom highWaterMark', function () {
// TODO: fix test.
if (!isNodeLowerThan('v16.0.0')) {
this.skip();
}
this.timeout(300);
const url = local.mockResponse(res => {
const firstPacketMaxSize = 65438;
const secondPacketSize = 10;
res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize - 1));
});
return expect(
fetch(url, {highWaterMark: 10}).then(res => res.clone().buffer())
).not.to.timeout;
});
it('should not timeout on cloning response without consuming one of the streams when the response size is double the custom large highWaterMark - 1', function () {
// TODO: fix test.
if (!isNodeLowerThan('v16.0.0')) {
this.skip();
}
this.timeout(300);
const url = local.mockResponse(res => {
res.end(crypto.randomBytes((2 * 512 * 1024) - 1));
});
return expect(
fetch(url, {highWaterMark: 512 * 1024}).then(res => res.clone().buffer())
).not.to.timeout;
});
it('should allow get all responses of a header', () => {
const url = `${base}cookie`;
return fetch(url).then(res => {
const expected = 'a=1, b=1';
expect(res.headers.get('set-cookie')).to.equal(expected);
expect(res.headers.get('Set-Cookie')).to.equal(expected);
});
});
it('should return all headers using raw()', () => {
const url = `${base}cookie`;
return fetch(url).then(res => {
const expected = [
'a=1',
'b=1'
];
expect(res.headers.raw()['set-cookie']).to.deep.equal(expected);
});
});
it('should allow deleting header', () => {
const url = `${base}cookie`;
return fetch(url).then(res => {
res.headers.delete('set-cookie');
expect(res.headers.get('set-cookie')).to.be.null;
});
});
it('should send request with connection keep-alive if agent is provided', () => {
const url = `${base}inspect`;
const options = {
agent: new http.Agent({
keepAlive: true
})
};
return fetch(url, options).then(res => {
return res.json();
}).then(res => {
expect(res.headers.connection).to.equal('keep-alive');
});
});
it('should support fetch with Request instance', () => {
const url = `${base}hello`;
const request = new Request(url);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('should support fetch with Node.js URL object', () => {
const url = `${base}hello`;
const urlObject = new URL(url);
const request = new Request(urlObject);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('should support fetch with WHATWG URL object', () => {
const url = `${base}hello`;
const urlObject = new URL(url);
const request = new Request(urlObject);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('should keep `?` sign in URL when no params are given', () => {
const url = `${base}question?`;
const urlObject = new URL(url);
const request = new Request(urlObject);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('if params are given, do not modify anything', () => {
const url = `${base}question?a=1`;
const urlObject = new URL(url);
const request = new Request(urlObject);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('should preserve the hash (#) symbol', () => {
const url = `${base}question?#`;
const urlObject = new URL(url);
const request = new Request(urlObject);
return fetch(request).then(res => {
expect(res.url).to.equal(url);
expect(res.ok).to.be.true;
expect(res.status).to.equal(200);
});
});
it('should support reading blob as text', () => {
return new Response('hello')
.blob()
.then(blob => blob.text())
.then(body => {
expect(body).to.equal('hello');
});
});
it('should support reading blob as arrayBuffer', () => {
return new Response('hello')
.blob()
.then(blob => blob.arrayBuffer())
.then(ab => {
const string = String.fromCharCode.apply(null, new Uint8Array(ab));
expect(string).to.equal('hello');
});
});
it('should support reading blob as stream', () => {
return new Response('hello')
.blob()
.then(blob => streamToPromise(stream.Readable.from(blob.stream()), data => {
const string = Buffer.from(data).toString();
expect(string).to.equal('hello');
}));
});
it('should support blob round-trip', () => {
const url = `${base}hello`;
let length;
let type;
return fetch(url).then(res => res.blob()).then(async blob => {
const url = `${base}inspect`;
length = blob.size;
type = blob.type;
return fetch(url, {
method: 'POST',
body: blob
});
}).then(res => res.json()).then(({body, headers}) => {
expect(body).to.equal('world');
expect(headers['content-type']).to.equal(type);
expect(headers['content-length']).to.equal(String(length));
});
});
it('should support overwrite Request instance', () => {
const url = `${base}inspect`;
const request = new Request(url, {
method: 'POST',
headers: {
a: '1'
}
});
return fetch(request, {
method: 'GET',
headers: {
a: '2'
}
}).then(res => {
return res.json();
}).then(body => {
expect(body.method).to.equal('GET');
expect(body.headers.a).to.equal('2');
});
});
it('should support arrayBuffer(), blob(), text(), json() and buffer() method in Body constructor', () => {
const body = new Body('a=1');
expect(body).to.have.property('arrayBuffer');
expect(body).to.have.property('blob');
expect(body).to.have.property('text');
expect(body).to.have.property('json');
expect(body).to.have.property('buffer');
});
/* eslint-disable-next-line func-names */
it('should create custom FetchError', function funcName() {
const systemError = new Error('system');
systemError.code = 'ESOMEERROR';
const err = new FetchError('test message', 'test-error', systemError);
expect(err).to.be.an.instanceof(Error);
expect(err).to.be.an.instanceof(FetchError);
expect(err.name).to.equal('FetchError');
expect(err.message).to.equal('test message');
expect(err.type).to.equal('test-error');
expect(err.code).to.equal('ESOMEERROR');
expect(err.errno).to.equal('ESOMEERROR');
// Reading the stack is quite slow (~30-50ms)
expect(err.stack).to.include('funcName').and.to.startWith(`${err.name}: ${err.message}`);
});
it('should support https request', function () {
this.timeout(5000);
const url = 'https://github.com/';
const options = {
method: 'HEAD'
};
return fetch(url, options).then(res => {
expect(res.status).to.equal(200);
expect(res.ok).to.be.true;
});
});
// Issue #414
it('should reject if attempt to accumulate body stream throws', () => {
const res = new Response(stream.Readable.from((async function * () {
yield Buffer.from('tada');
await new Promise(resolve => {
setTimeout(resolve, 200);
});
yield {tada: 'yes'};
})()));
return expect(res.text()).to.eventually.be.rejected
.and.be.an.instanceOf(FetchError)
.and.include({type: 'system'})
.and.have.property('message').that.include('Could not create Buffer');
});
it('supports supplying a lookup function to the agent', () => {
const url = `${base}redirect/301`;
let called = 0;
function lookupSpy(hostname, options, callback) {
called++;
// eslint-disable-next-line node/prefer-promises/dns
return lookup(hostname, options, callback);
}
const agent = http.Agent({lookup: lookupSpy});
return fetch(url, {agent}).then(() => {
expect(called).to.equal(2);
});
});
it('supports supplying a famliy option to the agent', () => {
const url = `${base}redirect/301`;
const families = [];
const family = Symbol('family');
function lookupSpy(hostname, options, callback) {
families.push(options.family);
// eslint-disable-next-line node/prefer-promises/dns
return lookup(hostname, {}, callback);
}
const agent = http.Agent({lookup: lookupSpy, family});
return fetch(url, {agent}).then(() => {
expect(families).to.have.length(2);
expect(families[0]).to.equal(family);
expect(families[1]).to.equal(family);
});
});
it('should allow a function supplying the agent', () => {
const url = `${base}inspect`;
const agent = new http.Agent({
keepAlive: true
});
let parsedURL;
return fetch(url, {
agent(_parsedURL) {
parsedURL = _parsedURL;
return agent;
}
}).then(res => {
return res.json();
}).then(res => {
// The agent provider should have been called
expect(parsedURL.protocol).to.equal('http:');
// The agent we returned should have been used
expect(res.headers.connection).to.equal('keep-alive');
});
});
it('should calculate content length and extract content type for each body type', () => {
const url = `${base}hello`;
const bodyContent = 'a=1';
const streamBody = stream.Readable.from(bodyContent);
const streamRequest = new Request(url, {
method: 'POST',
body: streamBody,
size: 1024
});
const blobBody = new Blob([bodyContent], {type: 'text/plain'});
const blobRequest = new Request(url, {
method: 'POST',
body: blobBody,
size: 1024
});
const formBody = new FormData();
formBody.append('a', '1');
const formRequest = new Request(url, {
method: 'POST',
body: formBody,
size: 1024
});
const bufferBody = Buffer.from(bodyContent);
const bufferRequest = new Request(url, {
method: 'POST',
body: bufferBody,
size: 1024
});
const stringRequest = new Request(url, {
method: 'POST',
body: bodyContent,
size: 1024
});
const nullRequest = new Request(url, {
method: 'GET',
body: null,
size: 1024
});
expect(getTotalBytes(streamRequest)).to.be.null;
expect(getTotalBytes(blobRequest)).to.equal(blobBody.size);
expect(getTotalBytes(formRequest)).to.not.be.null;
expect(getTotalBytes(bufferRequest)).to.equal(bufferBody.length);
expect(getTotalBytes(stringRequest)).to.equal(bodyContent.length);
expect(getTotalBytes(nullRequest)).to.equal(0);
expect(extractContentType(streamBody)).to.be.null;
expect(extractContentType(blobBody)).to.equal('text/plain');
expect(extractContentType(formBody)).to.startWith('multipart/form-data');
expect(extractContentType(bufferBody)).to.be.null;
expect(extractContentType(bodyContent)).to.equal('text/plain;charset=UTF-8');
expect(extractContentType(null)).to.be.null;
});
it('should encode URLs as UTF-8', async () => {
const url = `${base}möbius`;
const res = await fetch(url);
expect(res.url).to.equal(`${base}m%C3%B6bius`);
});
});