diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5effb95ad..2d5c4ba33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,3 @@ - Changelog ========= @@ -14,7 +13,7 @@ Changelog - Enhance: data URI support. - Enhance: drop existing blob implementation code and use fetch-blob as dependency instead. - Enhance: modernise the code behind `FetchError` and `AbortError`. -- Enhance: replace deprecated `url.parse()` and `url.replace()` with the new WHATWG `new URL()` +- Enhance: replace deprecated `url.parse()` and `url.replace()` with the new WHATWG's `new URL()` - Enhance: allow excluding a `user-agent` in a fetch request by setting it's header to null. - Fix: `Response.statusText` no longer sets a default message derived from the HTTP status code. - Fix: missing response stream error events. diff --git a/docs/v3-UPGRADE-GUIDE.md b/docs/v3-UPGRADE-GUIDE.md index 003a20c69..f49ce92ec 100644 --- a/docs/v3-UPGRADE-GUIDE.md +++ b/docs/v3-UPGRADE-GUIDE.md @@ -67,6 +67,10 @@ We are working towards changing body to become either null or a stream. The default user agent has been changed from `node-fetch/1.0 (+https://github.com/node-fetch/node-fetch)` to `node-fetch (+https://github.com/node-fetch/node-fetch)`. +## Arbitrary URLs are no longer supported + +Since in 3.x we are using the WHATWG's `new URL()`, arbitrary URL parsing will fail due to lack of base. + # Enhancements ## Data URI support @@ -85,9 +89,11 @@ We now use the new Node.js [WHATWG-compliant URL API][whatwg-nodejs-url], so UTF Since the v3.x required at least Node.js 10, we can utilise the new API. -## `AbortError` now uses a w3c defined message +## Creating Request/Response objects with relative URLs is no longer supported -To stay spec-compliant, we changed the `AbortError` message to `The operation was aborted.`. +We introduced Node.js `new URL()` API in 3.x, because it offers better UTF-8 support and is WHATWG URL compatible. The drawback is, given current limit of the API (nodejs/node#12682), it's not possible to support relative URL parsing without hacks. +Due to the lack of a browsing context in Node.js, we opted to drop support for relative URLs on Request/Response object, and it will now throw errors if you do so. +The main `fetch()` function will support absolute URLs and data url. ## Bundled TypeScript types diff --git a/package.json b/package.json index 6d85b9e74..4a4a8e8c7 100644 --- a/package.json +++ b/package.json @@ -1,161 +1,150 @@ { - "name": "node-fetch", - "version": "2.6.0", - "description": "A light-weight module that brings window.fetch to node.js", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "types/index.d.ts", - "files": [ - "src/**/*", - "dist/**/*", - "types/**/*.d.ts" - ], - "engines": { - "node": ">=10.0.0" - }, - "scripts": { - "build": "pika-pack --out dist/", - "prepare": "npm run build", - "test": "cross-env BABEL_ENV=test mocha --require @babel/register --throw-deprecation test/test.js", - "report": "cross-env BABEL_ENV=coverage nyc --reporter lcov --reporter text mocha -R spec test/test.js", - "coverage": "cross-env BABEL_ENV=coverage nyc --reporter json --reporter text mocha -R spec test/test.js && codecov -f coverage/coverage-final.json", - "lint": "xo" - }, - "repository": { - "type": "git", - "url": "https://github.com/node-fetch/node-fetch.git" - }, - "keywords": [ - "fetch", - "http", - "promise" - ], - "author": "David Frank", - "license": "MIT", - "bugs": { - "url": "https://github.com/node-fetch/node-fetch/issues" - }, - "homepage": "https://github.com/node-fetch/node-fetch", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - }, - "devDependencies": { - "@babel/core": "^7.8.7", - "@babel/preset-env": "^7.8.7", - "@babel/register": "^7.8.6", - "@pika/pack": "^0.5.0", - "@pika/plugin-build-node": "^0.9.2", - "@pika/plugin-build-types": "^0.9.2", - "@pika/plugin-copy-assets": "^0.9.2", - "@pika/plugin-standard-pkg": "^0.9.2", - "abort-controller": "^3.0.0", - "abortcontroller-polyfill": "^1.4.0", - "chai": "^4.2.0", - "chai-as-promised": "^7.1.1", - "chai-iterator": "^3.0.2", - "chai-string": "^1.5.0", - "codecov": "^3.6.5", - "cross-env": "^7.0.2", - "form-data": "^3.0.0", - "mocha": "^7.1.0", - "nyc": "^15.0.0", - "parted": "^0.1.1", - "promise": "^8.1.0", - "resumer": "0.0.0", - "string-to-arraybuffer": "^1.0.2", - "xo": "^0.27.2" - }, - "dependencies": { - "data-uri-to-buffer": "^3.0.0", - "fetch-blob": "^1.0.5", - "utf8": "^3.0.0" - }, - "@pika/pack": { - "pipeline": [ - [ - "@pika/plugin-standard-pkg" - ], - [ - "@pika/plugin-build-node" - ], - [ - "@pika/plugin-build-types" - ], - [ - "@pika/plugin-copy-assets", - { - "files": [ - "externals.d.ts" - ] - } - ] - ] - }, - "xo": { - "envs": [ - "node", - "browser" - ], - "rules": { - "valid-jsdoc": 0, - "no-multi-assign": 0, - "complexity": 0, - "unicorn/prefer-spread": 0, - "promise/prefer-await-to-then": 0, - "no-mixed-operators": 0, - "eqeqeq": 0, - "no-eq-null": 0, - "no-negated-condition": 0, - "prefer-named-capture-group": 0, - "unicorn/catch-error-name": 0, - "node/no-deprecated-api": 1 - }, - "ignores": [ - "dist" - ], - "overrides": [ - { - "files": "test/**/*.js", - "envs": [ - "node", - "mocha" - ], - "rules": { - "max-nested-callbacks": 0, - "no-unused-expressions": 0, - "eslint-comments/no-unused-disable": 0, - "new-cap": 0, - "guard-for-in": 0, - "no-new": 0 - } - }, - { - "files": "example.js", - "rules": { - "import/no-extraneous-dependencies": 0 - } - } - ] - }, - "babel": { - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "node": true - } - } - ] - ] - }, - "nyc": { - "require": [ - "@babel/register" - ], - "sourceMap": false, - "instrument": false - }, - "runkitExampleFilename": "example.js" -} \ No newline at end of file + "name": "node-fetch", + "version": "2.6.0", + "description": "A light-weight module that brings window.fetch to node.js", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "types/index.d.ts", + "files": [ + "src/**/*", + "dist/**/*", + "types/**/*.d.ts" + ], + "engines": { + "node": ">=10.0.0" + }, + "scripts": { + "build": "pika-pack --out dist/", + "prepare": "npm run build", + "test": "cross-env BABEL_ENV=test mocha --require @babel/register --throw-deprecation test/*.js", + "report": "cross-env BABEL_ENV=coverage nyc --reporter lcov --reporter text mocha -R spec test/*.js", + "coverage": "cross-env BABEL_ENV=coverage nyc --reporter json --reporter text mocha -R spec test/*.js && codecov -f coverage/coverage-final.json", + "lint": "xo" + }, + "repository": { + "type": "git", + "url": "https://github.com/node-fetch/node-fetch.git" + }, + "keywords": [ + "fetch", + "http", + "promise" + ], + "author": "David Frank", + "license": "MIT", + "bugs": { + "url": "https://github.com/node-fetch/node-fetch/issues" + }, + "homepage": "https://github.com/node-fetch/node-fetch", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + }, + "devDependencies": { + "@babel/core": "^7.8.4", + "@babel/preset-env": "^7.8.4", + "@babel/register": "^7.8.3", + "@pika/pack": "^0.5.0", + "@pika/plugin-build-node": "^0.8.1", + "@pika/plugin-build-types": "^0.9.2", + "@pika/plugin-copy-assets": "^0.8.1", + "@pika/plugin-standard-pkg": "^0.9.2", + "abort-controller": "^3.0.0", + "abortcontroller-polyfill": "^1.3.0", + "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", + "chai-iterator": "^3.0.2", + "chai-string": "^1.5.0", + "codecov": "^3.6.4", + "cross-env": "^7.0.0", + "form-data": "^3.0.0", + "mocha": "^7.0.0", + "nyc": "^15.0.0", + "parted": "^0.1.1", + "promise": "^8.0.3", + "resumer": "0.0.0", + "string-to-arraybuffer": "^1.0.2", + "xo": "^0.26.1" + }, + "dependencies": { + "data-uri-to-buffer": "^3.0.0", + "fetch-blob": "^1.0.5" + }, + "@pika/pack": { + "pipeline": [ + [ + "@pika/plugin-standard-pkg" + ], + [ + "@pika/plugin-build-node" + ], + [ + "@pika/plugin-build-types" + ], + [ + "@pika/plugin-copy-assets", + { + "files": [ + "externals.d.ts" + ] + } + ] + ] + }, + "xo": { + "envs": [ + "node", + "browser" + ], + "rules": { + "complexity": 0, + "promise/prefer-await-to-then": 0, + "no-mixed-operators": 0, + "no-negated-condition": 0 + }, + "ignores": [ + "dist" + ], + "overrides": [ + { + "files": "test/**/*.js", + "envs": [ + "node", + "mocha" + ], + "rules": { + "max-nested-callbacks": 0, + "no-unused-expressions": 0, + "new-cap": 0, + "guard-for-in": 0 + } + }, + { + "files": "example.js", + "rules": { + "import/no-extraneous-dependencies": 0 + } + } + ] + }, + "babel": { + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": true + } + } + ] + ] + }, + "nyc": { + "require": [ + "@babel/register" + ], + "sourceMap": false, + "instrument": false + }, + "runkitExampleFilename": "example.js" +} diff --git a/src/body.js b/src/body.js index ebb759fc9..9d19c89bc 100644 --- a/src/body.js +++ b/src/body.js @@ -26,7 +26,7 @@ export default function Body(body, { size = 0, timeout = 0 } = {}) { - if (body == null) { + if (body === null) { // Body is undefined or null body = null; } else if (isURLSearchParams(body)) { @@ -293,7 +293,7 @@ export function clone(instance, highWaterMark) { */ export function extractContentType(body) { // Body is null or undefined - if (body == null) { + if (body === null) { return null; } @@ -342,7 +342,7 @@ export function extractContentType(body) { */ export function getTotalBytes({body}) { // Body is null or undefined - if (body == null) { + if (body === null) { return 0; } @@ -373,7 +373,7 @@ export function getTotalBytes({body}) { * @returns {void} */ export function writeToStream(dest, {body}) { - if (body == null) { + if (body === null) { // Body is null dest.end(); } else if (isBlob(body)) { diff --git a/src/errors/fetch-error.js b/src/errors/fetch-error.js index 4ead12317..87b696a7b 100644 --- a/src/errors/fetch-error.js +++ b/src/errors/fetch-error.js @@ -23,6 +23,7 @@ export default class FetchError extends Error { // When err.type is `system`, err.erroredSysCall contains system error and err.code contains system error code if (systemError) { + // eslint-disable-next-line no-multi-assign this.code = this.errno = systemError.code; this.erroredSysCall = systemError; } diff --git a/src/headers.js b/src/headers.js index 297aa4f93..0588baac8 100644 --- a/src/headers.js +++ b/src/headers.js @@ -67,10 +67,12 @@ export default class Headers { // We don't worry about converting prop to ByteString here as append() // will handle it. + // eslint-disable-next-line no-eq-null, eqeqeq if (init == null) { // No op } else if (typeof init === 'object') { const method = init[Symbol.iterator]; + // eslint-disable-next-line no-eq-null, eqeqeq if (method != null) { if (typeof method !== 'function') { throw new TypeError('Header pairs must be iterable'); @@ -84,7 +86,7 @@ export default class Headers { throw new TypeError('Each header pair must be iterable'); } - pairs.push(Array.from(pair)); + pairs.push([...pair]); } for (const pair of pairs) { diff --git a/src/request.js b/src/request.js index 5de69f676..45206e2ae 100644 --- a/src/request.js +++ b/src/request.js @@ -7,9 +7,8 @@ * All spec algorithm step numbers are based on https://fetch.spec.whatwg.org/commit-snapshots/ae716822cb3a61843226cd090eefc6589446c1d2/. */ -import {parse as parseUrl, format as formatUrl} from 'url'; +import {format as formatUrl} from 'url'; import Stream from 'stream'; -import utf8 from 'utf8'; import Headers, {exportNodeCompatibleHeaders} from './headers'; import Body, {clone, extractContentType, getTotalBytes} from './body'; import {isAbortSignal} from './utils/is'; @@ -31,6 +30,26 @@ function isRequest(obj) { ); } +/** + * Wrapper around `new URL` to handle relative URLs (https://github.com/nodejs/node/issues/12682) + * + * @param {string} urlStr + * @return {void} + */ +function parseURL(urlStr) { + /* + Check whether the URL is absolute or not + + Scheme: https://tools.ietf.org/html/rfc3986#section-3.1 + Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3 + */ + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.exec(urlStr)) { + return new URL(urlStr); + } + + throw new TypeError('Only absolute URLs are supported'); +} + /** * Request class * @@ -42,31 +61,33 @@ export default class Request { constructor(input, init = {}) { let parsedURL; - // Normalize input and force URL to be encoded as UTF-8 (https://github.com/node-fetch/node-fetch/issues/245) + // Normalize input and force URL to be encoded as UTF-8 (https://github.com/bitinn/node-fetch/issues/245) if (!isRequest(input)) { if (input && input.href) { // In order to support Node.js' Url objects; though WHATWG's URL objects // will fall into this branch also (since their `toString()` will return // `href` property anyway) - parsedURL = parseUrl(utf8.encode(input.href)); + parsedURL = parseURL(input.href); } else { // Coerce input to a string before attempting to parse - parsedURL = parseUrl(utf8.encode(`${input}`)); + parsedURL = parseURL(`${input}`); } input = {}; } else { - parsedURL = parseUrl(utf8.encode(input.url)); + parsedURL = parseURL(input.url); } let method = init.method || input.method || 'GET'; method = method.toUpperCase(); + // eslint-disable-next-line no-eq-null, eqeqeq if ((init.body != null || isRequest(input) && input.body !== null) && (method === 'GET' || method === 'HEAD')) { throw new TypeError('Request with GET/HEAD method cannot have body'); } + // eslint-disable-next-line no-eq-null, eqeqeq const inputBody = init.body != null ? init.body : (isRequest(input) && input.body !== null ? @@ -80,7 +101,7 @@ export default class Request { const headers = new Headers(init.headers || input.headers || {}); - if (inputBody != null && !headers.has('Content-Type')) { + if (inputBody !== null && !headers.has('Content-Type')) { const contentType = extractContentType(inputBody); if (contentType) { headers.append('Content-Type', contentType); @@ -94,7 +115,7 @@ export default class Request { signal = init.signal; } - if (signal != null && !isAbortSignal(signal)) { + if (signal !== null && !isAbortSignal(signal)) { throw new TypeError('Expected signal to be an instanceof AbortSignal'); } @@ -200,14 +221,13 @@ export function getNodeRequestOptions(request) { // HTTP-network-or-cache fetch steps 2.4-2.7 let contentLengthValue = null; - if (request.body == null && /^(POST|PUT)$/i.test(request.method)) { + if (request.body === null && /^(POST|PUT)$/i.test(request.method)) { contentLengthValue = '0'; } - if (request.body != null) { + if (request.body !== null) { const totalBytes = getTotalBytes(request); - // Set Content-Length if totalBytes is a number (that is not NaN) - if (typeof totalBytes === 'number' && !Number.isNaN(totalBytes)) { + if (typeof totalBytes === 'number') { contentLengthValue = String(totalBytes); } } @@ -217,10 +237,8 @@ export function getNodeRequestOptions(request) { } // HTTP-network-or-cache fetch step 2.11 - if (headers.get('User-Agent') === 'null') { - headers.delete('User-Agent'); - } else if (!headers.has('User-Agent') && headers.get('User-Agent') !== 'null') { - headers.set('User-Agent', 'node-fetch (+https://github.com/node-fetch/node-fetch)'); + if (!headers.has('User-Agent')) { + headers.set('User-Agent', 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'); } // HTTP-network-or-cache fetch step 2.15 @@ -240,10 +258,21 @@ export function getNodeRequestOptions(request) { // HTTP-network fetch step 4.2 // chunked encoding is handled by Node.js - return { - ...parsedURL, + // manually spread the URL object instead of spread syntax + const reqOptions = { + path: parsedURL.pathname, + pathname: parsedURL.pathname, + hostname: parsedURL.hostname, + protocol: parsedURL.protocol, + port: parsedURL.port, + hash: parsedURL.hash, + search: parsedURL.search, + query: parsedURL.query, + href: parsedURL.href, method: request.method, headers: exportNodeCompatibleHeaders(headers), agent }; + + return reqOptions; } diff --git a/test/external-encoding.js b/test/external-encoding.js new file mode 100644 index 000000000..b7a313740 --- /dev/null +++ b/test/external-encoding.js @@ -0,0 +1,34 @@ +import fetch from '../src'; +import chai from 'chai'; + +const {expect} = chai; + +describe('external encoding', () => { + describe('data uri', () => { + it('should accept data uri', () => { + return fetch('data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=').then(r => { + expect(r.status).to.equal(200); + expect(r.headers.get('Content-Type')).to.equal('image/gif'); + + return r.buffer().then(b => { + expect(b).to.be.an.instanceOf(Buffer); + }); + }); + }); + + it('should accept data uri of plain text', () => { + return fetch('data:,Hello%20World!').then(r => { + expect(r.status).to.equal(200); + expect(r.headers.get('Content-Type')).to.equal('text/plain'); + return r.text().then(t => expect(t).to.equal('Hello World!')); + }); + }); + + it('should reject invalid data uri', () => { + return fetch('data:@@@@').catch(error => { + expect(error).to.exist; + expect(error.message).to.include('invalid URL'); + }); + }); + }); +}); diff --git a/test/headers.js b/test/headers.js new file mode 100644 index 000000000..ffdbb09af --- /dev/null +++ b/test/headers.js @@ -0,0 +1,232 @@ +import {Headers} from '../src'; +import chai from 'chai'; + +const {expect} = chai; + +describe('Headers', () => { + it('should have attributes conforming to Web IDL', () => { + const headers = new Headers(); + expect(Object.getOwnPropertyNames(headers)).to.be.empty; + const enumerableProperties = []; + + for (const property in headers) { + enumerableProperties.push(property); + } + + for (const toCheck of [ + 'append', + 'delete', + 'entries', + 'forEach', + 'get', + 'has', + 'keys', + 'set', + 'values' + ]) { + expect(enumerableProperties).to.contain(toCheck); + } + }); + + it('should allow iterating through all headers with forEach', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['b', '3'], + ['a', '1'] + ]); + expect(headers).to.have.property('forEach'); + + const result = []; + headers.forEach((val, key) => { + result.push([key, val]); + }); + + expect(result).to.deep.equal([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]); + }); + + it('should allow iterating through all headers with for-of loop', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + expect(headers).to.be.iterable; + + const result = []; + for (const pair of headers) { + result.push(pair); + } + + expect(result).to.deep.equal([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]); + }); + + it('should allow iterating through all headers with entries()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.entries()).to.be.iterable + .and.to.deep.iterate.over([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]); + }); + + it('should allow iterating through all headers with keys()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.keys()).to.be.iterable + .and.to.iterate.over(['a', 'b', 'c']); + }); + + it('should allow iterating through all headers with values()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]); + headers.append('b', '3'); + + expect(headers.values()).to.be.iterable + .and.to.iterate.over(['1', '2, 3', '4']); + }); + + it('should reject illegal header', () => { + const headers = new Headers(); + expect(() => new Headers({'He y': 'ok'})).to.throw(TypeError); + expect(() => new Headers({'Hé-y': 'ok'})).to.throw(TypeError); + expect(() => new Headers({'He-y': 'ăk'})).to.throw(TypeError); + expect(() => headers.append('Hé-y', 'ok')).to.throw(TypeError); + expect(() => headers.delete('Hé-y')).to.throw(TypeError); + expect(() => headers.get('Hé-y')).to.throw(TypeError); + expect(() => headers.has('Hé-y')).to.throw(TypeError); + expect(() => headers.set('Hé-y', 'ok')).to.throw(TypeError); + // Should reject empty header + expect(() => headers.append('', 'ok')).to.throw(TypeError); + }); + + it('should ignore unsupported attributes while reading headers', () => { + const FakeHeader = function () { }; + // Prototypes are currently ignored + // This might change in the future: #181 + FakeHeader.prototype.z = 'fake'; + + const res = new FakeHeader(); + res.a = 'string'; + res.b = ['1', '2']; + res.c = ''; + res.d = []; + res.e = 1; + res.f = [1, 2]; + res.g = {a: 1}; + res.h = undefined; + res.i = null; + res.j = NaN; + res.k = true; + res.l = false; + res.m = Buffer.from('test'); + + const h1 = new Headers(res); + h1.set('n', [1, 2]); + h1.append('n', ['3', 4]); + + const h1Raw = h1.raw(); + + expect(h1Raw.a).to.include('string'); + expect(h1Raw.b).to.include('1,2'); + expect(h1Raw.c).to.include(''); + expect(h1Raw.d).to.include(''); + expect(h1Raw.e).to.include('1'); + expect(h1Raw.f).to.include('1,2'); + expect(h1Raw.g).to.include('[object Object]'); + expect(h1Raw.h).to.include('undefined'); + expect(h1Raw.i).to.include('null'); + expect(h1Raw.j).to.include('NaN'); + expect(h1Raw.k).to.include('true'); + expect(h1Raw.l).to.include('false'); + expect(h1Raw.m).to.include('test'); + expect(h1Raw.n).to.include('1,2'); + expect(h1Raw.n).to.include('3,4'); + + expect(h1Raw.z).to.be.undefined; + }); + + it('should wrap headers', () => { + const h1 = new Headers({ + a: '1' + }); + const h1Raw = h1.raw(); + + const h2 = new Headers(h1); + h2.set('b', '1'); + const h2Raw = h2.raw(); + + const h3 = new Headers(h2); + h3.append('a', '2'); + const h3Raw = h3.raw(); + + expect(h1Raw.a).to.include('1'); + expect(h1Raw.a).to.not.include('2'); + + expect(h2Raw.a).to.include('1'); + expect(h2Raw.a).to.not.include('2'); + expect(h2Raw.b).to.include('1'); + + expect(h3Raw.a).to.include('1'); + expect(h3Raw.a).to.include('2'); + expect(h3Raw.b).to.include('1'); + }); + + it('should accept headers as an iterable of tuples', () => { + let headers; + + headers = new Headers([ + ['a', '1'], + ['b', '2'], + ['a', '3'] + ]); + expect(headers.get('a')).to.equal('1, 3'); + expect(headers.get('b')).to.equal('2'); + + headers = new Headers([ + new Set(['a', '1']), + ['b', '2'], + new Map([['a', null], ['3', null]]).keys() + ]); + expect(headers.get('a')).to.equal('1, 3'); + expect(headers.get('b')).to.equal('2'); + + headers = new Headers(new Map([ + ['a', '1'], + ['b', '2'] + ])); + expect(headers.get('a')).to.equal('1'); + expect(headers.get('b')).to.equal('2'); + }); + + it('should throw a TypeError if non-tuple exists in a headers initializer', () => { + expect(() => new Headers([['b', '2', 'huh?']])).to.throw(TypeError); + expect(() => new Headers(['b2'])).to.throw(TypeError); + expect(() => new Headers('b2')).to.throw(TypeError); + expect(() => new Headers({[Symbol.iterator]: 42})).to.throw(TypeError); + }); +}); diff --git a/test/test.js b/test/main.js similarity index 78% rename from test/test.js rename to test/main.js index 69fb236c1..40083574f 100644 --- a/test/test.js +++ b/test/main.js @@ -33,13 +33,13 @@ import HeadersOrig, {createHeadersLenient} from '../src/headers'; import RequestOrig from '../src/request'; import ResponseOrig from '../src/response'; import Body, {getTotalBytes, extractContentType} from '../src/body'; -import TestServer from './server'; +import TestServer from './utils/server'; const { Uint8Array: VMUint8Array } = vm.runInNewContext('this'); -import chaiTimeout from './chai-timeout'; +import chaiTimeout from './utils/chai-timeout'; chai.use(chaiPromised); chai.use(chaiIterator); @@ -60,6 +60,18 @@ after(done => { const itIf = val => val ? it : it.skip; +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', () => { it('should return a promise', () => { const url = `${base}hello`; @@ -1080,8 +1092,8 @@ describe('node-fetch', () => { try { expect(error).to.be.an.instanceof(Error).and.have.property('name', 'AbortError'); resolve(); - } catch (error2) { - reject(error2); + } catch (error_) { + reject(error_); } }); }), @@ -1366,7 +1378,7 @@ describe('node-fetch', () => { itIf(process.platform !== 'win32')('should allow POST request with form-data using stream as body', () => { const form = new FormData(); - form.append('my_field', fs.createReadStream(path.join(__dirname, 'dummy.txt'))); + form.append('my_field', fs.createReadStream(path.join(__dirname, './utils/dummy.txt'))); const url = `${base}multipart`; const opts = { @@ -2145,722 +2157,10 @@ describe('node-fetch', () => { expect(extractContentType(bodyContent)).to.equal('text/plain;charset=UTF-8'); expect(extractContentType(null)).to.be.null; }); -}); - -describe('Headers', () => { - it('should have attributes conforming to Web IDL', () => { - const headers = new Headers(); - expect(Object.getOwnPropertyNames(headers)).to.be.empty; - const enumerableProperties = []; - - for (const property in headers) { - enumerableProperties.push(property); - } - - for (const toCheck of [ - 'append', - 'delete', - 'entries', - 'forEach', - 'get', - 'has', - 'keys', - 'set', - 'values' - ]) { - expect(enumerableProperties).to.contain(toCheck); - } - }); - - it('should allow iterating through all headers with forEach', () => { - const headers = new Headers([ - ['b', '2'], - ['c', '4'], - ['b', '3'], - ['a', '1'] - ]); - expect(headers).to.have.property('forEach'); - - const result = []; - headers.forEach((val, key) => { - result.push([key, val]); - }); - - expect(result).to.deep.equal([ - ['a', '1'], - ['b', '2, 3'], - ['c', '4'] - ]); - }); - - it('should allow iterating through all headers with for-of loop', () => { - const headers = new Headers([ - ['b', '2'], - ['c', '4'], - ['a', '1'] - ]); - headers.append('b', '3'); - expect(headers).to.be.iterable; - - const result = []; - for (const pair of headers) { - result.push(pair); - } - - expect(result).to.deep.equal([ - ['a', '1'], - ['b', '2, 3'], - ['c', '4'] - ]); - }); - - it('should allow iterating through all headers with entries()', () => { - const headers = new Headers([ - ['b', '2'], - ['c', '4'], - ['a', '1'] - ]); - headers.append('b', '3'); - - expect(headers.entries()).to.be.iterable - .and.to.deep.iterate.over([ - ['a', '1'], - ['b', '2, 3'], - ['c', '4'] - ]); - }); - - it('should allow iterating through all headers with keys()', () => { - const headers = new Headers([ - ['b', '2'], - ['c', '4'], - ['a', '1'] - ]); - headers.append('b', '3'); - - expect(headers.keys()).to.be.iterable - .and.to.iterate.over(['a', 'b', 'c']); - }); - - it('should allow iterating through all headers with values()', () => { - const headers = new Headers([ - ['b', '2'], - ['c', '4'], - ['a', '1'] - ]); - headers.append('b', '3'); - - expect(headers.values()).to.be.iterable - .and.to.iterate.over(['1', '2, 3', '4']); - }); - - it('should reject illegal header', () => { - const headers = new Headers(); - expect(() => new Headers({'He y': 'ok'})).to.throw(TypeError); - expect(() => new Headers({'Hé-y': 'ok'})).to.throw(TypeError); - expect(() => new Headers({'He-y': 'ăk'})).to.throw(TypeError); - expect(() => headers.append('Hé-y', 'ok')).to.throw(TypeError); - expect(() => headers.delete('Hé-y')).to.throw(TypeError); - expect(() => headers.get('Hé-y')).to.throw(TypeError); - expect(() => headers.has('Hé-y')).to.throw(TypeError); - expect(() => headers.set('Hé-y', 'ok')).to.throw(TypeError); - // Should reject empty header - expect(() => headers.append('', 'ok')).to.throw(TypeError); - - // 'o k' is valid value but invalid name - new Headers({'He-y': 'o k'}); - }); - - it('should ignore unsupported attributes while reading headers', () => { - const FakeHeader = function () { }; - // Prototypes are currently ignored - // This might change in the future: #181 - FakeHeader.prototype.z = 'fake'; - - const res = new FakeHeader(); - res.a = 'string'; - res.b = ['1', '2']; - res.c = ''; - res.d = []; - res.e = 1; - res.f = [1, 2]; - res.g = {a: 1}; - res.h = undefined; - res.i = null; - res.j = NaN; - res.k = true; - res.l = false; - res.m = Buffer.from('test'); - - const h1 = new Headers(res); - h1.set('n', [1, 2]); - h1.append('n', ['3', 4]); - - const h1Raw = h1.raw(); - - expect(h1Raw.a).to.include('string'); - expect(h1Raw.b).to.include('1,2'); - expect(h1Raw.c).to.include(''); - expect(h1Raw.d).to.include(''); - expect(h1Raw.e).to.include('1'); - expect(h1Raw.f).to.include('1,2'); - expect(h1Raw.g).to.include('[object Object]'); - expect(h1Raw.h).to.include('undefined'); - expect(h1Raw.i).to.include('null'); - expect(h1Raw.j).to.include('NaN'); - expect(h1Raw.k).to.include('true'); - expect(h1Raw.l).to.include('false'); - expect(h1Raw.m).to.include('test'); - expect(h1Raw.n).to.include('1,2'); - expect(h1Raw.n).to.include('3,4'); - - expect(h1Raw.z).to.be.undefined; - }); - - it('should wrap headers', () => { - const h1 = new Headers({ - a: '1' - }); - const h1Raw = h1.raw(); - - const h2 = new Headers(h1); - h2.set('b', '1'); - const h2Raw = h2.raw(); - - const h3 = new Headers(h2); - h3.append('a', '2'); - const h3Raw = h3.raw(); - - expect(h1Raw.a).to.include('1'); - expect(h1Raw.a).to.not.include('2'); - - expect(h2Raw.a).to.include('1'); - expect(h2Raw.a).to.not.include('2'); - expect(h2Raw.b).to.include('1'); - - expect(h3Raw.a).to.include('1'); - expect(h3Raw.a).to.include('2'); - expect(h3Raw.b).to.include('1'); - }); - - it('should accept headers as an iterable of tuples', () => { - let headers; - - headers = new Headers([ - ['a', '1'], - ['b', '2'], - ['a', '3'] - ]); - expect(headers.get('a')).to.equal('1, 3'); - expect(headers.get('b')).to.equal('2'); - - headers = new Headers([ - new Set(['a', '1']), - ['b', '2'], - new Map([['a', null], ['3', null]]).keys() - ]); - expect(headers.get('a')).to.equal('1, 3'); - expect(headers.get('b')).to.equal('2'); - - headers = new Headers(new Map([ - ['a', '1'], - ['b', '2'] - ])); - expect(headers.get('a')).to.equal('1'); - expect(headers.get('b')).to.equal('2'); - }); - - it('should throw a TypeError if non-tuple exists in a headers initializer', () => { - expect(() => new Headers([['b', '2', 'huh?']])).to.throw(TypeError); - expect(() => new Headers(['b2'])).to.throw(TypeError); - expect(() => new Headers('b2')).to.throw(TypeError); - expect(() => new Headers({[Symbol.iterator]: 42})).to.throw(TypeError); - }); -}); - -describe('Response', () => { - it('should have attributes conforming to Web IDL', () => { - const res = new Response(); - const enumerableProperties = []; - for (const property in res) { - enumerableProperties.push(property); - } - - for (const toCheck of [ - 'body', - 'bodyUsed', - 'arrayBuffer', - 'blob', - 'json', - 'text', - 'url', - 'status', - 'ok', - 'redirected', - 'statusText', - 'headers', - 'clone' - ]) { - expect(enumerableProperties).to.contain(toCheck); - } - - for (const toCheck of [ - 'body', - 'bodyUsed', - 'url', - 'status', - 'ok', - 'redirected', - 'statusText', - 'headers' - ]) { - expect(() => { - res[toCheck] = 'abc'; - }).to.throw(); - } - }); - - it('should support empty options', () => { - let body = resumer().queue('a=1').end(); - body = body.pipe(new stream.PassThrough()); - const res = new Response(body); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support parsing headers', () => { - const res = new Response(null, { - headers: { - a: '1' - } - }); - expect(res.headers.get('a')).to.equal('1'); - }); - - it('should support text() method', () => { - const res = new Response('a=1'); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support json() method', () => { - const res = new Response('{"a":1}'); - return res.json().then(result => { - expect(result.a).to.equal(1); - }); - }); - - it('should support buffer() method', () => { - const res = new Response('a=1'); - return res.buffer().then(result => { - expect(result.toString()).to.equal('a=1'); - }); - }); - - it('should support blob() method', () => { - const res = new Response('a=1', { - method: 'POST', - headers: { - 'Content-Type': 'text/plain' - } - }); - return res.blob().then(result => { - expect(result).to.be.an.instanceOf(Blob); - expect(result.size).to.equal(3); - expect(result.type).to.equal('text/plain'); - }); - }); - - it('should support clone() method', () => { - let body = resumer().queue('a=1').end(); - body = body.pipe(new stream.PassThrough()); - const res = new Response(body, { - headers: { - a: '1' - }, - url: base, - status: 346, - statusText: 'production' - }); - const cl = res.clone(); - expect(cl.headers.get('a')).to.equal('1'); - expect(cl.url).to.equal(base); - expect(cl.status).to.equal(346); - expect(cl.statusText).to.equal('production'); - expect(cl.ok).to.be.false; - // Clone body shouldn't be the same body - expect(cl.body).to.not.equal(body); - return cl.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support stream as body', () => { - let body = resumer().queue('a=1').end(); - body = body.pipe(new stream.PassThrough()); - const res = new Response(body); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support string as body', () => { - const res = new Response('a=1'); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support buffer as body', () => { - const res = new Response(Buffer.from('a=1')); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support ArrayBuffer as body', () => { - const res = new Response(stringToArrayBuffer('a=1')); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support blob as body', () => { - const res = new Response(new Blob(['a=1'])); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support Uint8Array as body', () => { - const res = new Response(new Uint8Array(stringToArrayBuffer('a=1'))); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support DataView as body', () => { - const res = new Response(new DataView(stringToArrayBuffer('a=1'))); - return res.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should default to null as body', () => { - const res = new Response(); - expect(res.body).to.equal(null); - - return res.text().then(result => expect(result).to.equal('')); - }); - it('should default to 200 as status code', () => { - const res = new Response(null); - expect(res.status).to.equal(200); - }); - - it('should default to empty string as url', () => { - const res = new Response(); - expect(res.url).to.equal(''); - }); -}); - -describe('Request', () => { - it('should have attributes conforming to Web IDL', () => { - const req = new Request('https://github.com/'); - const enumerableProperties = []; - for (const property in req) { - enumerableProperties.push(property); - } - - for (const toCheck of [ - 'body', - 'bodyUsed', - 'arrayBuffer', - 'blob', - 'json', - 'text', - 'method', - 'url', - 'headers', - 'redirect', - 'clone', - 'signal' - ]) { - expect(enumerableProperties).to.contain(toCheck); - } - - for (const toCheck of [ - 'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect', 'signal' - ]) { - expect(() => { - req[toCheck] = 'abc'; - }).to.throw(); - } - }); - - it('should support wrapping Request instance', () => { - const url = `${base}hello`; - - const form = new FormData(); - form.append('a', '1'); - const {signal} = new AbortController(); - - const r1 = new Request(url, { - method: 'POST', - follow: 1, - body: form, - signal - }); - const r2 = new Request(r1, { - follow: 2 - }); - - expect(r2.url).to.equal(url); - expect(r2.method).to.equal('POST'); - expect(r2.signal).to.equal(signal); - // Note that we didn't clone the body - expect(r2.body).to.equal(form); - expect(r1.follow).to.equal(1); - expect(r2.follow).to.equal(2); - expect(r1.counter).to.equal(0); - expect(r2.counter).to.equal(0); - }); - - it('should override signal on derived Request instances', () => { - const parentAbortController = new AbortController(); - const derivedAbortController = new AbortController(); - const parentRequest = new Request('test', { - signal: parentAbortController.signal - }); - const derivedRequest = new Request(parentRequest, { - signal: derivedAbortController.signal - }); - expect(parentRequest.signal).to.equal(parentAbortController.signal); - expect(derivedRequest.signal).to.equal(derivedAbortController.signal); - }); - - it('should allow removing signal on derived Request instances', () => { - const parentAbortController = new AbortController(); - const parentRequest = new Request('test', { - signal: parentAbortController.signal - }); - const derivedRequest = new Request(parentRequest, { - signal: null - }); - expect(parentRequest.signal).to.equal(parentAbortController.signal); - expect(derivedRequest.signal).to.equal(null); - }); - - it('should throw error with GET/HEAD requests with body', () => { - expect(() => new Request('.', {body: ''})) - .to.throw(TypeError); - expect(() => new Request('.', {body: 'a'})) - .to.throw(TypeError); - expect(() => new Request('.', {body: '', method: 'HEAD'})) - .to.throw(TypeError); - expect(() => new Request('.', {body: 'a', method: 'HEAD'})) - .to.throw(TypeError); - expect(() => new Request('.', {body: 'a', method: 'get'})) - .to.throw(TypeError); - expect(() => new Request('.', {body: 'a', method: 'head'})) - .to.throw(TypeError); - }); - - it('should default to null as body', () => { - const req = new Request('.'); - expect(req.body).to.equal(null); - return req.text().then(result => expect(result).to.equal('')); - }); - - it('should support parsing headers', () => { - const url = base; - const req = new Request(url, { - headers: { - a: '1' - } - }); - expect(req.url).to.equal(url); - expect(req.headers.get('a')).to.equal('1'); - }); + it('URLs are encoded as UTF-8', () => { + const url = `${base}möbius`; - it('should support arrayBuffer() method', () => { - const url = base; - const req = new Request(url, { - method: 'POST', - body: 'a=1' - }); - expect(req.url).to.equal(url); - return req.arrayBuffer().then(result => { - expect(result).to.be.an.instanceOf(ArrayBuffer); - const str = String.fromCharCode.apply(null, new Uint8Array(result)); - expect(str).to.equal('a=1'); - }); - }); - - it('should support text() method', () => { - const url = base; - const req = new Request(url, { - method: 'POST', - body: 'a=1' - }); - expect(req.url).to.equal(url); - return req.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support json() method', () => { - const url = base; - const req = new Request(url, { - method: 'POST', - body: '{"a":1}' - }); - expect(req.url).to.equal(url); - return req.json().then(result => { - expect(result.a).to.equal(1); - }); - }); - - it('should support buffer() method', () => { - const url = base; - const req = new Request(url, { - method: 'POST', - body: 'a=1' - }); - expect(req.url).to.equal(url); - return req.buffer().then(result => { - expect(result.toString()).to.equal('a=1'); - }); - }); - - it('should support blob() method', () => { - const url = base; - const req = new Request(url, { - method: 'POST', - body: Buffer.from('a=1') - }); - expect(req.url).to.equal(url); - return req.blob().then(result => { - expect(result).to.be.an.instanceOf(Blob); - expect(result.size).to.equal(3); - expect(result.type).to.equal(''); - }); - }); - - it('should support arbitrary url', () => { - const url = 'anything'; - const req = new Request(url); - expect(req.url).to.equal('anything'); - }); - - it('should support clone() method', () => { - const url = base; - let body = resumer().queue('a=1').end(); - body = body.pipe(new stream.PassThrough()); - const agent = new http.Agent(); - const {signal} = new AbortController(); - const req = new Request(url, { - body, - method: 'POST', - redirect: 'manual', - headers: { - b: '2' - }, - follow: 3, - compress: false, - agent, - signal - }); - const cl = req.clone(); - expect(cl.url).to.equal(url); - expect(cl.method).to.equal('POST'); - expect(cl.redirect).to.equal('manual'); - expect(cl.headers.get('b')).to.equal('2'); - expect(cl.follow).to.equal(3); - expect(cl.compress).to.equal(false); - expect(cl.method).to.equal('POST'); - expect(cl.counter).to.equal(0); - expect(cl.agent).to.equal(agent); - expect(cl.signal).to.equal(signal); - // Clone body shouldn't be the same body - expect(cl.body).to.not.equal(body); - return Promise.all([cl.text(), req.text()]).then(results => { - expect(results[0]).to.equal('a=1'); - expect(results[1]).to.equal('a=1'); - }); - }); - - it('should support ArrayBuffer as body', () => { - const req = new Request('', { - method: 'POST', - body: stringToArrayBuffer('a=1') - }); - return req.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support Uint8Array as body', () => { - const req = new Request('', { - method: 'POST', - body: new Uint8Array(stringToArrayBuffer('a=1')) - }); - return req.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); - - it('should support DataView as body', () => { - const req = new Request('', { - method: 'POST', - body: new DataView(stringToArrayBuffer('a=1')) - }); - return req.text().then(result => { - expect(result).to.equal('a=1'); - }); - }); -}); - -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('external encoding', () => { - describe('data uri', () => { - it('should accept data uri', () => { - return fetch('data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=').then(r => { - expect(r.status).to.equal(200); - expect(r.headers.get('Content-Type')).to.equal('image/gif'); - - return r.buffer().then(b => { - expect(b).to.be.an.instanceOf(Buffer); - }); - }); - }); - - it('should accept data uri of plain text', () => { - return fetch('data:,Hello%20World!').then(r => { - expect(r.status).to.equal(200); - expect(r.headers.get('Content-Type')).to.equal('text/plain'); - return r.text().then(t => expect(t).to.equal('Hello World!')); - }); - }); - - it('should reject invalid data uri', () => { - return fetch('data:@@@@').catch(e => { - expect(e).to.exist; - expect(e.message).to.include('invalid URL'); - }); - }); + fetch(url).then(res => expect(res.url).to.equal(`${base}m%C3%B6bius`)); }); }); diff --git a/test/request.js b/test/request.js new file mode 100644 index 000000000..86b765c7e --- /dev/null +++ b/test/request.js @@ -0,0 +1,266 @@ +import * as stream from 'stream'; +import * as http from 'http'; +import {Request} from '../src'; +import TestServer from './utils/server'; +import {AbortController} from 'abortcontroller-polyfill/dist/abortcontroller'; +import chai from 'chai'; +import FormData from 'form-data'; +import Blob from 'fetch-blob'; +import resumer from 'resumer'; +import stringToArrayBuffer from 'string-to-arraybuffer'; + +const {expect} = chai; + +const local = new TestServer(); +const base = `http://${local.hostname}:${local.port}/`; + +describe('Request', () => { + it('should have attributes conforming to Web IDL', () => { + const req = new Request('https://github.com/'); + const enumerableProperties = []; + for (const property in req) { + enumerableProperties.push(property); + } + + for (const toCheck of [ + 'body', + 'bodyUsed', + 'arrayBuffer', + 'blob', + 'json', + 'text', + 'method', + 'url', + 'headers', + 'redirect', + 'clone', + 'signal' + ]) { + expect(enumerableProperties).to.contain(toCheck); + } + + for (const toCheck of [ + 'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect', 'signal' + ]) { + expect(() => { + req[toCheck] = 'abc'; + }).to.throw(); + } + }); + + it('should support wrapping Request instance', () => { + const url = `${base}hello`; + + const form = new FormData(); + form.append('a', '1'); + const {signal} = new AbortController(); + + const r1 = new Request(url, { + method: 'POST', + follow: 1, + body: form, + signal + }); + const r2 = new Request(r1, { + follow: 2 + }); + + expect(r2.url).to.equal(url); + expect(r2.method).to.equal('POST'); + expect(r2.signal).to.equal(signal); + // Note that we didn't clone the body + expect(r2.body).to.equal(form); + expect(r1.follow).to.equal(1); + expect(r2.follow).to.equal(2); + expect(r1.counter).to.equal(0); + expect(r2.counter).to.equal(0); + }); + + it('should override signal on derived Request instances', () => { + const parentAbortController = new AbortController(); + const derivedAbortController = new AbortController(); + const parentRequest = new Request(`${base}hello`, { + signal: parentAbortController.signal + }); + const derivedRequest = new Request(parentRequest, { + signal: derivedAbortController.signal + }); + expect(parentRequest.signal).to.equal(parentAbortController.signal); + expect(derivedRequest.signal).to.equal(derivedAbortController.signal); + }); + + it('should allow removing signal on derived Request instances', () => { + const parentAbortController = new AbortController(); + const parentRequest = new Request(`${base}hello`, { + signal: parentAbortController.signal + }); + const derivedRequest = new Request(parentRequest, { + signal: null + }); + expect(parentRequest.signal).to.equal(parentAbortController.signal); + expect(derivedRequest.signal).to.equal(null); + }); + + it('should throw error with GET/HEAD requests with body', () => { + expect(() => new Request('.', {body: ''})) + .to.throw(TypeError); + expect(() => new Request('.', {body: 'a'})) + .to.throw(TypeError); + expect(() => new Request('.', {body: '', method: 'HEAD'})) + .to.throw(TypeError); + expect(() => new Request('.', {body: 'a', method: 'HEAD'})) + .to.throw(TypeError); + expect(() => new Request('.', {body: 'a', method: 'get'})) + .to.throw(TypeError); + expect(() => new Request('.', {body: 'a', method: 'head'})) + .to.throw(TypeError); + }); + + it('should default to null as body', () => { + const req = new Request(base); + expect(req.body).to.equal(null); + return req.text().then(result => expect(result).to.equal('')); + }); + + it('should support parsing headers', () => { + const url = base; + const req = new Request(url, { + headers: { + a: '1' + } + }); + expect(req.url).to.equal(url); + expect(req.headers.get('a')).to.equal('1'); + }); + + it('should support arrayBuffer() method', () => { + const url = base; + const req = new Request(url, { + method: 'POST', + body: 'a=1' + }); + expect(req.url).to.equal(url); + return req.arrayBuffer().then(result => { + expect(result).to.be.an.instanceOf(ArrayBuffer); + const str = String.fromCharCode.apply(null, new Uint8Array(result)); + expect(str).to.equal('a=1'); + }); + }); + + it('should support text() method', () => { + const url = base; + const req = new Request(url, { + method: 'POST', + body: 'a=1' + }); + expect(req.url).to.equal(url); + return req.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support json() method', () => { + const url = base; + const req = new Request(url, { + method: 'POST', + body: '{"a":1}' + }); + expect(req.url).to.equal(url); + return req.json().then(result => { + expect(result.a).to.equal(1); + }); + }); + + it('should support buffer() method', () => { + const url = base; + const req = new Request(url, { + method: 'POST', + body: 'a=1' + }); + expect(req.url).to.equal(url); + return req.buffer().then(result => { + expect(result.toString()).to.equal('a=1'); + }); + }); + + it('should support blob() method', () => { + const url = base; + const req = new Request(url, { + method: 'POST', + body: Buffer.from('a=1') + }); + expect(req.url).to.equal(url); + return req.blob().then(result => { + expect(result).to.be.an.instanceOf(Blob); + expect(result.size).to.equal(3); + expect(result.type).to.equal(''); + }); + }); + + it('should support clone() method', () => { + const url = base; + let body = resumer().queue('a=1').end(); + body = body.pipe(new stream.PassThrough()); + const agent = new http.Agent(); + const {signal} = new AbortController(); + const req = new Request(url, { + body, + method: 'POST', + redirect: 'manual', + headers: { + b: '2' + }, + follow: 3, + compress: false, + agent, + signal + }); + const cl = req.clone(); + expect(cl.url).to.equal(url); + expect(cl.method).to.equal('POST'); + expect(cl.redirect).to.equal('manual'); + expect(cl.headers.get('b')).to.equal('2'); + expect(cl.follow).to.equal(3); + expect(cl.compress).to.equal(false); + expect(cl.method).to.equal('POST'); + expect(cl.counter).to.equal(0); + expect(cl.agent).to.equal(agent); + expect(cl.signal).to.equal(signal); + // Clone body shouldn't be the same body + expect(cl.body).to.not.equal(body); + return Promise.all([cl.text(), req.text()]).then(results => { + expect(results[0]).to.equal('a=1'); + expect(results[1]).to.equal('a=1'); + }); + }); + + it('should support ArrayBuffer as body', () => { + const req = new Request(base, { + method: 'POST', + body: stringToArrayBuffer('a=1') + }); + return req.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support Uint8Array as body', () => { + const req = new Request(base, { + method: 'POST', + body: new Uint8Array(stringToArrayBuffer('a=1')) + }); + return req.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support DataView as body', () => { + const req = new Request(base, { + method: 'POST', + body: new DataView(stringToArrayBuffer('a=1')) + }); + return req.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); +}); diff --git a/test/response.js b/test/response.js new file mode 100644 index 000000000..35a809004 --- /dev/null +++ b/test/response.js @@ -0,0 +1,200 @@ +import * as stream from 'stream'; +import {Response} from '../src'; +import TestServer from './utils/server'; +import chai from 'chai'; +import resumer from 'resumer'; +import stringToArrayBuffer from 'string-to-arraybuffer'; +import Blob from 'fetch-blob'; + +const {expect} = chai; + +const local = new TestServer(); +const base = `http://${local.hostname}:${local.port}/`; + +describe('Response', () => { + it('should have attributes conforming to Web IDL', () => { + const res = new Response(); + const enumerableProperties = []; + for (const property in res) { + enumerableProperties.push(property); + } + + for (const toCheck of [ + 'body', + 'bodyUsed', + 'arrayBuffer', + 'blob', + 'json', + 'text', + 'url', + 'status', + 'ok', + 'redirected', + 'statusText', + 'headers', + 'clone' + ]) { + expect(enumerableProperties).to.contain(toCheck); + } + + for (const toCheck of [ + 'body', + 'bodyUsed', + 'url', + 'status', + 'ok', + 'redirected', + 'statusText', + 'headers' + ]) { + expect(() => { + res[toCheck] = 'abc'; + }).to.throw(); + } + }); + + it('should support empty options', () => { + let body = resumer().queue('a=1').end(); + body = body.pipe(new stream.PassThrough()); + const res = new Response(body); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support parsing headers', () => { + const res = new Response(null, { + headers: { + a: '1' + } + }); + expect(res.headers.get('a')).to.equal('1'); + }); + + it('should support text() method', () => { + const res = new Response('a=1'); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support json() method', () => { + const res = new Response('{"a":1}'); + return res.json().then(result => { + expect(result.a).to.equal(1); + }); + }); + + it('should support buffer() method', () => { + const res = new Response('a=1'); + return res.buffer().then(result => { + expect(result.toString()).to.equal('a=1'); + }); + }); + + it('should support blob() method', () => { + const res = new Response('a=1', { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + } + }); + return res.blob().then(result => { + expect(result).to.be.an.instanceOf(Blob); + expect(result.size).to.equal(3); + expect(result.type).to.equal('text/plain'); + }); + }); + + it('should support clone() method', () => { + let body = resumer().queue('a=1').end(); + body = body.pipe(new stream.PassThrough()); + const res = new Response(body, { + headers: { + a: '1' + }, + url: base, + status: 346, + statusText: 'production' + }); + const cl = res.clone(); + expect(cl.headers.get('a')).to.equal('1'); + expect(cl.url).to.equal(base); + expect(cl.status).to.equal(346); + expect(cl.statusText).to.equal('production'); + expect(cl.ok).to.be.false; + // Clone body shouldn't be the same body + expect(cl.body).to.not.equal(body); + return cl.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support stream as body', () => { + let body = resumer().queue('a=1').end(); + body = body.pipe(new stream.PassThrough()); + const res = new Response(body); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support string as body', () => { + const res = new Response('a=1'); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support buffer as body', () => { + const res = new Response(Buffer.from('a=1')); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support ArrayBuffer as body', () => { + const res = new Response(stringToArrayBuffer('a=1')); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support blob as body', () => { + const res = new Response(new Blob(['a=1'])); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support Uint8Array as body', () => { + const res = new Response(new Uint8Array(stringToArrayBuffer('a=1'))); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should support DataView as body', () => { + const res = new Response(new DataView(stringToArrayBuffer('a=1'))); + return res.text().then(result => { + expect(result).to.equal('a=1'); + }); + }); + + it('should default to null as body', () => { + const res = new Response(); + expect(res.body).to.equal(null); + + return res.text().then(result => expect(result).to.equal('')); + }); + + it('should default to 200 as status code', () => { + const res = new Response(null); + expect(res.status).to.equal(200); + }); + + it('should default to empty string as url', () => { + const res = new Response(); + expect(res.url).to.equal(''); + }); +}); diff --git a/test/chai-timeout.js b/test/utils/chai-timeout.js similarity index 100% rename from test/chai-timeout.js rename to test/utils/chai-timeout.js diff --git a/test/dummy.txt b/test/utils/dummy.txt similarity index 100% rename from test/dummy.txt rename to test/utils/dummy.txt diff --git a/test/server.js b/test/utils/server.js similarity index 98% rename from test/server.js rename to test/utils/server.js index c0490394f..9677745dc 100644 --- a/test/server.js +++ b/test/utils/server.js @@ -364,6 +364,12 @@ export default class TestServer { }); req.pipe(parser); } + + if (p === '/m%C3%B6bius') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('ok'); + } } }