From 8b8c0c27e26db1aebc943bd65a97f2eb212cc5c7 Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Sat, 3 Feb 2024 22:23:31 +0200 Subject: [PATCH] feat: native fetch mocking (#2580) BREAKING CHANGE: drop support for Node < 18 --- .github/workflows/continuous-integration.yaml | 9 +- lib/common.js | 19 ++++ lib/create_response.js | 45 +++++++++ lib/intercept.js | 27 +++++- tests/test_fetch.js | 93 +++++++++++++++++++ 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 lib/create_response.js create mode 100644 tests/test_fetch.js diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index 630d7586a..74462c1dc 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -22,7 +22,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 cache: 'npm' - name: Install dependencies run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline @@ -70,11 +70,9 @@ jobs: fail-fast: false matrix: node-version: - - 10 - - 12 - - 14 - - 16 - 18 + - 20 + - 21 os: - macos-latest - ubuntu-latest @@ -99,7 +97,6 @@ jobs: run: npm run test - name: Test jest run: npm run test:jest - if: matrix.node-version >= 14 # separate job to set as required in branch protection, # as the build names above change each time Node versions change diff --git a/lib/common.js b/lib/common.js index 336bc4d37..a1f20fae3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -735,6 +735,24 @@ const expand = input => { return result } +/** + * @param {Request} request + */ +function convertFetchRequestToOptions(request) { + const url = new URL(request.url) + const options = { + ...urlToOptions(url), + method: request.method, + host: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + proto: url.protocol.slice(0, -1), + headers: Object.fromEntries(request.headers.entries()), + } + + return options +} + module.exports = { contentEncoding, dataEqual, @@ -765,4 +783,5 @@ module.exports = { setInterval, setTimeout, stringifyRequest, + convertFetchRequestToClientRequest: convertFetchRequestToOptions, } diff --git a/lib/create_response.js b/lib/create_response.js new file mode 100644 index 000000000..3884506c1 --- /dev/null +++ b/lib/create_response.js @@ -0,0 +1,45 @@ +'use strict' + +const { headersArrayToObject } = require('./common') + +/** + * Creates a Fetch API `Response` instance from the given + * `http.IncomingMessage` instance. + * Inspired by: https://github.com/mswjs/interceptors/blob/04152ed914f8041272b6e92ed374216b8177e1b2/src/interceptors/ClientRequest/utils/createResponse.ts#L8 + */ + +/** + * Response status codes for responses that cannot have body. + * @see https://fetch.spec.whatwg.org/#statuses + */ +const responseStatusCodesWithoutBody = [204, 205, 304] + +/** + * @param {IncomingMessage} message + */ +function createResponse(message) { + const responseBodyOrNull = responseStatusCodesWithoutBody.includes( + message.statusCode, + ) + ? null + : new ReadableStream({ + start(controller) { + message.on('data', chunk => controller.enqueue(chunk)) + message.on('end', () => controller.close()) + + /** + * @todo Should also listen to the "error" on the message + * and forward it to the controller. Otherwise the stream + * will pend indefinitely. + */ + }, + }) + + return new Response(responseBodyOrNull, { + status: message.statusCode, + statusText: message.statusMessage, + headers: headersArrayToObject(message.rawHeaders), + }) +} + +module.exports = { createResponse } diff --git a/lib/intercept.js b/lib/intercept.js index cf266d5a5..4896f6f5c 100644 --- a/lib/intercept.js +++ b/lib/intercept.js @@ -10,6 +10,7 @@ const { inherits } = require('util') const http = require('http') const debug = require('debug')('nock.intercept') const globalEmitter = require('./global_emitter') +const { createResponse } = require('./create_response') /** * @name NetConnectNotAllowedError @@ -302,7 +303,9 @@ function overrideClientRequest() { // Fallback to original ClientRequest if nock is off or the net connection is enabled. if (isOff() || isEnabledForNetConnect(options)) { - originalClientRequest.apply(this, arguments) + if (options.isFetchRequest === undefined) { + originalClientRequest.apply(this, arguments) + } } else { common.setImmediate( function () { @@ -434,6 +437,28 @@ function activate() { } }) + const originalFetch = global.fetch + global.fetch = async (input, init = undefined) => { + const request = new Request(input, init) + const options = common.convertFetchRequestToClientRequest(request) + options.isFetchRequest = true + const body = await request.arrayBuffer() + const clientRequest = new http.ClientRequest(options) + + // If mock found + if (clientRequest.interceptors) { + return new Promise((resolve, reject) => { + clientRequest.on('response', response => { + resolve(createResponse(response)) + }) + clientRequest.on('error', reject) + clientRequest.end(body) + }) + } else { + return originalFetch(input, init) + } + } + overrideClientRequest() } diff --git a/tests/test_fetch.js b/tests/test_fetch.js new file mode 100644 index 000000000..fc091c14d --- /dev/null +++ b/tests/test_fetch.js @@ -0,0 +1,93 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('..') +const assertRejects = require('assert-rejects') +const { startHttpServer } = require('./servers') + +describe('Native Fetch', () => { + it('input is string', async () => { + const scope = nock('http://example.test').get('/').reply() + + const { status } = await fetch('http://example.test/') + expect(status).to.equal(200) + scope.done() + }) + + it('input is URL', async () => { + const scope = nock('http://example.test').get('/').reply() + + const { status } = await fetch(new URL('http://example.test/')) + expect(status).to.equal(200) + scope.done() + }) + + it('input is Request object', async () => { + const scope = nock('http://example.test').get('/').reply() + + const { status } = await fetch(new Request('http://example.test/')) + expect(status).to.equal(200) + scope.done() + }) + + it('filter by body', async () => { + const scope = nock('http://example.test') + .post('/', { test: 'fetch' }) + .reply() + + const { status } = await fetch('http://example.test/', { + method: 'POST', + body: JSON.stringify({ test: 'fetch' }), + }) + expect(status).to.equal(200) + scope.done() + }) + + it('filter by request body', async () => { + const scope = nock('http://example.test') + .post('/', { test: 'fetch' }) + .reply() + + const { status } = await fetch( + new Request('http://example.test/', { + method: 'POST', + body: JSON.stringify({ test: 'fetch' }), + }), + ) + expect(status).to.equal(200) + scope.done() + }) + + it('no match', async () => { + nock('http://example.test').get('/').reply() + + await assertRejects( + fetch('http://example.test/wrong-path'), + /Nock: No match for request/, + ) + }) + + it('forward request if no mock', async () => { + const { origin } = await startHttpServer((request, response) => { + response.write('live') + response.end() + }) + + const { status } = await fetch(origin) + expect(status).to.equal(200) + }) + + it('should work with empty response', async () => { + nock('http://example.test').get('/').reply(204) + + const { status } = await fetch('http://example.test') + expect(status).to.equal(204) + }) + + it('should work https', async () => { + nock('https://example.test').get('/').reply() + + const { status } = await fetch('https://example.test') + expect(status).to.equal(200) + }) +})