From 6cdaa6e8ebb9592aa67081b5dc32e0c682423e44 Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Thu, 5 Aug 2021 13:00:26 +0200 Subject: [PATCH] port node-fetch tests + fixes --- index.js | 20 +- lib/api/api-fetch/body.js | 92 +- lib/api/api-fetch/headers.js | 47 +- lib/api/api-fetch/index.js | 74 +- lib/api/api-fetch/response.js | 138 +- lib/api/index.js | 2 + lib/core/errors.js | 9 + lib/core/request.js | 3 + package.json | 15 +- test/node-fetch/LICENSE.md | 22 + test/node-fetch/external-encoding.js | 43 + test/node-fetch/headers.js | 282 +++ test/node-fetch/main.js | 2305 +++++++++++++++++++++++++ test/node-fetch/request.js | 277 +++ test/node-fetch/response.js | 216 +++ test/node-fetch/utils/chai-timeout.js | 15 + test/node-fetch/utils/dummy.txt | 1 + test/node-fetch/utils/read-stream.js | 9 + test/node-fetch/utils/server.js | 430 +++++ 19 files changed, 3916 insertions(+), 84 deletions(-) create mode 100644 test/node-fetch/LICENSE.md create mode 100644 test/node-fetch/external-encoding.js create mode 100644 test/node-fetch/headers.js create mode 100644 test/node-fetch/main.js create mode 100644 test/node-fetch/request.js create mode 100644 test/node-fetch/response.js create mode 100644 test/node-fetch/utils/chai-timeout.js create mode 100644 test/node-fetch/utils/dummy.txt create mode 100644 test/node-fetch/utils/read-stream.js create mode 100644 test/node-fetch/utils/server.js diff --git a/index.js b/index.js index b4b90b30033..a7d082d4945 100644 --- a/index.js +++ b/index.js @@ -84,7 +84,25 @@ function makeDispatcher (fn) { module.exports.setGlobalDispatcher = setGlobalDispatcher module.exports.getGlobalDispatcher = getGlobalDispatcher -module.exports.fetch = makeDispatcher(api.fetch) +if (api.fetch) { + const _fetch = makeDispatcher(api.fetch) + module.exports.fetch = async function fetch (...args) { + try { + return await _fetch(...args) + } catch (err) { + // TODO (fix): This is a little weird. Spec compliant? + if (err.code === 'ERR_INVALID_URL') { + const er = new TypeError('Invalid URL') + er.cause = err + throw er + } + throw err + } + } + module.exports.Headers = api.fetch.Headers + module.exports.Response = api.fetch.Response +} + module.exports.request = makeDispatcher(api.request) module.exports.stream = makeDispatcher(api.stream) module.exports.pipeline = makeDispatcher(api.pipeline) diff --git a/lib/api/api-fetch/body.js b/lib/api/api-fetch/body.js index a012ded0496..2e91c00f282 100644 --- a/lib/api/api-fetch/body.js +++ b/lib/api/api-fetch/body.js @@ -1,9 +1,11 @@ 'use strict' const util = require('../../core/util') -const { Readable } = require('stream') +const { finished } = require('stream') +const { AbortError } = require('../../core/errors') -let TransformStream +let ReadableStream +let CountQueuingStrategy // https://fetch.spec.whatwg.org/#concept-bodyinit-extract function extractBody (body) { @@ -24,6 +26,10 @@ function extractBody (body) { source: body }, 'text/plain;charset=UTF-8'] } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + if (body instanceof DataView) { + // TODO: Blob doesn't seem to work with DataView? + body = body.buffer + } return [{ source: body }, null] @@ -39,20 +45,12 @@ function extractBody (body) { let stream if (util.isStream(body)) { - stream = Readable.toWeb(body) + stream = toWeb(body) } else { if (body.locked) { throw new TypeError('locked') } - - if (!TransformStream) { - TransformStream = require('stream/web').TransformStream - } - - // https://streams.spec.whatwg.org/#readablestream-create-a-proxy - const identityTransform = new TransformStream() - body.pipeThrough(identityTransform) - stream = identityTransform + stream = body } return [{ @@ -63,4 +61,74 @@ function extractBody (body) { } } +function toWeb (streamReadable) { + if (!ReadableStream) { + ReadableStream = require('stream/web').ReadableStream + } + if (!CountQueuingStrategy) { + CountQueuingStrategy = require('stream/web').CountQueuingStrategy + } + + if (util.isDestroyed(streamReadable)) { + const readable = new ReadableStream() + readable.cancel() + return readable + } + + const objectMode = streamReadable.readableObjectMode + const highWaterMark = streamReadable.readableHighWaterMark + // When not running in objectMode explicitly, we just fall + // back to a minimal strategy that just specifies the highWaterMark + // and no size algorithm. Using a ByteLengthQueuingStrategy here + // is unnecessary. + const strategy = objectMode + ? new CountQueuingStrategy({ highWaterMark }) + : { highWaterMark } + + let controller + + function onData (chunk) { + // Copy the Buffer to detach it from the pool. + if (Buffer.isBuffer(chunk) && !objectMode) { + chunk = new Uint8Array(chunk) + } + controller.enqueue(chunk) + if (controller.desiredSize <= 0) { + streamReadable.pause() + } + } + + streamReadable.pause() + + finished(streamReadable, (err) => { + if (err && err.code === 'ERR_STREAM_PREMATURE_CLOSE') { + const er = new AbortError() + er.cause = er + err = er + } + + if (err) { + controller.error(err) + } else { + controller.close() + } + }) + + streamReadable.on('data', onData) + + return new ReadableStream({ + start (c) { + controller = c + }, + + pull () { + streamReadable.resume() + }, + + cancel (reason) { + util.destroy(streamReadable, reason) + } + }, strategy) +} + module.exports = { extractBody } diff --git a/lib/api/api-fetch/headers.js b/lib/api/api-fetch/headers.js index 967df35260b..9b9384357b8 100644 --- a/lib/api/api-fetch/headers.js +++ b/lib/api/api-fetch/headers.js @@ -5,7 +5,11 @@ const { types } = require('util') const { validateHeaderName, validateHeaderValue } = require('http') const { kHeadersList } = require('../../core/symbols') -const { InvalidHTTPTokenError, HTTPInvalidHeaderValueError, InvalidArgumentError, InvalidThisError } = require('../../core/errors') +const { + InvalidHTTPTokenError, + HTTPInvalidHeaderValueError, + InvalidThisError +} = require('../../core/errors') function binarySearch (arr, val) { let low = 0 @@ -49,21 +53,21 @@ function isHeaders (object) { function fill (headers, object) { if (isHeaders(object)) { // Object is instance of Headers - headers[kHeadersList] = Array.splice(object[kHeadersList]) + headers[kHeadersList] = [...object[kHeadersList]] } else if (Array.isArray(object)) { // Support both 1D and 2D arrays of header entries if (Array.isArray(object[0])) { // Array of arrays for (let i = 0; i < object.length; i++) { if (object[i].length !== 2) { - throw new InvalidArgumentError(`The argument 'init' is not of length 2. Received ${object[i]}`) + throw new TypeError(`The argument 'init' is not of length 2. Received ${object[i]}`) } headers.append(object[i][0], object[i][1]) } } else if (typeof object[0] === 'string' || Buffer.isBuffer(object[0])) { // Flat array of strings or Buffers if (object.length % 2 !== 0) { - throw new InvalidArgumentError(`The argument 'init' is not even in length. Received ${object}`) + throw new TypeError(`The argument 'init' is not even in length. Received ${object}`) } for (let i = 0; i < object.length; i += 2) { headers.append( @@ -73,7 +77,7 @@ function fill (headers, object) { } } else { // All other array based entries - throw new InvalidArgumentError(`The argument 'init' is not a valid array entry. Received ${object}`) + throw new TypeError(`The argument 'init' is not a valid array entry. Received ${object}`) } } else if (!types.isBoxedPrimitive(object)) { // Object of key/value entries @@ -94,12 +98,20 @@ class Headers { constructor (init = {}) { // validateObject allowArray = true if (!Array.isArray(init) && typeof init !== 'object') { - throw new InvalidArgumentError('The argument \'init\' must be one of type Object or Array') + throw new TypeError('The argument \'init\' must be one of type Object or Array') } this[kHeadersList] = [] fill(this, init) } + get [Symbol.toStringTag] () { + return this.constructor.name + } + + toString () { + return Object.prototype.toString.call(this) + } + append (...args) { if (!isHeaders(this)) { throw new InvalidThisError('Header') @@ -233,8 +245,31 @@ class Headers { callback.call(thisArg, this[kHeadersList][index + 1], this[kHeadersList][index], this) } } + + [Symbol.for('nodejs.util.inspect.custom')] () { + return Object.fromEntries(this.entries()) + } } +// Re-shaping object for Web IDL tests. +Object.defineProperties( + Headers.prototype, + [ + 'append', + 'delete', + 'entries', + 'forEach', + 'get', + 'has', + 'keys', + 'set', + 'values' + ].reduce((result, property) => { + result[property] = { enumerable: true } + return result + }, {}) +) + Headers.prototype[Symbol.iterator] = Headers.prototype.entries module.exports = Headers diff --git a/lib/api/api-fetch/index.js b/lib/api/api-fetch/index.js index 77426ef957b..f6a7dabbca8 100644 --- a/lib/api/api-fetch/index.js +++ b/lib/api/api-fetch/index.js @@ -4,8 +4,9 @@ const Headers = require('./headers') const { kHeadersList } = require('../../core/symbols') -const { METHODS } = require('http') +const { METHODS, STATUS_CODES } = require('http') const Response = require('./response') + const { InvalidArgumentError, NotSupportedError, @@ -13,7 +14,9 @@ const { } = require('../../core/errors') const { addSignal, removeSignal } = require('../abort-signal') const { extractBody } = require('./body') +const { kUrlList } = require('./symbols') +let TransformStream let ReadableStream class FetchHandler { @@ -69,46 +72,42 @@ class FetchHandler { let response if (headers.has('location')) { if (this.redirect === 'manual') { - response = new Response({ + response = new Response(null, { type: 'opaqueredirect', + status: 0, url: this.url }) } else { - response = new Response({ - type: 'error', - url: this.url - }) + response = Response.error() } } else { const self = this if (!ReadableStream) { ReadableStream = require('stream/web').ReadableStream } - response = new Response({ - type: 'default', - url: this.url, - body: new ReadableStream({ - async start (controller) { - self.controller = controller - }, - async pull () { - resume() - }, - async cancel (reason) { - let err - if (reason instanceof Error) { - err = reason - } else if (typeof reason === 'string') { - err = new Error(reason) - } else { - err = new RequestAbortedError() - } - abort(err) + response = new Response(new ReadableStream({ + async start (controller) { + self.controller = controller + }, + async pull () { + resume() + }, + async cancel (reason) { + let err + if (reason instanceof Error) { + err = reason + } else if (typeof reason === 'string') { + err = new Error(reason) + } else { + err = new RequestAbortedError() } - }, { highWaterMark: 16384 }), - statusCode, + abort(err) + } + }, { highWaterMark: 16384 }), { + status: statusCode, + statusText: STATUS_CODES[statusCode], headers, - context + [kUrlList]: [this.url, ...((context && context.history) || [])] }) } @@ -182,7 +181,11 @@ async function fetch (opts) { } if (opts.redirect != null) { - // TODO: Validate + if ( + typeof opts.redirect !== 'string' || + !/^(follow|manual|error)/.test(opts.redirect)) { + throw new TypeError(`Redirect option '${opts.redirect}' is not a valid value of RequestRedirect`) + } } else { opts.redirect = 'follow' } @@ -214,6 +217,17 @@ async function fetch (opts) { const [body, contentType] = extractBody(opts.body) + if (body.stream) { + if (!TransformStream) { + TransformStream = require('stream/web').TransformStream + } + + // https://streams.spec.whatwg.org/#readablestream-create-a-proxy + const identityTransform = new TransformStream() + body.pipeThrough(identityTransform) + body.stream = identityTransform + } + if (contentType && !headers.has('content-type')) { headers.set('content-type', contentType) } diff --git a/lib/api/api-fetch/response.js b/lib/api/api-fetch/response.js index db36102d658..92f11306580 100644 --- a/lib/api/api-fetch/response.js +++ b/lib/api/api-fetch/response.js @@ -2,11 +2,12 @@ const Headers = require('./headers') const { Blob } = require('buffer') -const { STATUS_CODES } = require('http') +const { extractBody } = require('./body') const { NotSupportedError } = require('../../core/errors') const util = require('../../core/util') +const assert = require('assert') const { kType, @@ -14,30 +15,53 @@ const { kStatusText, kUrlList, kHeaders, - kBody + kBody, + kBodyUsed } = require('./symbols') class Response { - constructor ({ - type, - url, - body, - statusCode, + static error () { + return new Response(null, { + [kType]: 'error', + status: 0 + }) + } + + static redirect (url, status = 302) { + // TODO: Implement. + throw new NotSupportedError() + } + + constructor (bodyInit = null, { + status = 200, + statusText = '', headers, - context - }) { - this[kType] = type || 'default' - this[kStatus] = statusCode || 0 - this[kStatusText] = STATUS_CODES[statusCode] || '' + [kType]: type = 'default', + [kUrlList]: url + } = {}) { + const [body, contentType] = extractBody(bodyInit) + + this[kType] = type + this[kStatus] = status + this[kStatusText] = statusText this[kUrlList] = Array.isArray(url) ? url : (url ? [url] : []) - this[kHeaders] = headers || new Headers() - this[kBody] = body || null + this[kHeaders] = new Headers(headers) + this[kBody] = body + this[kBodyUsed] = false - if (context && context.history) { - this[kUrlList].push(...context.history) + if (contentType && !this.headers.has('content-type')) { + this.headers.set('content-type', contentType) } } + get [Symbol.toStringTag] () { + return this.constructor.name + } + + toString () { + return Object.prototype.toString.call(this) + } + get type () { return this[kType] } @@ -69,16 +93,23 @@ class Response { async blob () { const chunks = [] - if (this.body) { - if (this.bodyUsed || this.body.locked) { + if (this[kBody]) { + if (this.bodyUsed || (this[kBody].stream && this[kBody].stream.locked)) { throw new TypeError('unusable') } - for await (const chunk of this.body) { - chunks.push(chunk) + this[kBodyUsed] = true + + if (this[kBody].stream) { + for await (const chunk of this[kBody].stream) { + chunks.push(chunk) + } + } else if (this[kBody].source) { + chunks.push(this[kBody].source) + } else { + assert(false) } } - return new Blob(chunks, { type: this.headers.get('Content-Type') || '' }) } @@ -102,18 +133,27 @@ class Response { } get body () { - return this[kBody] + if (!this[kBody]) { + return null + } + + if (!this[kBody].stream) { + // TODO: Implement. + throw new NotSupportedError() + } + + return this[kBody].stream } get bodyUsed () { - return util.isDisturbed(this.body) + return util.isDisturbed(this[kBody] && this[kBody].stream) || this[kBodyUsed] } clone () { let body = null if (this[kBody]) { - if (util.isDisturbed(this[kBody])) { + if (this.bodyUsed) { throw new TypeError('disturbed') } @@ -121,21 +161,51 @@ class Response { throw new TypeError('locked') } - // https://fetch.spec.whatwg.org/#concept-body-clone - const [out1, out2] = this[kBody].tee() - - this[kBody] = out1 - body = out2 + if (this[kBody].stream) { + // https://fetch.spec.whatwg.org/#concept-body-clone + const [out1, out2] = this.body.tee() + this[kBody] = { stream: out1 } + body = out2 + } else if (this[kBody].source) { + // TODO: Is this spec compliant? + body = this[kBody].source + } else { + assert(false) + } } - return new Response({ - type: this[kType], - statusCode: this[kStatus], - url: this[kUrlList], + return new Response(body, { + status: this[kStatus], + statusText: this[kStatusText], headers: this[kHeaders], - body + [kType]: this[kType], + [kUrlList]: this[kUrlList] }) } } +// Re-shaping object for Web IDL tests. +Object.defineProperties( + Response.prototype, + [ + 'body', + 'bodyUsed', + 'arrayBuffer', + 'blob', + 'json', + 'text', + 'type', + 'url', + 'status', + 'ok', + 'redirected', + 'statusText', + 'headers', + 'clone' + ].reduce((result, property) => { + result[property] = { enumerable: true } + return result + }, {}) +) + module.exports = Response diff --git a/lib/api/index.js b/lib/api/index.js index 6e957a8bf73..cd84921862d 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -10,4 +10,6 @@ module.exports.connect = require('./api-connect') if (nodeMajor >= 16) { module.exports.fetch = require('./api-fetch') + module.exports.fetch.Headers = require('./api-fetch/headers') + module.exports.fetch.Response = require('./api-fetch/response') } diff --git a/lib/core/errors.js b/lib/core/errors.js index 8e9b13b46c5..779c2cb7a1e 100644 --- a/lib/core/errors.js +++ b/lib/core/errors.js @@ -1,5 +1,13 @@ 'use strict' +class AbortError extends Error { + constructor() { + super('The operation was aborted') + this.code = 'ABORT_ERR' + this.name = 'AbortError' + } +} + class UndiciError extends Error { constructor (message) { super(message) @@ -196,6 +204,7 @@ class InvalidThisError extends TypeError { } module.exports = { + AbortError, HTTPParserError, UndiciError, HeadersTimeoutError, diff --git a/lib/core/request.js b/lib/core/request.js index 33552ae83dd..f242930c1b9 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -52,6 +52,9 @@ class Request { this.body = null } else if (util.isStream(body)) { this.body = body + } else if (body instanceof DataView) { + // TODO: Why is DataView special? + this.body = body.buffer.byteLength ? Buffer.from(body.buffer) : null } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { this.body = body.byteLength ? Buffer.from(body) : null } else if (util.isBuffer(body)) { diff --git a/package.json b/package.json index b89098adc78..2340d633e1a 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "build:wasm": "node build/wasm.js --docker", "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "tap test/*.js --no-coverage && jest test/jest/test", + "test": "tap test/*.js --no-coverage && mocha test/node-fetch && jest test/jest/test", + "test:node-fetch": "mocha test/node-fetch", "test:tdd": "tap test/*.js -w --no-coverage-report", "test:typescript": "tsd", "coverage": "standard | snazzy && tap test/*.js", @@ -50,13 +51,22 @@ "@sinonjs/fake-timers": "^7.0.5", "@types/node": "^15.0.2", "abort-controller": "^3.0.0", + "abortcontroller-polyfill": "^1.7.3", + "busboy": "^0.3.1", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "chai-iterator": "^3.0.2", + "chai-string": "^1.5.0", "concurrently": "^6.1.0", "cronometro": "^0.8.0", + "delay": "^5.0.0", "docsify-cli": "^4.4.2", "https-pem": "^2.0.0", "husky": "^6.0.0", "jest": "^27.0.5", "jsfuzz": "^1.0.15", + "mocha": "^9.0.3", + "p-timeout": "^3.2.0", "pre-commit": "^1.2.2", "proxy": "^1.0.2", "proxyquire": "^2.1.3", @@ -72,6 +82,9 @@ "node": ">=12.18" }, "standard": { + "env": [ + "mocha" + ], "ignore": [ "lib/llhttp/constants.js", "lib/llhttp/utils.js" diff --git a/test/node-fetch/LICENSE.md b/test/node-fetch/LICENSE.md new file mode 100644 index 00000000000..41ca1b6eb4d --- /dev/null +++ b/test/node-fetch/LICENSE.md @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 - 2020 Node Fetch Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/test/node-fetch/external-encoding.js b/test/node-fetch/external-encoding.js new file mode 100644 index 00000000000..74ad9088eaf --- /dev/null +++ b/test/node-fetch/external-encoding.js @@ -0,0 +1,43 @@ +// const chai = require('chai') +// const { fetch } = require('../../index.js') + +// const { expect } = chai + +// describe('external encoding', () => { +// describe('data uri', () => { +// it('should accept base64-encoded gif 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 with specified charset', async () => { +// const r = await fetch('data:text/plain;charset=UTF-8;page=21,the%20data:1234,5678') +// expect(r.status).to.equal(200) +// expect(r.headers.get('Content-Type')).to.equal('text/plain;charset=UTF-8;page=21') + +// const b = await r.text() +// expect(b).to.equal('the data:1234,5678') +// }) + +// 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;charset=US-ASCII') +// 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('malformed data: URI') +// }) +// }) +// }) +// }) diff --git a/test/node-fetch/headers.js b/test/node-fetch/headers.js new file mode 100644 index 00000000000..2299d148218 --- /dev/null +++ b/test/node-fetch/headers.js @@ -0,0 +1,282 @@ +/* eslint no-unused-expressions: "off" */ + +// const { format } = require('util') +const chai = require('chai') +const chaiIterator = require('chai-iterator') +const Headers = require('../../lib/api/api-fetch/headers.js') + +chai.use(chaiIterator) + +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 = [] + for (const [key, value] of headers.entries()) { + result.push([key, value]) + } + + expect(result).to.deep.equal([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]) + }) + + it('should be iterable with forEach', () => { + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Accept', 'text/plain') + headers.append('Content-Type', 'text/html') + + const results = [] + headers.forEach((value, key, object) => { + results.push({ value, key, object }) + }) + + expect(results.length).to.equal(2) + expect({ key: 'accept', value: 'application/json, text/plain', object: headers }).to.deep.equal(results[0]) + expect({ key: 'content-type', value: 'text/html', object: headers }).to.deep.equal(results[1]) + }) + + // it('should set "this" to undefined by default on forEach', () => { + // const headers = new Headers({ Accept: 'application/json' }) + // headers.forEach(function () { + // expect(this).to.be.undefined + // }) + // }) + + it('should accept thisArg as a second argument for forEach', () => { + const headers = new Headers({ Accept: 'application/json' }) + const thisArg = {} + headers.forEach(function () { + expect(this).to.equal(thisArg) + }, thisArg) + }) + + 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 = Number.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) + }) + + // it('should use a custom inspect function', () => { + // const headers = new Headers([ + // ['Host', 'thehost'], + // ['Host', 'notthehost'], + // ['a', '1'], + // ['b', '2'], + // ['a', '3'] + // ]) + + // // eslint-disable-next-line quotes + // expect(format(headers)).to.equal("{ a: [ '1', '3' ], b: '2', host: 'thehost' }") + // }) +}) diff --git a/test/node-fetch/main.js b/test/node-fetch/main.js new file mode 100644 index 00000000000..387d6d6a348 --- /dev/null +++ b/test/node-fetch/main.js @@ -0,0 +1,2305 @@ +/* eslint no-unused-expressions: "off" */ + +// Test tools +// const zlib = require('zlib') +// const crypto = require('crypto') +// const http = require('http') +// const fs = require('fs') +const stream = require('stream') +// const path = require('path') +// const { lookup } = require('dns') +const vm = require('vm') +const chai = require('chai') +const chaiPromised = require('chai-as-promised') +const chaiIterator = require('chai-iterator') +const chaiString = require('chai-string') +// const FormData = require('form-data') +// const { FormData as FormDataNode } = require('formdata-node' +// const delay = require('delay') +// const AbortControllerMysticatea = require('abort-controller') +// const abortControllerPolyfill = require('abortcontroller-polyfill/dist/abortcontroller.js') +const { Blob } = require('buffer') + +// Test subjects +// const { fileFromSync } = require('fetch-blob/= require(js' + +const { + fetch, + // FetchError, + Headers, + // Request, + Response +} = require('../../index.js') +// const { FetchError as FetchErrorOrig } = require('../src/errors/fetch-error.js' +const HeadersOrig = require('../../lib/api/api-fetch/headers.js') +// const RequestOrig = require('../src/request.js' +const ResponseOrig = require('../../lib/api/api-fetch/response.js') +// const Body, { getTotalBytes, extractContentType } = require('../src/body.js' +const TestServer = require('./utils/server.js') +const chaiTimeout = require('./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.rejected + // .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('') + }) + }) + }) + + 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) + // .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) + // .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(''), + // 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 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`) + }) +}) diff --git a/test/node-fetch/request.js b/test/node-fetch/request.js new file mode 100644 index 00000000000..cf2a7bd1431 --- /dev/null +++ b/test/node-fetch/request.js @@ -0,0 +1,277 @@ +// import stream from 'stream'; +// import http from 'http'; + +// import AbortController from 'abort-controller'; +// import chai from 'chai'; +// import FormData from 'form-data'; +// import Blob from 'fetch-blob'; + +// import {Request} from '../src/index.js'; +// import TestServer from './utils/server.js'; + +// const {expect} = chai; + +// describe('Request', () => { +// const local = new TestServer(); +// let base; + +// before(async () => { +// await local.start(); +// base = `http://${local.hostname}:${local.port}/`; +// }); + +// after(async () => { +// return local.stop(); +// }); + +// it('should have attributes conforming to Web IDL', () => { +// const request = new Request('https://github.com/'); +// const enumerableProperties = []; +// for (const property in request) { +// 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(() => { +// request[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(base, {body: ''})) +// .to.throw(TypeError); +// expect(() => new Request(base, {body: 'a'})) +// .to.throw(TypeError); +// expect(() => new Request(base, {body: '', method: 'HEAD'})) +// .to.throw(TypeError); +// expect(() => new Request(base, {body: 'a', method: 'HEAD'})) +// .to.throw(TypeError); +// expect(() => new Request(base, {body: 'a', method: 'get'})) +// .to.throw(TypeError); +// expect(() => new Request(base, {body: 'a', method: 'head'})) +// .to.throw(TypeError); +// }); + +// it('should default to null as body', () => { +// const request = new Request(base); +// expect(request.body).to.equal(null); +// return request.text().then(result => expect(result).to.equal('')); +// }); + +// it('should support parsing headers', () => { +// const url = base; +// const request = new Request(url, { +// headers: { +// a: '1' +// } +// }); +// expect(request.url).to.equal(url); +// expect(request.headers.get('a')).to.equal('1'); +// }); + +// it('should support arrayBuffer() method', () => { +// const url = base; +// const request = new Request(url, { +// method: 'POST', +// body: 'a=1' +// }); +// expect(request.url).to.equal(url); +// return request.arrayBuffer().then(result => { +// expect(result).to.be.an.instanceOf(ArrayBuffer); +// const string = String.fromCharCode.apply(null, new Uint8Array(result)); +// expect(string).to.equal('a=1'); +// }); +// }); + +// it('should support text() method', () => { +// const url = base; +// const request = new Request(url, { +// method: 'POST', +// body: 'a=1' +// }); +// expect(request.url).to.equal(url); +// return request.text().then(result => { +// expect(result).to.equal('a=1'); +// }); +// }); + +// it('should support json() method', () => { +// const url = base; +// const request = new Request(url, { +// method: 'POST', +// body: '{"a":1}' +// }); +// expect(request.url).to.equal(url); +// return request.json().then(result => { +// expect(result.a).to.equal(1); +// }); +// }); + +// it('should support buffer() method', () => { +// const url = base; +// const request = new Request(url, { +// method: 'POST', +// body: 'a=1' +// }); +// expect(request.url).to.equal(url); +// return request.buffer().then(result => { +// expect(result.toString()).to.equal('a=1'); +// }); +// }); + +// it('should support blob() method', () => { +// const url = base; +// const request = new Request(url, { +// method: 'POST', +// body: Buffer.from('a=1') +// }); +// expect(request.url).to.equal(url); +// return request.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; +// const body = stream.Readable.from('a=1'); +// const agent = new http.Agent(); +// const {signal} = new AbortController(); +// const request = new Request(url, { +// body, +// method: 'POST', +// redirect: 'manual', +// headers: { +// b: '2' +// }, +// follow: 3, +// compress: false, +// agent, +// signal +// }); +// const cl = request.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(), request.text()]).then(results => { +// expect(results[0]).to.equal('a=1'); +// expect(results[1]).to.equal('a=1'); +// }); +// }); + +// it('should support ArrayBuffer as body', () => { +// const encoder = new TextEncoder(); +// const request = new Request(base, { +// method: 'POST', +// body: encoder.encode('a=1').buffer +// }); +// return request.text().then(result => { +// expect(result).to.equal('a=1'); +// }); +// }); + +// it('should support Uint8Array as body', () => { +// const encoder = new TextEncoder(); +// const request = new Request(base, { +// method: 'POST', +// body: encoder.encode('a=1') +// }); +// return request.text().then(result => { +// expect(result).to.equal('a=1'); +// }); +// }); + +// it('should support DataView as body', () => { +// const encoder = new TextEncoder(); +// const request = new Request(base, { +// method: 'POST', +// body: new DataView(encoder.encode('a=1').buffer) +// }); +// return request.text().then(result => { +// expect(result).to.equal('a=1'); +// }); +// }); +// }); diff --git a/test/node-fetch/response.js b/test/node-fetch/response.js new file mode 100644 index 00000000000..2f402c0cf91 --- /dev/null +++ b/test/node-fetch/response.js @@ -0,0 +1,216 @@ +/* eslint no-unused-expressions: "off" */ + +const chai = require('chai') +const stream = require('stream') +const Response = require('../../lib/api/api-fetch/response.js') +const TestServer = require('./utils/server.js') +const { Blob } = require('buffer') +const { kUrlList } = require('../../lib/api/api-fetch/symbols.js') + +const { expect } = chai + +describe('Response', () => { + const local = new TestServer() + let base + + before(async () => { + await local.start() + base = `http://${local.hostname}:${local.port}/` + }) + + after(async () => { + return local.stop() + }) + + 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', + 'type', + 'url', + 'status', + 'ok', + 'redirected', + 'statusText', + 'headers', + 'clone' + ]) { + expect(enumerableProperties).to.contain(toCheck) + } + + // TODO + // for (const toCheck of [ + // 'body', + // 'bodyUsed', + // 'type', + // 'url', + // 'status', + // 'ok', + // 'redirected', + // 'statusText', + // 'headers' + // ]) { + // expect(() => { + // res[toCheck] = 'abc' + // }).to.throw() + // } + }) + + it('should support empty options', () => { + const res = new Response(stream.Readable.from('a=1')) + 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) + }) + }) + + if (Blob) { + 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', () => { + const body = stream.Readable.from('a=1') + const res = new Response(body, { + headers: { + a: '1' + }, + [kUrlList]: base, + status: 346, + statusText: 'production' + }) + const cl = res.clone() + expect(cl.headers.get('a')).to.equal('1') + expect(cl.type).to.equal('default') + 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', () => { + const body = stream.Readable.from('a=1') + 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 encoder = new TextEncoder() + const res = new Response(encoder.encode('a=1')) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support blob as body', async () => { + 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 encoder = new TextEncoder() + const res = new Response(encoder.encode('a=1')) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support DataView as body', () => { + const encoder = new TextEncoder() + const res = new Response(new DataView(encoder.encode('a=1').buffer)) + 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('') + }) + + it('should support error() static method', () => { + const res = Response.error() + expect(res).to.be.an.instanceof(Response) + expect(res.type).to.equal('error') + expect(res.status).to.equal(0) + expect(res.statusText).to.equal('') + }) +}) diff --git a/test/node-fetch/utils/chai-timeout.js b/test/node-fetch/utils/chai-timeout.js new file mode 100644 index 00000000000..6838a4cc322 --- /dev/null +++ b/test/node-fetch/utils/chai-timeout.js @@ -0,0 +1,15 @@ +const pTimeout = require('p-timeout') + +module.exports = ({ Assertion }, utils) => { + utils.addProperty(Assertion.prototype, 'timeout', async function () { + let timeouted = false + await pTimeout(this._obj, 150, () => { + timeouted = true + }) + return this.assert( + timeouted, + 'expected promise to timeout but it was resolved', + 'expected promise not to timeout but it timed out' + ) + }) +} diff --git a/test/node-fetch/utils/dummy.txt b/test/node-fetch/utils/dummy.txt new file mode 100644 index 00000000000..5ca51916b39 --- /dev/null +++ b/test/node-fetch/utils/dummy.txt @@ -0,0 +1 @@ +i am a dummy \ No newline at end of file diff --git a/test/node-fetch/utils/read-stream.js b/test/node-fetch/utils/read-stream.js new file mode 100644 index 00000000000..7d791532934 --- /dev/null +++ b/test/node-fetch/utils/read-stream.js @@ -0,0 +1,9 @@ +module.exports = async function readStream (stream) { + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} diff --git a/test/node-fetch/utils/server.js b/test/node-fetch/utils/server.js new file mode 100644 index 00000000000..9b625ae92f5 --- /dev/null +++ b/test/node-fetch/utils/server.js @@ -0,0 +1,430 @@ +const http = require('http') +const zlib = require('zlib') +const { once } = require('events') +const Busboy = require('busboy') + +module.exports = class TestServer { + constructor () { + this.server = http.createServer(this.router) + // Node 8 default keepalive timeout is 5000ms + // make it shorter here as we want to close server quickly at the end of tests + this.server.keepAliveTimeout = 1000 + this.server.on('error', err => { + console.log(err.stack) + }) + this.server.on('connection', socket => { + socket.setTimeout(1500) + }) + } + + async start () { + this.server.listen(0, 'localhost') + return once(this.server, 'listening') + } + + async stop () { + this.server.close() + return once(this.server, 'close') + } + + get port () { + return this.server.address().port + } + + get hostname () { + return 'localhost' + } + + mockResponse (responseHandler) { + this.server.nextResponseHandler = responseHandler + return `http://${this.hostname}:${this.port}/mocked` + } + + router (request, res) { + const p = request.url + + if (p === '/mocked') { + if (this.nextResponseHandler) { + this.nextResponseHandler(res) + this.nextResponseHandler = undefined + } else { + throw new Error('No mocked response. Use ’TestServer.mockResponse()’.') + } + } + + if (p === '/hello') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('world') + } + + if (p.includes('question')) { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('ok') + } + + if (p === '/plain') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('text') + } + + if (p === '/no-status-text') { + res.writeHead(200, '', {}).end() + } + + if (p === '/options') { + res.statusCode = 200 + res.setHeader('Allow', 'GET, HEAD, OPTIONS') + res.end('hello world') + } + + if (p === '/html') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.end('') + } + + if (p === '/json') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + name: 'value' + })) + } + + if (p === '/gzip') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/gzip-truncated') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + // Truncate the CRC checksum and size check at the end of the stream + res.end(buffer.slice(0, -8)) + }) + } + + if (p === '/gzip-capital') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'GZip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/deflate') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'deflate') + zlib.deflate('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/brotli') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + if (typeof zlib.createBrotliDecompress === 'function') { + res.setHeader('Content-Encoding', 'br') + zlib.brotliCompress('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + } + + if (p === '/deflate-raw') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'deflate') + zlib.deflateRaw('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/sdch') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'sdch') + res.end('fake sdch string') + } + + if (p === '/invalid-content-encoding') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + res.end('fake gzip string') + } + + if (p === '/timeout') { + setTimeout(() => { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('text') + }, 1000) + } + + if (p === '/slow') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.write('test') + setTimeout(() => { + res.end('test') + }, 1000) + } + + if (p === '/cookie') { + res.statusCode = 200 + res.setHeader('Set-Cookie', ['a=1', 'b=1']) + res.end('cookie') + } + + if (p === '/size/chunk') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + setTimeout(() => { + res.write('test') + }, 10) + setTimeout(() => { + res.end('test') + }, 20) + } + + if (p === '/size/long') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('testtest') + } + + if (p === '/redirect/301') { + res.statusCode = 301 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/302') { + res.statusCode = 302 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/303') { + res.statusCode = 303 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/307') { + res.statusCode = 307 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/308') { + res.statusCode = 308 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/chain') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/301') + res.end() + } + + if (p === '/redirect/no-location') { + res.statusCode = 301 + res.end() + } + + if (p === '/redirect/slow') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/301') + setTimeout(() => { + res.end() + }, 1000) + } + + if (p === '/redirect/slow-chain') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/slow') + setTimeout(() => { + res.end() + }, 10) + } + + if (p === '/redirect/slow-stream') { + res.statusCode = 301 + res.setHeader('Location', '/slow') + res.end() + } + + if (p === '/redirect/bad-location') { + res.socket.write('HTTP/1.1 301\r\nLocation: ☃\r\nContent-Length: 0\r\n') + res.socket.end('\r\n') + } + + if (p === '/error/400') { + res.statusCode = 400 + res.setHeader('Content-Type', 'text/plain') + res.end('client error') + } + + if (p === '/error/404') { + res.statusCode = 404 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/error/500') { + res.statusCode = 500 + res.setHeader('Content-Type', 'text/plain') + res.end('server error') + } + + if (p === '/error/reset') { + res.destroy() + } + + if (p === '/error/premature') { + res.writeHead(200, { 'content-length': 50 }) + res.write('foo') + setTimeout(() => { + res.destroy() + }, 100) + } + + if (p === '/error/premature/chunked') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked' + }) + + res.write(`${JSON.stringify({ data: 'hi' })}\n`) + + setTimeout(() => { + res.write(`${JSON.stringify({ data: 'bye' })}\n`) + }, 200) + + setTimeout(() => { + res.destroy() + }, 400) + } + + if (p === '/error/json') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end('invalid json') + } + + if (p === '/no-content') { + res.statusCode = 204 + res.end() + } + + if (p === '/no-content/gzip') { + res.statusCode = 204 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/no-content/brotli') { + res.statusCode = 204 + res.setHeader('Content-Encoding', 'br') + res.end() + } + + if (p === '/not-modified') { + res.statusCode = 304 + res.end() + } + + if (p === '/not-modified/gzip') { + res.statusCode = 304 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/inspect') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + let body = '' + request.on('data', c => { + body += c + }) + request.on('end', () => { + res.end(JSON.stringify({ + method: request.method, + url: request.url, + headers: request.headers, + body + })) + }) + } + + if (p === '/multipart') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + const busboy = new Busboy({ headers: request.headers }) + let body = '' + busboy.on('file', async (fieldName, file, fileName) => { + body += `${fieldName}=${fileName}` + // consume file data + // eslint-disable-next-line no-empty, no-unused-vars + for await (const c of file) {} + }) + + busboy.on('field', (fieldName, value) => { + body += `${fieldName}=${value}` + }) + busboy.on('finish', () => { + res.end(JSON.stringify({ + method: request.method, + url: request.url, + headers: request.headers, + body + })) + }) + request.pipe(busboy) + } + + if (p === '/m%C3%B6bius') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('ok') + } + } +}