初始化
This commit is contained in:
230
node_modules/undici/lib/mock/mock-agent.js
generated
vendored
Normal file
230
node_modules/undici/lib/mock/mock-agent.js
generated
vendored
Normal 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
248
node_modules/undici/lib/mock/mock-call-history.js
generated
vendored
Normal 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
68
node_modules/undici/lib/mock/mock-client.js
generated
vendored
Normal 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
29
node_modules/undici/lib/mock/mock-errors.js
generated
vendored
Normal 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
209
node_modules/undici/lib/mock/mock-interceptor.js
generated
vendored
Normal 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
68
node_modules/undici/lib/mock/mock-pool.js
generated
vendored
Normal 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
31
node_modules/undici/lib/mock/mock-symbols.js
generated
vendored
Normal 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
435
node_modules/undici/lib/mock/mock-utils.js
generated
vendored
Normal 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
|
||||
}
|
||||
43
node_modules/undici/lib/mock/pending-interceptors-formatter.js
generated
vendored
Normal file
43
node_modules/undici/lib/mock/pending-interceptors-formatter.js
generated
vendored
Normal 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
347
node_modules/undici/lib/mock/snapshot-agent.js
generated
vendored
Normal 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
580
node_modules/undici/lib/mock/snapshot-recorder.js
generated
vendored
Normal 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
158
node_modules/undici/lib/mock/snapshot-utils.js
generated
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user