summaryrefslogtreecommitdiffstats
path: root/lib/mock
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-21 20:56:19 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-21 20:56:19 +0000
commit0b6210cd37b68b94252cb798598b12974a20e1c1 (patch)
treee371686554a877842d95aa94f100bee552ff2a8e /lib/mock
parentInitial commit. (diff)
downloadnode-undici-0b6210cd37b68b94252cb798598b12974a20e1c1.tar.xz
node-undici-0b6210cd37b68b94252cb798598b12974a20e1c1.zip
Adding upstream version 5.28.2+dfsg1+~cs23.11.12.3.upstream/5.28.2+dfsg1+_cs23.11.12.3upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'lib/mock')
-rw-r--r--lib/mock/mock-agent.js171
-rw-r--r--lib/mock/mock-client.js59
-rw-r--r--lib/mock/mock-errors.js17
-rw-r--r--lib/mock/mock-interceptor.js206
-rw-r--r--lib/mock/mock-pool.js59
-rw-r--r--lib/mock/mock-symbols.js23
-rw-r--r--lib/mock/mock-utils.js351
-rw-r--r--lib/mock/pending-interceptors-formatter.js40
-rw-r--r--lib/mock/pluralizer.js29
9 files changed, 955 insertions, 0 deletions
diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js
new file mode 100644
index 0000000..828e8af
--- /dev/null
+++ b/lib/mock/mock-agent.js
@@ -0,0 +1,171 @@
+'use strict'
+
+const { kClients } = require('../core/symbols')
+const Agent = require('../agent')
+const {
+ kAgent,
+ kMockAgentSet,
+ kMockAgentGet,
+ kDispatches,
+ kIsMockActive,
+ kNetConnect,
+ kGetNetConnect,
+ kOptions,
+ kFactory
+} = require('./mock-symbols')
+const MockClient = require('./mock-client')
+const MockPool = require('./mock-pool')
+const { matchValue, buildMockOptions } = require('./mock-utils')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const Dispatcher = require('../dispatcher')
+const Pluralizer = require('./pluralizer')
+const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
+
+class FakeWeakRef {
+ constructor (value) {
+ this.value = value
+ }
+
+ deref () {
+ return this.value
+ }
+}
+
+class MockAgent extends Dispatcher {
+ constructor (opts) {
+ super(opts)
+
+ this[kNetConnect] = true
+ this[kIsMockActive] = true
+
+ // Instantiate Agent and encapsulate
+ if ((opts && opts.agent && typeof opts.agent.dispatch !== 'function')) {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+ const agent = opts && opts.agent ? opts.agent : new Agent(opts)
+ this[kAgent] = agent
+
+ this[kClients] = agent[kClients]
+ this[kOptions] = buildMockOptions(opts)
+ }
+
+ get (origin) {
+ let dispatcher = this[kMockAgentGet](origin)
+
+ if (!dispatcher) {
+ dispatcher = this[kFactory](origin)
+ this[kMockAgentSet](origin, dispatcher)
+ }
+ return dispatcher
+ }
+
+ dispatch (opts, handler) {
+ // Call MockAgent.get to perform additional setup before dispatching as normal
+ this.get(opts.origin)
+ return this[kAgent].dispatch(opts, handler)
+ }
+
+ async close () {
+ await this[kAgent].close()
+ this[kClients].clear()
+ }
+
+ deactivate () {
+ this[kIsMockActive] = false
+ }
+
+ activate () {
+ this[kIsMockActive] = true
+ }
+
+ enableNetConnect (matcher) {
+ if (typeof matcher === 'string' || typeof matcher === 'function' || matcher instanceof RegExp) {
+ if (Array.isArray(this[kNetConnect])) {
+ this[kNetConnect].push(matcher)
+ } else {
+ this[kNetConnect] = [matcher]
+ }
+ } else if (typeof matcher === 'undefined') {
+ this[kNetConnect] = true
+ } else {
+ throw new InvalidArgumentError('Unsupported matcher. Must be one of String|Function|RegExp.')
+ }
+ }
+
+ disableNetConnect () {
+ this[kNetConnect] = false
+ }
+
+ // This is required to bypass issues caused by using global symbols - see:
+ // https://github.com/nodejs/undici/issues/1447
+ get isMockActive () {
+ return this[kIsMockActive]
+ }
+
+ [kMockAgentSet] (origin, dispatcher) {
+ this[kClients].set(origin, new FakeWeakRef(dispatcher))
+ }
+
+ [kFactory] (origin) {
+ const mockOptions = Object.assign({ agent: this }, this[kOptions])
+ return this[kOptions] && this[kOptions].connections === 1
+ ? new MockClient(origin, mockOptions)
+ : new MockPool(origin, mockOptions)
+ }
+
+ [kMockAgentGet] (origin) {
+ // First check if we can immediately find it
+ const ref = this[kClients].get(origin)
+ if (ref) {
+ return ref.deref()
+ }
+
+ // If the origin is not a string create a dummy parent pool and return to user
+ if (typeof origin !== 'string') {
+ const dispatcher = this[kFactory]('http://localhost:9999')
+ this[kMockAgentSet](origin, dispatcher)
+ return dispatcher
+ }
+
+ // If we match, create a pool and assign the same dispatches
+ for (const [keyMatcher, nonExplicitRef] of Array.from(this[kClients])) {
+ const nonExplicitDispatcher = nonExplicitRef.deref()
+ if (nonExplicitDispatcher && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) {
+ const dispatcher = this[kFactory](origin)
+ this[kMockAgentSet](origin, dispatcher)
+ dispatcher[kDispatches] = nonExplicitDispatcher[kDispatches]
+ return dispatcher
+ }
+ }
+ }
+
+ [kGetNetConnect] () {
+ return this[kNetConnect]
+ }
+
+ pendingInterceptors () {
+ const mockAgentClients = this[kClients]
+
+ return Array.from(mockAgentClients.entries())
+ .flatMap(([origin, scope]) => scope.deref()[kDispatches].map(dispatch => ({ ...dispatch, origin })))
+ .filter(({ pending }) => pending)
+ }
+
+ assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) {
+ const pending = this.pendingInterceptors()
+
+ if (pending.length === 0) {
+ return
+ }
+
+ const pluralizer = new Pluralizer('interceptor', 'interceptors').pluralize(pending.length)
+
+ throw new UndiciError(`
+${pluralizer.count} ${pluralizer.noun} ${pluralizer.is} pending:
+
+${pendingInterceptorsFormatter.format(pending)}
+`.trim())
+ }
+}
+
+module.exports = MockAgent
diff --git a/lib/mock/mock-client.js b/lib/mock/mock-client.js
new file mode 100644
index 0000000..5f31215
--- /dev/null
+++ b/lib/mock/mock-client.js
@@ -0,0 +1,59 @@
+'use strict'
+
+const { promisify } = require('util')
+const Client = require('../client')
+const { buildMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kMockAgent,
+ kClose,
+ kOriginalClose,
+ kOrigin,
+ kOriginalDispatch,
+ kConnected
+} = require('./mock-symbols')
+const { MockInterceptor } = require('./mock-interceptor')
+const Symbols = require('../core/symbols')
+const { InvalidArgumentError } = require('../core/errors')
+
+/**
+ * MockClient provides an API that extends the Client to influence the mockDispatches.
+ */
+class MockClient extends Client {
+ constructor (origin, opts) {
+ super(origin, opts)
+
+ if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+
+ this[kMockAgent] = opts.agent
+ this[kOrigin] = origin
+ this[kDispatches] = []
+ this[kConnected] = 1
+ this[kOriginalDispatch] = this.dispatch
+ this[kOriginalClose] = this.close.bind(this)
+
+ this.dispatch = buildMockDispatch.call(this)
+ this.close = this[kClose]
+ }
+
+ get [Symbols.kConnected] () {
+ return this[kConnected]
+ }
+
+ /**
+ * Sets up the base interceptor for mocking replies from undici.
+ */
+ intercept (opts) {
+ return new MockInterceptor(opts, this[kDispatches])
+ }
+
+ async [kClose] () {
+ await promisify(this[kOriginalClose])()
+ this[kConnected] = 0
+ this[kMockAgent][Symbols.kClients].delete(this[kOrigin])
+ }
+}
+
+module.exports = MockClient
diff --git a/lib/mock/mock-errors.js b/lib/mock/mock-errors.js
new file mode 100644
index 0000000..5442c0e
--- /dev/null
+++ b/lib/mock/mock-errors.js
@@ -0,0 +1,17 @@
+'use strict'
+
+const { UndiciError } = require('../core/errors')
+
+class MockNotMatchedError extends UndiciError {
+ constructor (message) {
+ super(message)
+ Error.captureStackTrace(this, MockNotMatchedError)
+ this.name = 'MockNotMatchedError'
+ this.message = message || 'The request does not match any registered mock dispatches'
+ this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED'
+ }
+}
+
+module.exports = {
+ MockNotMatchedError
+}
diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js
new file mode 100644
index 0000000..781e477
--- /dev/null
+++ b/lib/mock/mock-interceptor.js
@@ -0,0 +1,206 @@
+'use strict'
+
+const { getResponseData, buildKey, addMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kDispatchKey,
+ kDefaultHeaders,
+ kDefaultTrailers,
+ kContentLength,
+ kMockDispatch
+} = require('./mock-symbols')
+const { InvalidArgumentError } = require('../core/errors')
+const { buildURL } = require('../core/util')
+
+/**
+ * Defines the scope API for an interceptor reply
+ */
+class MockScope {
+ constructor (mockDispatch) {
+ this[kMockDispatch] = mockDispatch
+ }
+
+ /**
+ * Delay a reply by a set amount in ms.
+ */
+ delay (waitInMs) {
+ if (typeof waitInMs !== 'number' || !Number.isInteger(waitInMs) || waitInMs <= 0) {
+ throw new InvalidArgumentError('waitInMs must be a valid integer > 0')
+ }
+
+ this[kMockDispatch].delay = waitInMs
+ return this
+ }
+
+ /**
+ * For a defined reply, never mark as consumed.
+ */
+ persist () {
+ this[kMockDispatch].persist = true
+ return this
+ }
+
+ /**
+ * Allow one to define a reply for a set amount of matching requests.
+ */
+ times (repeatTimes) {
+ if (typeof repeatTimes !== 'number' || !Number.isInteger(repeatTimes) || repeatTimes <= 0) {
+ throw new InvalidArgumentError('repeatTimes must be a valid integer > 0')
+ }
+
+ this[kMockDispatch].times = repeatTimes
+ return this
+ }
+}
+
+/**
+ * Defines an interceptor for a Mock
+ */
+class MockInterceptor {
+ constructor (opts, mockDispatches) {
+ if (typeof opts !== 'object') {
+ throw new InvalidArgumentError('opts must be an object')
+ }
+ if (typeof opts.path === 'undefined') {
+ throw new InvalidArgumentError('opts.path must be defined')
+ }
+ if (typeof opts.method === 'undefined') {
+ opts.method = 'GET'
+ }
+ // See https://github.com/nodejs/undici/issues/1245
+ // As per RFC 3986, clients are not supposed to send URI
+ // fragments to servers when they retrieve a document,
+ if (typeof opts.path === 'string') {
+ if (opts.query) {
+ opts.path = buildURL(opts.path, opts.query)
+ } else {
+ // Matches https://github.com/nodejs/undici/blob/main/lib/fetch/index.js#L1811
+ const parsedURL = new URL(opts.path, 'data://')
+ opts.path = parsedURL.pathname + parsedURL.search
+ }
+ }
+ if (typeof opts.method === 'string') {
+ opts.method = opts.method.toUpperCase()
+ }
+
+ this[kDispatchKey] = buildKey(opts)
+ this[kDispatches] = mockDispatches
+ this[kDefaultHeaders] = {}
+ this[kDefaultTrailers] = {}
+ this[kContentLength] = false
+ }
+
+ createMockScopeDispatchData (statusCode, data, responseOptions = {}) {
+ const responseData = getResponseData(data)
+ const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {}
+ const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers }
+ const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers }
+
+ return { statusCode, data, headers, trailers }
+ }
+
+ validateReplyParameters (statusCode, data, responseOptions) {
+ if (typeof statusCode === 'undefined') {
+ throw new InvalidArgumentError('statusCode must be defined')
+ }
+ if (typeof data === 'undefined') {
+ throw new InvalidArgumentError('data must be defined')
+ }
+ if (typeof responseOptions !== 'object') {
+ throw new InvalidArgumentError('responseOptions must be an object')
+ }
+ }
+
+ /**
+ * Mock an undici request with a defined reply.
+ */
+ reply (replyData) {
+ // Values of reply aren't available right now as they
+ // can only be available when the reply callback is invoked.
+ if (typeof replyData === 'function') {
+ // We'll first wrap the provided callback in another function,
+ // this function will properly resolve the data from the callback
+ // when invoked.
+ const wrappedDefaultsCallback = (opts) => {
+ // Our reply options callback contains the parameter for statusCode, data and options.
+ const resolvedData = replyData(opts)
+
+ // Check if it is in the right format
+ if (typeof resolvedData !== 'object') {
+ throw new InvalidArgumentError('reply options callback must return an object')
+ }
+
+ const { statusCode, data = '', responseOptions = {} } = resolvedData
+ this.validateReplyParameters(statusCode, data, responseOptions)
+ // Since the values can be obtained immediately we return them
+ // from this higher order function that will be resolved later.
+ return {
+ ...this.createMockScopeDispatchData(statusCode, data, responseOptions)
+ }
+ }
+
+ // Add usual dispatch data, but this time set the data parameter to function that will eventually provide data.
+ const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback)
+ return new MockScope(newMockDispatch)
+ }
+
+ // We can have either one or three parameters, if we get here,
+ // we should have 1-3 parameters. So we spread the arguments of
+ // this function to obtain the parameters, since replyData will always
+ // just be the statusCode.
+ const [statusCode, data = '', responseOptions = {}] = [...arguments]
+ this.validateReplyParameters(statusCode, data, responseOptions)
+
+ // Send in-already provided data like usual
+ const dispatchData = this.createMockScopeDispatchData(statusCode, data, responseOptions)
+ const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData)
+ return new MockScope(newMockDispatch)
+ }
+
+ /**
+ * Mock an undici request with a defined error.
+ */
+ replyWithError (error) {
+ if (typeof error === 'undefined') {
+ throw new InvalidArgumentError('error must be defined')
+ }
+
+ const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error })
+ return new MockScope(newMockDispatch)
+ }
+
+ /**
+ * Set default reply headers on the interceptor for subsequent replies
+ */
+ defaultReplyHeaders (headers) {
+ if (typeof headers === 'undefined') {
+ throw new InvalidArgumentError('headers must be defined')
+ }
+
+ this[kDefaultHeaders] = headers
+ return this
+ }
+
+ /**
+ * Set default reply trailers on the interceptor for subsequent replies
+ */
+ defaultReplyTrailers (trailers) {
+ if (typeof trailers === 'undefined') {
+ throw new InvalidArgumentError('trailers must be defined')
+ }
+
+ this[kDefaultTrailers] = trailers
+ return this
+ }
+
+ /**
+ * Set reply content length header for replies on the interceptor
+ */
+ replyContentLength () {
+ this[kContentLength] = true
+ return this
+ }
+}
+
+module.exports.MockInterceptor = MockInterceptor
+module.exports.MockScope = MockScope
diff --git a/lib/mock/mock-pool.js b/lib/mock/mock-pool.js
new file mode 100644
index 0000000..0a3a7cd
--- /dev/null
+++ b/lib/mock/mock-pool.js
@@ -0,0 +1,59 @@
+'use strict'
+
+const { promisify } = require('util')
+const Pool = require('../pool')
+const { buildMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kMockAgent,
+ kClose,
+ kOriginalClose,
+ kOrigin,
+ kOriginalDispatch,
+ kConnected
+} = require('./mock-symbols')
+const { MockInterceptor } = require('./mock-interceptor')
+const Symbols = require('../core/symbols')
+const { InvalidArgumentError } = require('../core/errors')
+
+/**
+ * MockPool provides an API that extends the Pool to influence the mockDispatches.
+ */
+class MockPool extends Pool {
+ constructor (origin, opts) {
+ super(origin, opts)
+
+ if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+
+ this[kMockAgent] = opts.agent
+ this[kOrigin] = origin
+ this[kDispatches] = []
+ this[kConnected] = 1
+ this[kOriginalDispatch] = this.dispatch
+ this[kOriginalClose] = this.close.bind(this)
+
+ this.dispatch = buildMockDispatch.call(this)
+ this.close = this[kClose]
+ }
+
+ get [Symbols.kConnected] () {
+ return this[kConnected]
+ }
+
+ /**
+ * Sets up the base interceptor for mocking replies from undici.
+ */
+ intercept (opts) {
+ return new MockInterceptor(opts, this[kDispatches])
+ }
+
+ async [kClose] () {
+ await promisify(this[kOriginalClose])()
+ this[kConnected] = 0
+ this[kMockAgent][Symbols.kClients].delete(this[kOrigin])
+ }
+}
+
+module.exports = MockPool
diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js
new file mode 100644
index 0000000..8c4cbb6
--- /dev/null
+++ b/lib/mock/mock-symbols.js
@@ -0,0 +1,23 @@
+'use strict'
+
+module.exports = {
+ kAgent: Symbol('agent'),
+ kOptions: Symbol('options'),
+ kFactory: Symbol('factory'),
+ kDispatches: Symbol('dispatches'),
+ kDispatchKey: Symbol('dispatch key'),
+ kDefaultHeaders: Symbol('default headers'),
+ kDefaultTrailers: Symbol('default trailers'),
+ kContentLength: Symbol('content length'),
+ kMockAgent: Symbol('mock agent'),
+ kMockAgentSet: Symbol('mock agent set'),
+ kMockAgentGet: Symbol('mock agent get'),
+ kMockDispatch: Symbol('mock dispatch'),
+ kClose: Symbol('close'),
+ kOriginalClose: Symbol('original agent close'),
+ kOrigin: Symbol('origin'),
+ kIsMockActive: Symbol('is mock active'),
+ kNetConnect: Symbol('net connect'),
+ kGetNetConnect: Symbol('get net connect'),
+ kConnected: Symbol('connected')
+}
diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js
new file mode 100644
index 0000000..42ea185
--- /dev/null
+++ b/lib/mock/mock-utils.js
@@ -0,0 +1,351 @@
+'use strict'
+
+const { MockNotMatchedError } = require('./mock-errors')
+const {
+ kDispatches,
+ kMockAgent,
+ kOriginalDispatch,
+ kOrigin,
+ kGetNetConnect
+} = require('./mock-symbols')
+const { buildURL, nop } = require('../core/util')
+const { STATUS_CODES } = require('http')
+const {
+ types: {
+ isPromise
+ }
+} = require('util')
+
+function matchValue (match, value) {
+ if (typeof match === 'string') {
+ return match === value
+ }
+ if (match instanceof RegExp) {
+ return match.test(value)
+ }
+ if (typeof match === 'function') {
+ return match(value) === true
+ }
+ return false
+}
+
+function lowerCaseEntries (headers) {
+ return Object.fromEntries(
+ Object.entries(headers).map(([headerName, headerValue]) => {
+ return [headerName.toLocaleLowerCase(), headerValue]
+ })
+ )
+}
+
+/**
+ * @param {import('../../index').Headers|string[]|Record<string, string>} headers
+ * @param {string} key
+ */
+function getHeaderByName (headers, key) {
+ if (Array.isArray(headers)) {
+ for (let i = 0; i < headers.length; i += 2) {
+ if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) {
+ return headers[i + 1]
+ }
+ }
+
+ return undefined
+ } else if (typeof headers.get === 'function') {
+ return headers.get(key)
+ } else {
+ return lowerCaseEntries(headers)[key.toLocaleLowerCase()]
+ }
+}
+
+/** @param {string[]} headers */
+function buildHeadersFromArray (headers) { // fetch HeadersList
+ const clone = headers.slice()
+ const entries = []
+ for (let index = 0; index < clone.length; index += 2) {
+ entries.push([clone[index], clone[index + 1]])
+ }
+ return Object.fromEntries(entries)
+}
+
+function matchHeaders (mockDispatch, headers) {
+ if (typeof mockDispatch.headers === 'function') {
+ if (Array.isArray(headers)) { // fetch HeadersList
+ headers = buildHeadersFromArray(headers)
+ }
+ return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {})
+ }
+ if (typeof mockDispatch.headers === 'undefined') {
+ return true
+ }
+ if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') {
+ return false
+ }
+
+ for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) {
+ const headerValue = getHeaderByName(headers, matchHeaderName)
+
+ if (!matchValue(matchHeaderValue, headerValue)) {
+ return false
+ }
+ }
+ return true
+}
+
+function safeUrl (path) {
+ if (typeof path !== 'string') {
+ return path
+ }
+
+ const pathSegments = path.split('?')
+
+ if (pathSegments.length !== 2) {
+ return path
+ }
+
+ const qp = new URLSearchParams(pathSegments.pop())
+ qp.sort()
+ return [...pathSegments, qp.toString()].join('?')
+}
+
+function matchKey (mockDispatch, { path, method, body, headers }) {
+ const pathMatch = matchValue(mockDispatch.path, path)
+ const methodMatch = matchValue(mockDispatch.method, method)
+ const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true
+ const headersMatch = matchHeaders(mockDispatch, headers)
+ return pathMatch && methodMatch && bodyMatch && headersMatch
+}
+
+function getResponseData (data) {
+ if (Buffer.isBuffer(data)) {
+ return data
+ } else if (typeof data === 'object') {
+ return JSON.stringify(data)
+ } else {
+ return data.toString()
+ }
+}
+
+function getMockDispatch (mockDispatches, key) {
+ const basePath = key.query ? buildURL(key.path, key.query) : key.path
+ const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath
+
+ // Match path
+ let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath))
+ if (matchedMockDispatches.length === 0) {
+ throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`)
+ }
+
+ // Match method
+ matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method))
+ if (matchedMockDispatches.length === 0) {
+ throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}'`)
+ }
+
+ // Match body
+ matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true)
+ if (matchedMockDispatches.length === 0) {
+ throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}'`)
+ }
+
+ // Match headers
+ matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers))
+ if (matchedMockDispatches.length === 0) {
+ throw new MockNotMatchedError(`Mock dispatch not matched for headers '${typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers}'`)
+ }
+
+ return matchedMockDispatches[0]
+}
+
+function addMockDispatch (mockDispatches, key, data) {
+ const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false }
+ const replyData = typeof data === 'function' ? { callback: data } : { ...data }
+ const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
+ mockDispatches.push(newMockDispatch)
+ return newMockDispatch
+}
+
+function deleteMockDispatch (mockDispatches, key) {
+ const index = mockDispatches.findIndex(dispatch => {
+ if (!dispatch.consumed) {
+ return false
+ }
+ return matchKey(dispatch, key)
+ })
+ if (index !== -1) {
+ mockDispatches.splice(index, 1)
+ }
+}
+
+function buildKey (opts) {
+ const { path, method, body, headers, query } = opts
+ return {
+ path,
+ method,
+ body,
+ headers,
+ query
+ }
+}
+
+function generateKeyValues (data) {
+ return Object.entries(data).reduce((keyValuePairs, [key, value]) => [
+ ...keyValuePairs,
+ Buffer.from(`${key}`),
+ Array.isArray(value) ? value.map(x => Buffer.from(`${x}`)) : Buffer.from(`${value}`)
+ ], [])
+}
+
+/**
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
+ * @param {number} statusCode
+ */
+function getStatusText (statusCode) {
+ return STATUS_CODES[statusCode] || 'unknown'
+}
+
+async function getResponse (body) {
+ const buffers = []
+ for await (const data of body) {
+ buffers.push(data)
+ }
+ return Buffer.concat(buffers).toString('utf8')
+}
+
+/**
+ * Mock dispatch function used to simulate undici dispatches
+ */
+function mockDispatch (opts, handler) {
+ // Get mock dispatch from built key
+ const key = buildKey(opts)
+ const mockDispatch = getMockDispatch(this[kDispatches], key)
+
+ 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 { timesInvoked, times } = mockDispatch
+
+ // If it's used up and not persistent, mark as consumed
+ mockDispatch.consumed = !persist && timesInvoked >= times
+ mockDispatch.pending = timesInvoked < times
+
+ // If specified, trigger dispatch error
+ if (error !== null) {
+ deleteMockDispatch(this[kDispatches], key)
+ handler.onError(error)
+ return true
+ }
+
+ // Handle the request with a delay if necessary
+ if (typeof delay === 'number' && delay > 0) {
+ setTimeout(() => {
+ handleReply(this[kDispatches])
+ }, delay)
+ } else {
+ handleReply(this[kDispatches])
+ }
+
+ function handleReply (mockDispatches, _data = data) {
+ // 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({ ...opts, headers: optsHeaders })
+ : _data
+
+ // util.types.isPromise is likely needed for jest.
+ if (isPromise(body)) {
+ // If handleReply is asynchronous, throwing an error
+ // in the callback will reject the promise, rather than
+ // 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.
+ body.then((newData) => handleReply(mockDispatches, newData))
+ return
+ }
+
+ const responseData = getResponseData(body)
+ const responseHeaders = generateKeyValues(headers)
+ const responseTrailers = generateKeyValues(trailers)
+
+ handler.abort = nop
+ handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode))
+ handler.onData(Buffer.from(responseData))
+ handler.onComplete(responseTrailers)
+ deleteMockDispatch(mockDispatches, key)
+ }
+
+ function resume () {}
+
+ return true
+}
+
+function buildMockDispatch () {
+ const agent = this[kMockAgent]
+ const origin = this[kOrigin]
+ const originalDispatch = this[kOriginalDispatch]
+
+ return function dispatch (opts, handler) {
+ if (agent.isMockActive) {
+ try {
+ mockDispatch.call(this, opts, handler)
+ } catch (error) {
+ if (error instanceof MockNotMatchedError) {
+ const netConnect = agent[kGetNetConnect]()
+ if (netConnect === false) {
+ throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`)
+ }
+ if (checkNetConnect(netConnect, origin)) {
+ originalDispatch.call(this, opts, handler)
+ } else {
+ throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`)
+ }
+ } else {
+ throw error
+ }
+ }
+ } else {
+ originalDispatch.call(this, opts, handler)
+ }
+ }
+}
+
+function checkNetConnect (netConnect, origin) {
+ const url = new URL(origin)
+ if (netConnect === true) {
+ return true
+ } else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) {
+ return true
+ }
+ return false
+}
+
+function buildMockOptions (opts) {
+ if (opts) {
+ const { agent, ...mockOptions } = opts
+ return mockOptions
+ }
+}
+
+module.exports = {
+ getResponseData,
+ getMockDispatch,
+ addMockDispatch,
+ deleteMockDispatch,
+ buildKey,
+ generateKeyValues,
+ matchValue,
+ getResponse,
+ getStatusText,
+ mockDispatch,
+ buildMockDispatch,
+ checkNetConnect,
+ buildMockOptions,
+ getHeaderByName
+}
diff --git a/lib/mock/pending-interceptors-formatter.js b/lib/mock/pending-interceptors-formatter.js
new file mode 100644
index 0000000..1bc7539
--- /dev/null
+++ b/lib/mock/pending-interceptors-formatter.js
@@ -0,0 +1,40 @@
+'use strict'
+
+const { Transform } = require('stream')
+const { Console } = require('console')
+
+/**
+ * Gets the output of `console.table(…)` as a string.
+ */
+module.exports = class PendingInterceptorsFormatter {
+ constructor ({ disableColors } = {}) {
+ this.transform = new Transform({
+ transform (chunk, _enc, cb) {
+ cb(null, chunk)
+ }
+ })
+
+ this.logger = new Console({
+ stdout: this.transform,
+ inspectOptions: {
+ colors: !disableColors && !process.env.CI
+ }
+ })
+ }
+
+ format (pendingInterceptors) {
+ const withPrettyHeaders = pendingInterceptors.map(
+ ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin }) => ({
+ Method: method,
+ Origin: origin,
+ Path: path,
+ 'Status code': statusCode,
+ Persistent: persist ? '✅' : '❌',
+ Invocations: timesInvoked,
+ Remaining: persist ? Infinity : times - timesInvoked
+ }))
+
+ this.logger.table(withPrettyHeaders)
+ return this.transform.read().toString()
+ }
+}
diff --git a/lib/mock/pluralizer.js b/lib/mock/pluralizer.js
new file mode 100644
index 0000000..47f150b
--- /dev/null
+++ b/lib/mock/pluralizer.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const singulars = {
+ pronoun: 'it',
+ is: 'is',
+ was: 'was',
+ this: 'this'
+}
+
+const plurals = {
+ pronoun: 'they',
+ is: 'are',
+ was: 'were',
+ this: 'these'
+}
+
+module.exports = class Pluralizer {
+ constructor (singular, plural) {
+ this.singular = singular
+ this.plural = plural
+ }
+
+ pluralize (count) {
+ const one = count === 1
+ const keys = one ? singulars : plurals
+ const noun = one ? this.singular : this.plural
+ return { ...keys, count, noun }
+ }
+}