aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/mock
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
commit76cb9c2a39d477a64824a985ade40507e3bbade1 (patch)
tree41e997aa9c6f538d3a136af61dae9424db2005a9 /vanilla/node_modules/undici/lib/mock
parent819a39a21ac992b1393244a4c283bbb125208c69 (diff)
downloadneko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.gz
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.bz2
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.zip
feat(vanilla): add testing infrastructure and tests (NK-wjnczv)
Diffstat (limited to 'vanilla/node_modules/undici/lib/mock')
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-agent.js232
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-call-history.js248
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-client.js68
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-errors.js29
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-interceptor.js209
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-pool.js68
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-symbols.js31
-rw-r--r--vanilla/node_modules/undici/lib/mock/mock-utils.js480
-rw-r--r--vanilla/node_modules/undici/lib/mock/pending-interceptors-formatter.js43
-rw-r--r--vanilla/node_modules/undici/lib/mock/snapshot-agent.js353
-rw-r--r--vanilla/node_modules/undici/lib/mock/snapshot-recorder.js588
-rw-r--r--vanilla/node_modules/undici/lib/mock/snapshot-utils.js158
12 files changed, 2507 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/mock/mock-agent.js b/vanilla/node_modules/undici/lib/mock/mock-agent.js
new file mode 100644
index 0000000..61449e0
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-agent.js
@@ -0,0 +1,232 @@
+'use strict'
+
+const { kClients } = require('../core/symbols')
+const Agent = require('../dispatcher/agent')
+const {
+ kAgent,
+ kMockAgentSet,
+ kMockAgentGet,
+ kDispatches,
+ kIsMockActive,
+ kNetConnect,
+ kGetNetConnect,
+ kOptions,
+ kFactory,
+ kMockAgentRegisterCallHistory,
+ kMockAgentIsCallHistoryEnabled,
+ kMockAgentAddCallHistoryLog,
+ kMockAgentMockCallHistoryInstance,
+ kMockAgentAcceptsNonStandardSearchParameters,
+ kMockCallHistoryAddLog,
+ kIgnoreTrailingSlash
+} = require('./mock-symbols')
+const MockClient = require('./mock-client')
+const MockPool = require('./mock-pool')
+const { matchValue, normalizeSearchParams, buildAndValidateMockOptions, normalizeOrigin } = require('./mock-utils')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const Dispatcher = require('../dispatcher/dispatcher')
+const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
+const { MockCallHistory } = require('./mock-call-history')
+
+class MockAgent extends Dispatcher {
+ constructor (opts = {}) {
+ super(opts)
+
+ const mockOptions = buildAndValidateMockOptions(opts)
+
+ this[kNetConnect] = true
+ this[kIsMockActive] = true
+ this[kMockAgentIsCallHistoryEnabled] = mockOptions.enableCallHistory ?? false
+ this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions.acceptNonStandardSearchParameters ?? false
+ this[kIgnoreTrailingSlash] = mockOptions.ignoreTrailingSlash ?? false
+
+ // Instantiate Agent and encapsulate
+ if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+ const agent = opts?.agent ? opts.agent : new Agent(opts)
+ this[kAgent] = agent
+
+ this[kClients] = agent[kClients]
+ this[kOptions] = mockOptions
+
+ if (this[kMockAgentIsCallHistoryEnabled]) {
+ this[kMockAgentRegisterCallHistory]()
+ }
+ }
+
+ get (origin) {
+ // Normalize origin to handle URL objects and case-insensitive hostnames
+ const normalizedOrigin = normalizeOrigin(origin)
+ const originKey = this[kIgnoreTrailingSlash] ? normalizedOrigin.replace(/\/$/, '') : normalizedOrigin
+
+ let dispatcher = this[kMockAgentGet](originKey)
+
+ if (!dispatcher) {
+ dispatcher = this[kFactory](originKey)
+ this[kMockAgentSet](originKey, dispatcher)
+ }
+ return dispatcher
+ }
+
+ dispatch (opts, handler) {
+ opts.origin = normalizeOrigin(opts.origin)
+
+ // Call MockAgent.get to perform additional setup before dispatching as normal
+ this.get(opts.origin)
+
+ this[kMockAgentAddCallHistoryLog](opts)
+
+ const acceptNonStandardSearchParameters = this[kMockAgentAcceptsNonStandardSearchParameters]
+
+ const dispatchOpts = { ...opts }
+
+ if (acceptNonStandardSearchParameters && dispatchOpts.path) {
+ const [path, searchParams] = dispatchOpts.path.split('?')
+ const normalizedSearchParams = normalizeSearchParams(searchParams, acceptNonStandardSearchParameters)
+ dispatchOpts.path = `${path}?${normalizedSearchParams}`
+ }
+
+ return this[kAgent].dispatch(dispatchOpts, handler)
+ }
+
+ async close () {
+ this.clearCallHistory()
+ 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
+ }
+
+ enableCallHistory () {
+ this[kMockAgentIsCallHistoryEnabled] = true
+
+ return this
+ }
+
+ disableCallHistory () {
+ this[kMockAgentIsCallHistoryEnabled] = false
+
+ return this
+ }
+
+ getCallHistory () {
+ return this[kMockAgentMockCallHistoryInstance]
+ }
+
+ clearCallHistory () {
+ if (this[kMockAgentMockCallHistoryInstance] !== undefined) {
+ this[kMockAgentMockCallHistoryInstance].clear()
+ }
+ }
+
+ // This is required to bypass issues caused by using global symbols - see:
+ // https://github.com/nodejs/undici/issues/1447
+ get isMockActive () {
+ return this[kIsMockActive]
+ }
+
+ [kMockAgentRegisterCallHistory] () {
+ if (this[kMockAgentMockCallHistoryInstance] === undefined) {
+ this[kMockAgentMockCallHistoryInstance] = new MockCallHistory()
+ }
+ }
+
+ [kMockAgentAddCallHistoryLog] (opts) {
+ if (this[kMockAgentIsCallHistoryEnabled]) {
+ // additional setup when enableCallHistory class method is used after mockAgent instantiation
+ this[kMockAgentRegisterCallHistory]()
+
+ // add call history log on every call (intercepted or not)
+ this[kMockAgentMockCallHistoryInstance][kMockCallHistoryAddLog](opts)
+ }
+ }
+
+ [kMockAgentSet] (origin, dispatcher) {
+ this[kClients].set(origin, { count: 0, 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 result = this[kClients].get(origin)
+ if (result?.dispatcher) {
+ return result.dispatcher
+ }
+
+ // 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, result] of Array.from(this[kClients])) {
+ if (result && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) {
+ const dispatcher = this[kFactory](origin)
+ this[kMockAgentSet](origin, dispatcher)
+ dispatcher[kDispatches] = result.dispatcher[kDispatches]
+ return dispatcher
+ }
+ }
+ }
+
+ [kGetNetConnect] () {
+ return this[kNetConnect]
+ }
+
+ pendingInterceptors () {
+ const mockAgentClients = this[kClients]
+
+ return Array.from(mockAgentClients.entries())
+ .flatMap(([origin, result]) => result.dispatcher[kDispatches].map(dispatch => ({ ...dispatch, origin })))
+ .filter(({ pending }) => pending)
+ }
+
+ assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) {
+ const pending = this.pendingInterceptors()
+
+ if (pending.length === 0) {
+ return
+ }
+
+ throw new UndiciError(
+ pending.length === 1
+ ? `1 interceptor is pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim()
+ : `${pending.length} interceptors are pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim()
+ )
+ }
+}
+
+module.exports = MockAgent
diff --git a/vanilla/node_modules/undici/lib/mock/mock-call-history.js b/vanilla/node_modules/undici/lib/mock/mock-call-history.js
new file mode 100644
index 0000000..d4a92b2
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-call-history.js
@@ -0,0 +1,248 @@
+'use strict'
+
+const { kMockCallHistoryAddLog } = require('./mock-symbols')
+const { InvalidArgumentError } = require('../core/errors')
+
+function handleFilterCallsWithOptions (criteria, options, handler, store) {
+ switch (options.operator) {
+ case 'OR':
+ store.push(...handler(criteria))
+
+ return store
+ case 'AND':
+ return handler.call({ logs: store }, criteria)
+ default:
+ // guard -- should never happens because buildAndValidateFilterCallsOptions is called before
+ throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'')
+ }
+}
+
+function buildAndValidateFilterCallsOptions (options = {}) {
+ const finalOptions = {}
+
+ if ('operator' in options) {
+ if (typeof options.operator !== 'string' || (options.operator.toUpperCase() !== 'OR' && options.operator.toUpperCase() !== 'AND')) {
+ throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'')
+ }
+
+ return {
+ ...finalOptions,
+ operator: options.operator.toUpperCase()
+ }
+ }
+
+ return finalOptions
+}
+
+function makeFilterCalls (parameterName) {
+ return (parameterValue) => {
+ if (typeof parameterValue === 'string' || parameterValue == null) {
+ return this.logs.filter((log) => {
+ return log[parameterName] === parameterValue
+ })
+ }
+ if (parameterValue instanceof RegExp) {
+ return this.logs.filter((log) => {
+ return parameterValue.test(log[parameterName])
+ })
+ }
+
+ throw new InvalidArgumentError(`${parameterName} parameter should be one of string, regexp, undefined or null`)
+ }
+}
+function computeUrlWithMaybeSearchParameters (requestInit) {
+ // path can contains query url parameters
+ // or query can contains query url parameters
+ try {
+ const url = new URL(requestInit.path, requestInit.origin)
+
+ // requestInit.path contains query url parameters
+ // requestInit.query is then undefined
+ if (url.search.length !== 0) {
+ return url
+ }
+
+ // requestInit.query can be populated here
+ url.search = new URLSearchParams(requestInit.query).toString()
+
+ return url
+ } catch (error) {
+ throw new InvalidArgumentError('An error occurred when computing MockCallHistoryLog.url', { cause: error })
+ }
+}
+
+class MockCallHistoryLog {
+ constructor (requestInit = {}) {
+ this.body = requestInit.body
+ this.headers = requestInit.headers
+ this.method = requestInit.method
+
+ const url = computeUrlWithMaybeSearchParameters(requestInit)
+
+ this.fullUrl = url.toString()
+ this.origin = url.origin
+ this.path = url.pathname
+ this.searchParams = Object.fromEntries(url.searchParams)
+ this.protocol = url.protocol
+ this.host = url.host
+ this.port = url.port
+ this.hash = url.hash
+ }
+
+ toMap () {
+ return new Map([
+ ['protocol', this.protocol],
+ ['host', this.host],
+ ['port', this.port],
+ ['origin', this.origin],
+ ['path', this.path],
+ ['hash', this.hash],
+ ['searchParams', this.searchParams],
+ ['fullUrl', this.fullUrl],
+ ['method', this.method],
+ ['body', this.body],
+ ['headers', this.headers]]
+ )
+ }
+
+ toString () {
+ const options = { betweenKeyValueSeparator: '->', betweenPairSeparator: '|' }
+ let result = ''
+
+ this.toMap().forEach((value, key) => {
+ if (typeof value === 'string' || value === undefined || value === null) {
+ result = `${result}${key}${options.betweenKeyValueSeparator}${value}${options.betweenPairSeparator}`
+ }
+ if ((typeof value === 'object' && value !== null) || Array.isArray(value)) {
+ result = `${result}${key}${options.betweenKeyValueSeparator}${JSON.stringify(value)}${options.betweenPairSeparator}`
+ }
+ // maybe miss something for non Record / Array headers and searchParams here
+ })
+
+ // delete last betweenPairSeparator
+ return result.slice(0, -1)
+ }
+}
+
+class MockCallHistory {
+ logs = []
+
+ calls () {
+ return this.logs
+ }
+
+ firstCall () {
+ return this.logs.at(0)
+ }
+
+ lastCall () {
+ return this.logs.at(-1)
+ }
+
+ nthCall (number) {
+ if (typeof number !== 'number') {
+ throw new InvalidArgumentError('nthCall must be called with a number')
+ }
+ if (!Number.isInteger(number)) {
+ throw new InvalidArgumentError('nthCall must be called with an integer')
+ }
+ if (Math.sign(number) !== 1) {
+ throw new InvalidArgumentError('nthCall must be called with a positive value. use firstCall or lastCall instead')
+ }
+
+ // non zero based index. this is more human readable
+ return this.logs.at(number - 1)
+ }
+
+ filterCalls (criteria, options) {
+ // perf
+ if (this.logs.length === 0) {
+ return this.logs
+ }
+ if (typeof criteria === 'function') {
+ return this.logs.filter(criteria)
+ }
+ if (criteria instanceof RegExp) {
+ return this.logs.filter((log) => {
+ return criteria.test(log.toString())
+ })
+ }
+ if (typeof criteria === 'object' && criteria !== null) {
+ // no criteria - returning all logs
+ if (Object.keys(criteria).length === 0) {
+ return this.logs
+ }
+
+ const finalOptions = { operator: 'OR', ...buildAndValidateFilterCallsOptions(options) }
+
+ let maybeDuplicatedLogsFiltered = []
+ if ('protocol' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered)
+ }
+ if ('host' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered)
+ }
+ if ('port' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered)
+ }
+ if ('origin' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered)
+ }
+ if ('path' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered)
+ }
+ if ('hash' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered)
+ }
+ if ('fullUrl' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered)
+ }
+ if ('method' in criteria) {
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered)
+ }
+
+ const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)]
+
+ return uniqLogsFiltered
+ }
+
+ throw new InvalidArgumentError('criteria parameter should be one of function, regexp, or object')
+ }
+
+ filterCallsByProtocol = makeFilterCalls.call(this, 'protocol')
+
+ filterCallsByHost = makeFilterCalls.call(this, 'host')
+
+ filterCallsByPort = makeFilterCalls.call(this, 'port')
+
+ filterCallsByOrigin = makeFilterCalls.call(this, 'origin')
+
+ filterCallsByPath = makeFilterCalls.call(this, 'path')
+
+ filterCallsByHash = makeFilterCalls.call(this, 'hash')
+
+ filterCallsByFullUrl = makeFilterCalls.call(this, 'fullUrl')
+
+ filterCallsByMethod = makeFilterCalls.call(this, 'method')
+
+ clear () {
+ this.logs = []
+ }
+
+ [kMockCallHistoryAddLog] (requestInit) {
+ const log = new MockCallHistoryLog(requestInit)
+
+ this.logs.push(log)
+
+ return log
+ }
+
+ * [Symbol.iterator] () {
+ for (const log of this.calls()) {
+ yield log
+ }
+ }
+}
+
+module.exports.MockCallHistory = MockCallHistory
+module.exports.MockCallHistoryLog = MockCallHistoryLog
diff --git a/vanilla/node_modules/undici/lib/mock/mock-client.js b/vanilla/node_modules/undici/lib/mock/mock-client.js
new file mode 100644
index 0000000..b3be7ab
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-client.js
@@ -0,0 +1,68 @@
+'use strict'
+
+const { promisify } = require('node:util')
+const Client = require('../dispatcher/client')
+const { buildMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kMockAgent,
+ kClose,
+ kOriginalClose,
+ kOrigin,
+ kOriginalDispatch,
+ kConnected,
+ kIgnoreTrailingSlash
+} = 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) {
+ if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+
+ super(origin, opts)
+
+ this[kMockAgent] = opts.agent
+ this[kOrigin] = origin
+ this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
+ 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 && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
+ this[kDispatches]
+ )
+ }
+
+ cleanMocks () {
+ this[kDispatches] = []
+ }
+
+ async [kClose] () {
+ await promisify(this[kOriginalClose])()
+ this[kConnected] = 0
+ this[kMockAgent][Symbols.kClients].delete(this[kOrigin])
+ }
+}
+
+module.exports = MockClient
diff --git a/vanilla/node_modules/undici/lib/mock/mock-errors.js b/vanilla/node_modules/undici/lib/mock/mock-errors.js
new file mode 100644
index 0000000..69e4f9c
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-errors.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const { UndiciError } = require('../core/errors')
+
+const kMockNotMatchedError = Symbol.for('undici.error.UND_MOCK_ERR_MOCK_NOT_MATCHED')
+
+/**
+ * The request does not match any registered mock dispatches.
+ */
+class MockNotMatchedError extends UndiciError {
+ constructor (message) {
+ super(message)
+ this.name = 'MockNotMatchedError'
+ this.message = message || 'The request does not match any registered mock dispatches'
+ this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED'
+ }
+
+ static [Symbol.hasInstance] (instance) {
+ return instance && instance[kMockNotMatchedError] === true
+ }
+
+ get [kMockNotMatchedError] () {
+ return true
+ }
+}
+
+module.exports = {
+ MockNotMatchedError
+}
diff --git a/vanilla/node_modules/undici/lib/mock/mock-interceptor.js b/vanilla/node_modules/undici/lib/mock/mock-interceptor.js
new file mode 100644
index 0000000..1ea7aac
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-interceptor.js
@@ -0,0 +1,209 @@
+'use strict'
+
+const { getResponseData, buildKey, addMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kDispatchKey,
+ kDefaultHeaders,
+ kDefaultTrailers,
+ kContentLength,
+ kMockDispatch,
+ kIgnoreTrailingSlash
+} = require('./mock-symbols')
+const { InvalidArgumentError } = require('../core/errors')
+const { serializePathWithQuery } = 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 = serializePathWithQuery(opts.path, opts.query)
+ } else {
+ // Matches https://github.com/nodejs/undici/blob/main/lib/web/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[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
+ 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 (replyParameters) {
+ if (typeof replyParameters.statusCode === 'undefined') {
+ throw new InvalidArgumentError('statusCode must be defined')
+ }
+ if (typeof replyParameters.responseOptions !== 'object' || replyParameters.responseOptions === null) {
+ throw new InvalidArgumentError('responseOptions must be an object')
+ }
+ }
+
+ /**
+ * Mock an undici request with a defined reply.
+ */
+ reply (replyOptionsCallbackOrStatusCode) {
+ // Values of reply aren't available right now as they
+ // can only be available when the reply callback is invoked.
+ if (typeof replyOptionsCallbackOrStatusCode === '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 = replyOptionsCallbackOrStatusCode(opts)
+
+ // Check if it is in the right format
+ if (typeof resolvedData !== 'object' || resolvedData === null) {
+ throw new InvalidArgumentError('reply options callback must return an object')
+ }
+
+ const replyParameters = { data: '', responseOptions: {}, ...resolvedData }
+ this.validateReplyParameters(replyParameters)
+ // Since the values can be obtained immediately we return them
+ // from this higher order function that will be resolved later.
+ return {
+ ...this.createMockScopeDispatchData(replyParameters)
+ }
+ }
+
+ // 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, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
+ 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 replyParameters = {
+ statusCode: replyOptionsCallbackOrStatusCode,
+ data: arguments[1] === undefined ? '' : arguments[1],
+ responseOptions: arguments[2] === undefined ? {} : arguments[2]
+ }
+ this.validateReplyParameters(replyParameters)
+
+ // Send in-already provided data like usual
+ const dispatchData = this.createMockScopeDispatchData(replyParameters)
+ const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
+ 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 }, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
+ 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/vanilla/node_modules/undici/lib/mock/mock-pool.js b/vanilla/node_modules/undici/lib/mock/mock-pool.js
new file mode 100644
index 0000000..2121e3c
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-pool.js
@@ -0,0 +1,68 @@
+'use strict'
+
+const { promisify } = require('node:util')
+const Pool = require('../dispatcher/pool')
+const { buildMockDispatch } = require('./mock-utils')
+const {
+ kDispatches,
+ kMockAgent,
+ kClose,
+ kOriginalClose,
+ kOrigin,
+ kOriginalDispatch,
+ kConnected,
+ kIgnoreTrailingSlash
+} = 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) {
+ if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') {
+ throw new InvalidArgumentError('Argument opts.agent must implement Agent')
+ }
+
+ super(origin, opts)
+
+ this[kMockAgent] = opts.agent
+ this[kOrigin] = origin
+ this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
+ 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 && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
+ this[kDispatches]
+ )
+ }
+
+ cleanMocks () {
+ this[kDispatches] = []
+ }
+
+ async [kClose] () {
+ await promisify(this[kOriginalClose])()
+ this[kConnected] = 0
+ this[kMockAgent][Symbols.kClients].delete(this[kOrigin])
+ }
+}
+
+module.exports = MockPool
diff --git a/vanilla/node_modules/undici/lib/mock/mock-symbols.js b/vanilla/node_modules/undici/lib/mock/mock-symbols.js
new file mode 100644
index 0000000..940dbe6
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-symbols.js
@@ -0,0 +1,31 @@
+'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'),
+ kOriginalDispatch: Symbol('original dispatch'),
+ kOrigin: Symbol('origin'),
+ kIsMockActive: Symbol('is mock active'),
+ kNetConnect: Symbol('net connect'),
+ kGetNetConnect: Symbol('get net connect'),
+ kConnected: Symbol('connected'),
+ kIgnoreTrailingSlash: Symbol('ignore trailing slash'),
+ kMockAgentMockCallHistoryInstance: Symbol('mock agent mock call history name'),
+ kMockAgentRegisterCallHistory: Symbol('mock agent register mock call history'),
+ kMockAgentAddCallHistoryLog: Symbol('mock agent add call history log'),
+ kMockAgentIsCallHistoryEnabled: Symbol('mock agent is call history enabled'),
+ kMockAgentAcceptsNonStandardSearchParameters: Symbol('mock agent accepts non standard search parameters'),
+ kMockCallHistoryAddLog: Symbol('mock call history add log')
+}
diff --git a/vanilla/node_modules/undici/lib/mock/mock-utils.js b/vanilla/node_modules/undici/lib/mock/mock-utils.js
new file mode 100644
index 0000000..291a857
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/mock-utils.js
@@ -0,0 +1,480 @@
+'use strict'
+
+const { MockNotMatchedError } = require('./mock-errors')
+const {
+ kDispatches,
+ kMockAgent,
+ kOriginalDispatch,
+ kOrigin,
+ kGetNetConnect
+} = require('./mock-symbols')
+const { serializePathWithQuery } = require('../core/util')
+const { STATUS_CODES } = require('node:http')
+const {
+ types: {
+ isPromise
+ }
+} = require('node:util')
+const { InvalidArgumentError } = require('../core/errors')
+
+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 normalizeSearchParams (query) {
+ if (typeof query !== 'string') {
+ return query
+ }
+
+ const originalQp = new URLSearchParams(query)
+ const normalizedQp = new URLSearchParams()
+
+ for (let [key, value] of originalQp.entries()) {
+ key = key.replace('[]', '')
+
+ const valueRepresentsString = /^(['"]).*\1$/.test(value)
+ if (valueRepresentsString) {
+ normalizedQp.append(key, value)
+ continue
+ }
+
+ if (value.includes(',')) {
+ const values = value.split(',')
+ for (const v of values) {
+ normalizedQp.append(key, v)
+ }
+ continue
+ }
+
+ normalizedQp.append(key, value)
+ }
+
+ return normalizedQp
+}
+
+function safeUrl (path) {
+ if (typeof path !== 'string') {
+ return path
+ }
+ const pathSegments = path.split('?', 3)
+ 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 (data instanceof Uint8Array) {
+ return data
+ } else if (data instanceof ArrayBuffer) {
+ return data
+ } else if (typeof data === 'object') {
+ return JSON.stringify(data)
+ } else if (data) {
+ return data.toString()
+ } else {
+ return ''
+ }
+}
+
+function getMockDispatch (mockDispatches, key) {
+ const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path
+ const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath
+
+ const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath)
+
+ // Match path
+ let matchedMockDispatches = mockDispatches
+ .filter(({ consumed }) => !consumed)
+ .filter(({ path, ignoreTrailingSlash }) => {
+ return ignoreTrailingSlash
+ ? matchValue(removeTrailingSlash(safeUrl(path)), resolvedPathWithoutTrailingSlash)
+ : 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}' on path '${resolvedPath}'`)
+ }
+
+ // 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}' on path '${resolvedPath}'`)
+ }
+
+ // Match headers
+ matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers))
+ if (matchedMockDispatches.length === 0) {
+ const headers = typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers
+ throw new MockNotMatchedError(`Mock dispatch not matched for headers '${headers}' on path '${resolvedPath}'`)
+ }
+
+ return matchedMockDispatches[0]
+}
+
+function addMockDispatch (mockDispatches, key, data, opts) {
+ const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false, ...opts }
+ 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)
+ }
+}
+
+/**
+ * @param {string} path Path to remove trailing slash from
+ */
+function removeTrailingSlash (path) {
+ while (path.endsWith('/')) {
+ path = path.slice(0, -1)
+ }
+
+ if (path.length === 0) {
+ path = '/'
+ }
+
+ return path
+}
+
+function buildKey (opts) {
+ const { path, method, body, headers, query } = opts
+
+ return {
+ path,
+ method,
+ body,
+ headers,
+ query
+ }
+}
+
+function generateKeyValues (data) {
+ const keys = Object.keys(data)
+ const result = []
+ for (let i = 0; i < keys.length; ++i) {
+ const key = keys[i]
+ const value = data[key]
+ const name = Buffer.from(`${key}`)
+ if (Array.isArray(value)) {
+ for (let j = 0; j < value.length; ++j) {
+ result.push(name, Buffer.from(`${value[j]}`))
+ }
+ } else {
+ result.push(name, Buffer.from(`${value}`))
+ }
+ }
+ return result
+}
+
+/**
+ * @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
+ }
+
+ // 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) {
+ 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)
+ : 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.
+ 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.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.code === 'UND_MOCK_ERR_MOCK_NOT_MATCHED') {
+ 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 normalizeOrigin (origin) {
+ if (typeof origin !== 'string' && !(origin instanceof URL)) {
+ return origin
+ }
+
+ if (origin instanceof URL) {
+ return origin.origin
+ }
+
+ return origin.toLowerCase()
+}
+
+function buildAndValidateMockOptions (opts) {
+ const { agent, ...mockOptions } = opts
+
+ if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
+ throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
+ }
+
+ if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
+ throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
+ }
+
+ if ('ignoreTrailingSlash' in mockOptions && typeof mockOptions.ignoreTrailingSlash !== 'boolean') {
+ throw new InvalidArgumentError('options.ignoreTrailingSlash must to be a boolean')
+ }
+
+ return mockOptions
+}
+
+module.exports = {
+ getResponseData,
+ getMockDispatch,
+ addMockDispatch,
+ deleteMockDispatch,
+ buildKey,
+ generateKeyValues,
+ matchValue,
+ getResponse,
+ getStatusText,
+ mockDispatch,
+ buildMockDispatch,
+ checkNetConnect,
+ buildAndValidateMockOptions,
+ getHeaderByName,
+ buildHeadersFromArray,
+ normalizeSearchParams,
+ normalizeOrigin
+}
diff --git a/vanilla/node_modules/undici/lib/mock/pending-interceptors-formatter.js b/vanilla/node_modules/undici/lib/mock/pending-interceptors-formatter.js
new file mode 100644
index 0000000..ccca951
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/pending-interceptors-formatter.js
@@ -0,0 +1,43 @@
+'use strict'
+
+const { Transform } = require('node:stream')
+const { Console } = require('node:console')
+
+const PERSISTENT = process.versions.icu ? '✅' : 'Y '
+const NOT_PERSISTENT = process.versions.icu ? '❌' : 'N '
+
+/**
+ * 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 ? PERSISTENT : NOT_PERSISTENT,
+ Invocations: timesInvoked,
+ Remaining: persist ? Infinity : times - timesInvoked
+ }))
+
+ this.logger.table(withPrettyHeaders)
+ return this.transform.read().toString()
+ }
+}
diff --git a/vanilla/node_modules/undici/lib/mock/snapshot-agent.js b/vanilla/node_modules/undici/lib/mock/snapshot-agent.js
new file mode 100644
index 0000000..8028011
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/snapshot-agent.js
@@ -0,0 +1,353 @@
+'use strict'
+
+const Agent = require('../dispatcher/agent')
+const MockAgent = require('./mock-agent')
+const { SnapshotRecorder } = require('./snapshot-recorder')
+const WrapHandler = require('../handler/wrap-handler')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const { validateSnapshotMode } = require('./snapshot-utils')
+
+const kSnapshotRecorder = Symbol('kSnapshotRecorder')
+const kSnapshotMode = Symbol('kSnapshotMode')
+const kSnapshotPath = Symbol('kSnapshotPath')
+const kSnapshotLoaded = Symbol('kSnapshotLoaded')
+const kRealAgent = Symbol('kRealAgent')
+
+// Static flag to ensure warning is only emitted once per process
+let warningEmitted = false
+
+class SnapshotAgent extends MockAgent {
+ constructor (opts = {}) {
+ // Emit experimental warning only once
+ if (!warningEmitted) {
+ process.emitWarning(
+ 'SnapshotAgent is experimental and subject to change',
+ 'ExperimentalWarning'
+ )
+ warningEmitted = true
+ }
+
+ const {
+ mode = 'record',
+ snapshotPath = null,
+ ...mockAgentOpts
+ } = opts
+
+ super(mockAgentOpts)
+
+ validateSnapshotMode(mode)
+
+ // Validate snapshotPath is provided when required
+ if ((mode === 'playback' || mode === 'update') && !snapshotPath) {
+ throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`)
+ }
+
+ this[kSnapshotMode] = mode
+ this[kSnapshotPath] = snapshotPath
+
+ this[kSnapshotRecorder] = new SnapshotRecorder({
+ snapshotPath: this[kSnapshotPath],
+ mode: this[kSnapshotMode],
+ maxSnapshots: opts.maxSnapshots,
+ autoFlush: opts.autoFlush,
+ flushInterval: opts.flushInterval,
+ matchHeaders: opts.matchHeaders,
+ ignoreHeaders: opts.ignoreHeaders,
+ excludeHeaders: opts.excludeHeaders,
+ matchBody: opts.matchBody,
+ matchQuery: opts.matchQuery,
+ caseSensitive: opts.caseSensitive,
+ shouldRecord: opts.shouldRecord,
+ shouldPlayback: opts.shouldPlayback,
+ excludeUrls: opts.excludeUrls
+ })
+ this[kSnapshotLoaded] = false
+
+ // For recording/update mode, we need a real agent to make actual requests
+ // For playback mode, we need a real agent if there are excluded URLs
+ if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update' ||
+ (this[kSnapshotMode] === 'playback' && opts.excludeUrls && opts.excludeUrls.length > 0)) {
+ this[kRealAgent] = new Agent(opts)
+ }
+
+ // Auto-load snapshots in playback/update mode
+ if ((this[kSnapshotMode] === 'playback' || this[kSnapshotMode] === 'update') && this[kSnapshotPath]) {
+ this.loadSnapshots().catch(() => {
+ // Ignore load errors - file might not exist yet
+ })
+ }
+ }
+
+ dispatch (opts, handler) {
+ handler = WrapHandler.wrap(handler)
+ const mode = this[kSnapshotMode]
+
+ // Check if URL should be excluded (pass through without mocking/recording)
+ if (this[kSnapshotRecorder].isUrlExcluded(opts)) {
+ // Real agent is guaranteed by constructor when excludeUrls is configured
+ return this[kRealAgent].dispatch(opts, handler)
+ }
+
+ if (mode === 'playback' || mode === 'update') {
+ // Ensure snapshots are loaded
+ if (!this[kSnapshotLoaded]) {
+ // Need to load asynchronously, delegate to async version
+ return this.#asyncDispatch(opts, handler)
+ }
+
+ // Try to find existing snapshot (synchronous)
+ const snapshot = this[kSnapshotRecorder].findSnapshot(opts)
+
+ if (snapshot) {
+ // Use recorded response (synchronous)
+ return this.#replaySnapshot(snapshot, handler)
+ } else if (mode === 'update') {
+ // Make real request and record it (async required)
+ return this.#recordAndReplay(opts, handler)
+ } else {
+ // Playback mode but no snapshot found
+ const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`)
+ if (handler.onError) {
+ handler.onError(error)
+ return
+ }
+ throw error
+ }
+ } else if (mode === 'record') {
+ // Record mode - make real request and save response (async required)
+ return this.#recordAndReplay(opts, handler)
+ }
+ }
+
+ /**
+ * Async version of dispatch for when we need to load snapshots first
+ */
+ async #asyncDispatch (opts, handler) {
+ await this.loadSnapshots()
+ return this.dispatch(opts, handler)
+ }
+
+ /**
+ * Records a real request and replays the response
+ */
+ #recordAndReplay (opts, handler) {
+ const responseData = {
+ statusCode: null,
+ headers: {},
+ trailers: {},
+ body: []
+ }
+
+ const self = this // Capture 'this' context for use within nested handler callbacks
+
+ const recordingHandler = {
+ onRequestStart (controller, context) {
+ return handler.onRequestStart(controller, { ...context, history: this.history })
+ },
+
+ onRequestUpgrade (controller, statusCode, headers, socket) {
+ return handler.onRequestUpgrade(controller, statusCode, headers, socket)
+ },
+
+ onResponseStart (controller, statusCode, headers, statusMessage) {
+ responseData.statusCode = statusCode
+ responseData.headers = headers
+ return handler.onResponseStart(controller, statusCode, headers, statusMessage)
+ },
+
+ onResponseData (controller, chunk) {
+ responseData.body.push(chunk)
+ return handler.onResponseData(controller, chunk)
+ },
+
+ onResponseEnd (controller, trailers) {
+ responseData.trailers = trailers
+
+ // Record the interaction using captured 'self' context (fire and forget)
+ const responseBody = Buffer.concat(responseData.body)
+ self[kSnapshotRecorder].record(opts, {
+ statusCode: responseData.statusCode,
+ headers: responseData.headers,
+ body: responseBody,
+ trailers: responseData.trailers
+ })
+ .then(() => handler.onResponseEnd(controller, trailers))
+ .catch((error) => handler.onResponseError(controller, error))
+ }
+ }
+
+ // Use composed agent if available (includes interceptors), otherwise use real agent
+ const agent = this[kRealAgent]
+ return agent.dispatch(opts, recordingHandler)
+ }
+
+ /**
+ * Replays a recorded response
+ *
+ * @param {Object} snapshot - The recorded snapshot to replay.
+ * @param {Object} handler - The handler to call with the response data.
+ * @returns {void}
+ */
+ #replaySnapshot (snapshot, handler) {
+ try {
+ const { response } = snapshot
+
+ const controller = {
+ pause () { },
+ resume () { },
+ abort (reason) {
+ this.aborted = true
+ this.reason = reason
+ },
+
+ aborted: false,
+ paused: false
+ }
+
+ handler.onRequestStart(controller)
+
+ handler.onResponseStart(controller, response.statusCode, response.headers)
+
+ // Body is always stored as base64 string
+ const body = Buffer.from(response.body, 'base64')
+ handler.onResponseData(controller, body)
+
+ handler.onResponseEnd(controller, response.trailers)
+ } catch (error) {
+ handler.onError?.(error)
+ }
+ }
+
+ /**
+ * Loads snapshots from file
+ *
+ * @param {string} [filePath] - Optional file path to load snapshots from.
+ * @returns {Promise<void>} - Resolves when snapshots are loaded.
+ */
+ async loadSnapshots (filePath) {
+ await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath])
+ this[kSnapshotLoaded] = true
+
+ // In playback mode, set up MockAgent interceptors for all snapshots
+ if (this[kSnapshotMode] === 'playback') {
+ this.#setupMockInterceptors()
+ }
+ }
+
+ /**
+ * Saves snapshots to file
+ *
+ * @param {string} [filePath] - Optional file path to save snapshots to.
+ * @returns {Promise<void>} - Resolves when snapshots are saved.
+ */
+ async saveSnapshots (filePath) {
+ return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath])
+ }
+
+ /**
+ * Sets up MockAgent interceptors based on recorded snapshots.
+ *
+ * This method creates MockAgent interceptors for each recorded snapshot,
+ * allowing the SnapshotAgent to fall back to MockAgent's standard intercept
+ * mechanism in playback mode. Each interceptor is configured to persist
+ * (remain active for multiple requests) and responds with the recorded
+ * response data.
+ *
+ * Called automatically when loading snapshots in playback mode.
+ *
+ * @returns {void}
+ */
+ #setupMockInterceptors () {
+ for (const snapshot of this[kSnapshotRecorder].getSnapshots()) {
+ const { request, responses, response } = snapshot
+ const url = new URL(request.url)
+
+ const mockPool = this.get(url.origin)
+
+ // Handle both new format (responses array) and legacy format (response object)
+ const responseData = responses ? responses[0] : response
+ if (!responseData) continue
+
+ mockPool.intercept({
+ path: url.pathname + url.search,
+ method: request.method,
+ headers: request.headers,
+ body: request.body
+ }).reply(responseData.statusCode, responseData.body, {
+ headers: responseData.headers,
+ trailers: responseData.trailers
+ }).persist()
+ }
+ }
+
+ /**
+ * Gets the snapshot recorder
+ * @return {SnapshotRecorder} - The snapshot recorder instance
+ */
+ getRecorder () {
+ return this[kSnapshotRecorder]
+ }
+
+ /**
+ * Gets the current mode
+ * @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode
+ */
+ getMode () {
+ return this[kSnapshotMode]
+ }
+
+ /**
+ * Clears all snapshots
+ * @returns {void}
+ */
+ clearSnapshots () {
+ this[kSnapshotRecorder].clear()
+ }
+
+ /**
+ * Resets call counts for all snapshots (useful for test cleanup)
+ * @returns {void}
+ */
+ resetCallCounts () {
+ this[kSnapshotRecorder].resetCallCounts()
+ }
+
+ /**
+ * Deletes a specific snapshot by request options
+ * @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot
+ * @return {Promise<boolean>} - Returns true if the snapshot was deleted, false if not found
+ */
+ deleteSnapshot (requestOpts) {
+ return this[kSnapshotRecorder].deleteSnapshot(requestOpts)
+ }
+
+ /**
+ * Gets information about a specific snapshot
+ * @returns {import('./snapshot-recorder').SnapshotInfo|null} - Snapshot information or null if not found
+ */
+ getSnapshotInfo (requestOpts) {
+ return this[kSnapshotRecorder].getSnapshotInfo(requestOpts)
+ }
+
+ /**
+ * Replaces all snapshots with new data (full replacement)
+ * @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record<string, import('./snapshot-recorder').SnapshotEntry>} snapshotData - New snapshot data to replace existing snapshots
+ * @returns {void}
+ */
+ replaceSnapshots (snapshotData) {
+ this[kSnapshotRecorder].replaceSnapshots(snapshotData)
+ }
+
+ /**
+ * Closes the agent, saving snapshots and cleaning up resources.
+ *
+ * @returns {Promise<void>}
+ */
+ async close () {
+ await this[kSnapshotRecorder].close()
+ await this[kRealAgent]?.close()
+ await super.close()
+ }
+}
+
+module.exports = SnapshotAgent
diff --git a/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js b/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
new file mode 100644
index 0000000..b5d07fa
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
@@ -0,0 +1,588 @@
+'use strict'
+
+const { writeFile, readFile, mkdir } = require('node:fs/promises')
+const { dirname, resolve } = require('node:path')
+const { setTimeout, clearTimeout } = require('node:timers')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils')
+
+/**
+ * @typedef {Object} SnapshotRequestOptions
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} path - Request path
+ * @property {string} origin - Request origin (base URL)
+ * @property {import('./snapshot-utils').Headers|import('./snapshot-utils').UndiciHeaders} headers - Request headers
+ * @property {import('./snapshot-utils').NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object
+ * @property {string|Buffer} [body] - Request body (optional)
+ */
+
+/**
+ * @typedef {Object} SnapshotEntryRequest
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} url - Full URL of the request
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
+ * @property {string|Buffer} [body] - Request body (optional)
+ */
+
+/**
+ * @typedef {Object} SnapshotEntryResponse
+ * @property {number} statusCode - HTTP status code of the response
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized response headers as a lowercase object
+ * @property {string} body - Response body as a base64url encoded string
+ * @property {Object} [trailers] - Optional response trailers
+ */
+
+/**
+ * @typedef {Object} SnapshotEntry
+ * @property {SnapshotEntryRequest} request - The request object
+ * @property {Array<SnapshotEntryResponse>} responses - Array of response objects
+ * @property {number} callCount - Number of times this snapshot has been called
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
+ */
+
+/**
+ * @typedef {Object} SnapshotRecorderMatchOptions
+ * @property {Array<string>} [matchHeaders=[]] - Headers to match (empty array means match all headers)
+ * @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
+ * @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
+ * @property {boolean} [matchBody=true] - Whether to match request body
+ * @property {boolean} [matchQuery=true] - Whether to match query properties
+ * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
+ */
+
+/**
+ * @typedef {Object} SnapshotRecorderOptions
+ * @property {string} [snapshotPath] - Path to save/load snapshots
+ * @property {import('./snapshot-utils').SnapshotMode} [mode='record'] - Mode: 'record' or 'playback'
+ * @property {number} [maxSnapshots=Infinity] - Maximum number of snapshots to keep
+ * @property {boolean} [autoFlush=false] - Whether to automatically flush snapshots to disk
+ * @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds)
+ * @property {Array<string|RegExp>} [excludeUrls=[]] - URLs to exclude from recording
+ * @property {function} [shouldRecord=null] - Function to filter requests for recording
+ * @property {function} [shouldPlayback=null] - Function to filter requests
+ */
+
+/**
+ * @typedef {Object} SnapshotFormattedRequest
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} url - Full URL of the request (with query parameters if matchQuery is true)
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
+ * @property {string} body - Request body (optional, only if matchBody is true)
+ */
+
+/**
+ * @typedef {Object} SnapshotInfo
+ * @property {string} hash - Hash key for the snapshot
+ * @property {SnapshotEntryRequest} request - The request object
+ * @property {number} responseCount - Number of responses recorded for this request
+ * @property {number} callCount - Number of times this snapshot has been called
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
+ */
+
+/**
+ * Formats a request for consistent snapshot storage
+ * Caches normalized headers to avoid repeated processing
+ *
+ * @param {SnapshotRequestOptions} opts - Request options
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body
+ * @returns {SnapshotFormattedRequest} - Formatted request object
+ */
+function formatRequestKey (opts, headerFilters, matchOptions = {}) {
+ const url = new URL(opts.path, opts.origin)
+
+ // Cache normalized headers if not already done
+ const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
+ if (!opts._normalizedHeaders) {
+ opts._normalizedHeaders = normalized
+ }
+
+ return {
+ method: opts.method || 'GET',
+ url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
+ headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
+ body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
+ }
+}
+
+/**
+ * Filters headers based on matching configuration
+ *
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
+ */
+function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
+ if (!headers || typeof headers !== 'object') return {}
+
+ const {
+ caseSensitive = false
+ } = matchOptions
+
+ const filtered = {}
+ const { ignore, exclude, match } = headerFilters
+
+ for (const [key, value] of Object.entries(headers)) {
+ const headerKey = caseSensitive ? key : key.toLowerCase()
+
+ // Skip if in exclude list (for security)
+ if (exclude.has(headerKey)) continue
+
+ // Skip if in ignore list (for matching)
+ if (ignore.has(headerKey)) continue
+
+ // If matchHeaders is specified, only include those headers
+ if (match.size !== 0) {
+ if (!match.has(headerKey)) continue
+ }
+
+ filtered[headerKey] = value
+ }
+
+ return filtered
+}
+
+/**
+ * Filters headers for storage (only excludes sensitive headers)
+ *
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
+ */
+function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
+ if (!headers || typeof headers !== 'object') return {}
+
+ const {
+ caseSensitive = false
+ } = matchOptions
+
+ const filtered = {}
+ const { exclude: excludeSet } = headerFilters
+
+ for (const [key, value] of Object.entries(headers)) {
+ const headerKey = caseSensitive ? key : key.toLowerCase()
+
+ // Skip if in exclude list (for security)
+ if (excludeSet.has(headerKey)) continue
+
+ filtered[headerKey] = value
+ }
+
+ return filtered
+}
+
+/**
+ * Creates a hash key for request matching
+ * Properly orders headers to avoid conflicts and uses crypto hashing when available
+ *
+ * @param {SnapshotFormattedRequest} formattedRequest - Request object
+ * @returns {string} - Base64url encoded hash of the request
+ */
+function createRequestHash (formattedRequest) {
+ const parts = [
+ formattedRequest.method,
+ formattedRequest.url
+ ]
+
+ // Process headers in a deterministic way to avoid conflicts
+ if (formattedRequest.headers && typeof formattedRequest.headers === 'object') {
+ const headerKeys = Object.keys(formattedRequest.headers).sort()
+ for (const key of headerKeys) {
+ const values = Array.isArray(formattedRequest.headers[key])
+ ? formattedRequest.headers[key]
+ : [formattedRequest.headers[key]]
+
+ // Add header name
+ parts.push(key)
+
+ // Add all values for this header, sorted for consistency
+ for (const value of values.sort()) {
+ parts.push(String(value))
+ }
+ }
+ }
+
+ // Add body
+ parts.push(formattedRequest.body)
+
+ const content = parts.join('|')
+
+ return hashId(content)
+}
+
+class SnapshotRecorder {
+ /** @type {NodeJS.Timeout | null} */
+ #flushTimeout
+
+ /** @type {import('./snapshot-utils').IsUrlExcluded} */
+ #isUrlExcluded
+
+ /** @type {Map<string, SnapshotEntry>} */
+ #snapshots = new Map()
+
+ /** @type {string|undefined} */
+ #snapshotPath
+
+ /** @type {number} */
+ #maxSnapshots = Infinity
+
+ /** @type {boolean} */
+ #autoFlush = false
+
+ /** @type {import('./snapshot-utils').HeaderFilters} */
+ #headerFilters
+
+ /**
+ * Creates a new SnapshotRecorder instance
+ * @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder
+ */
+ constructor (options = {}) {
+ this.#snapshotPath = options.snapshotPath
+ this.#maxSnapshots = options.maxSnapshots || Infinity
+ this.#autoFlush = options.autoFlush || false
+ this.flushInterval = options.flushInterval || 30000 // 30 seconds default
+ this._flushTimer = null
+
+ // Matching configuration
+ /** @type {Required<SnapshotRecorderMatchOptions>} */
+ this.matchOptions = {
+ matchHeaders: options.matchHeaders || [], // empty means match all headers
+ ignoreHeaders: options.ignoreHeaders || [],
+ excludeHeaders: options.excludeHeaders || [],
+ matchBody: options.matchBody !== false, // default: true
+ matchQuery: options.matchQuery !== false, // default: true
+ caseSensitive: options.caseSensitive || false
+ }
+
+ // Cache processed header sets to avoid recreating them on every request
+ this.#headerFilters = createHeaderFilters(this.matchOptions)
+
+ // Request filtering callbacks
+ this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
+ this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
+
+ // URL pattern filtering
+ this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
+
+ // Start auto-flush timer if enabled
+ if (this.#autoFlush && this.#snapshotPath) {
+ this.#startAutoFlush()
+ }
+ }
+
+ /**
+ * Records a request-response interaction
+ * @param {SnapshotRequestOptions} requestOpts - Request options
+ * @param {SnapshotEntryResponse} response - Response data to record
+ * @return {Promise<void>} - Resolves when the recording is complete
+ */
+ async record (requestOpts, response) {
+ // Check if recording should be filtered out
+ if (!this.shouldRecord(requestOpts)) {
+ return // Skip recording
+ }
+
+ // Check URL exclusion patterns
+ if (this.isUrlExcluded(requestOpts)) {
+ return // Skip recording
+ }
+
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+
+ // Extract response data - always store body as base64
+ const normalizedHeaders = normalizeHeaders(response.headers)
+
+ /** @type {SnapshotEntryResponse} */
+ const responseData = {
+ statusCode: response.statusCode,
+ headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
+ body: Buffer.isBuffer(response.body)
+ ? response.body.toString('base64')
+ : Buffer.from(String(response.body || '')).toString('base64'),
+ trailers: response.trailers
+ }
+
+ // Remove oldest snapshot if we exceed maxSnapshots limit
+ if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
+ const oldestKey = this.#snapshots.keys().next().value
+ this.#snapshots.delete(oldestKey)
+ }
+
+ // Support sequential responses - if snapshot exists, add to responses array
+ const existingSnapshot = this.#snapshots.get(hash)
+ if (existingSnapshot && existingSnapshot.responses) {
+ existingSnapshot.responses.push(responseData)
+ existingSnapshot.timestamp = new Date().toISOString()
+ } else {
+ this.#snapshots.set(hash, {
+ request,
+ responses: [responseData], // Always store as array for consistency
+ callCount: 0,
+ timestamp: new Date().toISOString()
+ })
+ }
+
+ // Auto-flush if enabled
+ if (this.#autoFlush && this.#snapshotPath) {
+ this.#scheduleFlush()
+ }
+ }
+
+ /**
+ * Checks if a URL should be excluded from recording/playback
+ * @param {SnapshotRequestOptions} requestOpts - Request options to check
+ * @returns {boolean} - True if URL is excluded
+ */
+ isUrlExcluded (requestOpts) {
+ const url = new URL(requestOpts.path, requestOpts.origin).toString()
+ return this.#isUrlExcluded(url)
+ }
+
+ /**
+ * Finds a matching snapshot for the given request
+ * Returns the appropriate response based on call count for sequential responses
+ *
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found
+ */
+ findSnapshot (requestOpts) {
+ // Check if playback should be filtered out
+ if (!this.shouldPlayback(requestOpts)) {
+ return undefined // Skip playback
+ }
+
+ // Check URL exclusion patterns
+ if (this.isUrlExcluded(requestOpts)) {
+ return undefined // Skip playback
+ }
+
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ const snapshot = this.#snapshots.get(hash)
+
+ if (!snapshot) return undefined
+
+ // Handle sequential responses
+ const currentCallCount = snapshot.callCount || 0
+ const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
+ snapshot.callCount = currentCallCount + 1
+
+ return {
+ ...snapshot,
+ response: snapshot.responses[responseIndex]
+ }
+ }
+
+ /**
+ * Loads snapshots from file
+ * @param {string} [filePath] - Optional file path to load snapshots from
+ * @return {Promise<void>} - Resolves when snapshots are loaded
+ */
+ async loadSnapshots (filePath) {
+ const path = filePath || this.#snapshotPath
+ if (!path) {
+ throw new InvalidArgumentError('Snapshot path is required')
+ }
+
+ try {
+ const data = await readFile(resolve(path), 'utf8')
+ const parsed = JSON.parse(data)
+
+ // Convert array format back to Map
+ if (Array.isArray(parsed)) {
+ this.#snapshots.clear()
+ for (const { hash, snapshot } of parsed) {
+ this.#snapshots.set(hash, snapshot)
+ }
+ } else {
+ // Legacy object format
+ this.#snapshots = new Map(Object.entries(parsed))
+ }
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ // File doesn't exist yet - that's ok for recording mode
+ this.#snapshots.clear()
+ } else {
+ throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
+ }
+ }
+ }
+
+ /**
+ * Saves snapshots to file
+ *
+ * @param {string} [filePath] - Optional file path to save snapshots
+ * @returns {Promise<void>} - Resolves when snapshots are saved
+ */
+ async saveSnapshots (filePath) {
+ const path = filePath || this.#snapshotPath
+ if (!path) {
+ throw new InvalidArgumentError('Snapshot path is required')
+ }
+
+ const resolvedPath = resolve(path)
+
+ // Ensure directory exists
+ await mkdir(dirname(resolvedPath), { recursive: true })
+
+ // Convert Map to serializable format
+ const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
+ hash,
+ snapshot
+ }))
+
+ await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
+ }
+
+ /**
+ * Clears all recorded snapshots
+ * @returns {void}
+ */
+ clear () {
+ this.#snapshots.clear()
+ }
+
+ /**
+ * Gets all recorded snapshots
+ * @return {Array<SnapshotEntry>} - Array of all recorded snapshots
+ */
+ getSnapshots () {
+ return Array.from(this.#snapshots.values())
+ }
+
+ /**
+ * Gets snapshot count
+ * @return {number} - Number of recorded snapshots
+ */
+ size () {
+ return this.#snapshots.size
+ }
+
+ /**
+ * Resets call counts for all snapshots (useful for test cleanup)
+ * @returns {void}
+ */
+ resetCallCounts () {
+ for (const snapshot of this.#snapshots.values()) {
+ snapshot.callCount = 0
+ }
+ }
+
+ /**
+ * Deletes a specific snapshot by request options
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {boolean} - True if snapshot was deleted, false if not found
+ */
+ deleteSnapshot (requestOpts) {
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ return this.#snapshots.delete(hash)
+ }
+
+ /**
+ * Gets information about a specific snapshot
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {SnapshotInfo|null} - Snapshot information or null if not found
+ */
+ getSnapshotInfo (requestOpts) {
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ const snapshot = this.#snapshots.get(hash)
+
+ if (!snapshot) return null
+
+ return {
+ hash,
+ request: snapshot.request,
+ responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
+ callCount: snapshot.callCount || 0,
+ timestamp: snapshot.timestamp
+ }
+ }
+
+ /**
+ * Replaces all snapshots with new data (full replacement)
+ * @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record<string, SnapshotEntry>} snapshotData - New snapshot data to replace existing ones
+ * @returns {void}
+ */
+ replaceSnapshots (snapshotData) {
+ this.#snapshots.clear()
+
+ if (Array.isArray(snapshotData)) {
+ for (const { hash, snapshot } of snapshotData) {
+ this.#snapshots.set(hash, snapshot)
+ }
+ } else if (snapshotData && typeof snapshotData === 'object') {
+ // Legacy object format
+ this.#snapshots = new Map(Object.entries(snapshotData))
+ }
+ }
+
+ /**
+ * Starts the auto-flush timer
+ * @returns {void}
+ */
+ #startAutoFlush () {
+ return this.#scheduleFlush()
+ }
+
+ /**
+ * Stops the auto-flush timer
+ * @returns {void}
+ */
+ #stopAutoFlush () {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout)
+ // Ensure any pending flush is completed
+ this.saveSnapshots().catch(() => {
+ // Ignore flush errors
+ })
+ this.#flushTimeout = null
+ }
+ }
+
+ /**
+ * Schedules a flush (debounced to avoid excessive writes)
+ */
+ #scheduleFlush () {
+ this.#flushTimeout = setTimeout(() => {
+ this.saveSnapshots().catch(() => {
+ // Ignore flush errors
+ })
+ if (this.#autoFlush) {
+ this.#flushTimeout?.refresh()
+ } else {
+ this.#flushTimeout = null
+ }
+ }, 1000) // 1 second debounce
+ }
+
+ /**
+ * Cleanup method to stop timers
+ * @returns {void}
+ */
+ destroy () {
+ this.#stopAutoFlush()
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout)
+ this.#flushTimeout = null
+ }
+ }
+
+ /**
+ * Async close method that saves all recordings and performs cleanup
+ * @returns {Promise<void>}
+ */
+ async close () {
+ // Save any pending recordings if we have a snapshot path
+ if (this.#snapshotPath && this.#snapshots.size !== 0) {
+ await this.saveSnapshots()
+ }
+
+ // Perform cleanup
+ this.destroy()
+ }
+}
+
+module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }
diff --git a/vanilla/node_modules/undici/lib/mock/snapshot-utils.js b/vanilla/node_modules/undici/lib/mock/snapshot-utils.js
new file mode 100644
index 0000000..a14b69c
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/snapshot-utils.js
@@ -0,0 +1,158 @@
+'use strict'
+
+const { InvalidArgumentError } = require('../core/errors')
+const { runtimeFeatures } = require('../util/runtime-features.js')
+
+/**
+ * @typedef {Object} HeaderFilters
+ * @property {Set<string>} ignore - Set of headers to ignore for matching
+ * @property {Set<string>} exclude - Set of headers to exclude from matching
+ * @property {Set<string>} match - Set of headers to match (empty means match
+ */
+
+/**
+ * Creates cached header sets for performance
+ *
+ * @param {import('./snapshot-recorder').SnapshotRecorderMatchOptions} matchOptions - Matching options for headers
+ * @returns {HeaderFilters} - Cached sets for ignore, exclude, and match headers
+ */
+function createHeaderFilters (matchOptions = {}) {
+ const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = [], caseSensitive = false } = matchOptions
+
+ return {
+ ignore: new Set(ignoreHeaders.map(header => caseSensitive ? header : header.toLowerCase())),
+ exclude: new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())),
+ match: new Set(matchHeaders.map(header => caseSensitive ? header : header.toLowerCase()))
+ }
+}
+
+const crypto = runtimeFeatures.has('crypto')
+ ? require('node:crypto')
+ : null
+
+/**
+ * @callback HashIdFunction
+ * @param {string} value - The value to hash
+ * @returns {string} - The base64url encoded hash of the value
+ */
+
+/**
+ * Generates a hash for a given value
+ * @type {HashIdFunction}
+ */
+const hashId = crypto?.hash
+ ? (value) => crypto.hash('sha256', value, 'base64url')
+ : (value) => Buffer.from(value).toString('base64url')
+
+/**
+ * @typedef {(url: string) => boolean} IsUrlExcluded Checks if a URL matches any of the exclude patterns
+ */
+
+/** @typedef {{[key: Lowercase<string>]: string}} NormalizedHeaders */
+/** @typedef {Array<string>} UndiciHeaders */
+/** @typedef {Record<string, string|string[]>} Headers */
+
+/**
+ * @param {*} headers
+ * @returns {headers is UndiciHeaders}
+ */
+function isUndiciHeaders (headers) {
+ return Array.isArray(headers) && (headers.length & 1) === 0
+}
+
+/**
+ * Factory function to create a URL exclusion checker
+ * @param {Array<string| RegExp>} [excludePatterns=[]] - Array of patterns to exclude
+ * @returns {IsUrlExcluded} - A function that checks if a URL matches any of the exclude patterns
+ */
+function isUrlExcludedFactory (excludePatterns = []) {
+ if (excludePatterns.length === 0) {
+ return () => false
+ }
+
+ return function isUrlExcluded (url) {
+ let urlLowerCased
+
+ for (const pattern of excludePatterns) {
+ if (typeof pattern === 'string') {
+ if (!urlLowerCased) {
+ // Convert URL to lowercase only once
+ urlLowerCased = url.toLowerCase()
+ }
+ // Simple string match (case-insensitive)
+ if (urlLowerCased.includes(pattern.toLowerCase())) {
+ return true
+ }
+ } else if (pattern instanceof RegExp) {
+ // Regex pattern match
+ if (pattern.test(url)) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+}
+
+/**
+ * Normalizes headers for consistent comparison
+ *
+ * @param {Object|UndiciHeaders} headers - Headers to normalize
+ * @returns {NormalizedHeaders} - Normalized headers as a lowercase object
+ */
+function normalizeHeaders (headers) {
+ /** @type {NormalizedHeaders} */
+ const normalizedHeaders = {}
+
+ if (!headers) return normalizedHeaders
+
+ // Handle array format (undici internal format: [name, value, name, value, ...])
+ if (isUndiciHeaders(headers)) {
+ for (let i = 0; i < headers.length; i += 2) {
+ const key = headers[i]
+ const value = headers[i + 1]
+ if (key && value !== undefined) {
+ // Convert Buffers to strings if needed
+ const keyStr = Buffer.isBuffer(key) ? key.toString() : key
+ const valueStr = Buffer.isBuffer(value) ? value.toString() : value
+ normalizedHeaders[keyStr.toLowerCase()] = valueStr
+ }
+ }
+ return normalizedHeaders
+ }
+
+ // Handle object format
+ if (headers && typeof headers === 'object') {
+ for (const [key, value] of Object.entries(headers)) {
+ if (key && typeof key === 'string') {
+ normalizedHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
+ }
+ }
+ }
+
+ return normalizedHeaders
+}
+
+const validSnapshotModes = /** @type {const} */ (['record', 'playback', 'update'])
+
+/** @typedef {typeof validSnapshotModes[number]} SnapshotMode */
+
+/**
+ * @param {*} mode - The snapshot mode to validate
+ * @returns {asserts mode is SnapshotMode}
+ */
+function validateSnapshotMode (mode) {
+ if (!validSnapshotModes.includes(mode)) {
+ throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be one of: ${validSnapshotModes.join(', ')}`)
+ }
+}
+
+module.exports = {
+ createHeaderFilters,
+ hashId,
+ isUndiciHeaders,
+ normalizeHeaders,
+ isUrlExcludedFactory,
+ validateSnapshotMode
+}