Skip to content

Commit

Permalink
feat: native fetch mocking (#2580)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: drop support for Node < 18
  • Loading branch information
mikicho committed Feb 3, 2024
1 parent ad8c80e commit 8b8c0c2
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 7 deletions.
9 changes: 3 additions & 6 deletions .github/workflows/continuous-integration.yaml
Expand Up @@ -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
Expand Down Expand Up @@ -70,11 +70,9 @@ jobs:
fail-fast: false
matrix:
node-version:
- 10
- 12
- 14
- 16
- 18
- 20
- 21
os:
- macos-latest
- ubuntu-latest
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/common.js
Expand Up @@ -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,
Expand Down Expand Up @@ -765,4 +783,5 @@ module.exports = {
setInterval,
setTimeout,
stringifyRequest,
convertFetchRequestToClientRequest: convertFetchRequestToOptions,
}
45 changes: 45 additions & 0 deletions 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 }
27 changes: 26 additions & 1 deletion lib/intercept.js
Expand Up @@ -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
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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()
}

Expand Down
93 changes: 93 additions & 0 deletions 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)
})
})

0 comments on commit 8b8c0c2

Please sign in to comment.