From 0aace9f56f37b7c69da9429dca2c16d307419c26 Mon Sep 17 00:00:00 2001 From: marko1olo Date: Sat, 6 Jun 2026 07:52:17 +0400 Subject: [PATCH 1/4] fix(mock): emit request body lifecycle hooks Mock dispatch now forwards request bodies through onBodySent/onRequestSent before resolving the mocked reply, including async iterable bodies. Co-authored-by: OpenAI Codex Signed-off-by: marko1olo --- lib/mock/mock-utils.js | 126 +++++++++++++++++++++++++-- test/mock-agent.js | 188 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 9 deletions(-) diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 5b6b5bd79cf..1d7060048f8 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -346,17 +346,44 @@ function mockDispatch (opts, handler) { } } + let replyOpts = opts + const dispatches = this[kDispatches] + // Call onRequestStart to allow the handler to receive the controller handler.onRequestStart?.(controller, null) - // Handle the request with a delay if necessary - if (typeof delay === 'number' && delay > 0) { - timer = setTimeout(() => { - timer = null - handleReply(this[kDispatches]) - }, delay) - } else { - handleReply(this[kDispatches]) + const requestBody = dispatchRequestBody(opts.body, handler, controller) + + if (isPromise(requestBody)) { + requestBody.then((body) => { + if (body !== opts.body) { + replyOpts = { ...opts, body } + } + sendReply() + }, (error) => controller.abort(error)) + return true + } + + if (requestBody === null) { + return true + } + + if (requestBody !== opts.body) { + replyOpts = { ...opts, body: requestBody } + } + + sendReply() + + function sendReply () { + // Handle the request with a delay if necessary + if (typeof delay === 'number' && delay > 0) { + timer = setTimeout(() => { + timer = null + handleReply(dispatches) + }, delay) + } else { + handleReply(dispatches) + } } function handleReply (mockDispatches, _data = data) { @@ -370,7 +397,7 @@ function mockDispatch (opts, handler) { ? buildHeadersFromArray(opts.headers) : opts.headers const body = typeof _data === 'function' - ? _data({ ...opts, headers: optsHeaders }) + ? _data({ ...replyOpts, headers: optsHeaders }) : _data // util.types.isPromise is likely needed for jest. @@ -405,6 +432,87 @@ function mockDispatch (opts, handler) { return true } +function dispatchRequestBody (body, handler, controller) { + if (typeof handler.onBodySent !== 'function' && typeof handler.onRequestSent !== 'function') { + return body + } + + if (body == null) { + return callOnRequestSent(handler, controller) ? body : null + } + + if (body && typeof body[Symbol.asyncIterator] === 'function') { + return dispatchAsyncIterableBody(body, handler, controller) + } + + if (isIterableBody(body)) { + const chunks = [] + + for (const chunk of body) { + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk)) { + return null + } + } + + return callOnRequestSent(handler, controller) ? chunks : null + } + + if (!callOnBodySent(handler, controller, body)) { + return null + } + + return callOnRequestSent(handler, controller) ? body : null +} + +async function dispatchAsyncIterableBody (body, handler, controller) { + const chunks = [] + + for await (const chunk of body) { + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk)) { + return null + } + } + + if (!callOnRequestSent(handler, controller)) { + return null + } + + return { + async * [Symbol.asyncIterator] () { + yield * chunks + } + } +} + +function callOnBodySent (handler, controller, chunk) { + try { + handler.onBodySent?.(chunk) + return true + } catch (error) { + controller.abort(error) + return false + } +} + +function callOnRequestSent (handler, controller) { + try { + handler.onRequestSent?.() + return true + } catch (error) { + controller.abort(error) + return false + } +} + +function isIterableBody (body) { + return typeof body !== 'string' && + !Buffer.isBuffer(body) && + !ArrayBuffer.isView(body) && + typeof body[Symbol.iterator] === 'function' +} + function buildMockDispatch () { const agent = this[kMockAgent] const origin = this[kOrigin] diff --git a/test/mock-agent.js b/test/mock-agent.js index 500a8833116..1e6f437db9c 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -229,6 +229,194 @@ describe('MockAgent - dispatch', () => { onResponseError () {} })) }) + + test('should call request body lifecycle hooks', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: 'hello' + }, { + onRequestStart () { + events.push('start') + }, + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['start', 'body:hello', 'sent']) + }) + + test('should call request body lifecycle hooks for async iterable bodies', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + async function * body () { + yield Buffer.from('he') + yield Buffer.from('llo') + } + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: body() + }, { + onRequestStart () { + events.push('start') + }, + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['start', 'body:he', 'body:llo', 'sent']) + }) + + test('should replay async iterable request bodies to reply callbacks after lifecycle hooks', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + const response = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, async ({ body }) => { + const chunks = [] + + for await (const chunk of body) { + chunks.push(chunk) + } + + return Buffer.concat(chunks).toString() + }) + + async function * body () { + yield Buffer.from('he') + yield Buffer.from('llo') + } + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: body() + }, { + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData (_controller, chunk) { + response.push(chunk) + }, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['body:he', 'body:llo', 'sent']) + t.assert.strictEqual(Buffer.concat(response).toString(), 'hello') + }) + + test('should report request body lifecycle hook errors', async (t) => { + const baseUrl = 'http://localhost:9999' + const expected = new Error('fail') + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: 'hello' + }, { + onBodySent () { + throw expected + }, + onResponseStart () { + reject(new Error('response should not start')) + }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError (_controller, error) { + try { + t.assert.strictEqual(error, expected) + resolve() + } catch (assertionError) { + reject(assertionError) + } + } + }) + }) + }) }) test('MockAgent - .close should clean up registered pools', async (t) => { From d1306b1e235ecfddc23f4318c48aa63f9759558c Mon Sep 17 00:00:00 2001 From: marko1olo Date: Sat, 6 Jun 2026 07:55:49 +0400 Subject: [PATCH 2/4] fix(mock): stop lifecycle dispatch after abort Do not continue request body lifecycle processing or resolve a mock response after the mock request controller has been aborted. Co-authored-by: OpenAI Codex Signed-off-by: marko1olo --- lib/mock/mock-utils.js | 29 ++++++++++---- test/mock-agent.js | 91 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 1d7060048f8..bccd7324553 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -351,11 +351,17 @@ function mockDispatch (opts, handler) { // Call onRequestStart to allow the handler to receive the controller handler.onRequestStart?.(controller, null) + if (aborted) { + return true + } - const requestBody = dispatchRequestBody(opts.body, handler, controller) + const requestBody = dispatchRequestBody(opts.body, handler, controller, () => aborted) if (isPromise(requestBody)) { requestBody.then((body) => { + if (body === null) { + return + } if (body !== opts.body) { replyOpts = { ...opts, body } } @@ -432,7 +438,7 @@ function mockDispatch (opts, handler) { return true } -function dispatchRequestBody (body, handler, controller) { +function dispatchRequestBody (body, handler, controller, isAborted) { if (typeof handler.onBodySent !== 'function' && typeof handler.onRequestSent !== 'function') { return body } @@ -442,40 +448,49 @@ function dispatchRequestBody (body, handler, controller) { } if (body && typeof body[Symbol.asyncIterator] === 'function') { - return dispatchAsyncIterableBody(body, handler, controller) + return dispatchAsyncIterableBody(body, handler, controller, isAborted) } if (isIterableBody(body)) { const chunks = [] for (const chunk of body) { + if (isAborted()) { + return null + } chunks.push(chunk) if (!callOnBodySent(handler, controller, chunk)) { return null } } - return callOnRequestSent(handler, controller) ? chunks : null + return !isAborted() && callOnRequestSent(handler, controller) ? chunks : null } + if (isAborted()) { + return null + } if (!callOnBodySent(handler, controller, body)) { return null } - return callOnRequestSent(handler, controller) ? body : null + return !isAborted() && callOnRequestSent(handler, controller) ? body : null } -async function dispatchAsyncIterableBody (body, handler, controller) { +async function dispatchAsyncIterableBody (body, handler, controller, isAborted) { const chunks = [] for await (const chunk of body) { + if (isAborted()) { + return null + } chunks.push(chunk) if (!callOnBodySent(handler, controller, chunk)) { return null } } - if (!callOnRequestSent(handler, controller)) { + if (isAborted() || !callOnRequestSent(handler, controller)) { return null } diff --git a/test/mock-agent.js b/test/mock-agent.js index 1e6f437db9c..fe5fe52aabc 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -321,6 +321,48 @@ describe('MockAgent - dispatch', () => { t.assert.deepStrictEqual(events, ['start', 'body:he', 'body:llo', 'sent']) }) + test('should call request sent hook for requests without a body', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onRequestStart () { + events.push('start') + }, + onBodySent () { + events.push('body') + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['start', 'sent']) + }) + test('should replay async iterable request bodies to reply callbacks after lifecycle hooks', async (t) => { const baseUrl = 'http://localhost:9999' const events = [] @@ -401,6 +443,55 @@ describe('MockAgent - dispatch', () => { onBodySent () { throw expected }, + onRequestSent () { + reject(new Error('request should not be marked sent')) + }, + onResponseStart () { + reject(new Error('response should not start')) + }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError (_controller, error) { + try { + t.assert.strictEqual(error, expected) + resolve() + } catch (assertionError) { + reject(assertionError) + } + } + }) + }) + }) + + test('should not send request body lifecycle hooks after request start aborts', async (t) => { + const baseUrl = 'http://localhost:9999' + const expected = new Error('fail') + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: 'hello' + }, { + onRequestStart (controller) { + controller.abort(expected) + }, + onBodySent () { + reject(new Error('body should not be sent')) + }, + onRequestSent () { + reject(new Error('request should not be sent')) + }, onResponseStart () { reject(new Error('response should not start')) }, From a38ca0f8f4c88bd7076c46c238a5598adff66d0f Mon Sep 17 00:00:00 2001 From: marko1olo Date: Sat, 6 Jun 2026 08:05:52 +0400 Subject: [PATCH 3/4] test(mock): cover async body dispatch errors Signed-off-by: marko1olo --- test/mock-agent.js | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/mock-agent.js b/test/mock-agent.js index fe5fe52aabc..b496fdd5676 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -363,6 +363,57 @@ describe('MockAgent - dispatch', () => { t.assert.deepStrictEqual(events, ['start', 'sent']) }) + test('should report async iterable request body errors', async (t) => { + const baseUrl = 'http://localhost:9999' + const expected = new Error('fail') + const events = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + async function * body () { + yield Buffer.from('he') + throw expected + } + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: body() + }, { + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + reject(new Error('request should not be marked sent')) + }, + onResponseStart () { + reject(new Error('response should not start')) + }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError (_controller, error) { + try { + t.assert.strictEqual(error, expected) + resolve() + } catch (assertionError) { + reject(assertionError) + } + } + }) + }) + + t.assert.deepStrictEqual(events, ['body:he']) + }) + test('should replay async iterable request bodies to reply callbacks after lifecycle hooks', async (t) => { const baseUrl = 'http://localhost:9999' const events = [] From 4d066cdcd80bce4642a2e7f7b5c80976a16b2995 Mon Sep 17 00:00:00 2001 From: marko1olo Date: Sat, 6 Jun 2026 08:19:29 +0400 Subject: [PATCH 4/4] fix(mock): preserve lifecycle abort semantics Signed-off-by: marko1olo --- lib/mock/mock-utils.js | 62 +++++----- test/mock-agent.js | 258 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 28 deletions(-) diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index bccd7324553..9e8c4a15f79 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -18,6 +18,8 @@ const { } = require('node:util') const { InvalidArgumentError } = require('../core/errors') +const requestAborted = Symbol('request aborted') + function matchValue (match, value) { if (typeof match === 'string') { return match === value @@ -295,13 +297,9 @@ function mockDispatch (opts, handler) { mockDispatch.timesInvoked++ - // Here's where we resolve a callback if a callback is present for the dispatch data. - if (mockDispatch.data.callback) { - mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } - } - // Parse mockDispatch data - const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch + const { data: response, delay, persist } = mockDispatch + const { error } = response const { timesInvoked, times } = mockDispatch // If it's used up and not persistent, mark as consumed @@ -359,7 +357,7 @@ function mockDispatch (opts, handler) { if (isPromise(requestBody)) { requestBody.then((body) => { - if (body === null) { + if (body === requestAborted) { return } if (body !== opts.body) { @@ -370,7 +368,7 @@ function mockDispatch (opts, handler) { return true } - if (requestBody === null) { + if (requestBody === requestAborted) { return true } @@ -392,19 +390,27 @@ function mockDispatch (opts, handler) { } } - function handleReply (mockDispatches, _data = data) { + function handleReply (mockDispatches, _response = response) { // Don't send response if the request was aborted if (aborted) { return } + if (_response.callback) { + const { callback, ...responseDefaults } = _response + mockDispatch.data = { ...responseDefaults, ...callback(replyOpts) } + return handleReply(mockDispatches, mockDispatch.data) + } + + const { statusCode, data, headers, trailers } = _response + // fetch's HeadersList is a 1D string array const optsHeaders = Array.isArray(opts.headers) ? buildHeadersFromArray(opts.headers) : opts.headers - const body = typeof _data === 'function' - ? _data({ ...replyOpts, headers: optsHeaders }) - : _data + const body = typeof data === 'function' + ? data({ ...replyOpts, headers: optsHeaders }) + : data // util.types.isPromise is likely needed for jest. if (isPromise(body)) { @@ -413,7 +419,7 @@ function mockDispatch (opts, handler) { // synchronously throw the error, which breaks some tests. // Rather, we wait for the callback to resolve if it is a // promise, and then re-run handleReply with the new body. - return body.then((newData) => handleReply(mockDispatches, newData)) + return body.then((newData) => handleReply(mockDispatches, { ..._response, data: newData })) } // Check again if aborted after async body resolution @@ -444,7 +450,7 @@ function dispatchRequestBody (body, handler, controller, isAborted) { } if (body == null) { - return callOnRequestSent(handler, controller) ? body : null + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted } if (body && typeof body[Symbol.asyncIterator] === 'function') { @@ -456,25 +462,25 @@ function dispatchRequestBody (body, handler, controller, isAborted) { for (const chunk of body) { if (isAborted()) { - return null + return requestAborted } chunks.push(chunk) - if (!callOnBodySent(handler, controller, chunk)) { - return null + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted } } - return !isAborted() && callOnRequestSent(handler, controller) ? chunks : null + return callOnRequestSent(handler, controller, isAborted) ? chunks : requestAborted } if (isAborted()) { - return null + return requestAborted } if (!callOnBodySent(handler, controller, body)) { - return null + return requestAborted } - return !isAborted() && callOnRequestSent(handler, controller) ? body : null + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted } async function dispatchAsyncIterableBody (body, handler, controller, isAborted) { @@ -482,16 +488,16 @@ async function dispatchAsyncIterableBody (body, handler, controller, isAborted) for await (const chunk of body) { if (isAborted()) { - return null + return requestAborted } chunks.push(chunk) - if (!callOnBodySent(handler, controller, chunk)) { - return null + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted } } - if (isAborted() || !callOnRequestSent(handler, controller)) { - return null + if (!callOnRequestSent(handler, controller, isAborted)) { + return requestAborted } return { @@ -511,10 +517,10 @@ function callOnBodySent (handler, controller, chunk) { } } -function callOnRequestSent (handler, controller) { +function callOnRequestSent (handler, controller, isAborted) { try { handler.onRequestSent?.() - return true + return !isAborted() } catch (error) { controller.abort(error) return false diff --git a/test/mock-agent.js b/test/mock-agent.js index b496fdd5676..4beac0c4c94 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -273,6 +273,56 @@ describe('MockAgent - dispatch', () => { t.assert.deepStrictEqual(events, ['start', 'body:hello', 'sent']) }) + test('should call request sent lifecycle hook for null bodies', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + const chunks = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('mock response did not complete')) + }, 1000) + + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: null + }, { + onRequestStart () { + events.push('start') + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData (_controller, chunk) { + chunks.push(chunk) + }, + onResponseEnd () { + clearTimeout(timeout) + resolve() + }, + onResponseError (_controller, error) { + clearTimeout(timeout) + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['start', 'sent']) + t.assert.strictEqual(Buffer.concat(chunks).toString(), 'hello') + }) + test('should call request body lifecycle hooks for async iterable bodies', async (t) => { const baseUrl = 'http://localhost:9999' const events = [] @@ -414,6 +464,105 @@ describe('MockAgent - dispatch', () => { t.assert.deepStrictEqual(events, ['body:he']) }) + test('should pass replayed async iterable bodies to reply option callbacks', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + let callbackBody + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(({ body }) => { + events.push('callback') + callbackBody = body + return { statusCode: 200, data: 'hello' } + }) + + async function * body () { + yield Buffer.from('he') + yield Buffer.from('llo') + } + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: body() + }, { + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + const chunks = [] + for await (const chunk of callbackBody) { + chunks.push(chunk) + } + + t.assert.deepStrictEqual(events, ['body:he', 'body:llo', 'sent', 'callback']) + t.assert.strictEqual(Buffer.concat(chunks).toString(), 'hello') + }) + + test('should match request bodies when lifecycle hooks are present', async (t) => { + const baseUrl = 'http://localhost:9999' + const events = [] + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST', + body: /hello/ + }).reply(200, 'matched') + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: 'hello=there' + }, { + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + }, + onResponseStart () {}, + onResponseData (_controller, chunk) { + events.push(`response:${chunk.toString()}`) + }, + onResponseEnd () { + resolve() + }, + onResponseError (_controller, error) { + reject(error) + } + }) + }) + + t.assert.deepStrictEqual(events, ['body:hello=there', 'sent', 'response:matched']) + }) + test('should replay async iterable request bodies to reply callbacks after lifecycle hooks', async (t) => { const baseUrl = 'http://localhost:9999' const events = [] @@ -514,6 +663,115 @@ describe('MockAgent - dispatch', () => { }) }) + test('should not send delayed replies after request sent aborts', async (t) => { + const baseUrl = 'http://localhost:9999' + const expected = new Error('fail') + const events = [] + let requestController + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello').delay(20) + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: 'hello' + }, { + onRequestStart (controller) { + requestController = controller + }, + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + }, + onRequestSent () { + events.push('sent') + requestController.abort(expected) + }, + onResponseStart () { + reject(new Error('response should not start')) + }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError (_controller, error) { + try { + t.assert.strictEqual(error, expected) + setTimeout(resolve, 40) + } catch (assertionError) { + reject(assertionError) + } + } + }) + }) + + t.assert.deepStrictEqual(events, ['body:hello', 'sent']) + }) + + test('should stop reading async iterable request bodies after body hook aborts', async (t) => { + const baseUrl = 'http://localhost:9999' + const expected = new Error('fail') + const events = [] + let requestController + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + async function * body () { + events.push('pull:he') + yield Buffer.from('he') + events.push('pull:llo') + yield Buffer.from('llo') + } + + await new Promise((resolve, reject) => { + mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'POST', + body: body() + }, { + onRequestStart (controller) { + requestController = controller + }, + onBodySent (chunk) { + events.push(`body:${chunk.toString()}`) + requestController.abort(expected) + }, + onRequestSent () { + reject(new Error('request should not be marked sent')) + }, + onResponseStart () { + reject(new Error('response should not start')) + }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError (_controller, error) { + try { + t.assert.strictEqual(error, expected) + resolve() + } catch (assertionError) { + reject(assertionError) + } + } + }) + }) + + t.assert.deepStrictEqual(events, ['pull:he', 'body:he']) + }) + test('should not send request body lifecycle hooks after request start aborts', async (t) => { const baseUrl = 'http://localhost:9999' const expected = new Error('fail')