summaryrefslogtreecommitdiffstats
path: root/test/fetch
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-21 20:56:19 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-21 20:56:19 +0000
commit0b6210cd37b68b94252cb798598b12974a20e1c1 (patch)
treee371686554a877842d95aa94f100bee552ff2a8e /test/fetch
parentInitial commit. (diff)
downloadnode-undici-upstream.tar.xz
node-undici-upstream.zip
Adding upstream version 5.28.2+dfsg1+~cs23.11.12.3.upstream/5.28.2+dfsg1+_cs23.11.12.3upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--test/fetch/407-statuscode-window-null.js20
-rw-r--r--test/fetch/abort.js82
-rw-r--r--test/fetch/abort2.js60
-rw-r--r--test/fetch/about-uri.js21
-rw-r--r--test/fetch/blob-uri.js100
-rw-r--r--test/fetch/bundle.js41
-rw-r--r--test/fetch/client-error-stack-trace.js21
-rw-r--r--test/fetch/client-fetch.js688
-rw-r--r--test/fetch/client-node-max-header-size.js29
-rw-r--r--test/fetch/content-length.js29
-rw-r--r--test/fetch/cookies.js69
-rw-r--r--test/fetch/data-uri.js214
-rw-r--r--test/fetch/encoding.js58
-rw-r--r--test/fetch/fetch-leak.js44
-rw-r--r--test/fetch/fetch-timeouts.js56
-rw-r--r--test/fetch/file.js190
-rw-r--r--test/fetch/formdata.js401
-rw-r--r--test/fetch/general.js30
-rw-r--r--test/fetch/headers.js743
-rw-r--r--test/fetch/http2.js415
-rw-r--r--test/fetch/integrity.js150
-rw-r--r--test/fetch/issue-1447.js46
-rw-r--r--test/fetch/issue-2009.js28
-rw-r--r--test/fetch/issue-2021.js32
-rw-r--r--test/fetch/issue-2171.js25
-rw-r--r--test/fetch/issue-2242.js8
-rw-r--r--test/fetch/issue-2318.js25
-rw-r--r--test/fetch/issue-node-46525.js28
-rw-r--r--test/fetch/iterators.js140
-rw-r--r--test/fetch/jsdom-abortcontroller-1910-1464495619.js26
-rw-r--r--test/fetch/redirect-cross-origin-header.js48
-rw-r--r--test/fetch/redirect.js50
-rw-r--r--test/fetch/relative-url.js110
-rw-r--r--test/fetch/request.js514
-rw-r--r--test/fetch/resource-timing.js72
-rw-r--r--test/fetch/response-json.js113
-rw-r--r--test/fetch/response.js257
-rw-r--r--test/fetch/user-agent.js32
-rw-r--r--test/fetch/util.js281
39 files changed, 5296 insertions, 0 deletions
diff --git a/test/fetch/407-statuscode-window-null.js b/test/fetch/407-statuscode-window-null.js
new file mode 100644
index 0000000..e22554f
--- /dev/null
+++ b/test/fetch/407-statuscode-window-null.js
@@ -0,0 +1,20 @@
+'use strict'
+
+const { fetch } = require('../..')
+const { createServer } = require('http')
+const { once } = require('events')
+const { test } = require('tap')
+
+test('Receiving a 407 status code w/ a window option present should reject', async (t) => {
+ const server = createServer((req, res) => {
+ res.statusCode = 407
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ // if init.window exists, the spec tells us to set request.window to 'no-window',
+ // which later causes the request to be rejected if the status code is 407
+ await t.rejects(fetch(`http://localhost:${server.address().port}`, { window: null }))
+})
diff --git a/test/fetch/abort.js b/test/fetch/abort.js
new file mode 100644
index 0000000..e1ca1eb
--- /dev/null
+++ b/test/fetch/abort.js
@@ -0,0 +1,82 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { createServer } = require('http')
+const { once } = require('events')
+const { DOMException } = require('../../lib/fetch/constants')
+const { nodeMajor } = require('../../lib/core/util')
+
+const { AbortController: NPMAbortController } = require('abort-controller')
+
+test('Allow the usage of custom implementation of AbortController', async (t) => {
+ const body = {
+ fixes: 1605
+ }
+
+ const server = createServer((req, res) => {
+ res.statusCode = 200
+ res.end(JSON.stringify(body))
+ })
+
+ t.teardown(server.close.bind(server))
+
+ server.listen(0)
+ await once(server, 'listening')
+
+ const controller = new NPMAbortController()
+ const signal = controller.signal
+ controller.abort()
+
+ try {
+ await fetch(`http://localhost:${server.address().port}`, {
+ signal
+ })
+ } catch (e) {
+ t.equal(e.code, DOMException.ABORT_ERR)
+ }
+})
+
+test('allows aborting with custom errors', { skip: nodeMajor === 16 }, async (t) => {
+ const server = createServer().listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ t.test('Using AbortSignal.timeout with cause', async (t) => {
+ t.plan(2)
+
+ try {
+ await fetch(`http://localhost:${server.address().port}`, {
+ signal: AbortSignal.timeout(50)
+ })
+ t.fail('should throw')
+ } catch (err) {
+ if (err.name === 'TypeError') {
+ const cause = err.cause
+ t.equal(cause.name, 'HeadersTimeoutError')
+ t.equal(cause.code, 'UND_ERR_HEADERS_TIMEOUT')
+ } else if (err.name === 'TimeoutError') {
+ t.equal(err.code, DOMException.TIMEOUT_ERR)
+ t.equal(err.cause, undefined)
+ } else {
+ t.error(err)
+ }
+ }
+ })
+
+ t.test('Error defaults to an AbortError DOMException', async (t) => {
+ const ac = new AbortController()
+ ac.abort() // no reason
+
+ await t.rejects(
+ fetch(`http://localhost:${server.address().port}`, {
+ signal: ac.signal
+ }),
+ {
+ name: 'AbortError',
+ code: DOMException.ABORT_ERR
+ }
+ )
+ })
+})
diff --git a/test/fetch/abort2.js b/test/fetch/abort2.js
new file mode 100644
index 0000000..5f3853b
--- /dev/null
+++ b/test/fetch/abort2.js
@@ -0,0 +1,60 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { createServer } = require('http')
+const { once } = require('events')
+const { DOMException } = require('../../lib/fetch/constants')
+
+/* global AbortController */
+
+test('parallel fetch with the same AbortController works as expected', async (t) => {
+ const body = {
+ fixes: 1389,
+ bug: 'Ensure request is not aborted before enqueueing bytes into stream.'
+ }
+
+ const server = createServer((req, res) => {
+ res.statusCode = 200
+ res.end(JSON.stringify(body))
+ })
+
+ t.teardown(server.close.bind(server))
+
+ const abortController = new AbortController()
+
+ async function makeRequest () {
+ const result = await fetch(`http://localhost:${server.address().port}`, {
+ signal: abortController.signal
+ }).then(response => response.json())
+
+ abortController.abort()
+ return result
+ }
+
+ server.listen(0)
+ await once(server, 'listening')
+
+ const requests = Array.from({ length: 10 }, makeRequest)
+ const result = await Promise.allSettled(requests)
+
+ // since the requests are running parallel, any of them could resolve first.
+ // therefore we cannot rely on the order of the requests sent.
+ const { resolved, rejected } = result.reduce((a, b) => {
+ if (b.status === 'rejected') {
+ a.rejected.push(b)
+ } else {
+ a.resolved.push(b)
+ }
+
+ return a
+ }, { resolved: [], rejected: [] })
+
+ t.equal(rejected.length, 9) // out of 10 requests, only 1 should succeed
+ t.equal(resolved.length, 1)
+
+ t.ok(rejected.every(rej => rej.reason?.code === DOMException.ABORT_ERR))
+ t.same(resolved[0].value, body)
+
+ t.end()
+})
diff --git a/test/fetch/about-uri.js b/test/fetch/about-uri.js
new file mode 100644
index 0000000..ac9cbf2
--- /dev/null
+++ b/test/fetch/about-uri.js
@@ -0,0 +1,21 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+
+test('fetching about: uris', async (t) => {
+ t.test('about:blank', async (t) => {
+ await t.rejects(fetch('about:blank'))
+ })
+
+ t.test('All other about: urls should return an error', async (t) => {
+ try {
+ await fetch('about:config')
+ t.fail('fetching about:config should fail')
+ } catch (e) {
+ t.ok(e, 'this error was expected')
+ } finally {
+ t.end()
+ }
+ })
+})
diff --git a/test/fetch/blob-uri.js b/test/fetch/blob-uri.js
new file mode 100644
index 0000000..f9db96c
--- /dev/null
+++ b/test/fetch/blob-uri.js
@@ -0,0 +1,100 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { Blob } = require('buffer')
+
+test('fetching blob: uris', async (t) => {
+ const blobContents = 'hello world'
+ /** @type {import('buffer').Blob} */
+ let blob
+ /** @type {string} */
+ let objectURL
+
+ t.beforeEach(() => {
+ blob = new Blob([blobContents])
+ objectURL = URL.createObjectURL(blob)
+ })
+
+ t.test('a normal fetch request works', async (t) => {
+ const res = await fetch(objectURL)
+
+ t.equal(blobContents, await res.text())
+ t.equal(blob.type, res.headers.get('Content-Type'))
+ t.equal(`${blob.size}`, res.headers.get('Content-Length'))
+ t.end()
+ })
+
+ t.test('non-GET method to blob: fails', async (t) => {
+ try {
+ await fetch(objectURL, {
+ method: 'POST'
+ })
+ t.fail('expected POST to blob: uri to fail')
+ } catch (e) {
+ t.ok(e, 'Got the expected error')
+ } finally {
+ t.end()
+ }
+ })
+
+ // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L36-L41
+ t.test('fetching revoked URL should fail', async (t) => {
+ URL.revokeObjectURL(objectURL)
+
+ try {
+ await fetch(objectURL)
+ t.fail('expected revoked blob: url to fail')
+ } catch (e) {
+ t.ok(e, 'Got the expected error')
+ } finally {
+ t.end()
+ }
+ })
+
+ // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L28-L34
+ t.test('works with a fragment', async (t) => {
+ const res = await fetch(objectURL + '#fragment')
+
+ t.equal(blobContents, await res.text())
+ t.end()
+ })
+
+ // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56
+ t.test('Appending a query string to blob: url should cause fetch to fail', async (t) => {
+ try {
+ await fetch(objectURL + '?querystring')
+ t.fail('expected ?querystring blob: url to fail')
+ } catch (e) {
+ t.ok(e, 'Got the expected error')
+ } finally {
+ t.end()
+ }
+ })
+
+ // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L58-L62
+ t.test('Appending a path should cause fetch to fail', async (t) => {
+ try {
+ await fetch(objectURL + '/path')
+ t.fail('expected /path blob: url to fail')
+ } catch (e) {
+ t.ok(e, 'Got the expected error')
+ } finally {
+ t.end()
+ }
+ })
+
+ // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L64-L70
+ t.test('these http methods should fail', async (t) => {
+ for (const method of ['HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'CUSTOM']) {
+ try {
+ await fetch(objectURL, { method })
+ t.fail(`${method} fetch should have failed`)
+ } catch (e) {
+ t.ok(e, `${method} blob url - test succeeded`)
+ }
+ }
+
+ t.end()
+ })
+})
diff --git a/test/fetch/bundle.js b/test/fetch/bundle.js
new file mode 100644
index 0000000..aa1257a
--- /dev/null
+++ b/test/fetch/bundle.js
@@ -0,0 +1,41 @@
+'use strict'
+
+const { test, skip } = require('tap')
+const { nodeMajor } = require('../../lib/core/util')
+
+if (nodeMajor === 16) {
+ skip('esbuild uses static blocks with --keep-names which node 16.8 does not have')
+ process.exit()
+}
+
+const { Response, Request, FormData, Headers } = require('../../undici-fetch')
+
+test('bundle sets constructor.name and .name properly', (t) => {
+ t.equal(new Response().constructor.name, 'Response')
+ t.equal(Response.name, 'Response')
+
+ t.equal(new Request('http://a').constructor.name, 'Request')
+ t.equal(Request.name, 'Request')
+
+ t.equal(new Headers().constructor.name, 'Headers')
+ t.equal(Headers.name, 'Headers')
+
+ t.equal(new FormData().constructor.name, 'FormData')
+ t.equal(FormData.name, 'FormData')
+
+ t.end()
+})
+
+test('regression test for https://github.com/nodejs/node/issues/50263', (t) => {
+ const request = new Request('https://a', {
+ headers: {
+ test: 'abc'
+ },
+ method: 'POST'
+ })
+
+ const request1 = new Request(request, { body: 'does not matter' })
+
+ t.equal(request1.headers.get('test'), 'abc')
+ t.end()
+})
diff --git a/test/fetch/client-error-stack-trace.js b/test/fetch/client-error-stack-trace.js
new file mode 100644
index 0000000..7d94aa8
--- /dev/null
+++ b/test/fetch/client-error-stack-trace.js
@@ -0,0 +1,21 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { fetch: fetchIndex } = require('../../index-fetch')
+
+test('FETCH: request errors and prints trimmed stack trace', async (t) => {
+ try {
+ await fetch('http://a.com')
+ } catch (error) {
+ t.match(error.stack, `at Test.<anonymous> (${__filename}`)
+ }
+})
+
+test('FETCH-index: request errors and prints trimmed stack trace', async (t) => {
+ try {
+ await fetchIndex('http://a.com')
+ } catch (error) {
+ t.match(error.stack, `at Test.<anonymous> (${__filename}`)
+ }
+})
diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js
new file mode 100644
index 0000000..9009d54
--- /dev/null
+++ b/test/fetch/client-fetch.js
@@ -0,0 +1,688 @@
+/* globals AbortController */
+
+'use strict'
+
+const { test, teardown } = require('tap')
+const { createServer } = require('http')
+const { ReadableStream } = require('stream/web')
+const { Blob } = require('buffer')
+const { fetch, Response, Request, FormData, File } = require('../..')
+const { Client, setGlobalDispatcher, Agent } = require('../..')
+const { nodeMajor, nodeMinor } = require('../../lib/core/util')
+const nodeFetch = require('../../index-fetch')
+const { once } = require('events')
+const { gzipSync } = require('zlib')
+const { promisify } = require('util')
+const { randomFillSync, createHash } = require('crypto')
+
+setGlobalDispatcher(new Agent({
+ keepAliveTimeout: 1,
+ keepAliveMaxTimeout: 1
+}))
+
+test('function signature', (t) => {
+ t.plan(2)
+
+ t.equal(fetch.name, 'fetch')
+ t.equal(fetch.length, 1)
+})
+
+test('args validation', async (t) => {
+ t.plan(2)
+
+ await t.rejects(fetch(), TypeError)
+ await t.rejects(fetch('ftp://unsupported'), TypeError)
+})
+
+test('request json', (t) => {
+ t.plan(1)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}`)
+ t.strictSame(obj, await body.json())
+ })
+})
+
+test('request text', (t) => {
+ t.plan(1)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}`)
+ t.strictSame(JSON.stringify(obj), await body.text())
+ })
+})
+
+test('request arrayBuffer', (t) => {
+ t.plan(1)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}`)
+ t.strictSame(Buffer.from(JSON.stringify(obj)), Buffer.from(await body.arrayBuffer()))
+ })
+})
+
+test('should set type of blob object to the value of the `Content-Type` header from response', (t) => {
+ t.plan(1)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const response = await fetch(`http://localhost:${server.address().port}`)
+ t.equal('application/json', (await response.blob()).type)
+ })
+})
+
+test('pre aborted with readable request body', (t) => {
+ t.plan(2)
+
+ const server = createServer((req, res) => {
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const ac = new AbortController()
+ ac.abort()
+ await fetch(`http://localhost:${server.address().port}`, {
+ signal: ac.signal,
+ method: 'POST',
+ body: new ReadableStream({
+ async cancel (reason) {
+ t.equal(reason.name, 'AbortError')
+ }
+ }),
+ duplex: 'half'
+ }).catch(err => {
+ t.equal(err.name, 'AbortError')
+ })
+ })
+})
+
+test('pre aborted with closed readable request body', (t) => {
+ t.plan(2)
+
+ const server = createServer((req, res) => {
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const ac = new AbortController()
+ ac.abort()
+ const body = new ReadableStream({
+ async start (c) {
+ t.pass()
+ c.close()
+ },
+ async cancel (reason) {
+ t.fail()
+ }
+ })
+ queueMicrotask(() => {
+ fetch(`http://localhost:${server.address().port}`, {
+ signal: ac.signal,
+ method: 'POST',
+ body,
+ duplex: 'half'
+ }).catch(err => {
+ t.equal(err.name, 'AbortError')
+ })
+ })
+ })
+})
+
+test('unsupported formData 1', (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'asdasdsad')
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`)
+ .then(res => res.formData())
+ .catch(err => {
+ t.equal(err.name, 'TypeError')
+ })
+ })
+})
+
+test('multipart formdata not base64', async (t) => {
+ t.plan(2)
+ // Construct example form data, with text and blob fields
+ const formData = new FormData()
+ formData.append('field1', 'value1')
+ const blob = new Blob(['example\ntext file'], { type: 'text/plain' })
+ formData.append('field2', blob, 'file.txt')
+
+ const tempRes = new Response(formData)
+ const boundary = tempRes.headers.get('content-type').split('boundary=')[1]
+ const formRaw = await tempRes.text()
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary)
+ res.write(formRaw)
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ const listen = promisify(server.listen.bind(server))
+ await listen(0)
+
+ const res = await fetch(`http://localhost:${server.address().port}`)
+ const form = await res.formData()
+ t.equal(form.get('field1'), 'value1')
+
+ const text = await form.get('field2').text()
+ t.equal(text, 'example\ntext file')
+})
+
+// TODO(@KhafraDev): re-enable this test once the issue is fixed
+// See https://github.com/nodejs/node/issues/47301
+test('multipart formdata base64', { skip: nodeMajor >= 19 && nodeMinor >= 8 }, (t) => {
+ t.plan(1)
+
+ // Example form data with base64 encoding
+ const data = randomFillSync(Buffer.alloc(256))
+ const formRaw = `------formdata-undici-0.5786922755719377\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n${data.toString('base64')}\r\n------formdata-undici-0.5786922755719377--`
+ const server = createServer(async (req, res) => {
+ res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377')
+
+ for (let offset = 0; offset < formRaw.length;) {
+ res.write(formRaw.slice(offset, offset += 2))
+ await new Promise(resolve => setTimeout(resolve))
+ }
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`)
+ .then(res => res.formData())
+ .then(form => form.get('file').arrayBuffer())
+ .then(buffer => createHash('sha256').update(Buffer.from(buffer)).digest('base64'))
+ .then(digest => {
+ t.equal(createHash('sha256').update(data).digest('base64'), digest)
+ })
+ })
+})
+
+test('multipart fromdata non-ascii filed names', async (t) => {
+ t.plan(1)
+
+ const request = new Request('http://localhost', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'multipart/form-data; boundary=----formdata-undici-0.6204674738279623'
+ },
+ body:
+ '------formdata-undici-0.6204674738279623\r\n' +
+ 'Content-Disposition: form-data; name="fiŝo"\r\n' +
+ '\r\n' +
+ 'value1\r\n' +
+ '------formdata-undici-0.6204674738279623--'
+ })
+
+ const form = await request.formData()
+ t.equal(form.get('fiŝo'), 'value1')
+})
+
+test('busboy emit error', async (t) => {
+ t.plan(1)
+ const formData = new FormData()
+ formData.append('field1', 'value1')
+
+ const tempRes = new Response(formData)
+ const formRaw = await tempRes.text()
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary')
+ res.write(formRaw)
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ const listen = promisify(server.listen.bind(server))
+ await listen(0)
+
+ const res = await fetch(`http://localhost:${server.address().port}`)
+ await t.rejects(res.formData(), 'Unexpected end of multipart data')
+})
+
+// https://github.com/nodejs/undici/issues/2244
+test('parsing formData preserve full path on files', async (t) => {
+ t.plan(1)
+ const formData = new FormData()
+ formData.append('field1', new File(['foo'], 'a/b/c/foo.txt'))
+
+ const tempRes = new Response(formData)
+ const form = await tempRes.formData()
+
+ t.equal(form.get('field1').name, 'a/b/c/foo.txt')
+})
+
+test('urlencoded formData', (t) => {
+ t.plan(2)
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'application/x-www-form-urlencoded')
+ res.end('field1=value1&field2=value2')
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`)
+ .then(res => res.formData())
+ .then(formData => {
+ t.equal(formData.get('field1'), 'value1')
+ t.equal(formData.get('field2'), 'value2')
+ })
+ })
+})
+
+test('text with BOM', (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'application/x-www-form-urlencoded')
+ res.end('\uFEFFtest=\uFEFF')
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`)
+ .then(res => res.text())
+ .then(text => {
+ t.equal(text, 'test=\uFEFF')
+ })
+ })
+})
+
+test('formData with BOM', (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-type', 'application/x-www-form-urlencoded')
+ res.end('\uFEFFtest=\uFEFF')
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`)
+ .then(res => res.formData())
+ .then(formData => {
+ t.equal(formData.get('\uFEFFtest'), '\uFEFF')
+ })
+ })
+})
+
+test('locked blob body', (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`)
+ const reader = res.body.getReader()
+ res.blob().catch(err => {
+ t.equal(err.message, 'Body is unusable')
+ reader.cancel()
+ })
+ })
+})
+
+test('disturbed blob body', (t) => {
+ t.plan(2)
+
+ const server = createServer((req, res) => {
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`)
+ res.blob().then(() => {
+ t.pass(2)
+ })
+ res.blob().catch(err => {
+ t.equal(err.message, 'Body is unusable')
+ })
+ })
+})
+
+test('redirect with body', (t) => {
+ t.plan(3)
+
+ let count = 0
+ const server = createServer(async (req, res) => {
+ let body = ''
+ for await (const chunk of req) {
+ body += chunk
+ }
+ t.equal(body, 'asd')
+ if (count++ === 0) {
+ res.setHeader('location', 'asd')
+ res.statusCode = 302
+ res.end()
+ } else {
+ res.end(String(count))
+ }
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`, {
+ method: 'PUT',
+ body: 'asd'
+ })
+ t.equal(await res.text(), '2')
+ })
+})
+
+test('redirect with stream', (t) => {
+ t.plan(3)
+
+ const location = '/asd'
+ const body = 'hello!'
+ const server = createServer(async (req, res) => {
+ res.writeHead(302, { location })
+ let count = 0
+ const l = setInterval(() => {
+ res.write(body[count++])
+ if (count === body.length) {
+ res.end()
+ clearInterval(l)
+ }
+ }, 50)
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`, {
+ redirect: 'manual'
+ })
+ t.equal(res.status, 302)
+ t.equal(res.headers.get('location'), location)
+ t.equal(await res.text(), body)
+ })
+})
+
+test('fail to extract locked body', (t) => {
+ t.plan(1)
+
+ const stream = new ReadableStream({})
+ const reader = stream.getReader()
+ try {
+ // eslint-disable-next-line
+ new Response(stream)
+ } catch (err) {
+ t.equal(err.name, 'TypeError')
+ }
+ reader.cancel()
+})
+
+test('fail to extract locked body', (t) => {
+ t.plan(1)
+
+ const stream = new ReadableStream({})
+ const reader = stream.getReader()
+ try {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ method: 'PUT',
+ body: stream,
+ keepalive: true
+ })
+ } catch (err) {
+ t.equal(err.message, 'keepalive')
+ }
+ reader.cancel()
+})
+
+test('post FormData with Blob', (t) => {
+ t.plan(1)
+
+ const body = new FormData()
+ body.append('field1', new Blob(['asd1']))
+
+ const server = createServer((req, res) => {
+ req.pipe(res)
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`, {
+ method: 'PUT',
+ body
+ })
+ t.ok(/asd1/.test(await res.text()))
+ })
+})
+
+test('post FormData with File', (t) => {
+ t.plan(2)
+
+ const body = new FormData()
+ body.append('field1', new File(['asd1'], 'filename123'))
+
+ const server = createServer((req, res) => {
+ req.pipe(res)
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const res = await fetch(`http://localhost:${server.address().port}`, {
+ method: 'PUT',
+ body
+ })
+ const result = await res.text()
+ t.ok(/asd1/.test(result))
+ t.ok(/filename123/.test(result))
+ })
+})
+
+test('invalid url', async (t) => {
+ t.plan(1)
+
+ try {
+ await fetch('http://invalid')
+ } catch (e) {
+ t.match(e.cause.message, 'invalid')
+ }
+})
+
+test('custom agent', (t) => {
+ t.plan(2)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const dispatcher = new Client('http://localhost:' + server.address().port, {
+ keepAliveTimeout: 1,
+ keepAliveMaxTimeout: 1
+ })
+ const oldDispatch = dispatcher.dispatch
+ dispatcher.dispatch = function (options, handler) {
+ t.pass('custom dispatcher')
+ return oldDispatch.call(this, options, handler)
+ }
+ t.teardown(server.close.bind(server))
+ const body = await fetch(`http://localhost:${server.address().port}`, {
+ dispatcher
+ })
+ t.strictSame(obj, await body.json())
+ })
+})
+
+test('custom agent node fetch', (t) => {
+ t.plan(2)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ res.end(JSON.stringify(obj))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const dispatcher = new Client('http://localhost:' + server.address().port, {
+ keepAliveTimeout: 1,
+ keepAliveMaxTimeout: 1
+ })
+ const oldDispatch = dispatcher.dispatch
+ dispatcher.dispatch = function (options, handler) {
+ t.pass('custom dispatcher')
+ return oldDispatch.call(this, options, handler)
+ }
+ t.teardown(server.close.bind(server))
+ const body = await nodeFetch.fetch(`http://localhost:${server.address().port}`, {
+ dispatcher
+ })
+ t.strictSame(obj, await body.json())
+ })
+})
+
+test('error on redirect', async (t) => {
+ const server = createServer((req, res) => {
+ res.statusCode = 302
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const errorCause = await fetch(`http://localhost:${server.address().port}`, {
+ redirect: 'error'
+ }).catch((e) => e.cause)
+
+ t.equal(errorCause.message, 'unexpected redirect')
+ })
+})
+
+// https://github.com/nodejs/undici/issues/1527
+test('fetching with Request object - issue #1527', async (t) => {
+ const server = createServer((req, res) => {
+ t.pass()
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const body = JSON.stringify({ foo: 'bar' })
+ const request = new Request(`http://localhost:${server.address().port}`, {
+ method: 'POST',
+ body
+ })
+
+ await t.resolves(fetch(request))
+ t.end()
+})
+
+test('do not decode redirect body', (t) => {
+ t.plan(3)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ if (req.url === '/resource') {
+ t.pass('we redirect')
+ res.statusCode = 301
+ res.setHeader('location', '/resource/')
+ // Some dumb http servers set the content-encoding gzip
+ // even if there is no response
+ res.setHeader('content-encoding', 'gzip')
+ res.end()
+ return
+ }
+ t.pass('actual response')
+ res.setHeader('content-encoding', 'gzip')
+ res.end(gzipSync(JSON.stringify(obj)))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}/resource`)
+ t.strictSame(JSON.stringify(obj), await body.text())
+ })
+})
+
+test('decode non-redirect body with location header', (t) => {
+ t.plan(2)
+
+ const obj = { asd: true }
+ const server = createServer((req, res) => {
+ t.pass('response')
+ res.statusCode = 201
+ res.setHeader('location', '/resource/')
+ res.setHeader('content-encoding', 'gzip')
+ res.end(gzipSync(JSON.stringify(obj)))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}/resource`)
+ t.strictSame(JSON.stringify(obj), await body.text())
+ })
+})
+
+test('Receiving non-Latin1 headers', async (t) => {
+ const ContentDisposition = [
+ 'inline; filename=rock&roll.png',
+ 'inline; filename="rock\'n\'roll.png"',
+ 'inline; filename="image â\x80\x94 copy (1).png"; filename*=UTF-8\'\'image%20%E2%80%94%20copy%20(1).png',
+ 'inline; filename="_å\x9C\x96ç\x89\x87_ð\x9F\x96¼_image_.png"; filename*=UTF-8\'\'_%E5%9C%96%E7%89%87_%F0%9F%96%BC_image_.png',
+ 'inline; filename="100 % loading&perf.png"; filename*=UTF-8\'\'100%20%25%20loading%26perf.png'
+ ]
+
+ const server = createServer((req, res) => {
+ for (let i = 0; i < ContentDisposition.length; i++) {
+ res.setHeader(`Content-Disposition-${i + 1}`, ContentDisposition[i])
+ }
+
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const url = `http://localhost:${server.address().port}`
+ const response = await fetch(url, { method: 'HEAD' })
+ const cdHeaders = [...response.headers]
+ .filter(([k]) => k.startsWith('content-disposition'))
+ .map(([, v]) => v)
+ const lengths = cdHeaders.map(h => h.length)
+
+ t.same(cdHeaders, ContentDisposition)
+ t.same(lengths, [30, 34, 94, 104, 90])
+ t.end()
+})
+
+teardown(() => process.exit())
diff --git a/test/fetch/client-node-max-header-size.js b/test/fetch/client-node-max-header-size.js
new file mode 100644
index 0000000..737bae8
--- /dev/null
+++ b/test/fetch/client-node-max-header-size.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const { execSync } = require('node:child_process')
+const { test, skip } = require('tap')
+const { nodeMajor } = require('../../lib/core/util')
+
+if (nodeMajor === 16) {
+ skip('esbuild uses static blocks with --keep-names which node 16.8 does not have')
+ process.exit()
+}
+
+const command = 'node -e "require(\'./undici-fetch.js\').fetch(\'https://httpbin.org/get\')"'
+
+test("respect Node.js' --max-http-header-size", async (t) => {
+ t.throws(
+ // TODO: Drop the `--unhandled-rejections=throw` once we drop Node.js 14
+ () => execSync(`${command} --max-http-header-size=1 --unhandled-rejections=throw`),
+ /UND_ERR_HEADERS_OVERFLOW/,
+ 'max-http-header-size=1 should throw'
+ )
+
+ t.doesNotThrow(
+ () => execSync(command),
+ /UND_ERR_HEADERS_OVERFLOW/,
+ 'default max-http-header-size should not throw'
+ )
+
+ t.end()
+})
diff --git a/test/fetch/content-length.js b/test/fetch/content-length.js
new file mode 100644
index 0000000..9264091
--- /dev/null
+++ b/test/fetch/content-length.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { Blob } = require('buffer')
+const { fetch, FormData } = require('../..')
+
+// https://github.com/nodejs/undici/issues/1783
+test('Content-Length is set when using a FormData body with fetch', async (t) => {
+ const server = createServer((req, res) => {
+ // TODO: check the length's value once the boundary has a fixed length
+ t.ok('content-length' in req.headers) // request has content-length header
+ t.ok(!Number.isNaN(Number(req.headers['content-length'])))
+ res.end()
+ }).listen(0)
+
+ await once(server, 'listening')
+ t.teardown(server.close.bind(server))
+
+ const fd = new FormData()
+ fd.set('file', new Blob(['hello world 👋'], { type: 'text/plain' }), 'readme.md')
+ fd.set('string', 'some string value')
+
+ await fetch(`http://localhost:${server.address().port}`, {
+ method: 'POST',
+ body: fd
+ })
+})
diff --git a/test/fetch/cookies.js b/test/fetch/cookies.js
new file mode 100644
index 0000000..18b001d
--- /dev/null
+++ b/test/fetch/cookies.js
@@ -0,0 +1,69 @@
+'use strict'
+
+const { once } = require('events')
+const { createServer } = require('http')
+const { test } = require('tap')
+const { fetch, Headers } = require('../..')
+
+test('Can receive set-cookie headers from a server using fetch - issue #1262', async (t) => {
+ const server = createServer((req, res) => {
+ res.setHeader('set-cookie', 'name=value; Domain=example.com')
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const response = await fetch(`http://localhost:${server.address().port}`)
+
+ t.equal(response.headers.get('set-cookie'), 'name=value; Domain=example.com')
+
+ const response2 = await fetch(`http://localhost:${server.address().port}`, {
+ credentials: 'include'
+ })
+
+ t.equal(response2.headers.get('set-cookie'), 'name=value; Domain=example.com')
+
+ t.end()
+})
+
+test('Can send cookies to a server with fetch - issue #1463', async (t) => {
+ const server = createServer((req, res) => {
+ t.equal(req.headers.cookie, 'value')
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const headersInit = [
+ new Headers([['cookie', 'value']]),
+ { cookie: 'value' },
+ [['cookie', 'value']]
+ ]
+
+ for (const headers of headersInit) {
+ await fetch(`http://localhost:${server.address().port}`, { headers })
+ }
+
+ t.end()
+})
+
+test('Cookie header is delimited with a semicolon rather than a comma - issue #1905', async (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ t.equal(req.headers.cookie, 'FOO=lorem-ipsum-dolor-sit-amet; BAR=the-quick-brown-fox')
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await fetch(`http://localhost:${server.address().port}`, {
+ headers: [
+ ['cookie', 'FOO=lorem-ipsum-dolor-sit-amet'],
+ ['cookie', 'BAR=the-quick-brown-fox']
+ ]
+ })
+})
diff --git a/test/fetch/data-uri.js b/test/fetch/data-uri.js
new file mode 100644
index 0000000..6191bfe
--- /dev/null
+++ b/test/fetch/data-uri.js
@@ -0,0 +1,214 @@
+'use strict'
+
+const { test } = require('tap')
+const {
+ URLSerializer,
+ collectASequenceOfCodePoints,
+ stringPercentDecode,
+ parseMIMEType,
+ collectAnHTTPQuotedString
+} = require('../../lib/fetch/dataURL')
+const { fetch } = require('../..')
+
+test('https://url.spec.whatwg.org/#concept-url-serializer', (t) => {
+ t.test('url scheme gets appended', (t) => {
+ const url = new URL('https://www.google.com/')
+ const serialized = URLSerializer(url)
+
+ t.ok(serialized.startsWith(url.protocol))
+ t.end()
+ })
+
+ t.test('non-null url host with authentication', (t) => {
+ const url = new URL('https://username:password@google.com')
+ const serialized = URLSerializer(url)
+
+ t.ok(serialized.includes(`//${url.username}:${url.password}`))
+ t.ok(serialized.endsWith('@google.com/'))
+ t.end()
+ })
+
+ t.test('null url host', (t) => {
+ for (const url of ['web+demo:/.//not-a-host/', 'web+demo:/path/..//not-a-host/']) {
+ t.equal(
+ URLSerializer(new URL(url)),
+ 'web+demo:/.//not-a-host/'
+ )
+ }
+
+ t.end()
+ })
+
+ t.test('url with query works', (t) => {
+ t.equal(
+ URLSerializer(new URL('https://www.google.com/?fetch=undici')),
+ 'https://www.google.com/?fetch=undici'
+ )
+
+ t.end()
+ })
+
+ t.test('exclude fragment', (t) => {
+ t.equal(
+ URLSerializer(new URL('https://www.google.com/#frag')),
+ 'https://www.google.com/#frag'
+ )
+
+ t.equal(
+ URLSerializer(new URL('https://www.google.com/#frag'), true),
+ 'https://www.google.com/'
+ )
+
+ t.end()
+ })
+
+ t.end()
+})
+
+test('https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points', (t) => {
+ const input = 'text/plain;base64,'
+ const position = { position: 0 }
+ const result = collectASequenceOfCodePoints(
+ (char) => char !== ';',
+ input,
+ position
+ )
+
+ t.strictSame(result, 'text/plain')
+ t.strictSame(position.position, input.indexOf(';'))
+ t.end()
+})
+
+test('https://url.spec.whatwg.org/#string-percent-decode', (t) => {
+ t.test('encodes %{2} in range properly', (t) => {
+ const input = '%FF'
+ const percentDecoded = stringPercentDecode(input)
+
+ t.same(percentDecoded, new Uint8Array([255]))
+ t.end()
+ })
+
+ t.test('encodes %{2} not in range properly', (t) => {
+ const input = 'Hello %XD World'
+ const percentDecoded = stringPercentDecode(input)
+ const expected = [...input].map(c => c.charCodeAt(0))
+
+ t.same(percentDecoded, expected)
+ t.end()
+ })
+
+ t.test('normal string works', (t) => {
+ const input = 'Hello world'
+ const percentDecoded = stringPercentDecode(input)
+ const expected = [...input].map(c => c.charCodeAt(0))
+
+ t.same(percentDecoded, Uint8Array.from(expected))
+ t.end()
+ })
+
+ t.end()
+})
+
+test('https://mimesniff.spec.whatwg.org/#parse-a-mime-type', (t) => {
+ t.same(parseMIMEType('text/plain'), {
+ type: 'text',
+ subtype: 'plain',
+ parameters: new Map(),
+ essence: 'text/plain'
+ })
+
+ t.same(parseMIMEType('text/html;charset="shift_jis"iso-2022-jp'), {
+ type: 'text',
+ subtype: 'html',
+ parameters: new Map([['charset', 'shift_jis']]),
+ essence: 'text/html'
+ })
+
+ t.same(parseMIMEType('application/javascript'), {
+ type: 'application',
+ subtype: 'javascript',
+ parameters: new Map(),
+ essence: 'application/javascript'
+ })
+
+ t.end()
+})
+
+test('https://fetch.spec.whatwg.org/#collect-an-http-quoted-string', (t) => {
+ // https://fetch.spec.whatwg.org/#example-http-quoted-string
+ t.test('first', (t) => {
+ const position = { position: 0 }
+
+ t.strictSame(collectAnHTTPQuotedString('"\\', {
+ position: 0
+ }), '"\\')
+ t.strictSame(collectAnHTTPQuotedString('"\\', position, true), '\\')
+ t.strictSame(position.position, 2)
+ t.end()
+ })
+
+ t.test('second', (t) => {
+ const position = { position: 0 }
+ const input = '"Hello" World'
+
+ t.strictSame(collectAnHTTPQuotedString(input, {
+ position: 0
+ }), '"Hello"')
+ t.strictSame(collectAnHTTPQuotedString(input, position, true), 'Hello')
+ t.strictSame(position.position, 7)
+ t.end()
+ })
+
+ t.end()
+})
+
+// https://github.com/nodejs/undici/issues/1574
+test('too long base64 url', async (t) => {
+ const inputStr = 'a'.repeat(1 << 20)
+ const base64 = Buffer.from(inputStr).toString('base64')
+ const dataURIPrefix = 'data:application/octet-stream;base64,'
+ const dataURL = dataURIPrefix + base64
+ try {
+ const res = await fetch(dataURL)
+ const buf = await res.arrayBuffer()
+ const outputStr = Buffer.from(buf).toString('ascii')
+ t.same(outputStr, inputStr)
+ } catch (e) {
+ t.fail(`failed to fetch ${dataURL}`)
+ }
+})
+
+test('https://domain.com/#', (t) => {
+ t.plan(1)
+ const domain = 'https://domain.com/#a'
+ const serialized = URLSerializer(new URL(domain))
+ t.equal(serialized, domain)
+})
+
+test('https://domain.com/?', (t) => {
+ t.plan(1)
+ const domain = 'https://domain.com/?a=b'
+ const serialized = URLSerializer(new URL(domain))
+ t.equal(serialized, domain)
+})
+
+// https://github.com/nodejs/undici/issues/2474
+test('hash url', (t) => {
+ t.plan(1)
+ const domain = 'https://domain.com/#a#b'
+ const url = new URL(domain)
+ const serialized = URLSerializer(url, true)
+ t.equal(serialized, url.href.substring(0, url.href.length - url.hash.length))
+})
+
+// https://github.com/nodejs/undici/issues/2474
+test('data url that includes the hash', async (t) => {
+ t.plan(1)
+ const dataURL = 'data:,node#js#'
+ try {
+ const res = await fetch(dataURL)
+ t.equal(await res.text(), 'node')
+ } catch (error) {
+ t.fail(`failed to fetch ${dataURL}`)
+ }
+})
diff --git a/test/fetch/encoding.js b/test/fetch/encoding.js
new file mode 100644
index 0000000..75d8fc3
--- /dev/null
+++ b/test/fetch/encoding.js
@@ -0,0 +1,58 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { fetch } = require('../..')
+const { createBrotliCompress, createGzip, createDeflate } = require('zlib')
+
+test('content-encoding header is case-iNsENsITIve', async (t) => {
+ const contentCodings = 'GZiP, bR'
+ const text = 'Hello, World!'
+
+ const server = createServer((req, res) => {
+ const gzip = createGzip()
+ const brotli = createBrotliCompress()
+
+ res.setHeader('Content-Encoding', contentCodings)
+ res.setHeader('Content-Type', 'text/plain')
+
+ brotli.pipe(gzip).pipe(res)
+
+ brotli.write(text)
+ brotli.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const response = await fetch(`http://localhost:${server.address().port}`)
+
+ t.equal(await response.text(), text)
+ t.equal(response.headers.get('content-encoding'), contentCodings)
+})
+
+test('response decompression according to content-encoding should be handled in a correct order', async (t) => {
+ const contentCodings = 'deflate, gzip'
+ const text = 'Hello, World!'
+
+ const server = createServer((req, res) => {
+ const gzip = createGzip()
+ const deflate = createDeflate()
+
+ res.setHeader('Content-Encoding', contentCodings)
+ res.setHeader('Content-Type', 'text/plain')
+
+ gzip.pipe(deflate).pipe(res)
+
+ gzip.write(text)
+ gzip.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const response = await fetch(`http://localhost:${server.address().port}`)
+
+ t.equal(await response.text(), text)
+})
diff --git a/test/fetch/fetch-leak.js b/test/fetch/fetch-leak.js
new file mode 100644
index 0000000..b8e6b16
--- /dev/null
+++ b/test/fetch/fetch-leak.js
@@ -0,0 +1,44 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { createServer } = require('http')
+
+test('do not leak', (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ res.end()
+ })
+ t.teardown(server.close.bind(server))
+
+ let url
+ let done = false
+ server.listen(0, function attack () {
+ if (done) {
+ return
+ }
+ url ??= new URL(`http://127.0.0.1:${server.address().port}`)
+ const controller = new AbortController()
+ fetch(url, { signal: controller.signal })
+ .then(res => res.arrayBuffer())
+ .catch(() => {})
+ .then(attack)
+ })
+
+ let prev = Infinity
+ let count = 0
+ const interval = setInterval(() => {
+ done = true
+ global.gc()
+ const next = process.memoryUsage().heapUsed
+ if (next <= prev) {
+ t.pass()
+ } else if (count++ > 20) {
+ t.fail()
+ } else {
+ prev = next
+ }
+ }, 1e3)
+ t.teardown(() => clearInterval(interval))
+})
diff --git a/test/fetch/fetch-timeouts.js b/test/fetch/fetch-timeouts.js
new file mode 100644
index 0000000..b659aaa
--- /dev/null
+++ b/test/fetch/fetch-timeouts.js
@@ -0,0 +1,56 @@
+'use strict'
+
+const { test } = require('tap')
+
+const { fetch, Agent } = require('../..')
+const timers = require('../../lib/timers')
+const { createServer } = require('http')
+const FakeTimers = require('@sinonjs/fake-timers')
+
+test('Fetch very long request, timeout overridden so no error', (t) => {
+ const minutes = 6
+ const msToDelay = 1000 * 60 * minutes
+
+ t.setTimeout(undefined)
+ t.plan(1)
+
+ const clock = FakeTimers.install()
+ t.teardown(clock.uninstall.bind(clock))
+
+ const orgTimers = { ...timers }
+ Object.assign(timers, { setTimeout, clearTimeout })
+ t.teardown(() => {
+ Object.assign(timers, orgTimers)
+ })
+
+ const server = createServer((req, res) => {
+ setTimeout(() => {
+ res.end('hello')
+ }, msToDelay)
+ clock.tick(msToDelay + 1)
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`, {
+ path: '/',
+ method: 'GET',
+ dispatcher: new Agent({
+ headersTimeout: 0,
+ connectTimeout: 0,
+ bodyTimeout: 0
+ })
+ })
+ .then((response) => response.text())
+ .then((response) => {
+ t.equal('hello', response)
+ t.end()
+ })
+ .catch((err) => {
+ // This should not happen, a timeout error should not occur
+ t.error(err)
+ })
+
+ clock.tick(msToDelay - 1)
+ })
+})
diff --git a/test/fetch/file.js b/test/fetch/file.js
new file mode 100644
index 0000000..5901541
--- /dev/null
+++ b/test/fetch/file.js
@@ -0,0 +1,190 @@
+'use strict'
+
+const { Blob } = require('buffer')
+const { test } = require('tap')
+const { File, FileLike } = require('../../lib/fetch/file')
+
+test('args validation', (t) => {
+ t.plan(14)
+
+ t.throws(() => {
+ File.prototype.name.toString()
+ }, TypeError)
+ t.throws(() => {
+ File.prototype.lastModified.toString()
+ }, TypeError)
+ t.doesNotThrow(() => {
+ File.prototype[Symbol.toStringTag].charAt(0)
+ }, TypeError)
+
+ t.throws(() => {
+ FileLike.prototype.stream.call(null)
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.arrayBuffer.call(null)
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.slice.call(null)
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.text.call(null)
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.size.toString()
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.type.toString()
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.name.toString()
+ }, TypeError)
+ t.throws(() => {
+ FileLike.prototype.lastModified.toString()
+ }, TypeError)
+ t.doesNotThrow(() => {
+ FileLike.prototype[Symbol.toStringTag].charAt(0)
+ }, TypeError)
+
+ t.equal(File.prototype[Symbol.toStringTag], 'File')
+ t.equal(FileLike.prototype[Symbol.toStringTag], 'File')
+})
+
+test('return value of File.lastModified', (t) => {
+ t.plan(2)
+
+ const f = new File(['asd1'], 'filename123')
+ const lastModified = f.lastModified
+ t.ok(typeof lastModified === typeof Date.now())
+ t.ok(lastModified >= 0 && lastModified <= Date.now())
+})
+
+test('Symbol.toStringTag', (t) => {
+ t.plan(2)
+ t.equal(new File([], '')[Symbol.toStringTag], 'File')
+ t.equal(new FileLike()[Symbol.toStringTag], 'File')
+})
+
+test('arguments', (t) => {
+ t.throws(() => {
+ new File() // eslint-disable-line no-new
+ }, TypeError)
+
+ t.throws(() => {
+ new File([]) // eslint-disable-line no-new
+ }, TypeError)
+
+ t.end()
+})
+
+test('lastModified', (t) => {
+ const file = new File([], '')
+ const lastModified = Date.now() - 69_000
+
+ t.notOk(file === 0)
+
+ const file1 = new File([], '', { lastModified })
+ t.equal(file1.lastModified, lastModified)
+
+ t.equal(new File([], '', { lastModified: 0 }).lastModified, 0)
+
+ t.equal(
+ new File([], '', {
+ lastModified: true
+ }).lastModified,
+ 1
+ )
+
+ t.end()
+})
+
+test('File.prototype.text', async (t) => {
+ t.test('With Blob', async (t) => {
+ const blob1 = new Blob(['hello'])
+ const blob2 = new Blob([' '])
+ const blob3 = new Blob(['world'])
+
+ const file = new File([blob1, blob2, blob3], 'hello_world.txt')
+
+ t.equal(await file.text(), 'hello world')
+ t.end()
+ })
+
+ /* eslint-disable camelcase */
+ t.test('With TypedArray', async (t) => {
+ const uint8_1 = new Uint8Array(Buffer.from('hello'))
+ const uint8_2 = new Uint8Array(Buffer.from(' '))
+ const uint8_3 = new Uint8Array(Buffer.from('world'))
+
+ const file = new File([uint8_1, uint8_2, uint8_3], 'hello_world.txt')
+
+ t.equal(await file.text(), 'hello world')
+ t.end()
+ })
+
+ t.test('With TypedArray range', async (t) => {
+ const uint8_1 = new Uint8Array(Buffer.from('hello world'))
+ const uint8_2 = new Uint8Array(uint8_1.buffer, 1, 4)
+
+ const file = new File([uint8_2], 'hello_world.txt')
+
+ t.equal(await file.text(), 'ello')
+ t.end()
+ })
+ /* eslint-enable camelcase */
+
+ t.test('With ArrayBuffer', async (t) => {
+ const uint8 = new Uint8Array([65, 66, 67])
+ const ab = uint8.buffer
+
+ const file = new File([ab], 'file.txt')
+
+ t.equal(await file.text(), 'ABC')
+ t.end()
+ })
+
+ t.test('With string', async (t) => {
+ const string = 'hello world'
+ const file = new File([string], 'hello_world.txt')
+
+ t.equal(await file.text(), 'hello world')
+ t.end()
+ })
+
+ t.test('With Buffer', async (t) => {
+ const buffer = Buffer.from('hello world')
+
+ const file = new File([buffer], 'hello_world.txt')
+
+ t.equal(await file.text(), 'hello world')
+ t.end()
+ })
+
+ t.test('Mixed', async (t) => {
+ const blob = new Blob(['Hello, '])
+ const uint8 = new Uint8Array(Buffer.from('world! This'))
+ const string = ' is a test! Hope it passes!'
+
+ const file = new File([blob, uint8, string], 'mixed-messages.txt')
+
+ t.equal(
+ await file.text(),
+ 'Hello, world! This is a test! Hope it passes!'
+ )
+ t.end()
+ })
+
+ t.end()
+})
+
+test('endings=native', async (t) => {
+ const file = new File(['Hello\nWorld'], 'text.txt', { endings: 'native' })
+ const text = await file.text()
+
+ if (process.platform === 'win32') {
+ t.equal(text, 'Hello\r\nWorld', 'on windows, LF is replace with CRLF')
+ } else {
+ t.equal(text, 'Hello\nWorld', `on ${process.platform} LF stays LF`)
+ }
+
+ t.end()
+})
diff --git a/test/fetch/formdata.js b/test/fetch/formdata.js
new file mode 100644
index 0000000..fed95bf
--- /dev/null
+++ b/test/fetch/formdata.js
@@ -0,0 +1,401 @@
+'use strict'
+
+const { test } = require('tap')
+const { FormData, File, Response } = require('../../')
+const { Blob: ThirdPartyBlob } = require('formdata-node')
+const { Blob } = require('buffer')
+const { isFormDataLike } = require('../../lib/core/util')
+const ThirdPartyFormDataInvalid = require('form-data')
+
+test('arg validation', (t) => {
+ const form = new FormData()
+
+ // constructor
+ t.throws(() => {
+ // eslint-disable-next-line
+ new FormData('asd')
+ }, TypeError)
+
+ // append
+ t.throws(() => {
+ FormData.prototype.append.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.append()
+ }, TypeError)
+ t.throws(() => {
+ form.append('k', 'not usv', '')
+ }, TypeError)
+
+ // delete
+ t.throws(() => {
+ FormData.prototype.delete.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.delete()
+ }, TypeError)
+
+ // get
+ t.throws(() => {
+ FormData.prototype.get.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.get()
+ }, TypeError)
+
+ // getAll
+ t.throws(() => {
+ FormData.prototype.getAll.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.getAll()
+ }, TypeError)
+
+ // has
+ t.throws(() => {
+ FormData.prototype.has.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.has()
+ }, TypeError)
+
+ // set
+ t.throws(() => {
+ FormData.prototype.set.call(null)
+ }, TypeError)
+ t.throws(() => {
+ form.set('k')
+ }, TypeError)
+ t.throws(() => {
+ form.set('k', 'not usv', '')
+ }, TypeError)
+
+ // iterator
+ t.throws(() => {
+ Reflect.apply(FormData.prototype[Symbol.iterator], null)
+ }, TypeError)
+
+ // toStringTag
+ t.doesNotThrow(() => {
+ FormData.prototype[Symbol.toStringTag].charAt(0)
+ })
+
+ t.end()
+})
+
+test('append file', (t) => {
+ const form = new FormData()
+ form.set('asd', new File([], 'asd1', { type: 'text/plain' }), 'asd2')
+ form.append('asd2', new File([], 'asd1'), 'asd2')
+
+ t.equal(form.has('asd'), true)
+ t.equal(form.has('asd2'), true)
+ t.equal(form.get('asd').name, 'asd2')
+ t.equal(form.get('asd2').name, 'asd2')
+ t.equal(form.get('asd').type, 'text/plain')
+ form.delete('asd')
+ t.equal(form.get('asd'), null)
+ t.equal(form.has('asd2'), true)
+ t.equal(form.has('asd'), false)
+
+ t.end()
+})
+
+test('append blob', async (t) => {
+ const form = new FormData()
+ form.set('asd', new Blob(['asd1'], { type: 'text/plain' }))
+
+ t.equal(form.has('asd'), true)
+ t.equal(form.get('asd').type, 'text/plain')
+ t.equal(await form.get('asd').text(), 'asd1')
+ form.delete('asd')
+ t.equal(form.get('asd'), null)
+
+ t.end()
+})
+
+test('append third-party blob', async (t) => {
+ const form = new FormData()
+ form.set('asd', new ThirdPartyBlob(['asd1'], { type: 'text/plain' }))
+
+ t.equal(form.has('asd'), true)
+ t.equal(form.get('asd').type, 'text/plain')
+ t.equal(await form.get('asd').text(), 'asd1')
+ form.delete('asd')
+ t.equal(form.get('asd'), null)
+
+ t.end()
+})
+
+test('append string', (t) => {
+ const form = new FormData()
+ form.set('k1', 'v1')
+ form.set('k2', 'v2')
+ t.same([...form], [['k1', 'v1'], ['k2', 'v2']])
+ t.equal(form.has('k1'), true)
+ t.equal(form.get('k1'), 'v1')
+ form.append('k1', 'v1+')
+ t.same(form.getAll('k1'), ['v1', 'v1+'])
+ form.set('k2', 'v1++')
+ t.equal(form.get('k2'), 'v1++')
+ form.delete('asd')
+ t.equal(form.get('asd'), null)
+ t.end()
+})
+
+test('formData.entries', (t) => {
+ t.plan(2)
+ const form = new FormData()
+
+ t.test('with 0 entries', (t) => {
+ t.plan(1)
+
+ const entries = [...form.entries()]
+ t.same(entries, [])
+ })
+
+ t.test('with 1+ entries', (t) => {
+ t.plan(2)
+
+ form.set('k1', 'v1')
+ form.set('k2', 'v2')
+
+ const entries = [...form.entries()]
+ const entries2 = [...form.entries()]
+ t.same(entries, [['k1', 'v1'], ['k2', 'v2']])
+ t.same(entries, entries2)
+ })
+})
+
+test('formData.keys', (t) => {
+ t.plan(2)
+ const form = new FormData()
+
+ t.test('with 0 keys', (t) => {
+ t.plan(1)
+
+ const keys = [...form.entries()]
+ t.same(keys, [])
+ })
+
+ t.test('with 1+ keys', (t) => {
+ t.plan(2)
+
+ form.set('k1', 'v1')
+ form.set('k2', 'v2')
+
+ const keys = [...form.keys()]
+ const keys2 = [...form.keys()]
+ t.same(keys, ['k1', 'k2'])
+ t.same(keys, keys2)
+ })
+})
+
+test('formData.values', (t) => {
+ t.plan(2)
+ const form = new FormData()
+
+ t.test('with 0 values', (t) => {
+ t.plan(1)
+
+ const values = [...form.values()]
+ t.same(values, [])
+ })
+
+ t.test('with 1+ values', (t) => {
+ t.plan(2)
+
+ form.set('k1', 'v1')
+ form.set('k2', 'v2')
+
+ const values = [...form.values()]
+ const values2 = [...form.values()]
+ t.same(values, ['v1', 'v2'])
+ t.same(values, values2)
+ })
+})
+
+test('formData forEach', (t) => {
+ t.test('invalid arguments', (t) => {
+ t.throws(() => {
+ FormData.prototype.forEach.call({})
+ }, TypeError('Illegal invocation'))
+
+ t.throws(() => {
+ const fd = new FormData()
+
+ fd.forEach({})
+ }, TypeError)
+
+ t.end()
+ })
+
+ t.test('with a callback', (t) => {
+ const fd = new FormData()
+
+ fd.set('a', 'b')
+ fd.set('c', 'd')
+
+ let i = 0
+ fd.forEach((value, key, self) => {
+ if (i++ === 0) {
+ t.equal(value, 'b')
+ t.equal(key, 'a')
+ } else {
+ t.equal(value, 'd')
+ t.equal(key, 'c')
+ }
+
+ t.equal(fd, self)
+ })
+
+ t.end()
+ })
+
+ t.test('with a thisArg', (t) => {
+ const fd = new FormData()
+ fd.set('b', 'a')
+
+ fd.forEach(function (value, key, self) {
+ t.equal(this, globalThis)
+ t.equal(fd, self)
+ t.equal(key, 'b')
+ t.equal(value, 'a')
+ })
+
+ const thisArg = Symbol('thisArg')
+ fd.forEach(function () {
+ t.equal(this, thisArg)
+ }, thisArg)
+
+ t.end()
+ })
+
+ t.end()
+})
+
+test('formData toStringTag', (t) => {
+ const form = new FormData()
+ t.equal(form[Symbol.toStringTag], 'FormData')
+ t.equal(FormData.prototype[Symbol.toStringTag], 'FormData')
+ t.end()
+})
+
+test('formData.constructor.name', (t) => {
+ const form = new FormData()
+ t.equal(form.constructor.name, 'FormData')
+ t.end()
+})
+
+test('formData should be an instance of FormData', (t) => {
+ t.plan(3)
+
+ t.test('Invalid class FormData', (t) => {
+ class FormData {
+ constructor () {
+ this.data = []
+ }
+
+ append (key, value) {
+ this.data.push([key, value])
+ }
+
+ get (key) {
+ return this.data.find(([k]) => k === key)
+ }
+ }
+
+ const form = new FormData()
+ t.equal(isFormDataLike(form), false)
+ t.end()
+ })
+
+ t.test('Invalid function FormData', (t) => {
+ function FormData () {
+ const data = []
+ return {
+ append (key, value) {
+ data.push([key, value])
+ },
+ get (key) {
+ return data.find(([k]) => k === key)
+ }
+ }
+ }
+
+ const form = new FormData()
+ t.equal(isFormDataLike(form), false)
+ t.end()
+ })
+
+ test('Invalid third-party FormData', (t) => {
+ const form = new ThirdPartyFormDataInvalid()
+ t.equal(isFormDataLike(form), false)
+ t.end()
+ })
+
+ t.test('Valid FormData', (t) => {
+ const form = new FormData()
+ t.equal(isFormDataLike(form), true)
+ t.end()
+ })
+})
+
+test('FormData should be compatible with third-party libraries', (t) => {
+ t.plan(1)
+
+ class FormData {
+ constructor () {
+ this.data = []
+ }
+
+ get [Symbol.toStringTag] () {
+ return 'FormData'
+ }
+
+ append () {}
+ delete () {}
+ get () {}
+ getAll () {}
+ has () {}
+ set () {}
+ entries () {}
+ keys () {}
+ values () {}
+ forEach () {}
+ }
+
+ const form = new FormData()
+ t.equal(isFormDataLike(form), true)
+})
+
+test('arguments', (t) => {
+ t.equal(FormData.constructor.length, 1)
+ t.equal(FormData.prototype.append.length, 2)
+ t.equal(FormData.prototype.delete.length, 1)
+ t.equal(FormData.prototype.get.length, 1)
+ t.equal(FormData.prototype.getAll.length, 1)
+ t.equal(FormData.prototype.has.length, 1)
+ t.equal(FormData.prototype.set.length, 2)
+
+ t.end()
+})
+
+// https://github.com/nodejs/undici/pull/1814
+test('FormData returned from bodyMixin.formData is not a clone', async (t) => {
+ const fd = new FormData()
+ fd.set('foo', 'bar')
+
+ const res = new Response(fd)
+ fd.set('foo', 'foo')
+
+ const fd2 = await res.formData()
+
+ t.equal(fd2.get('foo'), 'bar')
+ t.equal(fd.get('foo'), 'foo')
+
+ fd2.set('foo', 'baz')
+
+ t.equal(fd2.get('foo'), 'baz')
+ t.equal(fd.get('foo'), 'foo')
+})
diff --git a/test/fetch/general.js b/test/fetch/general.js
new file mode 100644
index 0000000..0469875
--- /dev/null
+++ b/test/fetch/general.js
@@ -0,0 +1,30 @@
+'use strict'
+
+const { test } = require('tap')
+const {
+ File,
+ FormData,
+ Headers,
+ Request,
+ Response
+} = require('../../index')
+
+test('Symbol.toStringTag descriptor', (t) => {
+ for (const cls of [
+ File,
+ FormData,
+ Headers,
+ Request,
+ Response
+ ]) {
+ const desc = Object.getOwnPropertyDescriptor(cls.prototype, Symbol.toStringTag)
+ t.same(desc, {
+ value: cls.name,
+ writable: false,
+ enumerable: false,
+ configurable: true
+ })
+ }
+
+ t.end()
+})
diff --git a/test/fetch/headers.js b/test/fetch/headers.js
new file mode 100644
index 0000000..4846110
--- /dev/null
+++ b/test/fetch/headers.js
@@ -0,0 +1,743 @@
+'use strict'
+
+const tap = require('tap')
+const { Headers, fill } = require('../../lib/fetch/headers')
+const { kGuard } = require('../../lib/fetch/symbols')
+const { once } = require('events')
+const { fetch } = require('../..')
+const { createServer } = require('http')
+
+tap.test('Headers initialization', t => {
+ t.plan(8)
+
+ t.test('allows undefined', t => {
+ t.plan(1)
+
+ t.doesNotThrow(() => new Headers())
+ })
+
+ t.test('with array of header entries', t => {
+ t.plan(3)
+
+ t.test('fails on invalid array-based init', t => {
+ t.plan(3)
+ t.throws(
+ () => new Headers([['undici', 'fetch'], ['fetch']]),
+ TypeError('Headers constructor: expected name/value pair to be length 2, found 1.')
+ )
+ t.throws(() => new Headers(['undici', 'fetch', 'fetch']), TypeError)
+ t.throws(
+ () => new Headers([0, 1, 2]),
+ TypeError('Sequence: Value of type Number is not an Object.')
+ )
+ })
+
+ t.test('allows even length init', t => {
+ t.plan(1)
+ const init = [['undici', 'fetch'], ['fetch', 'undici']]
+ t.doesNotThrow(() => new Headers(init))
+ })
+
+ t.test('fails for event flattened init', t => {
+ t.plan(1)
+ const init = ['undici', 'fetch', 'fetch', 'undici']
+ t.throws(
+ () => new Headers(init),
+ TypeError('Sequence: Value of type String is not an Object.')
+ )
+ })
+ })
+
+ t.test('with object of header entries', t => {
+ t.plan(1)
+ const init = {
+ undici: 'fetch',
+ fetch: 'undici'
+ }
+ t.doesNotThrow(() => new Headers(init))
+ })
+
+ t.test('fails silently if a boxed primitive object is passed', t => {
+ t.plan(3)
+ /* eslint-disable no-new-wrappers */
+ t.doesNotThrow(() => new Headers(new Number()))
+ t.doesNotThrow(() => new Headers(new Boolean()))
+ t.doesNotThrow(() => new Headers(new String()))
+ /* eslint-enable no-new-wrappers */
+ })
+
+ t.test('fails if primitive is passed', t => {
+ t.plan(2)
+ const expectedTypeError = TypeError
+ t.throws(() => new Headers(1), expectedTypeError)
+ t.throws(() => new Headers('1'), expectedTypeError)
+ })
+
+ t.test('allows some weird stuff (because of webidl)', t => {
+ t.doesNotThrow(() => {
+ new Headers(function () {}) // eslint-disable-line no-new
+ })
+
+ t.doesNotThrow(() => {
+ new Headers(Function) // eslint-disable-line no-new
+ })
+
+ t.end()
+ })
+
+ t.test('allows a myriad of header values to be passed', t => {
+ t.plan(4)
+
+ // Headers constructor uses Headers.append
+
+ t.doesNotThrow(() => new Headers([
+ ['a', ['b', 'c']],
+ ['d', ['e', 'f']]
+ ]), 'allows any array values')
+ t.doesNotThrow(() => new Headers([
+ ['key', null]
+ ]), 'allows null values')
+ t.throws(() => new Headers([
+ ['key']
+ ]), 'throws when 2 arguments are not passed')
+ t.throws(() => new Headers([
+ ['key', 'value', 'value2']
+ ]), 'throws when too many arguments are passed')
+ })
+
+ t.test('accepts headers as objects with array values', t => {
+ t.plan(1)
+ const headers = new Headers({
+ c: '5',
+ b: ['3', '4'],
+ a: ['1', '2']
+ })
+
+ t.same([...headers.entries()], [
+ ['a', '1,2'],
+ ['b', '3,4'],
+ ['c', '5']
+ ])
+ })
+})
+
+tap.test('Headers append', t => {
+ t.plan(3)
+
+ t.test('adds valid header entry to instance', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ const name = 'undici'
+ const value = 'fetch'
+ t.doesNotThrow(() => headers.append(name, value))
+ t.equal(headers.get(name), value)
+ })
+
+ t.test('adds valid header to existing entry', t => {
+ t.plan(4)
+ const headers = new Headers()
+
+ const name = 'undici'
+ const value1 = 'fetch1'
+ const value2 = 'fetch2'
+ const value3 = 'fetch3'
+ headers.append(name, value1)
+ t.equal(headers.get(name), value1)
+ t.doesNotThrow(() => headers.append(name, value2))
+ t.doesNotThrow(() => headers.append(name, value3))
+ t.equal(headers.get(name), [value1, value2, value3].join(', '))
+ })
+
+ t.test('throws on invalid entry', t => {
+ t.plan(3)
+ const headers = new Headers()
+
+ t.throws(() => headers.append(), 'throws on missing name and value')
+ t.throws(() => headers.append('undici'), 'throws on missing value')
+ t.throws(() => headers.append('invalid @ header ? name', 'valid value'), 'throws on invalid name')
+ })
+})
+
+tap.test('Headers delete', t => {
+ t.plan(4)
+
+ t.test('deletes valid header entry from instance', t => {
+ t.plan(3)
+ const headers = new Headers()
+
+ const name = 'undici'
+ const value = 'fetch'
+ headers.append(name, value)
+ t.equal(headers.get(name), value)
+ t.doesNotThrow(() => headers.delete(name))
+ t.equal(headers.get(name), null)
+ })
+
+ t.test('does not mutate internal list when no match is found', t => {
+ t.plan(3)
+
+ const headers = new Headers()
+ const name = 'undici'
+ const value = 'fetch'
+ headers.append(name, value)
+ t.equal(headers.get(name), value)
+ t.doesNotThrow(() => headers.delete('not-undici'))
+ t.equal(headers.get(name), value)
+ })
+
+ t.test('throws on invalid entry', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ t.throws(() => headers.delete(), 'throws on missing namee')
+ t.throws(() => headers.delete('invalid @ header ? name'), 'throws on invalid name')
+ })
+
+ // https://github.com/nodejs/undici/issues/2429
+ t.test('`Headers#delete` returns undefined', t => {
+ t.plan(2)
+ const headers = new Headers({ test: 'test' })
+
+ t.same(headers.delete('test'), undefined)
+ t.same(headers.delete('test2'), undefined)
+ })
+})
+
+tap.test('Headers get', t => {
+ t.plan(3)
+
+ t.test('returns null if not found in instance', t => {
+ t.plan(1)
+ const headers = new Headers()
+ headers.append('undici', 'fetch')
+
+ t.equal(headers.get('not-undici'), null)
+ })
+
+ t.test('returns header values from valid header name', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ const name = 'undici'; const value1 = 'fetch1'; const value2 = 'fetch2'
+ headers.append(name, value1)
+ t.equal(headers.get(name), value1)
+ headers.append(name, value2)
+ t.equal(headers.get(name), [value1, value2].join(', '))
+ })
+
+ t.test('throws on invalid entry', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ t.throws(() => headers.get(), 'throws on missing name')
+ t.throws(() => headers.get('invalid @ header ? name'), 'throws on invalid name')
+ })
+})
+
+tap.test('Headers has', t => {
+ t.plan(2)
+
+ t.test('returns boolean existence for a header name', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ const name = 'undici'
+ headers.append('not-undici', 'fetch')
+ t.equal(headers.has(name), false)
+ headers.append(name, 'fetch')
+ t.equal(headers.has(name), true)
+ })
+
+ t.test('throws on invalid entry', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ t.throws(() => headers.has(), 'throws on missing name')
+ t.throws(() => headers.has('invalid @ header ? name'), 'throws on invalid name')
+ })
+})
+
+tap.test('Headers set', t => {
+ t.plan(5)
+
+ t.test('sets valid header entry to instance', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ const name = 'undici'
+ const value = 'fetch'
+ headers.append('not-undici', 'fetch')
+ t.doesNotThrow(() => headers.set(name, value))
+ t.equal(headers.get(name), value)
+ })
+
+ t.test('overwrites existing entry', t => {
+ t.plan(4)
+ const headers = new Headers()
+
+ const name = 'undici'
+ const value1 = 'fetch1'
+ const value2 = 'fetch2'
+ t.doesNotThrow(() => headers.set(name, value1))
+ t.equal(headers.get(name), value1)
+ t.doesNotThrow(() => headers.set(name, value2))
+ t.equal(headers.get(name), value2)
+ })
+
+ t.test('allows setting a myriad of values', t => {
+ t.plan(4)
+ const headers = new Headers()
+
+ t.doesNotThrow(() => headers.set('a', ['b', 'c']), 'sets array values properly')
+ t.doesNotThrow(() => headers.set('b', null), 'allows setting null values')
+ t.throws(() => headers.set('c'), 'throws when 2 arguments are not passed')
+ t.doesNotThrow(() => headers.set('c', 'd', 'e'), 'ignores extra arguments')
+ })
+
+ t.test('throws on invalid entry', t => {
+ t.plan(3)
+ const headers = new Headers()
+
+ t.throws(() => headers.set(), 'throws on missing name and value')
+ t.throws(() => headers.set('undici'), 'throws on missing value')
+ t.throws(() => headers.set('invalid @ header ? name', 'valid value'), 'throws on invalid name')
+ })
+
+ // https://github.com/nodejs/undici/issues/2431
+ t.test('`Headers#set` returns undefined', t => {
+ t.plan(2)
+ const headers = new Headers()
+
+ t.same(headers.set('a', 'b'), undefined)
+
+ t.notOk(headers.set('c', 'd') instanceof Map)
+ })
+})
+
+tap.test('Headers forEach', t => {
+ const headers = new Headers([['a', 'b'], ['c', 'd']])
+
+ t.test('standard', t => {
+ t.equal(typeof headers.forEach, 'function')
+
+ headers.forEach((value, key, headerInstance) => {
+ t.ok(value === 'b' || value === 'd')
+ t.ok(key === 'a' || key === 'c')
+ t.equal(headers, headerInstance)
+ })
+
+ t.end()
+ })
+
+ t.test('when no thisArg is set, it is globalThis', (t) => {
+ headers.forEach(function () {
+ t.equal(this, globalThis)
+ })
+
+ t.end()
+ })
+
+ t.test('with thisArg', t => {
+ const thisArg = { a: Math.random() }
+ headers.forEach(function () {
+ t.equal(this, thisArg)
+ }, thisArg)
+
+ t.end()
+ })
+
+ t.end()
+})
+
+tap.test('Headers as Iterable', t => {
+ t.plan(7)
+
+ t.test('should freeze values while iterating', t => {
+ t.plan(1)
+ const init = [
+ ['foo', '123'],
+ ['bar', '456']
+ ]
+ const expected = [
+ ['foo', '123'],
+ ['x-x-bar', '456']
+ ]
+ const headers = new Headers(init)
+ for (const [key, val] of headers) {
+ headers.delete(key)
+ headers.set(`x-${key}`, val)
+ }
+ t.strictSame([...headers], expected)
+ })
+
+ t.test('returns combined and sorted entries using .forEach()', t => {
+ t.plan(8)
+ const init = [
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['abc', '4'],
+ ['b', '5']
+ ]
+ const expected = [
+ ['a', '1'],
+ ['abc', '4'],
+ ['b', '2, 5'],
+ ['c', '3']
+ ]
+ const headers = new Headers(init)
+ const that = {}
+ let i = 0
+ headers.forEach(function (value, key, _headers) {
+ t.strictSame(expected[i++], [key, value])
+ t.equal(this, that)
+ }, that)
+ })
+
+ t.test('returns combined and sorted entries using .entries()', t => {
+ t.plan(4)
+ const init = [
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['abc', '4'],
+ ['b', '5']
+ ]
+ const expected = [
+ ['a', '1'],
+ ['abc', '4'],
+ ['b', '2, 5'],
+ ['c', '3']
+ ]
+ const headers = new Headers(init)
+ let i = 0
+ for (const header of headers.entries()) {
+ t.strictSame(header, expected[i++])
+ }
+ })
+
+ t.test('returns combined and sorted keys using .keys()', t => {
+ t.plan(4)
+ const init = [
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['abc', '4'],
+ ['b', '5']
+ ]
+ const expected = ['a', 'abc', 'b', 'c']
+ const headers = new Headers(init)
+ let i = 0
+ for (const key of headers.keys()) {
+ t.strictSame(key, expected[i++])
+ }
+ })
+
+ t.test('returns combined and sorted values using .values()', t => {
+ t.plan(4)
+ const init = [
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['abc', '4'],
+ ['b', '5']
+ ]
+ const expected = ['1', '4', '2, 5', '3']
+ const headers = new Headers(init)
+ let i = 0
+ for (const value of headers.values()) {
+ t.strictSame(value, expected[i++])
+ }
+ })
+
+ t.test('returns combined and sorted entries using for...of loop', t => {
+ t.plan(5)
+ const init = [
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['abc', '4'],
+ ['b', '5'],
+ ['d', ['6', '7']]
+ ]
+ const expected = [
+ ['a', '1'],
+ ['abc', '4'],
+ ['b', '2, 5'],
+ ['c', '3'],
+ ['d', '6,7']
+ ]
+ let i = 0
+ for (const header of new Headers(init)) {
+ t.strictSame(header, expected[i++])
+ }
+ })
+
+ t.test('validate append ordering', t => {
+ t.plan(1)
+ const headers = new Headers([['b', '2'], ['c', '3'], ['e', '5']])
+ headers.append('d', '4')
+ headers.append('a', '1')
+ headers.append('f', '6')
+ headers.append('c', '7')
+ headers.append('abc', '8')
+
+ const expected = [...new Map([
+ ['a', '1'],
+ ['abc', '8'],
+ ['b', '2'],
+ ['c', '3, 7'],
+ ['d', '4'],
+ ['e', '5'],
+ ['f', '6']
+ ])]
+
+ t.same([...headers], expected)
+ })
+})
+
+tap.test('arg validation', (t) => {
+ // fill
+ t.throws(() => {
+ fill({}, 0)
+ }, TypeError)
+
+ const headers = new Headers()
+
+ // constructor
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Headers(0)
+ }, TypeError)
+
+ // get [Symbol.toStringTag]
+ t.doesNotThrow(() => {
+ Object.prototype.toString.call(Headers.prototype)
+ })
+
+ // toString
+ t.doesNotThrow(() => {
+ Headers.prototype.toString.call(null)
+ })
+
+ // append
+ t.throws(() => {
+ Headers.prototype.append.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.append()
+ }, TypeError)
+
+ // delete
+ t.throws(() => {
+ Headers.prototype.delete.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.delete()
+ }, TypeError)
+
+ // get
+ t.throws(() => {
+ Headers.prototype.get.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.get()
+ }, TypeError)
+
+ // has
+ t.throws(() => {
+ Headers.prototype.has.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.has()
+ }, TypeError)
+
+ // set
+ t.throws(() => {
+ Headers.prototype.set.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.set()
+ }, TypeError)
+
+ // forEach
+ t.throws(() => {
+ Headers.prototype.forEach.call(null)
+ }, TypeError)
+ t.throws(() => {
+ headers.forEach()
+ }, TypeError)
+ t.throws(() => {
+ headers.forEach(1)
+ }, TypeError)
+
+ // inspect
+ t.throws(() => {
+ Headers.prototype[Symbol.for('nodejs.util.inspect.custom')].call(null)
+ }, TypeError)
+
+ t.end()
+})
+
+tap.test('function signature verification', (t) => {
+ t.test('function length', (t) => {
+ t.equal(Headers.prototype.append.length, 2)
+ t.equal(Headers.prototype.constructor.length, 0)
+ t.equal(Headers.prototype.delete.length, 1)
+ t.equal(Headers.prototype.entries.length, 0)
+ t.equal(Headers.prototype.forEach.length, 1)
+ t.equal(Headers.prototype.get.length, 1)
+ t.equal(Headers.prototype.has.length, 1)
+ t.equal(Headers.prototype.keys.length, 0)
+ t.equal(Headers.prototype.set.length, 2)
+ t.equal(Headers.prototype.values.length, 0)
+ t.equal(Headers.prototype[Symbol.iterator].length, 0)
+ t.equal(Headers.prototype.toString.length, 0)
+
+ t.end()
+ })
+
+ t.test('function equality', (t) => {
+ t.equal(Headers.prototype.entries, Headers.prototype[Symbol.iterator])
+ t.equal(Headers.prototype.toString, Object.prototype.toString)
+
+ t.end()
+ })
+
+ t.test('toString and Symbol.toStringTag', (t) => {
+ t.equal(Object.prototype.toString.call(Headers.prototype), '[object Headers]')
+ t.equal(Headers.prototype[Symbol.toStringTag], 'Headers')
+ t.equal(Headers.prototype.toString.call(null), '[object Null]')
+
+ t.end()
+ })
+
+ t.end()
+})
+
+tap.test('various init paths of Headers', (t) => {
+ const h1 = new Headers()
+ const h2 = new Headers({})
+ const h3 = new Headers(undefined)
+ t.equal([...h1.entries()].length, 0)
+ t.equal([...h2.entries()].length, 0)
+ t.equal([...h3.entries()].length, 0)
+
+ t.end()
+})
+
+tap.test('immutable guard', (t) => {
+ const headers = new Headers()
+ headers.set('key', 'val')
+ headers[kGuard] = 'immutable'
+
+ t.throws(() => {
+ headers.set('asd', 'asd')
+ })
+ t.throws(() => {
+ headers.append('asd', 'asd')
+ })
+ t.throws(() => {
+ headers.delete('asd')
+ })
+ t.equal(headers.get('key'), 'val')
+ t.equal(headers.has('key'), true)
+
+ t.end()
+})
+
+tap.test('request-no-cors guard', (t) => {
+ const headers = new Headers()
+ headers[kGuard] = 'request-no-cors'
+ t.doesNotThrow(() => { headers.set('key', 'val') })
+ t.doesNotThrow(() => { headers.append('key', 'val') })
+ t.doesNotThrow(() => { headers.delete('key') })
+ t.end()
+})
+
+tap.test('invalid headers', (t) => {
+ t.doesNotThrow(() => new Headers({ "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~": 'test' }))
+
+ const chars = '"(),/:;<=>?@[\\]{}'.split('')
+
+ for (const char of chars) {
+ t.throws(() => new Headers({ [char]: 'test' }), TypeError, `The string "${char}" should throw an error.`)
+ }
+
+ for (const byte of ['\r', '\n', '\t', ' ', String.fromCharCode(128), '']) {
+ t.throws(() => {
+ new Headers().set(byte, 'test')
+ }, TypeError, 'invalid header name')
+ }
+
+ for (const byte of [
+ '\0',
+ '\r',
+ '\n'
+ ]) {
+ t.throws(() => {
+ new Headers().set('a', `a${byte}b`)
+ }, TypeError, 'not allowed at all in header value')
+ }
+
+ t.doesNotThrow(() => {
+ new Headers().set('a', '\r')
+ })
+
+ t.doesNotThrow(() => {
+ new Headers().set('a', '\n')
+ })
+
+ t.throws(() => {
+ new Headers().set('a', Symbol('symbol'))
+ }, TypeError, 'symbols should throw')
+
+ t.end()
+})
+
+tap.test('headers that might cause a ReDoS', (t) => {
+ t.doesNotThrow(() => {
+ // This test will time out if the ReDoS attack is successful.
+ const headers = new Headers()
+ const attack = 'a' + '\t'.repeat(500_000) + '\ta'
+ headers.append('fhqwhgads', attack)
+ })
+
+ t.end()
+})
+
+tap.test('Headers.prototype.getSetCookie', (t) => {
+ t.test('Mutating the returned list does not affect the set-cookie list', (t) => {
+ const h = new Headers([
+ ['set-cookie', 'a=b'],
+ ['set-cookie', 'c=d']
+ ])
+
+ const old = h.getSetCookie()
+ h.getSetCookie().push('oh=no')
+ const now = h.getSetCookie()
+
+ t.same(old, now)
+ t.end()
+ })
+
+ // https://github.com/nodejs/undici/issues/1935
+ t.test('When Headers are cloned, so are the cookies', async (t) => {
+ const server = createServer((req, res) => {
+ res.setHeader('Set-Cookie', 'test=onetwo')
+ res.end('Hello World!')
+ }).listen(0)
+
+ await once(server, 'listening')
+ t.teardown(server.close.bind(server))
+
+ const res = await fetch(`http://localhost:${server.address().port}`)
+ const entries = Object.fromEntries(res.headers.entries())
+
+ t.same(res.headers.getSetCookie(), ['test=onetwo'])
+ t.ok('set-cookie' in entries)
+ })
+
+ t.end()
+})
diff --git a/test/fetch/http2.js b/test/fetch/http2.js
new file mode 100644
index 0000000..9f6997f
--- /dev/null
+++ b/test/fetch/http2.js
@@ -0,0 +1,415 @@
+'use strict'
+
+const { createSecureServer } = require('node:http2')
+const { createReadStream, readFileSync } = require('node:fs')
+const { once } = require('node:events')
+const { Blob } = require('node:buffer')
+const { Readable } = require('node:stream')
+
+const { test, plan } = require('tap')
+const pem = require('https-pem')
+
+const { Client, fetch, Headers } = require('../..')
+
+const nodeVersion = Number(process.version.split('v')[1].split('.')[0])
+
+plan(7)
+
+test('[Fetch] Issue#2311', async t => {
+ const expectedBody = 'hello from client!'
+
+ const server = createSecureServer(pem, async (req, res) => {
+ let body = ''
+
+ req.setEncoding('utf8')
+
+ res.writeHead(200, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'x-custom-h2': req.headers['x-my-header']
+ })
+
+ for await (const chunk of req) {
+ body += chunk
+ }
+
+ res.end(body)
+ })
+
+ t.plan(1)
+
+ server.listen()
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ method: 'POST',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ },
+ body: expectedBody
+ }
+ )
+
+ const responseBody = await response.text()
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ t.equal(responseBody, expectedBody)
+})
+
+test('[Fetch] Simple GET with h2', async t => {
+ const server = createSecureServer(pem)
+ const expectedRequestBody = 'hello h2!'
+
+ server.on('stream', async (stream, headers) => {
+ stream.respond({
+ 'content-type': 'text/plain; charset=utf-8',
+ 'x-custom-h2': headers['x-my-header'],
+ 'x-method': headers[':method'],
+ ':status': 200
+ })
+
+ stream.end(expectedRequestBody)
+ })
+
+ t.plan(5)
+
+ server.listen()
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ method: 'GET',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ }
+ }
+ )
+
+ const responseBody = await response.text()
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ t.equal(responseBody, expectedRequestBody)
+ t.equal(response.headers.get('x-method'), 'GET')
+ t.equal(response.headers.get('x-custom-h2'), 'foo')
+ // https://github.com/nodejs/undici/issues/2415
+ t.throws(() => {
+ response.headers.get(':status')
+ }, TypeError)
+
+ // See https://fetch.spec.whatwg.org/#concept-response-status-message
+ t.equal(response.statusText, '')
+})
+
+test('[Fetch] Should handle h2 request with body (string or buffer)', async t => {
+ const server = createSecureServer(pem)
+ const expectedBody = 'hello from client!'
+ const expectedRequestBody = 'hello h2!'
+ const requestBody = []
+
+ server.on('stream', async (stream, headers) => {
+ stream.on('data', chunk => requestBody.push(chunk))
+
+ stream.respond({
+ 'content-type': 'text/plain; charset=utf-8',
+ 'x-custom-h2': headers['x-my-header'],
+ ':status': 200
+ })
+
+ stream.end(expectedRequestBody)
+ })
+
+ t.plan(2)
+
+ server.listen()
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ method: 'POST',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ },
+ body: expectedBody
+ }
+ )
+
+ const responseBody = await response.text()
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ t.equal(Buffer.concat(requestBody).toString('utf-8'), expectedBody)
+ t.equal(responseBody, expectedRequestBody)
+})
+
+// Skipping for now, there is something odd in the way the body is handled
+test(
+ '[Fetch] Should handle h2 request with body (stream)',
+ { skip: nodeVersion === 16 },
+ async t => {
+ const server = createSecureServer(pem)
+ const expectedBody = readFileSync(__filename, 'utf-8')
+ const stream = createReadStream(__filename)
+ const requestChunks = []
+
+ server.on('stream', async (stream, headers) => {
+ t.equal(headers[':method'], 'PUT')
+ t.equal(headers[':path'], '/')
+ t.equal(headers[':scheme'], 'https')
+
+ stream.respond({
+ 'content-type': 'text/plain; charset=utf-8',
+ 'x-custom-h2': headers['x-my-header'],
+ ':status': 200
+ })
+
+ for await (const chunk of stream) {
+ requestChunks.push(chunk)
+ }
+
+ stream.end('hello h2!')
+ })
+
+ t.plan(8)
+
+ server.listen(0)
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ method: 'PUT',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ },
+ body: Readable.toWeb(stream),
+ duplex: 'half'
+ }
+ )
+
+ const responseBody = await response.text()
+
+ t.equal(response.status, 200)
+ t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8')
+ t.equal(response.headers.get('x-custom-h2'), 'foo')
+ t.equal(responseBody, 'hello h2!')
+ t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody)
+ }
+)
+test('Should handle h2 request with body (Blob)', { skip: !Blob }, async t => {
+ const server = createSecureServer(pem)
+ const expectedBody = 'asd'
+ const requestChunks = []
+ const body = new Blob(['asd'], {
+ type: 'text/plain'
+ })
+
+ server.on('stream', async (stream, headers) => {
+ t.equal(headers[':method'], 'POST')
+ t.equal(headers[':path'], '/')
+ t.equal(headers[':scheme'], 'https')
+
+ stream.on('data', chunk => requestChunks.push(chunk))
+
+ stream.respond({
+ 'content-type': 'text/plain; charset=utf-8',
+ 'x-custom-h2': headers['x-my-header'],
+ ':status': 200
+ })
+
+ stream.end('hello h2!')
+ })
+
+ t.plan(8)
+
+ server.listen(0)
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ body,
+ method: 'POST',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ }
+ }
+ )
+
+ const responseBody = await response.arrayBuffer()
+
+ t.equal(response.status, 200)
+ t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8')
+ t.equal(response.headers.get('x-custom-h2'), 'foo')
+ t.same(new TextDecoder().decode(responseBody).toString(), 'hello h2!')
+ t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody)
+})
+
+test(
+ 'Should handle h2 request with body (Blob:ArrayBuffer)',
+ { skip: !Blob },
+ async t => {
+ const server = createSecureServer(pem)
+ const expectedBody = 'hello'
+ const requestChunks = []
+ const expectedResponseBody = { hello: 'h2' }
+ const buf = Buffer.from(expectedBody)
+ const body = new ArrayBuffer(buf.byteLength)
+
+ buf.copy(new Uint8Array(body))
+
+ server.on('stream', async (stream, headers) => {
+ t.equal(headers[':method'], 'PUT')
+ t.equal(headers[':path'], '/')
+ t.equal(headers[':scheme'], 'https')
+
+ stream.on('data', chunk => requestChunks.push(chunk))
+
+ stream.respond({
+ 'content-type': 'application/json',
+ 'x-custom-h2': headers['x-my-header'],
+ ':status': 200
+ })
+
+ stream.end(JSON.stringify(expectedResponseBody))
+ })
+
+ t.plan(8)
+
+ server.listen(0)
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ body,
+ method: 'PUT',
+ dispatcher: client,
+ headers: {
+ 'x-my-header': 'foo',
+ 'content-type': 'text-plain'
+ }
+ }
+ )
+
+ const responseBody = await response.json()
+
+ t.equal(response.status, 200)
+ t.equal(response.headers.get('content-type'), 'application/json')
+ t.equal(response.headers.get('x-custom-h2'), 'foo')
+ t.same(responseBody, expectedResponseBody)
+ t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody)
+ }
+)
+
+test('Issue#2415', async (t) => {
+ t.plan(1)
+ const server = createSecureServer(pem)
+
+ server.on('stream', async (stream, headers) => {
+ stream.respond({
+ ':status': 200
+ })
+ stream.end('test')
+ })
+
+ server.listen()
+ await once(server, 'listening')
+
+ const client = new Client(`https://localhost:${server.address().port}`, {
+ connect: {
+ rejectUnauthorized: false
+ },
+ allowH2: true
+ })
+
+ const response = await fetch(
+ `https://localhost:${server.address().port}/`,
+ // Needs to be passed to disable the reject unauthorized
+ {
+ method: 'GET',
+ dispatcher: client
+ }
+ )
+
+ await response.text()
+
+ t.teardown(server.close.bind(server))
+ t.teardown(client.close.bind(client))
+
+ t.doesNotThrow(() => new Headers(response.headers))
+})
diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js
new file mode 100644
index 0000000..f91f693
--- /dev/null
+++ b/test/fetch/integrity.js
@@ -0,0 +1,150 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { createHash, getHashes } = require('crypto')
+const { gzipSync } = require('zlib')
+const { fetch, setGlobalDispatcher, Agent } = require('../..')
+const { once } = require('events')
+
+const supportedHashes = getHashes()
+
+setGlobalDispatcher(new Agent({
+ keepAliveTimeout: 1,
+ keepAliveMaxTimeout: 1
+}))
+
+test('request with correct integrity checksum', (t) => {
+ const body = 'Hello world!'
+ const hash = createHash('sha256').update(body).digest('base64')
+
+ const server = createServer((req, res) => {
+ res.end(body)
+ })
+
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const response = await fetch(`http://localhost:${server.address().port}`, {
+ integrity: `sha256-${hash}`
+ })
+ t.strictSame(body, await response.text())
+ t.end()
+ })
+})
+
+test('request with wrong integrity checksum', (t) => {
+ const body = 'Hello world!'
+ const hash = 'c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51b'
+
+ const server = createServer((req, res) => {
+ res.end(body)
+ })
+
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, () => {
+ fetch(`http://localhost:${server.address().port}`, {
+ integrity: `sha256-${hash}`
+ }).then(response => {
+ t.pass('request did not fail')
+ }).catch((err) => {
+ t.equal(err.cause.message, 'integrity mismatch')
+ }).finally(() => {
+ t.end()
+ })
+ })
+})
+
+test('request with integrity checksum on encoded body', (t) => {
+ const body = 'Hello world!'
+ const hash = createHash('sha256').update(body).digest('base64')
+
+ const server = createServer((req, res) => {
+ res.setHeader('content-encoding', 'gzip')
+ res.end(gzipSync(body))
+ })
+
+ t.teardown(server.close.bind(server))
+
+ server.listen(0, async () => {
+ const response = await fetch(`http://localhost:${server.address().port}`, {
+ integrity: `sha256-${hash}`
+ })
+ t.strictSame(body, await response.text())
+ t.end()
+ })
+})
+
+test('request with a totally incorrect integrity', async (t) => {
+ const server = createServer((req, res) => {
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await t.resolves(fetch(`http://localhost:${server.address().port}`, {
+ integrity: 'what-integrityisthis'
+ }))
+})
+
+test('request with mixed in/valid integrities', async (t) => {
+ const body = 'Hello world!'
+ const hash = createHash('sha256').update(body).digest('base64')
+
+ const server = createServer((req, res) => {
+ res.end(body)
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await t.resolves(fetch(`http://localhost:${server.address().port}`, {
+ integrity: `invalid-integrity sha256-${hash}`
+ }))
+})
+
+test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, async (t) => {
+ const body = 'Hello world!'
+ const hash = createHash('sha384').update(body).digest('base64')
+
+ const server = createServer((req, res) => {
+ res.end(body)
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ // request should succeed
+ await t.resolves(fetch(`http://localhost:${server.address().port}`, {
+ integrity: `sha384-${hash}`
+ }))
+
+ // request should fail
+ await t.rejects(fetch(`http://localhost:${server.address().port}`, {
+ integrity: 'sha384-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
+ }))
+})
+
+test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, async (t) => {
+ const body = 'Hello world!'
+ const hash = createHash('sha512').update(body).digest('base64')
+
+ const server = createServer((req, res) => {
+ res.end(body)
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ // request should succeed
+ await t.resolves(fetch(`http://localhost:${server.address().port}`, {
+ integrity: `sha512-${hash}`
+ }))
+
+ // request should fail
+ await t.rejects(fetch(`http://localhost:${server.address().port}`, {
+ integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
+ }))
+})
diff --git a/test/fetch/issue-1447.js b/test/fetch/issue-1447.js
new file mode 100644
index 0000000..503b344
--- /dev/null
+++ b/test/fetch/issue-1447.js
@@ -0,0 +1,46 @@
+'use strict'
+
+const { test, skip } = require('tap')
+const { nodeMajor } = require('../../lib/core/util')
+
+if (nodeMajor === 16) {
+ skip('esbuild uses static blocks with --keep-names which node 16.8 does not have')
+ process.exit()
+}
+
+const undici = require('../..')
+const { fetch: theoreticalGlobalFetch } = require('../../undici-fetch')
+
+test('Mocking works with both fetches', async (t) => {
+ const mockAgent = new undici.MockAgent()
+ const body = JSON.stringify({ foo: 'bar' })
+
+ mockAgent.disableNetConnect()
+ undici.setGlobalDispatcher(mockAgent)
+ const pool = mockAgent.get('https://example.com')
+
+ pool.intercept({
+ path: '/path',
+ method: 'POST',
+ body (bodyString) {
+ t.equal(bodyString, body)
+ return true
+ }
+ }).reply(200, { ok: 1 }).times(2)
+
+ const url = new URL('https://example.com/path').href
+
+ // undici fetch from node_modules
+ await undici.fetch(url, {
+ method: 'POST',
+ body
+ })
+
+ // the global fetch bundled with esbuild
+ await theoreticalGlobalFetch(url, {
+ method: 'POST',
+ body
+ })
+
+ t.end()
+})
diff --git a/test/fetch/issue-2009.js b/test/fetch/issue-2009.js
new file mode 100644
index 0000000..0b7b3e9
--- /dev/null
+++ b/test/fetch/issue-2009.js
@@ -0,0 +1,28 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+const { createServer } = require('http')
+const { once } = require('events')
+
+test('issue 2009', async (t) => {
+ const server = createServer((req, res) => {
+ res.setHeader('a', 'b')
+ res.flushHeaders()
+
+ res.socket.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ for (let i = 0; i < 10; i++) {
+ await t.resolves(
+ fetch(`http://localhost:${server.address().port}`).then(
+ async (resp) => {
+ await resp.body.cancel('Some message')
+ }
+ )
+ )
+ }
+})
diff --git a/test/fetch/issue-2021.js b/test/fetch/issue-2021.js
new file mode 100644
index 0000000..cd28a71
--- /dev/null
+++ b/test/fetch/issue-2021.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const { test } = require('tap')
+const { once } = require('events')
+const { createServer } = require('http')
+const { fetch } = require('../..')
+
+// https://github.com/nodejs/undici/issues/2021
+test('content-length header is removed on redirect', async (t) => {
+ const server = createServer((req, res) => {
+ if (req.url === '/redirect') {
+ res.writeHead(302, { Location: '/redirect2' })
+ res.end()
+ return
+ }
+
+ res.end()
+ }).listen(0).unref()
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const body = 'a+b+c'
+
+ await t.resolves(fetch(`http://localhost:${server.address().port}/redirect`, {
+ method: 'POST',
+ body,
+ headers: {
+ 'content-length': Buffer.byteLength(body)
+ }
+ }))
+})
diff --git a/test/fetch/issue-2171.js b/test/fetch/issue-2171.js
new file mode 100644
index 0000000..b04ae0e
--- /dev/null
+++ b/test/fetch/issue-2171.js
@@ -0,0 +1,25 @@
+'use strict'
+
+const { fetch } = require('../..')
+const { DOMException } = require('../../lib/fetch/constants')
+const { once } = require('events')
+const { createServer } = require('http')
+const { test } = require('tap')
+
+test('error reason is forwarded - issue #2171', { skip: !AbortSignal.timeout }, async (t) => {
+ const server = createServer(() => {}).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const timeout = AbortSignal.timeout(100)
+ await t.rejects(
+ fetch(`http://localhost:${server.address().port}`, {
+ signal: timeout
+ }),
+ {
+ name: 'TimeoutError',
+ code: DOMException.TIMEOUT_ERR
+ }
+ )
+})
diff --git a/test/fetch/issue-2242.js b/test/fetch/issue-2242.js
new file mode 100644
index 0000000..fe70412
--- /dev/null
+++ b/test/fetch/issue-2242.js
@@ -0,0 +1,8 @@
+'use strict'
+
+const { test } = require('tap')
+const { fetch } = require('../..')
+
+test('fetch with signal already aborted', async (t) => {
+ await t.rejects(fetch('http://localhost', { signal: AbortSignal.abort('Already aborted') }), 'Already aborted')
+})
diff --git a/test/fetch/issue-2318.js b/test/fetch/issue-2318.js
new file mode 100644
index 0000000..e4f610d
--- /dev/null
+++ b/test/fetch/issue-2318.js
@@ -0,0 +1,25 @@
+'use strict'
+
+const { test } = require('tap')
+const { once } = require('events')
+const { createServer } = require('http')
+const { fetch } = require('../..')
+
+test('Undici overrides user-provided `Host` header', async (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ t.equal(req.headers.host, `localhost:${server.address().port}`)
+
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await fetch(`http://localhost:${server.address().port}`, {
+ headers: {
+ host: 'www.idk.org'
+ }
+ })
+})
diff --git a/test/fetch/issue-node-46525.js b/test/fetch/issue-node-46525.js
new file mode 100644
index 0000000..6fd9810
--- /dev/null
+++ b/test/fetch/issue-node-46525.js
@@ -0,0 +1,28 @@
+'use strict'
+
+const { once } = require('events')
+const { createServer } = require('http')
+const { test } = require('tap')
+const { fetch } = require('../..')
+
+// https://github.com/nodejs/node/issues/46525
+test('No warning when reusing AbortController', async (t) => {
+ function onWarning (error) {
+ t.error(error, 'Got warning')
+ }
+
+ const server = createServer((req, res) => res.end()).listen(0)
+
+ await once(server, 'listening')
+
+ process.on('warning', onWarning)
+ t.teardown(() => {
+ process.off('warning', onWarning)
+ return server.close()
+ })
+
+ const controller = new AbortController()
+ for (let i = 0; i < 15; i++) {
+ await fetch(`http://localhost:${server.address().port}`, { signal: controller.signal })
+ }
+})
diff --git a/test/fetch/iterators.js b/test/fetch/iterators.js
new file mode 100644
index 0000000..6c6761d
--- /dev/null
+++ b/test/fetch/iterators.js
@@ -0,0 +1,140 @@
+'use strict'
+
+const { test } = require('tap')
+const { Headers, FormData } = require('../..')
+
+test('Implements " Iterator" properly', (t) => {
+ t.test('all Headers iterators implement Headers Iterator', (t) => {
+ const headers = new Headers([['a', 'b'], ['c', 'd']])
+
+ for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) {
+ const gen = headers[iterable]()
+ // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object
+ const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()))
+ const iteratorProto = Object.getPrototypeOf(gen)
+
+ t.ok(gen.constructor === Object)
+ t.ok(gen.prototype === undefined)
+ // eslint-disable-next-line no-proto
+ t.equal(gen.__proto__[Symbol.toStringTag], 'Headers Iterator')
+ // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049
+ t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor)
+ // eslint-disable-next-line no-proto
+ t.ok(gen.__proto__.next.__proto__ === Function.prototype)
+ // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object
+ // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%."
+ t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator])
+ t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype)
+ }
+
+ t.end()
+ })
+
+ t.test('all FormData iterators implement FormData Iterator', (t) => {
+ const fd = new FormData()
+
+ for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) {
+ const gen = fd[iterable]()
+ // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object
+ const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()))
+ const iteratorProto = Object.getPrototypeOf(gen)
+
+ t.ok(gen.constructor === Object)
+ t.ok(gen.prototype === undefined)
+ // eslint-disable-next-line no-proto
+ t.equal(gen.__proto__[Symbol.toStringTag], 'FormData Iterator')
+ // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049
+ t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor)
+ // eslint-disable-next-line no-proto
+ t.ok(gen.__proto__.next.__proto__ === Function.prototype)
+ // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object
+ // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%."
+ t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator])
+ t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype)
+ }
+
+ t.end()
+ })
+
+ t.test('Iterator symbols are properly set', (t) => {
+ t.test('Headers', (t) => {
+ const headers = new Headers([['a', 'b'], ['c', 'd']])
+ const gen = headers.entries()
+
+ t.equal(typeof gen[Symbol.toStringTag], 'string')
+ t.equal(typeof gen[Symbol.iterator], 'function')
+ t.end()
+ })
+
+ t.test('FormData', (t) => {
+ const fd = new FormData()
+ const gen = fd.entries()
+
+ t.equal(typeof gen[Symbol.toStringTag], 'string')
+ t.equal(typeof gen[Symbol.iterator], 'function')
+ t.end()
+ })
+
+ t.end()
+ })
+
+ t.test('Iterator does not inherit Generator prototype methods', (t) => {
+ t.test('Headers', (t) => {
+ const headers = new Headers([['a', 'b'], ['c', 'd']])
+ const gen = headers.entries()
+
+ t.equal(gen.return, undefined)
+ t.equal(gen.throw, undefined)
+ t.equal(typeof gen.next, 'function')
+
+ t.end()
+ })
+
+ t.test('FormData', (t) => {
+ const fd = new FormData()
+ const gen = fd.entries()
+
+ t.equal(gen.return, undefined)
+ t.equal(gen.throw, undefined)
+ t.equal(typeof gen.next, 'function')
+
+ t.end()
+ })
+
+ t.end()
+ })
+
+ t.test('Symbol.iterator', (t) => {
+ // Headers
+ const headerValues = new Headers([['a', 'b']]).entries()[Symbol.iterator]()
+ t.same(Array.from(headerValues), [['a', 'b']])
+
+ // FormData
+ const formdata = new FormData()
+ formdata.set('a', 'b')
+ const formdataValues = formdata.entries()[Symbol.iterator]()
+ t.same(Array.from(formdataValues), [['a', 'b']])
+
+ t.end()
+ })
+
+ t.test('brand check', (t) => {
+ // Headers
+ t.throws(() => {
+ const gen = new Headers().entries()
+ // eslint-disable-next-line no-proto
+ gen.__proto__.next()
+ }, TypeError)
+
+ // FormData
+ t.throws(() => {
+ const gen = new FormData().entries()
+ // eslint-disable-next-line no-proto
+ gen.__proto__.next()
+ }, TypeError)
+
+ t.end()
+ })
+
+ t.end()
+})
diff --git a/test/fetch/jsdom-abortcontroller-1910-1464495619.js b/test/fetch/jsdom-abortcontroller-1910-1464495619.js
new file mode 100644
index 0000000..e5a86ab
--- /dev/null
+++ b/test/fetch/jsdom-abortcontroller-1910-1464495619.js
@@ -0,0 +1,26 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { fetch } = require('../..')
+const { JSDOM } = require('jsdom')
+
+// https://github.com/nodejs/undici/pull/1910#issuecomment-1464495619
+test('third party AbortControllers', async (t) => {
+ const server = createServer((_, res) => res.end()).listen(0)
+
+ const { AbortController } = new JSDOM().window
+ let controller = new AbortController()
+
+ t.teardown(() => {
+ controller.abort()
+ controller = null
+ return server.close()
+ })
+ await once(server, 'listening')
+
+ await t.resolves(fetch(`http://localhost:${server.address().port}`, {
+ signal: controller.signal
+ }))
+})
diff --git a/test/fetch/redirect-cross-origin-header.js b/test/fetch/redirect-cross-origin-header.js
new file mode 100644
index 0000000..fca48c4
--- /dev/null
+++ b/test/fetch/redirect-cross-origin-header.js
@@ -0,0 +1,48 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { fetch } = require('../..')
+
+test('Cross-origin redirects clear forbidden headers', async (t) => {
+ t.plan(5)
+
+ const server1 = createServer((req, res) => {
+ t.equal(req.headers.cookie, undefined)
+ t.equal(req.headers.authorization, undefined)
+
+ res.end('redirected')
+ }).listen(0)
+
+ const server2 = createServer((req, res) => {
+ t.equal(req.headers.authorization, 'test')
+ t.equal(req.headers.cookie, 'ddd=dddd')
+
+ res.writeHead(302, {
+ ...req.headers,
+ Location: `http://localhost:${server1.address().port}`
+ })
+ res.end()
+ }).listen(0)
+
+ t.teardown(() => {
+ server1.close()
+ server2.close()
+ })
+
+ await Promise.all([
+ once(server1, 'listening'),
+ once(server2, 'listening')
+ ])
+
+ const res = await fetch(`http://localhost:${server2.address().port}`, {
+ headers: {
+ Authorization: 'test',
+ Cookie: 'ddd=dddd'
+ }
+ })
+
+ const text = await res.text()
+ t.equal(text, 'redirected')
+})
diff --git a/test/fetch/redirect.js b/test/fetch/redirect.js
new file mode 100644
index 0000000..7e3681b
--- /dev/null
+++ b/test/fetch/redirect.js
@@ -0,0 +1,50 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { fetch } = require('../..')
+
+// https://github.com/nodejs/undici/issues/1776
+test('Redirecting with a body does not cancel the current request - #1776', async (t) => {
+ const server = createServer((req, res) => {
+ if (req.url === '/redirect') {
+ res.statusCode = 301
+ res.setHeader('location', '/redirect/')
+ res.write('<a href="/redirect/">Moved Permanently</a>')
+ setTimeout(() => res.end(), 500)
+ return
+ }
+
+ res.write(req.url)
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const resp = await fetch(`http://localhost:${server.address().port}/redirect`)
+ t.equal(await resp.text(), '/redirect/')
+ t.ok(resp.redirected)
+})
+
+test('Redirecting with an empty body does not throw an error - #2027', async (t) => {
+ const server = createServer((req, res) => {
+ if (req.url === '/redirect') {
+ res.statusCode = 307
+ res.setHeader('location', '/redirect/')
+ res.write('<a href="/redirect/">Moved Permanently</a>')
+ res.end()
+ return
+ }
+ res.write(req.url)
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const resp = await fetch(`http://localhost:${server.address().port}/redirect`, { method: 'PUT', body: '' })
+ t.equal(await resp.text(), '/redirect/')
+ t.ok(resp.redirected)
+})
diff --git a/test/fetch/relative-url.js b/test/fetch/relative-url.js
new file mode 100644
index 0000000..1a4f819
--- /dev/null
+++ b/test/fetch/relative-url.js
@@ -0,0 +1,110 @@
+'use strict'
+
+const { test, afterEach } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const {
+ getGlobalOrigin,
+ setGlobalOrigin,
+ Response,
+ Request,
+ fetch
+} = require('../..')
+
+afterEach(() => setGlobalOrigin(undefined))
+
+test('setGlobalOrigin & getGlobalOrigin', (t) => {
+ t.equal(getGlobalOrigin(), undefined)
+
+ setGlobalOrigin('http://localhost:3000')
+ t.same(getGlobalOrigin(), new URL('http://localhost:3000'))
+
+ setGlobalOrigin(undefined)
+ t.equal(getGlobalOrigin(), undefined)
+
+ setGlobalOrigin(new URL('http://localhost:3000'))
+ t.same(getGlobalOrigin(), new URL('http://localhost:3000'))
+
+ t.throws(() => {
+ setGlobalOrigin('invalid.url')
+ }, TypeError)
+
+ t.throws(() => {
+ setGlobalOrigin('wss://invalid.protocol')
+ }, TypeError)
+
+ t.throws(() => setGlobalOrigin(true))
+
+ t.end()
+})
+
+test('Response.redirect', (t) => {
+ t.throws(() => {
+ Response.redirect('/relative/path', 302)
+ }, TypeError('Failed to parse URL from /relative/path'))
+
+ t.doesNotThrow(() => {
+ setGlobalOrigin('http://localhost:3000')
+ Response.redirect('/relative/path', 302)
+ })
+
+ setGlobalOrigin('http://localhost:3000')
+ const response = Response.redirect('/relative/path', 302)
+ // See step #7 of https://fetch.spec.whatwg.org/#dom-response-redirect
+ t.equal(response.headers.get('location'), 'http://localhost:3000/relative/path')
+
+ t.end()
+})
+
+test('new Request', (t) => {
+ t.throws(
+ () => new Request('/relative/path'),
+ TypeError('Failed to parse URL from /relative/path')
+ )
+
+ t.doesNotThrow(() => {
+ setGlobalOrigin('http://localhost:3000')
+ // eslint-disable-next-line no-new
+ new Request('/relative/path')
+ })
+
+ setGlobalOrigin('http://localhost:3000')
+ const request = new Request('/relative/path')
+ t.equal(request.url, 'http://localhost:3000/relative/path')
+
+ t.end()
+})
+
+test('fetch', async (t) => {
+ await t.rejects(async () => {
+ await fetch('/relative/path')
+ }, TypeError('Failed to parse URL from /relative/path'))
+
+ t.test('Basic fetch', async (t) => {
+ const server = createServer((req, res) => {
+ t.equal(req.url, '/relative/path')
+ res.end()
+ }).listen(0)
+
+ setGlobalOrigin(`http://localhost:${server.address().port}`)
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await t.resolves(fetch('/relative/path'))
+ })
+
+ t.test('fetch return', async (t) => {
+ const server = createServer((req, res) => {
+ t.equal(req.url, '/relative/path')
+ res.end()
+ }).listen(0)
+
+ setGlobalOrigin(`http://localhost:${server.address().port}`)
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ const response = await fetch('/relative/path')
+
+ t.equal(response.url, `http://localhost:${server.address().port}/relative/path`)
+ })
+})
diff --git a/test/fetch/request.js b/test/fetch/request.js
new file mode 100644
index 0000000..db2c8e8
--- /dev/null
+++ b/test/fetch/request.js
@@ -0,0 +1,514 @@
+/* globals AbortController */
+
+'use strict'
+
+const { test, teardown } = require('tap')
+const {
+ Request,
+ Headers,
+ fetch
+} = require('../../')
+const {
+ Blob: ThirdPartyBlob,
+ FormData: ThirdPartyFormData
+} = require('formdata-node')
+
+const hasSignalReason = 'reason' in AbortSignal.prototype
+
+test('arg validation', async (t) => {
+ // constructor
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request()
+ }, TypeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', 0)
+ }, TypeError)
+ t.throws(() => {
+ const url = new URL('http://asd')
+ url.password = 'asd'
+ // eslint-disable-next-line
+ new Request(url)
+ }, TypeError)
+ t.throws(() => {
+ const url = new URL('http://asd')
+ url.username = 'asd'
+ // eslint-disable-next-line
+ new Request(url)
+ }, TypeError)
+ t.doesNotThrow(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', undefined)
+ }, TypeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ window: {}
+ })
+ }, TypeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ window: 1
+ })
+ }, TypeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ mode: 'navigate'
+ })
+ })
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ referrerPolicy: 'agjhagna'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ mode: 'agjhagna'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ credentials: 'agjhagna'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ cache: 'agjhagna'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ method: 'agjhagnaöööö'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Request('http://asd', {
+ method: 'TRACE'
+ })
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.destination.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.referrer.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.referrerPolicy.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.mode.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.credentials.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.cache.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.redirect.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.integrity.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.keepalive.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.isReloadNavigation.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.isHistoryNavigation.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.signal.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ Request.prototype.body
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ Request.prototype.bodyUsed
+ }, TypeError)
+
+ t.throws(() => {
+ Request.prototype.clone.call(null)
+ }, TypeError)
+
+ t.doesNotThrow(() => {
+ Request.prototype[Symbol.toStringTag].charAt(0)
+ })
+
+ for (const method of [
+ 'text',
+ 'json',
+ 'arrayBuffer',
+ 'blob',
+ 'formData'
+ ]) {
+ await t.rejects(async () => {
+ await new Request('http://localhost')[method].call({
+ blob () {
+ return {
+ text () {
+ return Promise.resolve('emulating this')
+ }
+ }
+ }
+ })
+ }, TypeError)
+ }
+
+ t.end()
+})
+
+test('undefined window', t => {
+ t.doesNotThrow(() => new Request('http://asd', { window: undefined }))
+ t.end()
+})
+
+test('undefined body', t => {
+ const req = new Request('http://asd', { body: undefined })
+ t.equal(req.body, null)
+ t.end()
+})
+
+test('undefined method', t => {
+ const req = new Request('http://asd', { method: undefined })
+ t.equal(req.method, 'GET')
+ t.end()
+})
+
+test('undefined headers', t => {
+ const req = new Request('http://asd', { headers: undefined })
+ t.equal([...req.headers.entries()].length, 0)
+ t.end()
+})
+
+test('undefined referrer', t => {
+ const req = new Request('http://asd', { referrer: undefined })
+ t.equal(req.referrer, 'about:client')
+ t.end()
+})
+
+test('undefined referrerPolicy', t => {
+ const req = new Request('http://asd', { referrerPolicy: undefined })
+ t.equal(req.referrerPolicy, '')
+ t.end()
+})
+
+test('undefined mode', t => {
+ const req = new Request('http://asd', { mode: undefined })
+ t.equal(req.mode, 'cors')
+ t.end()
+})
+
+test('undefined credentials', t => {
+ const req = new Request('http://asd', { credentials: undefined })
+ t.equal(req.credentials, 'same-origin')
+ t.end()
+})
+
+test('undefined cache', t => {
+ const req = new Request('http://asd', { cache: undefined })
+ t.equal(req.cache, 'default')
+ t.end()
+})
+
+test('undefined redirect', t => {
+ const req = new Request('http://asd', { redirect: undefined })
+ t.equal(req.redirect, 'follow')
+ t.end()
+})
+
+test('undefined keepalive', t => {
+ const req = new Request('http://asd', { keepalive: undefined })
+ t.equal(req.keepalive, false)
+ t.end()
+})
+
+test('undefined integrity', t => {
+ const req = new Request('http://asd', { integrity: undefined })
+ t.equal(req.integrity, '')
+ t.end()
+})
+
+test('null integrity', t => {
+ const req = new Request('http://asd', { integrity: null })
+ t.equal(req.integrity, 'null')
+ t.end()
+})
+
+test('undefined signal', t => {
+ const req = new Request('http://asd', { signal: undefined })
+ t.equal(req.signal.aborted, false)
+ t.end()
+})
+
+test('pre aborted signal', t => {
+ const ac = new AbortController()
+ ac.abort('gwak')
+ const req = new Request('http://asd', { signal: ac.signal })
+ t.equal(req.signal.aborted, true)
+ if (hasSignalReason) {
+ t.equal(req.signal.reason, 'gwak')
+ }
+ t.end()
+})
+
+test('post aborted signal', t => {
+ t.plan(2)
+
+ const ac = new AbortController()
+ const req = new Request('http://asd', { signal: ac.signal })
+ t.equal(req.signal.aborted, false)
+ ac.signal.addEventListener('abort', () => {
+ if (hasSignalReason) {
+ t.equal(req.signal.reason, 'gwak')
+ } else {
+ t.pass()
+ }
+ }, { once: true })
+ ac.abort('gwak')
+})
+
+test('pre aborted signal cloned', t => {
+ const ac = new AbortController()
+ ac.abort('gwak')
+ const req = new Request('http://asd', { signal: ac.signal }).clone()
+ t.equal(req.signal.aborted, true)
+ if (hasSignalReason) {
+ t.equal(req.signal.reason, 'gwak')
+ }
+ t.end()
+})
+
+test('URLSearchParams body with Headers object - issue #1407', async (t) => {
+ const body = new URLSearchParams({
+ abc: 123
+ })
+
+ const request = new Request(
+ 'http://localhost',
+ {
+ method: 'POST',
+ body,
+ headers: {
+ Authorization: 'test'
+ }
+ }
+ )
+
+ t.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded;charset=UTF-8')
+ t.equal(request.headers.get('authorization'), 'test')
+ t.equal(await request.text(), 'abc=123')
+})
+
+test('post aborted signal cloned', t => {
+ t.plan(2)
+
+ const ac = new AbortController()
+ const req = new Request('http://asd', { signal: ac.signal }).clone()
+ t.equal(req.signal.aborted, false)
+ ac.signal.addEventListener('abort', () => {
+ if (hasSignalReason) {
+ t.equal(req.signal.reason, 'gwak')
+ } else {
+ t.pass()
+ }
+ }, { once: true })
+ ac.abort('gwak')
+})
+
+test('Passing headers in init', (t) => {
+ // https://github.com/nodejs/undici/issues/1400
+ t.test('Headers instance', (t) => {
+ const req = new Request('http://localhost', {
+ headers: new Headers({ key: 'value' })
+ })
+
+ t.equal(req.headers.get('key'), 'value')
+ t.end()
+ })
+
+ t.test('key:value object', (t) => {
+ const req = new Request('http://localhost', {
+ headers: { key: 'value' }
+ })
+
+ t.equal(req.headers.get('key'), 'value')
+ t.end()
+ })
+
+ t.test('[key, value][]', (t) => {
+ const req = new Request('http://localhost', {
+ headers: [['key', 'value']]
+ })
+
+ t.equal(req.headers.get('key'), 'value')
+ t.end()
+ })
+
+ t.end()
+})
+
+test('Symbol.toStringTag', (t) => {
+ const req = new Request('http://localhost')
+
+ t.equal(req[Symbol.toStringTag], 'Request')
+ t.equal(Request.prototype[Symbol.toStringTag], 'Request')
+ t.end()
+})
+
+test('invalid RequestInit values', (t) => {
+ /* eslint-disable no-new */
+ t.throws(() => {
+ new Request('http://l', { mode: 'CoRs' })
+ }, TypeError, 'not exact case = error')
+
+ t.throws(() => {
+ new Request('http://l', { mode: 'random' })
+ }, TypeError)
+
+ t.throws(() => {
+ new Request('http://l', { credentials: 'OMIt' })
+ }, TypeError, 'not exact case = error')
+
+ t.throws(() => {
+ new Request('http://l', { credentials: 'random' })
+ }, TypeError)
+
+ t.throws(() => {
+ new Request('http://l', { cache: 'DeFaULt' })
+ }, TypeError, 'not exact case = error')
+
+ t.throws(() => {
+ new Request('http://l', { cache: 'random' })
+ }, TypeError)
+
+ t.throws(() => {
+ new Request('http://l', { redirect: 'FOllOW' })
+ }, TypeError, 'not exact case = error')
+
+ t.throws(() => {
+ new Request('http://l', { redirect: 'random' })
+ }, TypeError)
+ /* eslint-enable no-new */
+
+ t.end()
+})
+
+test('RequestInit.signal option', async (t) => {
+ t.throws(() => {
+ // eslint-disable-next-line no-new
+ new Request('http://asd', {
+ signal: true
+ })
+ }, TypeError)
+
+ await t.rejects(fetch('http://asd', {
+ signal: false
+ }), TypeError)
+})
+
+test('constructing Request with third party Blob body', async (t) => {
+ const blob = new ThirdPartyBlob(['text'])
+ const req = new Request('http://asd', {
+ method: 'POST',
+ body: blob
+ })
+ t.equal(await req.text(), 'text')
+})
+test('constructing Request with third party FormData body', async (t) => {
+ const form = new ThirdPartyFormData()
+ form.set('key', 'value')
+ const req = new Request('http://asd', {
+ method: 'POST',
+ body: form
+ })
+ const contentType = req.headers.get('content-type').split('=')
+ t.equal(contentType[0], 'multipart/form-data; boundary')
+ t.ok((await req.text()).startsWith(`--${contentType[1]}`))
+})
+
+// https://github.com/nodejs/undici/issues/2050
+test('set-cookie headers get cleared when passing a Request as first param', (t) => {
+ const req1 = new Request('http://localhost', {
+ headers: {
+ 'set-cookie': 'a=1'
+ }
+ })
+
+ t.same([...req1.headers], [['set-cookie', 'a=1']])
+ const req2 = new Request(req1, { headers: {} })
+
+ t.same([...req2.headers], [])
+ t.same(req2.headers.getSetCookie(), [])
+ t.end()
+})
+
+// https://github.com/nodejs/undici/issues/2124
+test('request.referrer', (t) => {
+ for (const referrer of ['about://client', 'about://client:1234']) {
+ const request = new Request('http://a', { referrer })
+
+ t.equal(request.referrer, 'about:client')
+ }
+
+ t.end()
+})
+
+// https://github.com/nodejs/undici/issues/2445
+test('Clone the set-cookie header when Request is passed as the first parameter and no header is passed.', (t) => {
+ t.plan(2)
+ const request = new Request('http://localhost', { headers: { 'set-cookie': 'A' } })
+ const request2 = new Request(request)
+ request2.headers.append('set-cookie', 'B')
+ t.equal(request.headers.getSetCookie().join(', '), request.headers.get('set-cookie'))
+ t.equal(request2.headers.getSetCookie().join(', '), request2.headers.get('set-cookie'))
+})
+
+// Tests for optimization introduced in https://github.com/nodejs/undici/pull/2456
+test('keys to object prototypes method', (t) => {
+ t.plan(1)
+ const request = new Request('http://localhost', { method: 'hasOwnProperty' })
+ t.ok(typeof request.method === 'string')
+})
+
+// https://github.com/nodejs/undici/issues/2465
+test('Issue#2465', async (t) => {
+ t.plan(1)
+ const request = new Request('http://localhost', { body: new SharedArrayBuffer(0), method: 'POST' })
+ t.equal(await request.text(), '[object SharedArrayBuffer]')
+})
+
+teardown(() => process.exit())
diff --git a/test/fetch/resource-timing.js b/test/fetch/resource-timing.js
new file mode 100644
index 0000000..d266f28
--- /dev/null
+++ b/test/fetch/resource-timing.js
@@ -0,0 +1,72 @@
+'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { nodeMajor, nodeMinor } = require('../../lib/core/util')
+const { fetch } = require('../..')
+
+const {
+ PerformanceObserver,
+ performance
+} = require('perf_hooks')
+
+const skip = nodeMajor < 18 || (nodeMajor === 18 && nodeMinor < 2)
+
+test('should create a PerformanceResourceTiming after each fetch request', { skip }, (t) => {
+ t.plan(8)
+
+ const obs = new PerformanceObserver(list => {
+ const expectedResourceEntryName = `http://localhost:${server.address().port}/`
+
+ const entries = list.getEntries()
+ t.equal(entries.length, 1)
+ const [entry] = entries
+ t.same(entry.name, expectedResourceEntryName)
+ t.strictSame(entry.entryType, 'resource')
+
+ t.ok(entry.duration >= 0)
+ t.ok(entry.startTime >= 0)
+
+ const entriesByName = list.getEntriesByName(expectedResourceEntryName)
+ t.equal(entriesByName.length, 1)
+ t.strictSame(entriesByName[0], entry)
+
+ obs.disconnect()
+ performance.clearResourceTimings()
+ })
+
+ obs.observe({ entryTypes: ['resource'] })
+
+ const server = createServer((req, res) => {
+ res.end('ok')
+ }).listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}`)
+ t.strictSame('ok', await body.text())
+ })
+
+ t.teardown(server.close.bind(server))
+})
+
+test('should include encodedBodySize in performance entry', { skip }, (t) => {
+ t.plan(4)
+ const obs = new PerformanceObserver(list => {
+ const [entry] = list.getEntries()
+ t.equal(entry.encodedBodySize, 2)
+ t.equal(entry.decodedBodySize, 2)
+ t.equal(entry.transferSize, 2 + 300)
+
+ obs.disconnect()
+ performance.clearResourceTimings()
+ })
+
+ obs.observe({ entryTypes: ['resource'] })
+
+ const server = createServer((req, res) => {
+ res.end('ok')
+ }).listen(0, async () => {
+ const body = await fetch(`http://localhost:${server.address().port}`)
+ t.strictSame('ok', await body.text())
+ })
+
+ t.teardown(server.close.bind(server))
+})
diff --git a/test/fetch/response-json.js b/test/fetch/response-json.js
new file mode 100644
index 0000000..6244fbf
--- /dev/null
+++ b/test/fetch/response-json.js
@@ -0,0 +1,113 @@
+'use strict'
+
+const { test } = require('tap')
+const { Response } = require('../../')
+
+// https://github.com/web-platform-tests/wpt/pull/32825/
+
+const APPLICATION_JSON = 'application/json'
+const FOO_BAR = 'foo/bar'
+
+const INIT_TESTS = [
+ [undefined, 200, '', APPLICATION_JSON, {}],
+ [{ status: 400 }, 400, '', APPLICATION_JSON, {}],
+ [{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}],
+ [{ headers: {} }, 200, '', APPLICATION_JSON, {}],
+ [{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}],
+ [{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }]
+]
+
+test('Check response returned by static json() with init', async (t) => {
+ for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) {
+ const response = Response.json('hello world', init)
+ t.equal(response.type, 'default', "Response's type is default")
+ t.equal(response.status, expectedStatus, "Response's status is " + expectedStatus)
+ t.equal(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText))
+ t.equal(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType)
+ for (const key in expectedHeaders) {
+ t.equal(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key]))
+ }
+
+ const data = await response.json()
+ t.equal(data, 'hello world', "Response's body is 'hello world'")
+ }
+
+ t.end()
+})
+
+test('Throws TypeError when calling static json() with an invalid status', (t) => {
+ const nullBodyStatus = [204, 205, 304]
+
+ for (const status of nullBodyStatus) {
+ t.throws(() => {
+ Response.json('hello world', { status })
+ }, TypeError, `Throws TypeError when calling static json() with a status of ${status}`)
+ }
+
+ t.end()
+})
+
+test('Check static json() encodes JSON objects correctly', async (t) => {
+ const response = Response.json({ foo: 'bar' })
+ const data = await response.json()
+ t.equal(typeof data, 'object', "Response's json body is an object")
+ t.equal(data.foo, 'bar', "Response's json body is { foo: 'bar' }")
+
+ t.end()
+})
+
+test('Check static json() throws when data is not encodable', (t) => {
+ t.throws(() => {
+ Response.json(Symbol('foo'))
+ }, TypeError)
+
+ t.end()
+})
+
+test('Check static json() throws when data is circular', (t) => {
+ const a = { b: 1 }
+ a.a = a
+
+ t.throws(() => {
+ Response.json(a)
+ }, TypeError)
+
+ t.end()
+})
+
+test('Check static json() propagates JSON serializer errors', (t) => {
+ class CustomError extends Error {
+ name = 'CustomError'
+ }
+
+ t.throws(() => {
+ Response.json({ get foo () { throw new CustomError('bar') } })
+ }, CustomError)
+
+ t.end()
+})
+
+// note: these tests are not part of any WPTs
+test('unserializable values', (t) => {
+ t.throws(() => {
+ Response.json(Symbol('symbol'))
+ }, TypeError)
+
+ t.throws(() => {
+ Response.json(undefined)
+ }, TypeError)
+
+ t.throws(() => {
+ Response.json()
+ }, TypeError)
+
+ t.end()
+})
+
+test('invalid init', (t) => {
+ t.throws(() => {
+ Response.json(null, 3)
+ }, TypeError)
+
+ t.end()
+})
diff --git a/test/fetch/response.js b/test/fetch/response.js
new file mode 100644
index 0000000..422c7ef
--- /dev/null
+++ b/test/fetch/response.js
@@ -0,0 +1,257 @@
+'use strict'
+
+const { test } = require('tap')
+const {
+ Response
+} = require('../../')
+const { ReadableStream } = require('stream/web')
+const {
+ Blob: ThirdPartyBlob,
+ FormData: ThirdPartyFormData
+} = require('formdata-node')
+
+test('arg validation', async (t) => {
+ // constructor
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(null, 0)
+ }, TypeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(null, {
+ status: 99
+ })
+ }, RangeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(null, {
+ status: 600
+ })
+ }, RangeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(null, {
+ status: '600'
+ })
+ }, RangeError)
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(null, {
+ statusText: '\u0000'
+ })
+ }, TypeError)
+
+ for (const nullStatus of [204, 205, 304]) {
+ t.throws(() => {
+ // eslint-disable-next-line
+ new Response(new ArrayBuffer(16), {
+ status: nullStatus
+ })
+ }, TypeError)
+ }
+
+ t.doesNotThrow(() => {
+ Response.prototype[Symbol.toStringTag].charAt(0)
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.type.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.url.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.redirected.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.status.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.ok.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.statusText.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.headers.toString()
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ Response.prototype.body
+ }, TypeError)
+
+ t.throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ Response.prototype.bodyUsed
+ }, TypeError)
+
+ t.throws(() => {
+ Response.prototype.clone.call(null)
+ }, TypeError)
+
+ await t.rejects(async () => {
+ await new Response('http://localhost').text.call({
+ blob () {
+ return {
+ text () {
+ return Promise.resolve('emulating response.blob()')
+ }
+ }
+ }
+ })
+ }, TypeError)
+
+ t.end()
+})
+
+test('response clone', (t) => {
+ // https://github.com/nodejs/undici/issues/1122
+ const response1 = new Response(null, { status: 201 })
+ const response2 = new Response(undefined, { status: 201 })
+
+ t.equal(response1.body, response1.clone().body)
+ t.equal(response2.body, response2.clone().body)
+ t.equal(response2.body, null)
+ t.end()
+})
+
+test('Symbol.toStringTag', (t) => {
+ const resp = new Response()
+
+ t.equal(resp[Symbol.toStringTag], 'Response')
+ t.equal(Response.prototype[Symbol.toStringTag], 'Response')
+ t.end()
+})
+
+test('async iterable body', async (t) => {
+ const asyncIterable = {
+ async * [Symbol.asyncIterator] () {
+ yield 'a'
+ yield 'b'
+ yield 'c'
+ }
+ }
+
+ const response = new Response(asyncIterable)
+ t.equal(await response.text(), 'abc')
+ t.end()
+})
+
+// https://github.com/nodejs/node/pull/43752#issuecomment-1179678544
+test('Modifying headers using Headers.prototype.set', (t) => {
+ const response = new Response('body', {
+ headers: {
+ 'content-type': 'test/test',
+ 'Content-Encoding': 'hello/world'
+ }
+ })
+
+ const response2 = response.clone()
+
+ response.headers.set('content-type', 'application/wasm')
+ response.headers.set('Content-Encoding', 'world/hello')
+
+ t.equal(response.headers.get('content-type'), 'application/wasm')
+ t.equal(response.headers.get('Content-Encoding'), 'world/hello')
+
+ response2.headers.delete('content-type')
+ response2.headers.delete('Content-Encoding')
+
+ t.equal(response2.headers.get('content-type'), null)
+ t.equal(response2.headers.get('Content-Encoding'), null)
+
+ t.end()
+})
+
+// https://github.com/nodejs/node/issues/43838
+test('constructing a Response with a ReadableStream body', { skip: process.version.startsWith('v16.') }, async (t) => {
+ const text = '{"foo":"bar"}'
+ const uint8 = new TextEncoder().encode(text)
+
+ t.test('Readable stream with Uint8Array chunks', async (t) => {
+ const readable = new ReadableStream({
+ start (controller) {
+ controller.enqueue(uint8)
+ controller.close()
+ }
+ })
+
+ const response1 = new Response(readable)
+ const response2 = response1.clone()
+ const response3 = response1.clone()
+
+ t.equal(await response1.text(), text)
+ t.same(await response2.arrayBuffer(), uint8.buffer)
+ t.same(await response3.json(), JSON.parse(text))
+
+ t.end()
+ })
+
+ t.test('Readable stream with non-Uint8Array chunks', async (t) => {
+ const readable = new ReadableStream({
+ start (controller) {
+ controller.enqueue(text) // string
+ controller.close()
+ }
+ })
+
+ const response = new Response(readable)
+
+ await t.rejects(response.text(), TypeError)
+
+ t.end()
+ })
+
+ t.test('Readable with ArrayBuffer chunk still throws', { skip: process.version.startsWith('v16.') }, async (t) => {
+ const readable = new ReadableStream({
+ start (controller) {
+ controller.enqueue(uint8.buffer)
+ controller.close()
+ }
+ })
+
+ const response1 = new Response(readable)
+ const response2 = response1.clone()
+ const response3 = response1.clone()
+ // const response4 = response1.clone()
+
+ await t.rejects(response1.arrayBuffer(), TypeError)
+ await t.rejects(response2.text(), TypeError)
+ await t.rejects(response3.json(), TypeError)
+ // TODO: on Node v16.8.0, this throws a TypeError
+ // because the body is detected as disturbed.
+ // await t.rejects(response4.blob(), TypeError)
+
+ t.end()
+ })
+
+ t.end()
+})
+
+test('constructing Response with third party Blob body', async (t) => {
+ const blob = new ThirdPartyBlob(['text'])
+ const res = new Response(blob)
+ t.equal(await res.text(), 'text')
+})
+test('constructing Response with third party FormData body', async (t) => {
+ const form = new ThirdPartyFormData()
+ form.set('key', 'value')
+ const res = new Response(form)
+ const contentType = res.headers.get('content-type').split('=')
+ t.equal(contentType[0], 'multipart/form-data; boundary')
+ t.ok((await res.text()).startsWith(`--${contentType[1]}`))
+})
+
+// https://github.com/nodejs/undici/issues/2465
+test('Issue#2465', async (t) => {
+ t.plan(1)
+ const response = new Response(new SharedArrayBuffer(0))
+ t.equal(await response.text(), '[object SharedArrayBuffer]')
+})
diff --git a/test/fetch/user-agent.js b/test/fetch/user-agent.js
new file mode 100644
index 0000000..2e37ea5
--- /dev/null
+++ b/test/fetch/user-agent.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const { test, skip } = require('tap')
+const events = require('events')
+const http = require('http')
+const undici = require('../../')
+const { nodeMajor } = require('../../lib/core/util')
+
+if (nodeMajor === 16) {
+ skip('esbuild uses static blocks with --keep-names which node 16.8 does not have')
+ process.exit()
+}
+
+const nodeBuild = require('../../undici-fetch.js')
+
+test('user-agent defaults correctly', async (t) => {
+ const server = http.createServer((req, res) => {
+ res.end(JSON.stringify({ userAgentHeader: req.headers['user-agent'] }))
+ })
+ t.teardown(server.close.bind(server))
+
+ server.listen(0)
+ await events.once(server, 'listening')
+ const url = `http://localhost:${server.address().port}`
+ const [nodeBuildJSON, undiciJSON] = await Promise.all([
+ nodeBuild.fetch(url).then((body) => body.json()),
+ undici.fetch(url).then((body) => body.json())
+ ])
+
+ t.same(nodeBuildJSON.userAgentHeader, 'node')
+ t.same(undiciJSON.userAgentHeader, 'undici')
+})
diff --git a/test/fetch/util.js b/test/fetch/util.js
new file mode 100644
index 0000000..02b75bc
--- /dev/null
+++ b/test/fetch/util.js
@@ -0,0 +1,281 @@
+'use strict'
+
+const t = require('tap')
+const { test } = t
+
+const util = require('../../lib/fetch/util')
+const { HeadersList } = require('../../lib/fetch/headers')
+
+test('responseURL', (t) => {
+ t.plan(2)
+
+ t.ok(util.responseURL({
+ urlList: [
+ new URL('http://asd'),
+ new URL('http://fgh')
+ ]
+ }))
+ t.notOk(util.responseURL({
+ urlList: []
+ }))
+})
+
+test('responseLocationURL', (t) => {
+ t.plan(3)
+
+ const acceptHeaderList = new HeadersList()
+ acceptHeaderList.append('Accept', '*/*')
+
+ const locationHeaderList = new HeadersList()
+ locationHeaderList.append('Location', 'http://asd')
+
+ t.notOk(util.responseLocationURL({
+ status: 200
+ }))
+ t.notOk(util.responseLocationURL({
+ status: 301,
+ headersList: acceptHeaderList
+ }))
+ t.ok(util.responseLocationURL({
+ status: 301,
+ headersList: locationHeaderList,
+ urlList: [
+ new URL('http://asd'),
+ new URL('http://fgh')
+ ]
+ }))
+})
+
+test('requestBadPort', (t) => {
+ t.plan(3)
+
+ t.equal('allowed', util.requestBadPort({
+ urlList: [new URL('https://asd')]
+ }))
+ t.equal('blocked', util.requestBadPort({
+ urlList: [new URL('http://asd:7')]
+ }))
+ t.equal('blocked', util.requestBadPort({
+ urlList: [new URL('https://asd:7')]
+ }))
+})
+
+// https://html.spec.whatwg.org/multipage/origin.html#same-origin
+// look at examples
+test('sameOrigin', (t) => {
+ t.test('first test', (t) => {
+ const A = {
+ protocol: 'https:',
+ hostname: 'example.org',
+ port: ''
+ }
+
+ const B = {
+ protocol: 'https:',
+ hostname: 'example.org',
+ port: ''
+ }
+
+ t.ok(util.sameOrigin(A, B))
+ t.end()
+ })
+
+ t.test('second test', (t) => {
+ const A = {
+ protocol: 'https:',
+ hostname: 'example.org',
+ port: '314'
+ }
+
+ const B = {
+ protocol: 'https:',
+ hostname: 'example.org',
+ port: '420'
+ }
+
+ t.notOk(util.sameOrigin(A, B))
+ t.end()
+ })
+
+ t.test('obviously shouldn\'t be equal', (t) => {
+ t.notOk(util.sameOrigin(
+ { protocol: 'http:', hostname: 'example.org' },
+ { protocol: 'https:', hostname: 'example.org' }
+ ))
+
+ t.notOk(util.sameOrigin(
+ { protocol: 'https:', hostname: 'example.org' },
+ { protocol: 'https:', hostname: 'example.com' }
+ ))
+
+ t.end()
+ })
+
+ t.test('file:// urls', (t) => {
+ // urls with opaque origins should return true
+
+ const a = new URL('file:///C:/undici')
+ const b = new URL('file:///var/undici')
+
+ t.ok(util.sameOrigin(a, b))
+ t.end()
+ })
+
+ t.end()
+})
+
+test('isURLPotentiallyTrustworthy', (t) => {
+ const valid = ['http://127.0.0.1', 'http://localhost.localhost',
+ 'http://[::1]', 'http://adb.localhost', 'https://something.com', 'wss://hello.com',
+ 'file:///link/to/file.txt', 'data:text/plain;base64,randomstring', 'about:blank', 'about:srcdoc']
+ const invalid = ['http://121.3.4.5:55', 'null:8080', 'something:8080']
+
+ t.plan(valid.length + invalid.length + 1)
+ t.notOk(util.isURLPotentiallyTrustworthy('string'))
+
+ for (const url of valid) {
+ const instance = new URL(url)
+ t.ok(util.isURLPotentiallyTrustworthy(instance))
+ }
+
+ for (const url of invalid) {
+ const instance = new URL(url)
+ t.notOk(util.isURLPotentiallyTrustworthy(instance))
+ }
+})
+
+test('setRequestReferrerPolicyOnRedirect', nested => {
+ nested.plan(7)
+
+ nested.test('should set referrer policy from response headers on redirect', t => {
+ const request = {
+ referrerPolicy: 'no-referrer, strict-origin-when-cross-origin'
+ }
+
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'origin')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, 'origin')
+ })
+
+ nested.test('should select the first valid policy from a response', t => {
+ const request = {
+ referrerPolicy: 'no-referrer, strict-origin-when-cross-origin'
+ }
+
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'asdas, origin')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, 'origin')
+ })
+
+ nested.test('should select the first valid policy from a response#2', t => {
+ const request = {
+ referrerPolicy: 'no-referrer, strict-origin-when-cross-origin'
+ }
+
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'no-referrer, asdas, origin, 0943sd')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, 'origin')
+ })
+
+ nested.test('should pick the last fallback over invalid policy tokens', t => {
+ const request = {
+ referrerPolicy: 'no-referrer, strict-origin-when-cross-origin'
+ }
+
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'origin, asdas, asdaw34')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, 'origin')
+ })
+
+ nested.test('should set not change request referrer policy if no Referrer-Policy from initial redirect response', t => {
+ const request = {
+ referrerPolicy: 'no-referrer, strict-origin-when-cross-origin'
+ }
+
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, 'no-referrer, strict-origin-when-cross-origin')
+ })
+
+ nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => {
+ const initial = 'no-referrer, strict-origin-when-cross-origin'
+ const request = {
+ referrerPolicy: initial
+ }
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'asdasd')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, initial)
+ })
+
+ nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => {
+ const initial = 'no-referrer, strict-origin-when-cross-origin'
+ const request = {
+ referrerPolicy: initial
+ }
+ const actualResponse = {
+ headersList: new HeadersList()
+ }
+
+ t.plan(1)
+
+ actualResponse.headersList.append('Connection', 'close')
+ actualResponse.headersList.append('Location', 'https://some-location.com/redirect')
+ actualResponse.headersList.append('Referrer-Policy', 'asdasd, asdasa, 12daw,')
+ util.setRequestReferrerPolicyOnRedirect(request, actualResponse)
+
+ t.equal(request.referrerPolicy, initial)
+ })
+})