初始化

This commit is contained in:
2025-12-12 15:54:04 +08:00
commit 83a8a3bb9a
2404 changed files with 508391 additions and 0 deletions

230
node_modules/undici/lib/mock/mock-agent.js generated vendored Normal file
View File

@@ -0,0 +1,230 @@
'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 } = 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) {
const originKey = this[kIgnoreTrailingSlash]
? origin.replace(/\/$/, '')
: origin
let dispatcher = this[kMockAgentGet](originKey)
if (!dispatcher) {
dispatcher = this[kFactory](originKey)
this[kMockAgentSet](originKey, dispatcher)
}
return dispatcher
}
dispatch (opts, handler) {
// 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

248
node_modules/undici/lib/mock/mock-call-history.js generated vendored Normal file
View File

@@ -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

68
node_modules/undici/lib/mock/mock-client.js generated vendored Normal file
View File

@@ -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

29
node_modules/undici/lib/mock/mock-errors.js generated vendored Normal file
View File

@@ -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
}

209
node_modules/undici/lib/mock/mock-interceptor.js generated vendored Normal file
View File

@@ -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

68
node_modules/undici/lib/mock/mock-pool.js generated vendored Normal file
View File

@@ -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

31
node_modules/undici/lib/mock/mock-symbols.js generated vendored Normal file
View File

@@ -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')
}

435
node_modules/undici/lib/mock/mock-utils.js generated vendored Normal file
View File

@@ -0,0 +1,435 @@
'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
}
// 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.onConnect?.(err => handler.onError(err), null)
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 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
}

View File

@@ -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()
}
}

347
node_modules/undici/lib/mock/snapshot-agent.js generated vendored Normal file
View File

@@ -0,0 +1,347 @@
'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
if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update') {
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]
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

580
node_modules/undici/lib/mock/snapshot-recorder.js generated vendored Normal file
View File

@@ -0,0 +1,580 @@
'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
const url = new URL(requestOpts.path, requestOpts.origin).toString()
if (this.#isUrlExcluded(url)) {
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()
}
}
/**
* 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
const url = new URL(requestOpts.path, requestOpts.origin).toString()
if (this.#isUrlExcluded(url)) {
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 }

158
node_modules/undici/lib/mock/snapshot-utils.js generated vendored Normal file
View File

@@ -0,0 +1,158 @@
'use strict'
const { InvalidArgumentError } = require('../core/errors')
/**
* @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()))
}
}
let crypto
try {
crypto = require('node:crypto')
} catch { /* Fallback if crypto is not available */ }
/**
* @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
}