Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
at v0.4.5 1176 lines 42 kB view raw
1// Source: https://github.com/remix-run/web-std-io/blob/7a8596e/packages/fetch/test/main.js 2 3import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 4 5import FormDataPolyfill from 'form-data'; 6import { ReadableStream } from 'node:stream/web'; 7import stream from 'node:stream'; 8import vm from 'node:vm'; 9 10import TestServer from './utils/server.js'; 11import { fetch } from '../fetch'; 12 13const { Uint8Array: VMUint8Array } = vm.runInNewContext('this'); 14 15async function streamToPromise<T>( 16 stream: ReadableStream<T>, 17 dataHandler: (data: T) => void 18) { 19 for await (const chunk of stream) { 20 dataHandler(chunk); 21 } 22} 23 24async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> { 25 const chunks: T[] = []; 26 for await (const chunk of stream) chunks.push(chunk); 27 return chunks; 28} 29 30describe(fetch, () => { 31 const local = new TestServer(); 32 let baseURL: string; 33 34 beforeEach(async () => { 35 await local.start(); 36 baseURL = `http://${local.hostname}:${local.port}/`; 37 }); 38 39 afterEach(async () => { 40 await local.stop(); 41 }); 42 43 it('should reject with error if url is protocol relative', async () => { 44 // [Type Error: Invalid URL] 45 await expect(() => 46 fetch('//example.com/') 47 ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`); 48 }); 49 50 it('should reject with error if url is relative path', async () => { 51 // [Type Error: Invalid URL] 52 await expect(() => 53 fetch('/some/path') 54 ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`); 55 }); 56 57 it('should reject with error if protocol is unsupported', async () => { 58 // URL scheme 'ftp' is not supported 59 await expect( 60 fetch('ftp://example.com/') 61 ).rejects.toThrowErrorMatchingInlineSnapshot( 62 `[TypeError: URL scheme "ftp:" is not supported.]` 63 ); 64 }); 65 66 it('should reject with error on network failure', async () => { 67 await expect(() => fetch('http://localhost:50000/')).rejects.toThrow(); 68 }, 1_000); 69 70 it('should resolve into response', async () => { 71 const response = await fetch(new URL('hello', baseURL)); 72 expect(response.url).toBe(`${baseURL}hello`); 73 expect(response).toBeInstanceOf(Response); 74 expect(response).toMatchObject({ 75 headers: expect.any(Headers), 76 body: expect.any(ReadableStream), 77 bodyUsed: false, 78 ok: true, 79 status: 200, 80 statusText: 'OK', 81 }); 82 }); 83 84 it('should support https request', async () => { 85 const response = await fetch('https://github.com/', { method: 'HEAD' }); 86 expect(response.status).toBe(200); 87 }, 5000); 88 89 describe('response methods', () => { 90 it('should accept plain text response', async () => { 91 const response = await fetch(new URL('plain', baseURL)); 92 expect(response.headers.get('content-type')).toBe('text/plain'); 93 const text = await response.text(); 94 expect(response.bodyUsed).toBe(true); 95 expect(text).toBe('text'); 96 }); 97 98 it('should accept html response (like plain text)', async () => { 99 const response = await fetch(new URL('html', baseURL)); 100 expect(response.headers.get('content-type')).toBe('text/html'); 101 const text = await response.text(); 102 expect(response.bodyUsed).toBe(true); 103 expect(text).toBe('<html></html>'); 104 }); 105 106 it('should accept json response', async () => { 107 const response = await fetch(new URL('json', baseURL)); 108 expect(response.headers.get('content-type')).toBe('application/json'); 109 const text = await response.json(); 110 expect(response.bodyUsed).toBe(true); 111 expect(text).toEqual({ name: 'value' }); 112 }); 113 }); 114 115 describe('request headers', () => { 116 it('should send request with custom headers', async () => { 117 const response = await fetch(new URL('inspect', baseURL), { 118 headers: { 'x-custom-header': 'abc' }, 119 }); 120 expect(await response.json()).toMatchObject({ 121 headers: expect.objectContaining({ 'x-custom-header': 'abc' }), 122 }); 123 }); 124 125 it('should send custom Content-Type with body', async () => { 126 const response = await fetch(new URL('inspect', baseURL), { 127 headers: { 'content-type': 'some/magic' }, 128 body: 'test', 129 }); 130 expect(await response.json()).toMatchObject({ 131 headers: expect.objectContaining({ 'content-type': 'some/magic' }), 132 }); 133 }); 134 135 it('should prefer init headers when Request is passed', async () => { 136 const request = new Request(new URL('inspect', baseURL), { 137 headers: { 'x-custom-header': 'abc' }, 138 }); 139 const response = await fetch(request, { 140 headers: { 'x-custom-header': 'def' }, 141 }); 142 expect(await response.json()).toMatchObject({ 143 headers: expect.objectContaining({ 'x-custom-header': 'def' }), 144 }); 145 }); 146 147 it('should send request with custom User-Agent', async () => { 148 const response = await fetch(new URL('inspect', baseURL), { 149 headers: { 'user-agent': 'faked' }, 150 }); 151 expect(await response.json()).toMatchObject({ 152 headers: expect.objectContaining({ 'user-agent': 'faked' }), 153 }); 154 }); 155 156 it('should set default Accept header', async () => { 157 const response = await fetch(new URL('inspect', baseURL)); 158 expect(await response.json()).toMatchObject({ 159 headers: expect.objectContaining({ accept: '*/*' }), 160 }); 161 }); 162 163 it('should send custom Accept header', async () => { 164 const response = await fetch(new URL('inspect', baseURL), { 165 headers: { accept: 'application/json' }, 166 }); 167 expect(await response.json()).toMatchObject({ 168 headers: expect.objectContaining({ accept: 'application/json' }), 169 }); 170 }); 171 172 it('should accept headers instance', async () => { 173 const response = await fetch(new URL('inspect', baseURL), { 174 headers: new Headers({ 'x-custom-header': 'abc' }), 175 }); 176 expect(await response.json()).toMatchObject({ 177 headers: expect.objectContaining({ 'x-custom-header': 'abc' }), 178 }); 179 }); 180 181 it('should accept custom "host" header', async () => { 182 const response = await fetch(new URL('inspect', baseURL), { 183 headers: { host: 'example.com' }, 184 }); 185 expect(await response.json()).toMatchObject({ 186 headers: expect.objectContaining({ host: 'example.com' }), 187 }); 188 }); 189 190 it('should accept custom "HoSt" header', async () => { 191 const response = await fetch(new URL('inspect', baseURL), { 192 headers: { HoSt: 'example.com' }, 193 }); 194 expect(await response.json()).toMatchObject({ 195 headers: expect.objectContaining({ host: 'example.com' }), 196 }); 197 }); 198 }); 199 200 describe('redirects', () => { 201 it.each([[301], [302], [303], [307], [308]])( 202 'should follow redirect code %d', 203 async status => { 204 const response = await fetch(new URL(`redirect/${status}`, baseURL)); 205 expect(response.headers.get('X-Inspect')).toBe('inspect'); 206 } 207 ); 208 209 it('should follow redirect chain', async () => { 210 const response = await fetch(new URL('redirect/chain', baseURL)); 211 expect(response.headers.get('X-Inspect')).toBe('inspect'); 212 }); 213 214 it.each([ 215 ['POST', 301, 'GET'], 216 ['PUT', 301, 'PUT'], 217 ['POST', 302, 'GET'], 218 ['PATCH', 302, 'PATCH'], 219 ['PUT', 303, 'GET'], 220 ['PATCH', 307, 'PATCH'], 221 ])( 222 'should follow %s request redirect code %d with %s', 223 async (inputMethod, code, outputMethod) => { 224 const response = await fetch(new URL(`redirect/${code}`, baseURL), { 225 method: inputMethod, 226 body: 'a=1', 227 }); 228 expect(response.headers.get('X-Inspect')).toBe('inspect'); 229 expect(response.url).toBe(`${baseURL}inspect`); 230 expect(await response.json()).toMatchObject({ 231 method: outputMethod, 232 body: outputMethod === 'GET' ? '' : 'a=1', 233 }); 234 } 235 ); 236 237 it('should not follow non-GET redirect if body is a readable stream', async () => { 238 await expect(() => 239 fetch(new URL('redirect/307', baseURL), { 240 method: 'POST', 241 body: stream.Readable.from('tada'), 242 }) 243 ).rejects.toThrowErrorMatchingInlineSnapshot( 244 `[Error: Cannot follow redirect with a streamed body]` 245 ); 246 }); 247 248 it('should not follow non HTTP(s) redirect', async () => { 249 await expect(() => 250 fetch(new URL('redirect/301/file', baseURL)) 251 ).rejects.toThrowErrorMatchingInlineSnapshot( 252 `[Error: URL scheme must be a HTTP(S) scheme]` 253 ); 254 }); 255 256 it('should support redirect mode, manual flag', async () => { 257 const response = await fetch(new URL('redirect/301', baseURL), { 258 redirect: 'manual', 259 }); 260 expect(response.status).toBe(301); 261 expect(response.headers.get('location')).toBe(`${baseURL}inspect`); 262 }); 263 264 it('should support redirect mode, manual flag, broken Location header', async () => { 265 const response = await fetch(new URL('redirect/bad-location', baseURL), { 266 redirect: 'manual', 267 }); 268 expect(response.status).toBe(301); 269 expect(response.headers.get('location')).toBe( 270 `${baseURL}redirect/%C3%A2%C2%98%C2%83` 271 ); 272 }); 273 274 it('should support redirect mode, error flag', async () => { 275 await expect(() => 276 fetch(new URL('redirect/301', baseURL), { 277 redirect: 'error', 278 }) 279 ).rejects.toThrowErrorMatchingInlineSnapshot( 280 `[Error: URI requested responds with a redirect, redirect mode is set to error]` 281 ); 282 }); 283 284 it('should support redirect mode, manual flag when there is no redirect', async () => { 285 const response = await fetch(new URL('hello', baseURL), { 286 redirect: 'manual', 287 }); 288 expect(response.status).toBe(200); 289 expect(response.headers.has('location')).toBe(false); 290 }); 291 292 it('should follow redirect code 301 and keep existing headers', async () => { 293 const response = await fetch(new URL('inspect', baseURL), { 294 headers: new Headers({ 'x-custom-header': 'abc' }), 295 }); 296 expect(await response.json()).toMatchObject({ 297 headers: expect.objectContaining({ 298 'x-custom-header': 'abc', 299 }), 300 }); 301 }); 302 303 it('should treat broken redirect as ordinary response for redirect: "manual"', async () => { 304 const response = await fetch(new URL('redirect/no-location', baseURL), { 305 redirect: 'manual', 306 }); 307 expect(response.status).toBe(301); 308 expect(response.headers.has('location')).toBe(false); 309 }); 310 311 it('should throw on broken redirects for redirect: "follow"', async () => { 312 await expect(() => 313 fetch(new URL('redirect/no-location', baseURL), { 314 redirect: 'follow', 315 }) 316 ).rejects.toThrowErrorMatchingInlineSnapshot( 317 `[Error: URI requested responds with an invalid redirect URL]` 318 ); 319 }); 320 321 it('should throw a TypeError on an invalid redirect option', async () => { 322 await expect(() => 323 fetch(new URL('redirect/no-location', baseURL), { 324 // @ts-ignore: Intentionally invalid 325 redirect: 'foobar', 326 }) 327 ).rejects.toThrowErrorMatchingInlineSnapshot( 328 `[TypeError: Request constructor: foobar is not an accepted type. Expected one of follow, manual, error.]` 329 ); 330 }); 331 332 it('should set redirected property on response when redirect', async () => { 333 const response = await fetch(new URL('redirect/301', baseURL)); 334 expect(response.redirected).toBe(true); 335 }); 336 337 it('should not set redirected property on response without redirect', async () => { 338 const response = await fetch(new URL('hello', baseURL)); 339 expect(response.redirected).toBe(false); 340 }); 341 342 it('should follow redirect after empty chunked transfer-encoding', async () => { 343 const response = await fetch(new URL('redirect/chunked', baseURL)); 344 expect(response.status).toBe(200); 345 expect(response.ok).toBe(true); 346 }); 347 }); 348 349 describe('error handling', () => { 350 it('should handle client-error response', async () => { 351 const response = await fetch(new URL('error/400', baseURL)); 352 expect(response.headers.get('content-type')).toBe('text/plain'); 353 expect(response.status).toBe(400); 354 expect(response.statusText).toBe('Bad Request'); 355 expect(response.ok).toBe(false); 356 expect(await response.text()).toBe('client error'); 357 }); 358 359 it('should handle server-error response', async () => { 360 const response = await fetch(new URL('error/500', baseURL)); 361 expect(response.headers.get('content-type')).toBe('text/plain'); 362 expect(response.status).toBe(500); 363 expect(response.statusText).toBe('Internal Server Error'); 364 expect(response.ok).toBe(false); 365 expect(await response.text()).toBe('server error'); 366 }); 367 368 it('should handle network-error response', async () => { 369 await expect(() => 370 fetch(new URL('error/reset', baseURL)) 371 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: socket hang up]`); 372 }); 373 374 it('should handle premature close properly', async () => { 375 const response = await fetch(new URL('redirect/301/rn', baseURL)); 376 expect(response.status).toBe(403); 377 }); 378 379 it('should handle network-error partial response', async () => { 380 const response = await fetch(new URL('error/premature', baseURL)); 381 expect(response.status).toBe(200); 382 expect(response.ok).toBe(true); 383 await expect(() => 384 response.text() 385 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`); 386 }); 387 388 it('should handle network-error in chunked response', async () => { 389 const response = await fetch(new URL('error/premature/chunked', baseURL)); 390 expect(response.status).toBe(200); 391 expect(response.ok).toBe(true); 392 await expect(() => 393 collectStream(response.body!) 394 ).rejects.toMatchInlineSnapshot(`[Error: aborted]`); 395 }); 396 397 it('should handle network-error in chunked response in consumeBody', async () => { 398 const response = await fetch(new URL('error/premature/chunked', baseURL)); 399 expect(response.status).toBe(200); 400 expect(response.ok).toBe(true); 401 await expect(() => 402 response.text() 403 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`); 404 }); 405 }); 406 407 describe('responses', () => { 408 it('should handle chunked response with more than 1 chunk in the final packet', async () => { 409 const response = await fetch(new URL('chunked/multiple-ending', baseURL)); 410 expect(response.ok).toBe(true); 411 expect(await response.text()).toBe('foobar'); 412 }); 413 414 it('should handle chunked response with final chunk and EOM in separate packets', async () => { 415 const response = await fetch(new URL('chunked/split-ending', baseURL)); 416 expect(response.ok).toBe(true); 417 expect(await response.text()).toBe('foobar'); 418 }); 419 420 it('should reject invalid json response', async () => { 421 const response = await fetch(new URL('error/json', baseURL)); 422 expect(response.headers.get('content-type')).toBe('application/json'); 423 await expect(() => response.json()).rejects.toThrow(/Unexpected token/); 424 }); 425 426 it('should reject decoding body twice', async () => { 427 const response = await fetch(new URL('plain', baseURL)); 428 expect(response.headers.get('Content-Type')).toBe('text/plain'); 429 await response.text(); 430 expect(response.bodyUsed).toBe(true); 431 await expect(() => response.text()).rejects.toThrow(/Body is unusable/); 432 }); 433 434 it('should handle response with no status text', async () => { 435 const response = await fetch(new URL('no-status-text', baseURL)); 436 expect(response.statusText).toBe(''); 437 }); 438 439 it('should allow piping response body as stream', async () => { 440 const response = await fetch(new URL('hello', baseURL)); 441 const onResult = vi.fn(data => { 442 expect(Buffer.from(data).toString()).toBe('world'); 443 }); 444 await streamToPromise(response.body!, onResult); 445 expect(onResult).toHaveBeenCalledOnce(); 446 }); 447 448 it('should allow cloning response body to two streams', async () => { 449 const response = await fetch(new URL('hello', baseURL)); 450 const clone = response.clone(); 451 const onResult = vi.fn(data => { 452 expect(Buffer.from(data).toString()).toBe('world'); 453 }); 454 await Promise.all([ 455 streamToPromise(response.body!, onResult), 456 streamToPromise(clone.body!, onResult), 457 ]); 458 expect(onResult).toHaveBeenCalledTimes(2); 459 }); 460 461 describe('no content', () => { 462 it('should handle no content response', async () => { 463 const response = await fetch(new URL('no-content', baseURL)); 464 expect(response.status).toBe(204); 465 expect(response.statusText).toBe('No Content'); 466 expect(response.ok).toBe(true); 467 expect(await response.text()).toBe(''); 468 }); 469 470 it('should reject when trying to parse no content response as json', async () => { 471 const response = await fetch(new URL('no-content', baseURL)); 472 expect(response.status).toBe(204); 473 expect(response.statusText).toBe('No Content'); 474 expect(response.ok).toBe(true); 475 await expect(() => 476 response.json() 477 ).rejects.toThrowErrorMatchingInlineSnapshot( 478 `[SyntaxError: Unexpected end of JSON input]` 479 ); 480 }); 481 482 it('should handle no content response with gzip encoding', async () => { 483 const response = await fetch(new URL('no-content/gzip', baseURL)); 484 expect(response.status).toBe(204); 485 expect(response.statusText).toBe('No Content'); 486 expect(response.headers.get('Content-Encoding')).toBe('gzip'); 487 expect(response.ok).toBe(true); 488 expect(await response.text()).toBe(''); 489 }); 490 491 it('should handle 304 response', async () => { 492 const response = await fetch(new URL('not-modified', baseURL)); 493 expect(response.status).toBe(304); 494 expect(response.statusText).toBe('Not Modified'); 495 expect(response.ok).toBe(false); 496 expect(await response.text()).toBe(''); 497 }); 498 499 it('should handle 304 response with gzip encoding', async () => { 500 const response = await fetch(new URL('not-modified/gzip', baseURL)); 501 expect(response.status).toBe(304); 502 expect(response.statusText).toBe('Not Modified'); 503 expect(response.headers.get('Content-Encoding')).toBe('gzip'); 504 expect(response.ok).toBe(false); 505 expect(await response.text()).toBe(''); 506 }); 507 }); 508 }); 509 510 describe('content encoding', () => { 511 it('should decompress gzip response', async () => { 512 const response = await fetch(new URL('gzip', baseURL)); 513 expect(response.headers.get('content-type')).toBe('text/plain'); 514 expect(response.headers.get('content-encoding')).toBe('gzip'); 515 expect(await response.text()).toBe('hello world'); 516 }); 517 518 it('should decompress slightly invalid gzip response', async () => { 519 const response = await fetch(new URL('gzip-truncated', baseURL)); 520 expect(response.headers.get('content-type')).toBe('text/plain'); 521 expect(response.headers.get('content-encoding')).toBe('gzip'); 522 expect(await response.text()).toBe('hello world'); 523 }); 524 525 it('should make capitalised Content-Encoding lowercase', async () => { 526 const response = await fetch(new URL('gzip-capital', baseURL)); 527 expect(response.headers.get('content-type')).toBe('text/plain'); 528 expect(response.headers.get('content-encoding')).toBe('gzip'); 529 expect(await response.text()).toBe('hello world'); 530 }); 531 532 it('should decompress deflate response', async () => { 533 const response = await fetch(new URL('deflate', baseURL)); 534 expect(response.headers.get('content-type')).toBe('text/plain'); 535 expect(response.headers.get('content-encoding')).toBe('deflate'); 536 expect(await response.text()).toBe('hello world'); 537 }); 538 539 it('should decompress deflate raw response from old apache server', async () => { 540 const response = await fetch(new URL('deflate-raw', baseURL)); 541 expect(response.headers.get('content-type')).toBe('text/plain'); 542 expect(response.headers.get('content-encoding')).toBe('deflate'); 543 expect(await response.text()).toBe('hello world'); 544 }); 545 546 it('should decompress brotli response', async () => { 547 const response = await fetch(new URL('brotli', baseURL)); 548 expect(response.headers.get('content-type')).toBe('text/plain'); 549 expect(response.headers.get('content-encoding')).toBe('br'); 550 expect(await response.text()).toBe('hello world'); 551 }); 552 553 it('should skip decompression if unsupported', async () => { 554 const response = await fetch(new URL('sdch', baseURL)); 555 expect(response.headers.get('content-type')).toBe('text/plain'); 556 expect(response.headers.get('content-encoding')).toBe('sdch'); 557 expect(await response.text()).toBe('fake sdch string'); 558 }); 559 560 it('should reject if response compression is invalid', async () => { 561 const response = await fetch( 562 new URL('invalid-content-encoding', baseURL) 563 ); 564 expect(response.headers.get('content-type')).toBe('text/plain'); 565 expect(response.headers.get('content-encoding')).toBe('gzip'); 566 await expect(() => 567 response.text() 568 ).rejects.toThrowErrorMatchingInlineSnapshot( 569 `[Error: incorrect header check]` 570 ); 571 }); 572 573 it('should handle errors on invalid body stream even if it is not used', async () => { 574 const response = await fetch( 575 new URL('invalid-content-encoding', baseURL) 576 ); 577 expect(response.headers.get('content-type')).toBe('text/plain'); 578 expect(response.headers.get('content-encoding')).toBe('gzip'); 579 await new Promise(resolve => setTimeout(resolve, 20)); 580 }); 581 582 it('should reject when invalid body stream is used later', async () => { 583 const response = await fetch( 584 new URL('invalid-content-encoding', baseURL) 585 ); 586 expect(response.headers.get('content-type')).toBe('text/plain'); 587 expect(response.headers.get('content-encoding')).toBe('gzip'); 588 await new Promise(resolve => setTimeout(resolve, 20)); 589 await expect(() => 590 response.text() 591 ).rejects.toThrowErrorMatchingInlineSnapshot( 592 `[Error: incorrect header check]` 593 ); 594 }); 595 }); 596 597 describe('AbortController', () => { 598 let controller: AbortController; 599 600 beforeEach(() => { 601 controller = new AbortController(); 602 }); 603 604 it('should support request cancellation with signal', async () => { 605 const response$ = fetch(new URL('timeout', baseURL), { 606 method: 'POST', 607 signal: controller.signal, 608 headers: { 609 'Content-Type': 'application/json', 610 body: JSON.stringify({ hello: 'world' }), 611 }, 612 }); 613 setTimeout(() => controller.abort(), 100); 614 await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot( 615 `[AbortError: This operation was aborted]` 616 ); 617 }); 618 619 it('should support multiple request cancellation with signal', async () => { 620 const fetches = [ 621 fetch(new URL('timeout', baseURL), { signal: controller.signal }), 622 fetch(new URL('timeout', baseURL), { 623 method: 'POST', 624 signal: controller.signal, 625 headers: { 626 'Content-Type': 'application/json', 627 body: JSON.stringify({ hello: 'world' }), 628 }, 629 }), 630 ]; 631 setTimeout(() => controller.abort(), 100); 632 await expect(fetches[0]).rejects.toThrowErrorMatchingInlineSnapshot( 633 `[AbortError: This operation was aborted]` 634 ); 635 await expect(fetches[1]).rejects.toThrowErrorMatchingInlineSnapshot( 636 `[AbortError: This operation was aborted]` 637 ); 638 }); 639 640 it('should reject immediately if signal has already been aborted', async () => { 641 controller.abort(); 642 await expect(() => { 643 return fetch(new URL('timeout', baseURL), { 644 signal: controller.signal, 645 }); 646 }).rejects.toThrowErrorMatchingInlineSnapshot( 647 `[AbortError: This operation was aborted]` 648 ); 649 }); 650 651 it('should allow redirects to be aborted', async () => { 652 const request = new Request(new URL('redirect/slow', baseURL), { 653 signal: controller.signal, 654 }); 655 setTimeout(() => controller.abort(), 20); 656 await expect(() => 657 fetch(request) 658 ).rejects.toThrowErrorMatchingInlineSnapshot( 659 `[AbortError: This operation was aborted]` 660 ); 661 }); 662 663 it('should allow redirected response body to be aborted', async () => { 664 const response = await fetch(new URL('redirect/slow-stream', baseURL), { 665 signal: controller.signal, 666 }); 667 expect(response.headers.get('content-type')).toBe('text/plain'); 668 const text$ = response.text(); 669 controller.abort(); 670 await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot( 671 `[AbortError: This operation was aborted]` 672 ); 673 }); 674 675 it('should reject response body when aborted before stream completes', async () => { 676 const response = await fetch(new URL('slow', baseURL), { 677 signal: controller.signal, 678 }); 679 const text$ = response.text(); 680 controller.abort(); 681 await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot( 682 `[AbortError: This operation was aborted]` 683 ); 684 }); 685 686 it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', async () => { 687 const response$ = fetch(new URL('slow', baseURL), { 688 signal: controller.signal, 689 }); 690 controller.abort(); 691 await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot( 692 `[AbortError: This operation was aborted]` 693 ); 694 }); 695 696 it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', async () => { 697 const response = await fetch(new URL('slow', baseURL), { 698 signal: controller.signal, 699 }); 700 const done$ = expect(() => 701 response.arrayBuffer() 702 ).rejects.toThrowErrorMatchingInlineSnapshot( 703 `[AbortError: This operation was aborted]` 704 ); 705 controller.abort(); 706 await done$; 707 }); 708 709 it('should cancel request body of type Stream with AbortError when aborted', async () => { 710 const body = new stream.Readable({ objectMode: true }); 711 body._read = () => {}; 712 const response$ = fetch(new URL('slow', baseURL), { 713 signal: controller.signal, 714 method: 'POST', 715 body, 716 }); 717 const bodyError$ = new Promise(resolve => { 718 body.on('error', error => { 719 expect(error).toMatchInlineSnapshot( 720 `[AbortError: The operation was aborted]` 721 ); 722 resolve(null); 723 }); 724 }); 725 controller.abort(); 726 await bodyError$; 727 await expect(response$).rejects.toMatchInlineSnapshot( 728 `[AbortError: This operation was aborted]` 729 ); 730 }); 731 732 it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', async () => { 733 const url = new URL('inspect', baseURL); 734 await Promise.all([ 735 expect(() => 736 fetch(url, { signal: {} as any }) 737 ).rejects.toThrowErrorMatchingInlineSnapshot( 738 `[TypeError: The "signal" argument must be an instance of AbortSignal. Received an instance of Object]` 739 ), 740 expect(() => 741 fetch(url, { signal: Object.create(null) as any }) 742 ).rejects.toThrowErrorMatchingInlineSnapshot( 743 `[TypeError: The "signal" argument must be an instance of AbortSignal. Received [Object: null prototype] {}]` 744 ), 745 ]); 746 }); 747 748 it('should gracefully handle a nullish signal', async () => { 749 const url = new URL('inspect', baseURL); 750 await Promise.all([ 751 expect(fetch(url, { signal: null })).resolves.toMatchObject({ 752 ok: true, 753 }), 754 expect(fetch(url, { signal: undefined })).resolves.toMatchObject({ 755 ok: true, 756 }), 757 ]); 758 }); 759 }); 760 761 describe('request body', () => { 762 it('should allow POST request with empty body', async () => { 763 const response = await fetch(new URL('inspect', baseURL), { 764 method: 'POST', 765 }); 766 const inspect = await response.json(); 767 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 768 expect(inspect).not.toHaveProperty('headers.content-type'); 769 expect(inspect).toMatchObject({ 770 method: 'POST', 771 headers: { 772 'content-length': '0', 773 }, 774 }); 775 }); 776 777 it('should allow POST request with string body', async () => { 778 const response = await fetch(new URL('inspect', baseURL), { 779 method: 'POST', 780 body: 'a=1', 781 }); 782 const inspect = await response.json(); 783 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 784 expect(inspect).toMatchObject({ 785 method: 'POST', 786 body: 'a=1', 787 headers: { 788 'content-type': 'text/plain;charset=UTF-8', 789 'content-length': '3', 790 }, 791 }); 792 }); 793 794 it('should allow POST request with Buffer body', async () => { 795 const response = await fetch(new URL('inspect', baseURL), { 796 method: 'POST', 797 body: Buffer.from('a=1', 'utf8'), 798 }); 799 const inspect = await response.json(); 800 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 801 expect(inspect).not.toHaveProperty('headers.content-type'); 802 expect(inspect).toMatchObject({ 803 method: 'POST', 804 body: 'a=1', 805 headers: { 806 'content-length': '3', 807 }, 808 }); 809 }); 810 811 it('should allow POST request with ArrayBuffer body', async () => { 812 const response = await fetch(new URL('inspect', baseURL), { 813 method: 'POST', 814 body: new TextEncoder().encode('Hello, world!\n').buffer, 815 }); 816 const inspect = await response.json(); 817 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 818 expect(inspect).not.toHaveProperty('headers.content-type'); 819 expect(inspect).toMatchObject({ 820 method: 'POST', 821 body: 'Hello, world!\n', 822 headers: { 823 'content-length': '14', 824 }, 825 }); 826 }); 827 828 it('should allow POST request with ArrayBuffer body from a VM context', async () => { 829 const response = await fetch(new URL('inspect', baseURL), { 830 method: 'POST', 831 body: new VMUint8Array(Buffer.from('Hello, world!\n')).buffer, 832 }); 833 const inspect = await response.json(); 834 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 835 expect(inspect).not.toHaveProperty('headers.content-type'); 836 expect(inspect).toMatchObject({ 837 method: 'POST', 838 body: 'Hello, world!\n', 839 headers: { 840 'content-length': '14', 841 }, 842 }); 843 }); 844 845 it('should allow POST request with ArrayBufferView (Uint8Array) body', async () => { 846 const response = await fetch(new URL('inspect', baseURL), { 847 method: 'POST', 848 body: new TextEncoder().encode('Hello, world!\n'), 849 }); 850 const inspect = await response.json(); 851 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 852 expect(inspect).not.toHaveProperty('headers.content-type'); 853 expect(inspect).toMatchObject({ 854 method: 'POST', 855 body: 'Hello, world!\n', 856 headers: { 857 'content-length': '14', 858 }, 859 }); 860 }); 861 862 it('should allow POST request with ArrayBufferView (DataView) body', async () => { 863 const response = await fetch(new URL('inspect', baseURL), { 864 method: 'POST', 865 body: new DataView(new TextEncoder().encode('Hello, world!\n').buffer), 866 }); 867 const inspect = await response.json(); 868 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 869 expect(inspect).not.toHaveProperty('headers.content-type'); 870 expect(inspect).toMatchObject({ 871 method: 'POST', 872 body: 'Hello, world!\n', 873 headers: { 874 'content-length': '14', 875 }, 876 }); 877 }); 878 879 it('should allow POST request with ArrayBufferView (Uint8Array) body from a VM context', async () => { 880 const response = await fetch(new URL('inspect', baseURL), { 881 method: 'POST', 882 body: new VMUint8Array(new TextEncoder().encode('Hello, world!\n')), 883 }); 884 const inspect = await response.json(); 885 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 886 expect(inspect).not.toHaveProperty('headers.content-type'); 887 expect(inspect).toMatchObject({ 888 method: 'POST', 889 body: 'Hello, world!\n', 890 headers: { 891 'content-length': '14', 892 }, 893 }); 894 }); 895 896 it('should allow POST request with ArrayBufferView (Uint8Array, offset, length) body', async () => { 897 const response = await fetch(new URL('inspect', baseURL), { 898 method: 'POST', 899 body: new TextEncoder().encode('Hello, world!\n').subarray(7, 13), 900 }); 901 const inspect = await response.json(); 902 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 903 expect(inspect).not.toHaveProperty('headers.content-type'); 904 expect(inspect).toMatchObject({ 905 method: 'POST', 906 body: 'world!', 907 headers: { 908 'content-length': '6', 909 }, 910 }); 911 }); 912 913 it('should allow POST request with blob body without type', async () => { 914 const response = await fetch(new URL('inspect', baseURL), { 915 method: 'POST', 916 body: new Blob(['a=1']), 917 }); 918 const inspect = await response.json(); 919 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 920 expect(inspect).not.toHaveProperty('headers.content-type'); 921 expect(inspect).toMatchObject({ 922 method: 'POST', 923 body: 'a=1', 924 headers: { 925 'content-length': '3', 926 }, 927 }); 928 }); 929 930 it('should allow POST request with blob body with type', async () => { 931 const response = await fetch(new URL('inspect', baseURL), { 932 method: 'POST', 933 body: new Blob(['a=1'], { 934 type: 'text/plain;charset=utf-8', 935 }), 936 }); 937 const inspect = await response.json(); 938 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 939 expect(inspect).toMatchObject({ 940 method: 'POST', 941 body: 'a=1', 942 headers: { 943 'content-type': 'text/plain;charset=utf-8', 944 'content-length': '3', 945 }, 946 }); 947 }); 948 949 it('should preserve blob body on roundtrip', async () => { 950 const body = new Blob(['a=1']); 951 let response = await fetch(new URL('inspect', baseURL), { 952 method: 'POST', 953 body, 954 }); 955 expect(await response.json()).toMatchObject({ body: 'a=1' }); 956 response = await fetch(new URL('inspect', baseURL), { 957 method: 'POST', 958 body: new Blob(['a=1']), 959 }); 960 expect(await response.json()).toMatchObject({ body: 'a=1' }); 961 }); 962 963 it('should allow POST request with readable stream as body', async () => { 964 const response = await fetch(new URL('inspect', baseURL), { 965 method: 'POST', 966 body: stream.Readable.from('a=1'), 967 }); 968 const inspect = await response.json(); 969 expect(inspect).not.toHaveProperty('headers.content-type'); 970 expect(inspect).not.toHaveProperty('headers.content-length'); 971 expect(inspect).toMatchObject({ 972 method: 'POST', 973 body: 'a=1', 974 headers: { 975 'transfer-encoding': 'chunked', 976 }, 977 }); 978 }); 979 980 it('should allow POST request with FormData as body', async () => { 981 const form = new FormData(); 982 form.append('a', '1'); 983 984 const response = await fetch(new URL('multipart', baseURL), { 985 method: 'POST', 986 body: form, 987 }); 988 const inspect = await response.json(); 989 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 990 expect(inspect).toMatchObject({ 991 method: 'POST', 992 body: 'a=1', 993 headers: { 994 'content-type': expect.stringMatching( 995 /^multipart\/form-data; boundary=/ 996 ), 997 'content-length': '109', 998 }, 999 }); 1000 }); 1001 1002 it('should allow POST request with form-data using stream as body', async () => { 1003 const form = new FormDataPolyfill(); 1004 form.append('my_field', stream.Readable.from('dummy')); 1005 1006 const response = await fetch(new URL('multipart', baseURL), { 1007 method: 'POST', 1008 body: form, 1009 }); 1010 const inspect = await response.json(); 1011 expect(inspect).not.toHaveProperty('headers.content-length'); 1012 expect(inspect).toMatchObject({ 1013 method: 'POST', 1014 body: 'my_field=undefined', 1015 headers: { 1016 'transfer-encoding': 'chunked', 1017 'content-type': expect.stringMatching( 1018 /^multipart\/form-data; boundary=/ 1019 ), 1020 }, 1021 }); 1022 }); 1023 1024 it('should allow POST request with URLSearchParams as body', async () => { 1025 const params = new URLSearchParams(); 1026 params.set('key1', 'value1'); 1027 params.set('key2', 'value2'); 1028 1029 const response = await fetch(new URL('multipart', baseURL), { 1030 method: 'POST', 1031 body: params, 1032 }); 1033 const inspect = await response.json(); 1034 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 1035 expect(inspect).toMatchObject({ 1036 method: 'POST', 1037 body: 'key1=value1key2=value2', 1038 headers: { 1039 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 1040 'content-length': '23', 1041 }, 1042 }); 1043 }); 1044 1045 it('should allow POST request with extended URLSearchParams as body', async () => { 1046 class CustomSearchParameters extends URLSearchParams {} 1047 const params = new CustomSearchParameters(); 1048 params.set('key1', 'value1'); 1049 params.set('key2', 'value2'); 1050 1051 const response = await fetch(new URL('multipart', baseURL), { 1052 method: 'POST', 1053 body: params, 1054 }); 1055 const inspect = await response.json(); 1056 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 1057 expect(inspect).toMatchObject({ 1058 method: 'POST', 1059 body: 'key1=value1key2=value2', 1060 headers: { 1061 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 1062 'content-length': '23', 1063 }, 1064 }); 1065 }); 1066 1067 it('should allow POST request with invalid body', async () => { 1068 const response = await fetch(new URL('inspect', baseURL), { 1069 method: 'POST', 1070 body: { a: 1 } as any, 1071 }); 1072 const inspect = await response.json(); 1073 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 1074 expect(inspect).toMatchObject({ 1075 method: 'POST', 1076 body: '[object Object]', 1077 headers: { 1078 'content-type': 'text/plain;charset=UTF-8', 1079 'content-length': expect.any(String), 1080 }, 1081 }); 1082 }); 1083 1084 it('should overwrite Content-Length if possible', async () => { 1085 const response = await fetch(new URL('inspect', baseURL), { 1086 method: 'POST', 1087 body: new Blob(['a=1']), 1088 headers: { 1089 'Content-Length': '1000', 1090 }, 1091 }); 1092 const inspect = await response.json(); 1093 expect(inspect).not.toHaveProperty('headers.transfer-encoding'); 1094 expect(inspect).not.toHaveProperty('headers.content-type'); 1095 expect(inspect).toMatchObject({ 1096 method: 'POST', 1097 body: 'a=1', 1098 headers: { 1099 'content-length': '3', 1100 }, 1101 }); 1102 }); 1103 1104 it.each([['PUT'], ['DELETE'], ['PATCH']])( 1105 'should allow %s request', 1106 async method => { 1107 const response = await fetch(new URL('inspect', baseURL), { 1108 method, 1109 body: 'a=1', 1110 }); 1111 const inspect = await response.json(); 1112 expect(inspect).toMatchObject({ 1113 method, 1114 body: 'a=1', 1115 }); 1116 } 1117 ); 1118 1119 it('should allow HEAD requests', async () => { 1120 const response = await fetch(new URL('inspect', baseURL), { 1121 method: 'HEAD', 1122 }); 1123 expect(response.status).toBe(200); 1124 expect(await response.text()).toBe(''); 1125 }); 1126 1127 it('should allow HEAD requests with Content-Encoding header', async () => { 1128 const response = await fetch(new URL('error/404', baseURL), { 1129 method: 'HEAD', 1130 }); 1131 expect(response.status).toBe(404); 1132 expect(response.headers.get('Content-Encoding')).toBe('gzip'); 1133 expect(await response.text()).toBe(''); 1134 }); 1135 1136 it('should allow OPTIONS request', async () => { 1137 const response = await fetch(new URL('options', baseURL), { 1138 method: 'OPTIONS', 1139 }); 1140 expect(response.status).toBe(200); 1141 expect(response.headers.get('Allow')).toBe('GET, HEAD, OPTIONS'); 1142 expect(await response.text()).toBe('hello world'); 1143 }); 1144 1145 it('should support fetch with Request instance', async () => { 1146 const request = new Request(new URL('hello', baseURL)); 1147 const response = await fetch(request); 1148 expect(response.url).toBe(request.url); 1149 expect(response.ok).toBe(true); 1150 expect(response.status).toBe(200); 1151 }); 1152 }); 1153 1154 describe('request URL', () => { 1155 it('should keep `?` sign in URL when no params are given', async () => { 1156 const response = await fetch(new URL('question?', baseURL)); 1157 expect(response.url).toBe(`${baseURL}question?`); 1158 }); 1159 1160 it('if params are given, do not modify anything', async () => { 1161 const response = await fetch(new URL('question?a=1', baseURL)); 1162 expect(response.url).toBe(`${baseURL}question?a=1`); 1163 }); 1164 1165 it('should preserve the hash (#) symbol', async () => { 1166 const response = await fetch(new URL('question?#', baseURL)); 1167 expect(response.url).toBe(`${baseURL}question?#`); 1168 }); 1169 1170 it('should encode URLs as UTF-8', async () => { 1171 const url = new URL('möbius', baseURL); 1172 const res = await fetch(url); 1173 expect(res.url).to.equal(`${baseURL}m%C3%B6bius`); 1174 }); 1175 }); 1176});