diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 629bc28ba66..dbe53575f1d 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -5,6 +5,7 @@ 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') @@ -12,7 +13,7 @@ const kSnapshotPath = Symbol('kSnapshotPath') const kSnapshotLoaded = Symbol('kSnapshotLoaded') const kRealAgent = Symbol('kRealAgent') -// Static flag to ensure warning is only emitted once +// Static flag to ensure warning is only emitted once per process let warningEmitted = false class SnapshotAgent extends MockAgent { @@ -26,26 +27,24 @@ class SnapshotAgent extends MockAgent { warningEmitted = true } - const mockOptions = { ...opts } - delete mockOptions.mode - delete mockOptions.snapshotPath + const { + mode = 'record', + snapshotPath = null, + ...mockAgentOpts + } = opts - super(mockOptions) + super(mockAgentOpts) - // Validate mode option - const validModes = ['record', 'playback', 'update'] - const mode = opts.mode || 'record' - if (!validModes.includes(mode)) { - throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be one of: ${validModes.join(', ')}`) - } + validateSnapshotMode(mode) // Validate snapshotPath is provided when required - if ((mode === 'playback' || mode === 'update') && !opts.snapshotPath) { + if ((mode === 'playback' || mode === 'update') && !snapshotPath) { throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`) } this[kSnapshotMode] = mode - this[kSnapshotPath] = opts.snapshotPath + this[kSnapshotPath] = snapshotPath + this[kSnapshotRecorder] = new SnapshotRecorder({ snapshotPath: this[kSnapshotPath], mode: this[kSnapshotMode], @@ -85,7 +84,7 @@ class SnapshotAgent extends MockAgent { // Ensure snapshots are loaded if (!this[kSnapshotLoaded]) { // Need to load asynchronously, delegate to async version - return this._asyncDispatch(opts, handler) + return this.#asyncDispatch(opts, handler) } // Try to find existing snapshot (synchronous) @@ -93,10 +92,10 @@ class SnapshotAgent extends MockAgent { if (snapshot) { // Use recorded response (synchronous) - return this._replaySnapshot(snapshot, handler) + return this.#replaySnapshot(snapshot, handler) } else if (mode === 'update') { // Make real request and record it (async required) - return this._recordAndReplay(opts, handler) + 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}`) @@ -108,16 +107,14 @@ class SnapshotAgent extends MockAgent { } } else if (mode === 'record') { // Record mode - make real request and save response (async required) - return this._recordAndReplay(opts, handler) - } else { - throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be 'record', 'playback', or 'update'`) + return this.#recordAndReplay(opts, handler) } } /** * Async version of dispatch for when we need to load snapshots first */ - async _asyncDispatch (opts, handler) { + async #asyncDispatch (opts, handler) { await this.loadSnapshots() return this.dispatch(opts, handler) } @@ -125,7 +122,7 @@ class SnapshotAgent extends MockAgent { /** * Records a real request and replays the response */ - _recordAndReplay (opts, handler) { + #recordAndReplay (opts, handler) { const responseData = { statusCode: null, headers: {}, @@ -180,8 +177,12 @@ class SnapshotAgent extends MockAgent { /** * 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) { + #replaySnapshot (snapshot, handler) { try { const { response } = snapshot @@ -213,6 +214,9 @@ class SnapshotAgent extends MockAgent { /** * Loads snapshots from file + * + * @param {string} [filePath] - Optional file path to load snapshots from. + * @returns {Promise} - Resolves when snapshots are loaded. */ async loadSnapshots (filePath) { await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath]) @@ -220,12 +224,15 @@ class SnapshotAgent extends MockAgent { // In playback mode, set up MockAgent interceptors for all snapshots if (this[kSnapshotMode] === 'playback') { - this._setupMockInterceptors() + this.#setupMockInterceptors() } } /** * Saves snapshots to file + * + * @param {string} [filePath] - Optional file path to save snapshots to. + * @returns {Promise} - Resolves when snapshots are saved. */ async saveSnapshots (filePath) { return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath]) @@ -242,9 +249,9 @@ class SnapshotAgent extends MockAgent { * * Called automatically when loading snapshots in playback mode. * - * @private + * @returns {void} */ - _setupMockInterceptors () { + #setupMockInterceptors () { for (const snapshot of this[kSnapshotRecorder].getSnapshots()) { const { request, responses, response } = snapshot const url = new URL(request.url) @@ -269,6 +276,7 @@ class SnapshotAgent extends MockAgent { /** * Gets the snapshot recorder + * @return {SnapshotRecorder} - The snapshot recorder instance */ getRecorder () { return this[kSnapshotRecorder] @@ -276,6 +284,7 @@ class SnapshotAgent extends MockAgent { /** * Gets the current mode + * @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode */ getMode () { return this[kSnapshotMode] @@ -283,6 +292,7 @@ class SnapshotAgent extends MockAgent { /** * Clears all snapshots + * @returns {void} */ clearSnapshots () { this[kSnapshotRecorder].clear() @@ -290,6 +300,7 @@ class SnapshotAgent extends MockAgent { /** * Resets call counts for all snapshots (useful for test cleanup) + * @returns {void} */ resetCallCounts () { this[kSnapshotRecorder].resetCallCounts() @@ -297,6 +308,8 @@ class SnapshotAgent extends MockAgent { /** * Deletes a specific snapshot by request options + * @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot + * @return {Promise} - Returns true if the snapshot was deleted, false if not found */ deleteSnapshot (requestOpts) { return this[kSnapshotRecorder].deleteSnapshot(requestOpts) @@ -304,6 +317,7 @@ class SnapshotAgent extends MockAgent { /** * 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) @@ -311,13 +325,19 @@ class SnapshotAgent extends MockAgent { /** * Replaces all snapshots with new data (full replacement) + * @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record} 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} + */ async close () { - // Close recorder (saves snapshots and cleans up timers) await this[kSnapshotRecorder].close() await this[kRealAgent]?.close() await super.close() diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 8e8a00b2068..e810fe79507 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -2,20 +2,93 @@ 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') -let crypto -try { - crypto = require('node:crypto') -} catch { - // Fallback if crypto is not available -} +/** + * @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} 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} [matchHeaders=[]] - Headers to match (empty array means match all headers) + * @property {Array} [ignoreHeaders=[]] - Headers to ignore for matching + * @property {Array} [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} [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, cachedSets, matchOptions = {}) { +function formatRequestKey (opts, headerFilters, matchOptions = {}) { const url = new URL(opts.path, opts.origin) // Cache normalized headers if not already done @@ -27,37 +100,40 @@ function formatRequestKey (opts, cachedSets, matchOptions = {}) { return { method: opts.method || 'GET', url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`, - headers: filterHeadersForMatching(normalized, cachedSets, matchOptions), - body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : undefined + 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, cachedSets, matchOptions = {}) { +function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { - matchHeaders = null, caseSensitive = false } = matchOptions const filtered = {} - const { ignoreSet, excludeSet, matchSet } = cachedSets + 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 (excludeSet.has(headerKey)) continue + if (exclude.has(headerKey)) continue // Skip if in ignore list (for matching) - if (ignoreSet.has(headerKey)) continue + if (ignore.has(headerKey)) continue // If matchHeaders is specified, only include those headers - if (matchHeaders && Array.isArray(matchHeaders)) { - if (!matchSet.has(headerKey)) continue + if (match.size !== 0) { + if (!match.has(headerKey)) continue } filtered[headerKey] = value @@ -68,17 +144,20 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { /** * 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, matchOptions = {}) { +function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { - excludeHeaders = [], caseSensitive = false } = matchOptions const filtered = {} - const excludeSet = new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase())) + const { exclude: excludeSet } = headerFilters for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -92,77 +171,29 @@ function filterHeadersForStorage (headers, matchOptions = {}) { return filtered } -/** - * Creates cached header sets for performance - */ -function createHeaderSetsCache (matchOptions = {}) { - const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = null, caseSensitive = false } = matchOptions - - return { - ignoreSet: new Set(ignoreHeaders.map(h => caseSensitive ? h : h.toLowerCase())), - excludeSet: new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase())), - matchSet: matchHeaders && Array.isArray(matchHeaders) - ? new Set(matchHeaders.map(h => caseSensitive ? h : h.toLowerCase())) - : null - } -} - -/** - * Normalizes headers for consistent comparison - */ -function normalizeHeaders (headers) { - if (!headers) return {} - - const normalized = {} - - // Handle array format (undici internal format: [name, value, name, value, ...]) - if (Array.isArray(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() : String(key) - const valueStr = Buffer.isBuffer(value) ? value.toString() : String(value) - normalized[keyStr.toLowerCase()] = valueStr - } - } - return normalized - } - - // Handle object format - if (headers && typeof headers === 'object') { - for (const [key, value] of Object.entries(headers)) { - if (key && typeof key === 'string') { - normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value) - } - } - } - - return normalized -} - /** * 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 (request) { +function createRequestHash (formattedRequest) { const parts = [ - request.method, - request.url + formattedRequest.method, + formattedRequest.url ] // Process headers in a deterministic way to avoid conflicts - if (request.headers && typeof request.headers === 'object') { - const headerKeys = Object.keys(request.headers).sort() + if (formattedRequest.headers && typeof formattedRequest.headers === 'object') { + const headerKeys = Object.keys(formattedRequest.headers).sort() for (const key of headerKeys) { - const lowerKey = key.toLowerCase() - const values = Array.isArray(request.headers[key]) - ? request.headers[key] - : [request.headers[key]] + const values = Array.isArray(formattedRequest.headers[key]) + ? formattedRequest.headers[key] + : [formattedRequest.headers[key]] // Add header name - parts.push(lowerKey) + parts.push(key) // Add all values for this header, sorted for consistency for (const value of values.sort()) { @@ -172,57 +203,50 @@ function createRequestHash (request) { } // Add body - parts.push(request.body || '') + parts.push(formattedRequest.body) const content = parts.join('|') - // Use crypto hash if available for better collision resistance - if (crypto && crypto.createHash) { - return crypto.createHash('sha256').update(content, 'utf8').digest('base64url') - } - - // Fallback to base64 encoding - return Buffer.from(content).toString('base64url') + return hashId(content) } -/** - * Checks if a URL matches any of the exclude patterns - */ -function isUrlExcluded (url, excludePatterns = []) { - if (!excludePatterns.length) return false - - for (const pattern of excludePatterns) { - if (typeof pattern === 'string') { - // Simple string match (case-insensitive) - if (url.toLowerCase().includes(pattern.toLowerCase())) { - return true - } - } else if (pattern instanceof RegExp) { - // Regex pattern match - if (pattern.test(url)) { - return true - } - } - } +class SnapshotRecorder { + /** @type {NodeJS.Timeout | null} */ + #flushTimeout - return false -} + /** @type {import('./snapshot-utils').IsUrlExcluded} */ + #isUrlExcluded -class SnapshotRecorder { + /** @type {Map} */ + #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.snapshots = new Map() - this.snapshotPath = options.snapshotPath - this.mode = options.mode || 'record' - this.loaded = false - this.maxSnapshots = options.maxSnapshots || Infinity - this.autoFlush = options.autoFlush || false + 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 - this._flushTimeout = null // Matching configuration + /** @type {Required} */ this.matchOptions = { - matchHeaders: options.matchHeaders || null, // null means match all headers + matchHeaders: options.matchHeaders || [], // empty means match all headers ignoreHeaders: options.ignoreHeaders || [], excludeHeaders: options.excludeHeaders || [], matchBody: options.matchBody !== false, // default: true @@ -231,46 +255,49 @@ class SnapshotRecorder { } // Cache processed header sets to avoid recreating them on every request - this._headerSetsCache = createHeaderSetsCache(this.matchOptions) + this.#headerFilters = createHeaderFilters(this.matchOptions) // Request filtering callbacks - this.shouldRecord = options.shouldRecord || null // function(requestOpts) -> boolean - this.shouldPlayback = options.shouldPlayback || null // function(requestOpts) -> boolean + this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean + this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean // URL pattern filtering - this.excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings + this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings // Start auto-flush timer if enabled - if (this.autoFlush && this.snapshotPath) { - this._startAutoFlush() + 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} - Resolves when the recording is complete */ async record (requestOpts, response) { // Check if recording should be filtered out - if (this.shouldRecord && typeof this.shouldRecord === 'function') { - if (!this.shouldRecord(requestOpts)) { - return // Skip recording - } + if (!this.shouldRecord(requestOpts)) { + return // Skip recording } // Check URL exclusion patterns const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (isUrlExcluded(url, this.excludeUrls)) { + if (this.#isUrlExcluded(url)) { return // Skip recording } - const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions) + 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.matchOptions), + headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions), body: Buffer.isBuffer(response.body) ? response.body.toString('base64') : Buffer.from(String(response.body || '')).toString('base64'), @@ -278,18 +305,18 @@ class SnapshotRecorder { } // 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) + 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) + const existingSnapshot = this.#snapshots.get(hash) if (existingSnapshot && existingSnapshot.responses) { existingSnapshot.responses.push(responseData) existingSnapshot.timestamp = new Date().toISOString() } else { - this.snapshots.set(hash, { + this.#snapshots.set(hash, { request, responses: [responseData], // Always store as array for consistency callCount: 0, @@ -298,32 +325,33 @@ class SnapshotRecorder { } // Auto-flush if enabled - if (this.autoFlush && this.snapshotPath) { - this._scheduleFlush() + 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 && typeof this.shouldPlayback === 'function') { - if (!this.shouldPlayback(requestOpts)) { - return undefined // Skip playback - } + if (!this.shouldPlayback(requestOpts)) { + return undefined // Skip playback } // Check URL exclusion patterns const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (isUrlExcluded(url, this.excludeUrls)) { + if (this.#isUrlExcluded(url)) { return undefined // Skip playback } - const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) - const snapshot = this.snapshots.get(hash) + const snapshot = this.#snapshots.get(hash) if (!snapshot) return undefined @@ -340,9 +368,11 @@ class SnapshotRecorder { /** * Loads snapshots from file + * @param {string} [filePath] - Optional file path to load snapshots from + * @return {Promise} - Resolves when snapshots are loaded */ async loadSnapshots (filePath) { - const path = filePath || this.snapshotPath + const path = filePath || this.#snapshotPath if (!path) { throw new InvalidArgumentError('Snapshot path is required') } @@ -353,21 +383,18 @@ class SnapshotRecorder { // Convert array format back to Map if (Array.isArray(parsed)) { - this.snapshots.clear() + this.#snapshots.clear() for (const { hash, snapshot } of parsed) { - this.snapshots.set(hash, snapshot) + this.#snapshots.set(hash, snapshot) } } else { // Legacy object format - this.snapshots = new Map(Object.entries(parsed)) + this.#snapshots = new Map(Object.entries(parsed)) } - - this.loaded = true } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist yet - that's ok for recording mode - this.snapshots.clear() - this.loaded = true + this.#snapshots.clear() } else { throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error }) } @@ -376,9 +403,12 @@ class SnapshotRecorder { /** * Saves snapshots to file + * + * @param {string} [filePath] - Optional file path to save snapshots + * @returns {Promise} - Resolves when snapshots are saved */ async saveSnapshots (filePath) { - const path = filePath || this.snapshotPath + const path = filePath || this.#snapshotPath if (!path) { throw new InvalidArgumentError('Snapshot path is required') } @@ -389,7 +419,7 @@ class SnapshotRecorder { await mkdir(dirname(resolvedPath), { recursive: true }) // Convert Map to serializable format - const data = Array.from(this.snapshots.entries()).map(([hash, snapshot]) => ({ + const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({ hash, snapshot })) @@ -399,57 +429,65 @@ class SnapshotRecorder { /** * Clears all recorded snapshots + * @returns {void} */ clear () { - this.snapshots.clear() + this.#snapshots.clear() } /** * Gets all recorded snapshots + * @return {Array} - Array of all recorded snapshots */ getSnapshots () { - return Array.from(this.snapshots.values()) + return Array.from(this.#snapshots.values()) } /** * Gets snapshot count + * @return {number} - Number of recorded snapshots */ size () { - return this.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()) { + 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._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) - return this.snapshots.delete(hash) + 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._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) - const snapshot = this.snapshots.get(hash) + 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), + responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots callCount: snapshot.callCount || 0, timestamp: snapshot.timestamp } @@ -457,76 +495,80 @@ class SnapshotRecorder { /** * Replaces all snapshots with new data (full replacement) + * @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record} snapshotData - New snapshot data to replace existing ones + * @returns {void} */ replaceSnapshots (snapshotData) { - this.snapshots.clear() + this.#snapshots.clear() if (Array.isArray(snapshotData)) { for (const { hash, snapshot } of snapshotData) { - this.snapshots.set(hash, snapshot) + this.#snapshots.set(hash, snapshot) } } else if (snapshotData && typeof snapshotData === 'object') { // Legacy object format - this.snapshots = new Map(Object.entries(snapshotData)) + this.#snapshots = new Map(Object.entries(snapshotData)) } } /** * Starts the auto-flush timer + * @returns {void} */ - _startAutoFlush () { - if (!this._flushTimer) { - this._flushTimer = setInterval(() => { - this.saveSnapshots().catch(() => { - // Ignore flush errors - they shouldn't interrupt normal operation - }) - }, this.flushInterval) - } + #startAutoFlush () { + return this.#scheduleFlush() } /** * Stops the auto-flush timer + * @returns {void} */ - _stopAutoFlush () { - if (this._flushTimer) { - clearInterval(this._flushTimer) - this._flushTimer = null + #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 () { - // Simple debouncing - clear existing timeout and set new one - if (this._flushTimeout) { - clearTimeout(this._flushTimeout) - } - this._flushTimeout = setTimeout(() => { + #scheduleFlush () { + this.#flushTimeout = setTimeout(() => { this.saveSnapshots().catch(() => { // Ignore flush errors }) - this._flushTimeout = null + 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 + this.#stopAutoFlush() + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout) + this.#flushTimeout = null } } /** * Async close method that saves all recordings and performs cleanup + * @returns {Promise} */ async close () { // Save any pending recordings if we have a snapshot path - if (this.snapshotPath && this.snapshots.size > 0) { + if (this.#snapshotPath && this.#snapshots.size !== 0) { await this.saveSnapshots() } @@ -535,4 +577,4 @@ class SnapshotRecorder { } } -module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderSetsCache } +module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters } diff --git a/lib/mock/snapshot-utils.js b/lib/mock/snapshot-utils.js new file mode 100644 index 00000000000..ebad12e888f --- /dev/null +++ b/lib/mock/snapshot-utils.js @@ -0,0 +1,158 @@ +'use strict' + +const { InvalidArgumentError } = require('../core/errors') + +/** + * @typedef {Object} HeaderFilters + * @property {Set} ignore - Set of headers to ignore for matching + * @property {Set} exclude - Set of headers to exclude from matching + * @property {Set} 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}} NormalizedHeaders */ +/** @typedef {Array} UndiciHeaders */ +/** @typedef {Record} 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} [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 +} diff --git a/test/snapshot-recorder.js b/test/snapshot-recorder.js index 3491184a623..55fa38c747e 100644 --- a/test/snapshot-recorder.js +++ b/test/snapshot-recorder.js @@ -5,7 +5,7 @@ const assert = require('node:assert') const { tmpdir } = require('node:os') const { join } = require('node:path') const { unlink } = require('node:fs/promises') -const { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderSetsCache } = require('../lib/mock/snapshot-recorder') +const { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters } = require('../lib/mock/snapshot-recorder') test('SnapshotRecorder - basic recording and retrieval', (t) => { const recorder = new SnapshotRecorder() @@ -52,7 +52,7 @@ test('SnapshotRecorder - request key formatting', (t) => { body: '{"filter": "active"}' } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const formatted = formatRequestKey(requestOpts, cachedSets) assert.strictEqual(formatted.method, 'POST') @@ -117,7 +117,7 @@ test('SnapshotRecorder - header normalization', (t) => { } } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const formatted1 = formatRequestKey(requestOpts1, cachedSets) const formatted2 = formatRequestKey(requestOpts2, cachedSets) @@ -187,7 +187,7 @@ test('SnapshotRecorder - array header handling', (t) => { } } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const formatted = formatRequestKey(requestOpts, cachedSets) // Array headers should be joined with comma @@ -208,7 +208,7 @@ test('SnapshotRecorder - query parameter handling', (t) => { method: 'GET' } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const formatted1 = formatRequestKey(requestOpts1, cachedSets) const formatted2 = formatRequestKey(requestOpts2, cachedSets) @@ -262,7 +262,7 @@ test('SnapshotRecorder - custom header matching', (t) => { // Test matchHeaders option const matchSpecificOptions = { matchHeaders: ['content-type', 'accept'] } - const matchSpecificCachedSets = createHeaderSetsCache(matchSpecificOptions) + const matchSpecificCachedSets = createHeaderFilters(matchSpecificOptions) const matchSpecific = filterHeadersForMatching(headers, matchSpecificCachedSets, matchSpecificOptions) assert.deepStrictEqual(matchSpecific, { @@ -272,7 +272,7 @@ test('SnapshotRecorder - custom header matching', (t) => { // Test ignoreHeaders option const ignoreOptions = { ignoreHeaders: ['authorization', 'x-request-id'] } - const ignoreCachedSets = createHeaderSetsCache(ignoreOptions) + const ignoreCachedSets = createHeaderFilters(ignoreOptions) const ignoreAuth = filterHeadersForMatching(headers, ignoreCachedSets, ignoreOptions) assert.deepStrictEqual(ignoreAuth, { @@ -282,7 +282,7 @@ test('SnapshotRecorder - custom header matching', (t) => { // Test excludeHeaders option const excludeOptions = { excludeHeaders: ['authorization'] } - const excludeCachedSets = createHeaderSetsCache(excludeOptions) + const excludeCachedSets = createHeaderFilters(excludeOptions) const excludeSensitive = filterHeadersForMatching(headers, excludeCachedSets, excludeOptions) assert.deepStrictEqual(excludeSensitive, { @@ -302,7 +302,7 @@ test('SnapshotRecorder - header filtering for storage', (t) => { // Test excluding sensitive headers from storage const filtered = filterHeadersForStorage(headers, { - excludeHeaders: ['set-cookie', 'authorization'] + exclude: new Set(['set-cookie', 'authorization']) }) assert.deepStrictEqual(filtered, { @@ -320,7 +320,7 @@ test('SnapshotRecorder - case sensitivity in header filtering', (t) => { // Test case insensitive (default) const caseInsensitiveOptions = { ignoreHeaders: ['authorization', 'x-request-id'] } - const caseInsensitiveCachedSets = createHeaderSetsCache(caseInsensitiveOptions) + const caseInsensitiveCachedSets = createHeaderFilters(caseInsensitiveOptions) const caseInsensitive = filterHeadersForMatching(headers, caseInsensitiveCachedSets, caseInsensitiveOptions) assert.deepStrictEqual(caseInsensitive, { @@ -329,7 +329,7 @@ test('SnapshotRecorder - case sensitivity in header filtering', (t) => { // Test case sensitive const caseSensitiveOptions = { ignoreHeaders: ['authorization', 'x-request-id'], caseSensitive: true } - const caseSensitiveCachedSets = createHeaderSetsCache(caseSensitiveOptions) + const caseSensitiveCachedSets = createHeaderFilters(caseSensitiveOptions) const caseSensitive = filterHeadersForMatching(headers, caseSensitiveCachedSets, caseSensitiveOptions) // Should keep all headers since case doesn't match @@ -359,7 +359,7 @@ test('SnapshotRecorder - request formatting with match options', (t) => { matchBody: false, matchQuery: false } - const cachedSets = createHeaderSetsCache(matchOptions) + const cachedSets = createHeaderFilters(matchOptions) const formatted = formatRequestKey(requestOpts, cachedSets, matchOptions) assert.strictEqual(formatted.method, 'POST') @@ -367,7 +367,7 @@ test('SnapshotRecorder - request formatting with match options', (t) => { assert.deepStrictEqual(formatted.headers, { 'content-type': 'application/json' }) - assert.strictEqual(formatted.body, undefined) // No body + assert.strictEqual(formatted.body, '') // No body }) test('SnapshotRecorder - redirect responses are stored correctly', (t) => { diff --git a/test/snapshot-testing.js b/test/snapshot-testing.js index a519a5aeaf5..c81620d38ea 100644 --- a/test/snapshot-testing.js +++ b/test/snapshot-testing.js @@ -103,7 +103,7 @@ function createSequentialHandler (responses) { } async function createLargeSnapshotFile (path, size = 1000) { - const { createRequestHash, formatRequestKey, createHeaderSetsCache } = require('../lib/mock/snapshot-recorder') + const { createRequestHash, formatRequestKey, createHeaderFilters } = require('../lib/mock/snapshot-recorder') const snapshots = [] for (let i = 0; i < size; i++) { @@ -113,7 +113,7 @@ async function createLargeSnapshotFile (path, size = 1000) { method: 'GET' } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const requestKey = formatRequestKey(requestOpts, cachedSets) const hash = createRequestHash(requestKey) @@ -1057,13 +1057,13 @@ describe('SnapshotAgent - Management Features', () => { assert(postInfo, 'Post snapshot should still exist after deleting user snapshot') // Test replaceSnapshots - create a snapshot with proper hash - const { createRequestHash, formatRequestKey, createHeaderSetsCache } = require('../lib/mock/snapshot-recorder') + const { createRequestHash, formatRequestKey, createHeaderFilters } = require('../lib/mock/snapshot-recorder') const mockRequestOpts = { origin, path: '/api/mock', method: 'GET' } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilters({}) const mockRequest = formatRequestKey(mockRequestOpts, cachedSets) const mockHash = createRequestHash(mockRequest)