Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use @mswjs/interceptors for mocking - WIP #2517

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
26 changes: 26 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,3 +4,29 @@ Nock’s changelog can be found directly in the [GitHub release notes](https://g
These are automatically created by [semantic-release](https://github.com/semantic-release/semantic-release) based on their [commit message conventions](https://semantic-release.gitbook.io/semantic-release#commit-message-format).

Migration guides are available for major versions in the [migration guides directory](https://github.com/nock/nock/tree/main/migration_guides).

Remove this before merge:
Breaking changes:
1. recorder.play and nockDone are async
3. Small - Fix headers matcher gets non-string values (this test: `should match headers with function: gets the expected argument`)
2. Fix - socket ref/unref return this
4. increased Nock compatibility with Node
5. We no longer support in undefined content-length (this test: `Content Encoding should accept gzipped content`)


Topics to discuss:
2. GET requests no longer may have body. we can discuss this with msw/interceptors maintainer.
3. 204, 205, 304 responses can not have body.
4. Are we OK that we emit "internal-response" to the end user as well?
5. Test timeout without actually wait
6. should denote the response client is authorized for HTTPS requests
8. getPeerCertificate does not return string: https://nodejs.org/api/tls.html#tlssocketgetpeercertificatedetailed
test: "socket has getPeerCertificate() method which returns a random base64 string"
9. why the behavior is different than Node's? test: "Request with `Expect: 100-continue` triggers continue event"
10. Do we need to call the original request on passthrough?
test: "when http.get and http.request have been overridden before nock overrides them, http.get calls through to the expected method"
11. why?
test: "mocking a request which sends an empty buffer should finalize"

For me:
Why tests stuck if expect fails in req callback?
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -1199,7 +1199,7 @@ nock.recorder.rec({
dont_print: true,
})
// ... some HTTP calls
const nockCalls = nock.recorder.play()
const nockCalls = await nock.recorder.play()
```

The `nockCalls` var will contain an array of strings representing the generated code you need.
Expand All @@ -1217,7 +1217,7 @@ nock.recorder.rec({
output_objects: true,
})
// ... some HTTP calls
const nockCallObjects = nock.recorder.play()
const nockCallObjects = await nock.recorder.play()
```

The returned call objects have the following properties:
Expand Down
12 changes: 6 additions & 6 deletions lib/back.js
Expand Up @@ -73,8 +73,8 @@ function Back(fixtureName, options, nockedFn) {
const fixture = path.join(Back.fixtures, fixtureName)
const context = _mode.start(fixture, options)

const nockDone = function () {
_mode.finish(fixture, options, context)
const nockDone = async function () {
await _mode.finish(fixture, options, context)
}

debug('context:', context)
Expand Down Expand Up @@ -157,9 +157,9 @@ const record = {
return context
},

finish: function (fixture, options, context) {
finish: async function (fixture, options, context) {
if (context.isRecording) {
let outputs = recorder.outputs()
let outputs = await recorder.outputs()

if (typeof options.afterRecord === 'function') {
outputs = options.afterRecord(outputs)
Expand Down Expand Up @@ -200,8 +200,8 @@ const update = {
return context
},

finish: function (fixture, options, context) {
let outputs = recorder.outputs()
finish: async function (fixture, options, context) {
let outputs = await recorder.outputs()

if (typeof options.afterRecord === 'function') {
outputs = options.afterRecord(outputs)
Expand Down
124 changes: 43 additions & 81 deletions lib/common.js
Expand Up @@ -4,6 +4,7 @@ const debug = require('debug')('nock.common')
const timers = require('timers')
const url = require('url')
const util = require('util')
const http = require('http')

/**
* Normalizes the request options so that it always has `host` property.
Expand Down Expand Up @@ -50,85 +51,6 @@ function isUtf8Representable(buffer) {
return reconstructedBuffer.equals(buffer)
}

// Array where all information about all the overridden requests are held.
let requestOverrides = {}

/**
* Overrides the current `request` function of `http` and `https` modules with
* our own version which intercepts issues HTTP/HTTPS requests and forwards them
* to the given `newRequest` function.
*
* @param {Function} newRequest - a function handling requests; it accepts four arguments:
* - proto - a string with the overridden module's protocol name (either `http` or `https`)
* - overriddenRequest - the overridden module's request function already bound to module's object
* - options - the options of the issued request
* - callback - the callback of the issued request
*/
function overrideRequests(newRequest) {
debug('overriding requests')
;['http', 'https'].forEach(function (proto) {
debug('- overriding request for', proto)

const moduleName = proto // 1 to 1 match of protocol and module is fortunate :)
const module = {
http: require('http'),
https: require('https'),
}[moduleName]
const overriddenRequest = module.request
const overriddenGet = module.get

if (requestOverrides[moduleName]) {
throw new Error(
`Module's request already overridden for ${moduleName} protocol.`,
)
}

// Store the properties of the overridden request so that it can be restored later on.
requestOverrides[moduleName] = {
module,
request: overriddenRequest,
get: overriddenGet,
}
// https://nodejs.org/api/http.html#http_http_request_url_options_callback
module.request = function (input, options, callback) {
return newRequest(proto, overriddenRequest.bind(module), [
input,
options,
callback,
])
}
// https://nodejs.org/api/http.html#http_http_get_options_callback
module.get = function (input, options, callback) {
const req = newRequest(proto, overriddenGet.bind(module), [
input,
options,
callback,
])
req.end()
return req
}

debug('- overridden request for', proto)
})
}

/**
* Restores `request` function of `http` and `https` modules to values they
* held before they were overridden by us.
*/
function restoreOverriddenRequests() {
debug('restoring requests')
Object.entries(requestOverrides).forEach(
([proto, { module, request, get }]) => {
debug('- restoring request for', proto)
module.request = request
module.get = get
debug('- restored request for', proto)
},
)
requestOverrides = {}
}

/**
* In WHATWG URL vernacular, this returns the origin portion of a URL.
* However, the port is not included if it's standard and not already present on the host.
Expand Down Expand Up @@ -619,6 +541,7 @@ function clearTimer(clear, ids) {
}

function removeAllTimers() {
debug('remove all timers')
clearTimer(clearTimeout, timeouts)
clearTimer(clearInterval, intervals)
clearTimer(clearImmediate, immediates)
Expand Down Expand Up @@ -652,6 +575,45 @@ function isRequestDestroyed(req) {
)
}

/**
* @param {Request} request
*/
function convertFetchRequestToClientRequest(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())
};

// By default, Node adds a host header, but for maximum backward compatibility, we are now removing it.
// However, we need to consider leaving the header and fixing the tests.
if (options.headers.host === options.host) {
const { host, ...restHeaders } = options.headers
options.headers = restHeaders
}

return new http.ClientRequest(options);
}

/**
* Inspired by the createDeferredPromise() (https://github.com/nodejs/node/blob/696fd4b14fc34cc2d01497a3abd9bb441b89be50/lib/internal/util.js#L468-L477)
*/
function createDeferredPromise() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});

return { promise, resolve, reject };
}

/**
* Returns true if the given value is a plain object and not an Array.
* @param {*} value
Expand Down Expand Up @@ -759,13 +721,13 @@ module.exports = {
normalizeClientRequestArgs,
normalizeOrigin,
normalizeRequestOptions,
overrideRequests,
percentDecode,
percentEncode,
removeAllTimers,
restoreOverriddenRequests,
setImmediate,
setInterval,
setTimeout,
stringifyRequest,
convertFetchRequestToClientRequest,
createDeferredPromise,
}
70 changes: 70 additions & 0 deletions lib/create_response.js
@@ -0,0 +1,70 @@
const { IncomingMessage } = require('http')
const { headersArrayToObject } = require('./common')
const { STATUS_CODES } = require('http')

/**
* 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
* TODO: maybe MSW can export this? so no duplicate code
*/

/**
* 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 || 200
)
? null
: new ReadableStream({
start(controller) {
message.on('data', (chunk) => controller.enqueue(chunk))
message.on('end', () => controller.close())
message.on('error', (error) => controller.error(error))

/**
* @todo Should also listen to the "error" on the message
* and forward it to the controller. Otherwise the stream
* will pend indefinitely.
*/
},
})

const lowercaseHeaders = headersArrayToObject(message.rawHeaders)
const headers = {}

// TODO, DISCUSS BEFORE MERGE: temp hack to bring back the original header name in the least intrusive way.
// I think the mswjs/interceptors needs to expose better API for rawHeaders mocking.
const consumedHeaders = []
for (let i = 0; i < message.rawHeaders.length; i+=2) {
const rawHeader = message.rawHeaders[i]
const lowerRawHeader = message.rawHeaders[i].toLowerCase()
if (!consumedHeaders.includes(lowerRawHeader)) {
headers[rawHeader] = lowercaseHeaders[lowerRawHeader]
consumedHeaders.push(lowerRawHeader)
}
}

const response = new Response(responseBodyOrNull, {
status: message.statusCode,
statusText: message.statusMessage || STATUS_CODES[message.statusCode],
headers,
})

// reset set-cookie headers for response.headers.cookies value to be correct
if (lowercaseHeaders['set-cookie']) {
response.headers.delete('set-cookie')
lowercaseHeaders['set-cookie'].map(c => response.headers.append('set-cookie', c))
}

return response
}

module.exports = { createResponse }