Skip to content

Commit

Permalink
Merge branch 'main' into fix/response-without-body
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Sep 21, 2023
2 parents 898e4a8 + 217c6e9 commit 8884353
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 31 deletions.
74 changes: 47 additions & 27 deletions src/interceptors/ClientRequest/NodeClientRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,6 @@ export class NodeClientRequest extends ClientRequest {
return this.passthrough(chunk, encoding, callback)
}

// Notify the interceptor about the request.
// This will call any "request" listeners the users have.
this.logger.info(
'emitting the "request" event for %d listener(s)...',
this.emitter.listenerCount('request')
)

// Add the last "request" listener that always resolves
// the pending response Promise. This way if the consumer
// hasn't handled the request themselves, we will prevent
Expand All @@ -181,18 +174,31 @@ export class NodeClientRequest extends ClientRequest {
}

if (requestController.responsePromise.state === 'pending') {
this.logger.info(
'request has not been handled in listeners, executing fail-safe listener...'
)

requestController.responsePromise.resolve(undefined)
}
})

// Execute the resolver Promise like a side-effect.
// Node.js 16 forces "ClientRequest.end" to be synchronous and return "this".
until(async () => {
// Notify the interceptor about the request.
// This will call any "request" listeners the users have.
this.logger.info(
'emitting the "request" event for %d listener(s)...',
this.emitter.listenerCount('request')
)

await emitAsync(this.emitter, 'request', {
request: interactiveRequest,
requestId,
})

this.logger.info('all "request" listeners done!')

const mockedResponse = await requestController.responsePromise
this.logger.info('event.respondWith called with:', mockedResponse)

Expand Down Expand Up @@ -220,6 +226,8 @@ export class NodeClientRequest extends ClientRequest {
'encountered resolver exception, aborting request...',
resolverResult.error
)

this.destroyed = true
this.emit('error', resolverResult.error)
this.terminate()

Expand All @@ -229,7 +237,17 @@ export class NodeClientRequest extends ClientRequest {
const mockedResponse = resolverResult.data

if (mockedResponse) {
this.logger.info('received mocked response:', mockedResponse)
this.logger.info(
'received mocked response:',
mockedResponse.status,
mockedResponse.statusText
)

/**
* @note Ignore this request being destroyed by TLS in Node.js
* due to connection errors.
*/
this.destroyed = false

// Handle mocked "Response.error" network error responses.
if (mockedResponse.type === 'error') {
Expand Down Expand Up @@ -448,10 +466,29 @@ export class NodeClientRequest extends ClientRequest {
}
this.logger.info('mocked response headers ready:', headers)

/**
* Set the internal "res" property to the mocked "OutgoingMessage"
* to make the "ClientRequest" instance think there's data received
* from the socket.
* @see https://github.com/nodejs/node/blob/9c405f2591f5833d0247ed0fafdcd68c5b14ce7a/lib/_http_client.js#L501
*
* Set the response immediately so the interceptor could stream data
* chunks to the request client as they come in.
*/
// @ts-ignore
this.res = this.response
this.emit('response', this.response)

const isResponseStreamFinished = new DeferredPromise<void>()

const finishResponseStream = () => {
this.logger.info('finished response stream!')

// Push "null" to indicate that the response body is complete
// and shouldn't be written to anymore.
this.response.push(null)
this.response.complete = true

isResponseStreamFinished.resolve()
}

Expand All @@ -475,29 +512,12 @@ export class NodeClientRequest extends ClientRequest {
finishResponseStream()
}

/**
* Set the internal "res" property to the mocked "OutgoingMessage"
* to make the "ClientRequest" instance think there's data received
* from the socket.
* @see https://github.com/nodejs/node/blob/9c405f2591f5833d0247ed0fafdcd68c5b14ce7a/lib/_http_client.js#L501
*
* Set the response immediately so the interceptor could stream data
* chunks to the request client as they come in.
*/
// @ts-ignore
this.res = this.response
this.emit('response', this.response)

isResponseStreamFinished.then(() => {
this.logger.info('finalizing response...')

// Push "null" to indicate that the response body is complete
// and shouldn't be written to anymore.
this.response.push(null)
this.response.complete = true
this.response.emit('end')

this.terminate()

this.logger.info('request complete!')
})
}

Expand Down
4 changes: 3 additions & 1 deletion src/interceptors/ClientRequest/http.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
} from './utils/normalizeClientRequestArgs'

export function get(protocol: Protocol, options: NodeClientOptions) {
return (...args: ClientRequestArgs): ClientRequest => {
return function interceptorsHttpGet(
...args: ClientRequestArgs
): ClientRequest {
const clientRequestArgs = normalizeClientRequestArgs(
`${protocol}:`,
...args
Expand Down
4 changes: 3 additions & 1 deletion src/interceptors/ClientRequest/http.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
const logger = new Logger('http request')

export function request(protocol: Protocol, options: NodeClientOptions) {
return (...args: ClientRequestArgs): ClientRequest => {
return function interceptorsHttpRequest(
...args: ClientRequestArgs
): ClientRequest {
logger.info('request call (protocol "%s"):', protocol, args)

const clientRequestArgs = normalizeClientRequestArgs(
Expand Down
56 changes: 56 additions & 0 deletions test/modules/http/response/http-response-delay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { it, expect, beforeAll, afterAll } from 'vitest'
import http from 'http'
import { HttpServer } from '@open-draft/test-server/http'
import { sleep, waitForClientRequest } from '../../../helpers'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'

const interceptor = new ClientRequestInterceptor()

const httpServer = new HttpServer((app) => {
app.get('/resource', (req, res) => {
res.send('original response')
})
})

beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})

afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})

it('supports custom delay before responding with a mock', async () => {
interceptor.once('request', async ({ request }) => {
await sleep(750)
request.respondWith(new Response('mocked response'))
})

const requestStart = Date.now()
const request = http.get('https://non-existing-host.com')
const { res, text } = await waitForClientRequest(request)
const requestEnd = Date.now()

expect(res.statusCode).toBe(200)
expect(await text()).toBe('mocked response')
expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700)
})

it('supports custom delay before receiving the original response', async () => {
interceptor.once('request', async () => {
// This will simply delay the request execution before
// it receives the original response.
await sleep(750)
})

const requestStart = Date.now()
const request = http.get(httpServer.http.url('/resource'))
const { res, text } = await waitForClientRequest(request)
const requestEnd = Date.now()

expect(res.statusCode).toBe(200)
expect(await text()).toBe('original response')
expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700)
})
21 changes: 19 additions & 2 deletions test/third-party/got.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest'
import got from 'got'
import { HttpServer } from '@open-draft/test-server/http'
import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest'
import { sleep } from '../helpers'

const httpServer = new HttpServer((app) => {
app.get('/user', (req, res) => {
Expand All @@ -10,7 +11,8 @@ const httpServer = new HttpServer((app) => {
})

const interceptor = new ClientRequestInterceptor()
interceptor.on('request', ({ request }) => {

interceptor.on('request', function rootListener({ request }) {
if (request.url.toString() === httpServer.http.url('/test')) {
request.respondWith(new Response('mocked-body'))
}
Expand All @@ -37,5 +39,20 @@ it('bypasses an unhandled request made with "got"', async () => {
const res = await got(httpServer.http.url('/user'))

expect(res.statusCode).toBe(200)
expect(res.body).toEqual(`{"id":1}`)
expect(res.body).toBe(`{"id":1}`)
})

it('supports timeout before resolving request as-is', async () => {
interceptor.once('request', async ({ request }) => {
await sleep(750)
request.respondWith(new Response('mocked response'))
})

const requestStart = Date.now()
const res = await got('https://intentionally-non-existing-host.com')
const requestEnd = Date.now()

expect(res.statusCode).toBe(200)
expect(res.body).toBe('mocked response')
expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700)
})

0 comments on commit 8884353

Please sign in to comment.