Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions lib/mock/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,16 +312,45 @@ function mockDispatch (opts, handler) {
return true
}

// Track whether the request has been aborted
let aborted = false
let timer = null

function abort (err) {
if (aborted) {
return
}
aborted = true

// Clear the pending delayed response if any
if (timer !== null) {
clearTimeout(timer)
timer = null
}

// Notify the handler of the abort
handler.onError(err)
}

// Call onConnect to allow the handler to register the abort callback
handler.onConnect?.(abort, null)

// Handle the request with a delay if necessary
if (typeof delay === 'number' && delay > 0) {
setTimeout(() => {
timer = setTimeout(() => {
timer = null
handleReply(this[kDispatches])
}, delay)
} else {
handleReply(this[kDispatches])
}

function handleReply (mockDispatches, _data = data) {
// Don't send response if the request was aborted
if (aborted) {
return
}

// fetch's HeadersList is a 1D string array
const optsHeaders = Array.isArray(opts.headers)
? buildHeadersFromArray(opts.headers)
Expand All @@ -340,11 +369,15 @@ function mockDispatch (opts, handler) {
return body.then((newData) => handleReply(mockDispatches, newData))
}

// Check again if aborted after async body resolution
if (aborted) {
return
}

const responseData = getResponseData(body)
const responseHeaders = generateKeyValues(headers)
const responseTrailers = generateKeyValues(trailers)

handler.onConnect?.(err => handler.onError(err), null)
handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode))
handler.onData?.(Buffer.from(responseData))
handler.onComplete?.(responseTrailers)
Expand Down
137 changes: 137 additions & 0 deletions test/mock-delayed-abort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use strict'

const { test } = require('node:test')
const { MockAgent, interceptors } = require('..')
const DecoratorHandler = require('../lib/handler/decorator-handler')
const { tspl } = require('@matteo.collina/tspl')

test('MockAgent with delayed response and AbortSignal should not cause uncaught errors', async (t) => {
const p = tspl(t, { plan: 2 })

const agent = new MockAgent()
t.after(() => agent.close())

const mockPool = agent.get('https://example.com')
mockPool.intercept({ path: '/test', method: 'GET' })
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
.delay(100)

const ac = new AbortController()

// Abort the request after 10ms
setTimeout(() => {
ac.abort(new Error('Request aborted'))
}, 10)

try {
await agent.request({
origin: 'https://example.com',
path: '/test',
method: 'GET',
signal: ac.signal
})
p.fail('Should have thrown an error')
} catch (err) {
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
}

// Wait for the delayed response to fire - should not cause any uncaught errors
await new Promise(resolve => setTimeout(resolve, 150))

p.ok(true, 'No uncaught errors after delayed response')
})

test('MockAgent with delayed response and composed interceptor (decompress) should not cause uncaught errors', async (t) => {
const p = tspl(t, { plan: 2 })

// The decompress interceptor has assertions that fail if onResponseStart is called after onError
const agent = new MockAgent().compose(interceptors.decompress())
t.after(() => agent.close())

const mockPool = agent.get('https://example.com')
mockPool.intercept({ path: '/test', method: 'GET' })
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
.delay(100)

const ac = new AbortController()

// Abort the request after 10ms
setTimeout(() => {
ac.abort(new Error('Request aborted'))
}, 10)

try {
await agent.request({
origin: 'https://example.com',
path: '/test',
method: 'GET',
signal: ac.signal
})
p.fail('Should have thrown an error')
} catch (err) {
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
}

// Wait for the delayed response to fire - should not cause any uncaught errors
await new Promise(resolve => setTimeout(resolve, 150))

p.ok(true, 'No uncaught errors after delayed response')
})

test('MockAgent with delayed response and DecoratorHandler should not call onResponseStart after onError', async (t) => {
const p = tspl(t, { plan: 2 })

class TestDecoratorHandler extends DecoratorHandler {
#onErrorCalled = false

onResponseStart (controller, statusCode, headers, statusMessage) {
if (this.#onErrorCalled) {
p.fail('onResponseStart should not be called after onError')
}
return super.onResponseStart(controller, statusCode, headers, statusMessage)
}

onResponseError (controller, err) {
this.#onErrorCalled = true
return super.onResponseError(controller, err)
}
}

const agent = new MockAgent()
t.after(() => agent.close())

const mockPool = agent.get('https://example.com')
mockPool.intercept({ path: '/test', method: 'GET' })
.reply(200, { success: true }, { headers: { 'content-type': 'application/json' } })
.delay(100)

const ac = new AbortController()

// Abort the request after 10ms
setTimeout(() => {
ac.abort(new Error('Request aborted'))
}, 10)

const originalDispatch = agent.dispatch.bind(agent)
agent.dispatch = (opts, handler) => {
const decoratedHandler = new TestDecoratorHandler(handler)
return originalDispatch(opts, decoratedHandler)
}

try {
await agent.request({
origin: 'https://example.com',
path: '/test',
method: 'GET',
signal: ac.signal
})
p.fail('Should have thrown an error')
} catch (err) {
p.ok(err.message === 'Request aborted' || err.name === 'AbortError', 'Error should be related to abort')
}

// Wait for the delayed response to fire
await new Promise(resolve => setTimeout(resolve, 150))

p.ok(true, 'Decorator handler invariants maintained')
})
Loading