From 75d5116dc116ea31d14719f0be9d9e6972f7614e Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 08:57:16 +0200 Subject: [PATCH 01/12] refactor: snapshot-recorder --- lib/mock/snapshot-recorder.js | 110 ++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 8e8a00b2068..f2ee3d0fd9f 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -11,9 +11,34 @@ try { // 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') + /** * Formats a request for consistent snapshot storage * Caches normalized headers to avoid repeated processing + * + * @param {Object} opts - Request options + * @param {string} [opts.method='GET'] - HTTP method (default: 'GET') + * @param {string} opts.path - Request path + * @param {string} opts.origin - Request origin (base URL) + * @param {Object} opts.headers - Request headers + * @param {string|Buffer} [opts.body] - Request body (optional) + * @param {NormalizedHeaders} opts._normalizedHeaders - Request headers + * @param {Object} cachedSets - Cached header sets for performance + * @param {Object} [matchOptions] - Matching options for headers and body + * @returns {Object} - Formatted request object */ function formatRequestKey (opts, cachedSets, matchOptions = {}) { const url = new URL(opts.path, opts.origin) @@ -34,6 +59,7 @@ function formatRequestKey (opts, cachedSets, matchOptions = {}) { /** * Filters headers based on matching configuration + * */ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} @@ -107,44 +133,61 @@ function createHeaderSetsCache (matchOptions = {}) { } } +/** @typedef {{[key: Lowercase]: string}} NormalizedHeaders */ +/** @typedef {Array} UndiciHeaders */ + +/** + * @param {*} headers + * @returns {headers is UndiciHeaders} + */ +function isUndiciHeaders (headers) { + return Array.isArray(headers) && (headers.length & 1) === 0 +} + /** * Normalizes headers for consistent comparison + * + * @param {Object|UndiciHeaders} headers - Headers to normalize + * @returns {NormalizedHeaders} - Normalized headers as a lowercase object */ function normalizeHeaders (headers) { - if (!headers) return {} + /** @type {NormalizedHeaders} */ + const normalizedHeaders = {} - const normalized = {} + if (!headers) return normalizedHeaders // Handle array format (undici internal format: [name, value, name, value, ...]) - if (Array.isArray(headers)) { + 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() : String(key) - const valueStr = Buffer.isBuffer(value) ? value.toString() : String(value) - normalized[keyStr.toLowerCase()] = valueStr + const keyStr = Buffer.isBuffer(key) ? key.toString() : key + const valueStr = Buffer.isBuffer(value) ? value.toString() : value + normalizedHeaders[keyStr.toLowerCase()] = valueStr } } - return normalized + return normalizedHeaders } // 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) + normalizedHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value) } } } - return normalized + return normalizedHeaders } /** * Creates a hash key for request matching * Properly orders headers to avoid conflicts and uses crypto hashing when available + * + * @param {Request} request - Request object */ function createRequestHash (request) { const parts = [ @@ -176,17 +219,14 @@ function createRequestHash (request) { 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 + * @param {string} url - The URL to check + * @param {Array} [excludePatterns=[]] - Array of patterns to exclude + * @returns {boolean} - True if the URL matches any exclude pattern, false otherwise */ function isUrlExcluded (url, excludePatterns = []) { if (!excludePatterns.length) return false @@ -209,6 +249,12 @@ function isUrlExcluded (url, excludePatterns = []) { } class SnapshotRecorder { + /** @type {NodeJS.Timeout | null} */ + #flushTimeout = null + + /** @type {Array} */ + #excludeUrls = [] + constructor (options = {}) { this.snapshots = new Map() this.snapshotPath = options.snapshotPath @@ -218,7 +264,7 @@ class SnapshotRecorder { this.autoFlush = options.autoFlush || false this.flushInterval = options.flushInterval || 30000 // 30 seconds default this._flushTimer = null - this._flushTimeout = null + this.#flushTimeout = null // Matching configuration this.matchOptions = { @@ -238,7 +284,7 @@ class SnapshotRecorder { this.shouldPlayback = options.shouldPlayback || null // function(requestOpts) -> boolean // URL pattern filtering - this.excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings + this.#excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings // Start auto-flush timer if enabled if (this.autoFlush && this.snapshotPath) { @@ -259,7 +305,7 @@ class SnapshotRecorder { // Check URL exclusion patterns const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (isUrlExcluded(url, this.excludeUrls)) { + if (isUrlExcluded(url, this.#excludeUrls)) { return // Skip recording } @@ -317,7 +363,7 @@ class SnapshotRecorder { // Check URL exclusion patterns const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (isUrlExcluded(url, this.excludeUrls)) { + if (isUrlExcluded(url, this.#excludeUrls)) { return undefined // Skip playback } @@ -340,6 +386,7 @@ class SnapshotRecorder { /** * Loads snapshots from file + * @param {string} [filePath] - Optional file path to load snapshots from */ async loadSnapshots (filePath) { const path = filePath || this.snapshotPath @@ -399,6 +446,7 @@ class SnapshotRecorder { /** * Clears all recorded snapshots + * @returns {void} */ clear () { this.snapshots.clear() @@ -486,8 +534,9 @@ class SnapshotRecorder { /** * Stops the auto-flush timer + * @returns {void} */ - _stopAutoFlush () { + #stopAutoFlush () { if (this._flushTimer) { clearInterval(this._flushTimer) this._flushTimer = null @@ -499,14 +548,14 @@ class SnapshotRecorder { */ _scheduleFlush () { // Simple debouncing - clear existing timeout and set new one - if (this._flushTimeout) { - clearTimeout(this._flushTimeout) + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout) } - this._flushTimeout = setTimeout(() => { + this.#flushTimeout = setTimeout(() => { this.saveSnapshots().catch(() => { // Ignore flush errors }) - this._flushTimeout = null + this.#flushTimeout = null }, 1000) // 1 second debounce } @@ -514,19 +563,20 @@ class SnapshotRecorder { * Cleanup method to stop timers */ 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() } From 49bfff94e7520974fdf5baa5a88631181b34ab3f Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 09:13:29 +0200 Subject: [PATCH 02/12] improve further --- lib/mock/snapshot-agent.js | 8 +++----- lib/mock/snapshot-recorder.js | 23 +++++++++++++++++++++++ lib/mock/snapshot-utils.js | 21 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 lib/mock/snapshot-utils.js diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 629bc28ba66..d35f2a312af 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') @@ -32,12 +33,9 @@ class SnapshotAgent extends MockAgent { super(mockOptions) - // 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) { diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index f2ee3d0fd9f..fa77ce8e429 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -25,6 +25,25 @@ const hashId = crypto?.hash ? (value) => crypto.hash('sha256', value, 'base64url') : (value) => Buffer.from(value).toString('base64url') +/** + * @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 {Object} [matchOptions] - Options for matching headers and body + * @property {Array} [matchHeaders=null] - Headers to match (null 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 propertyeters + * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive + * @property {function} [shouldRecord=null] - Function to filter requests for recording + * @property {function} [shouldPlayback=null] - Function to filter requests + */ + /** * Formats a request for consistent snapshot storage * Caches normalized headers to avoid repeated processing @@ -255,6 +274,10 @@ class SnapshotRecorder { /** @type {Array} */ #excludeUrls = [] + /** + * Creates a new SnapshotRecorder instance + * @param {SnapshotRecorderOptions} [options={}] - Configuration options for the recorder + */ constructor (options = {}) { this.snapshots = new Map() this.snapshotPath = options.snapshotPath diff --git a/lib/mock/snapshot-utils.js b/lib/mock/snapshot-utils.js new file mode 100644 index 00000000000..1c5a4a584ed --- /dev/null +++ b/lib/mock/snapshot-utils.js @@ -0,0 +1,21 @@ +'use strict' + +const { InvalidArgumentError } = require('../core/errors') + +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 = { + validateSnapshotMode +} From b9912d6119d7981ba4f42378d9b8928b4a1591ce Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 11:43:33 +0200 Subject: [PATCH 03/12] simplify matchHeaders --- lib/mock/snapshot-recorder.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index fa77ce8e429..00643ec11a4 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -34,7 +34,7 @@ const hashId = crypto?.hash * @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds) * @property {Array} [excludeUrls=[]] - URLs to exclude from recording * @property {Object} [matchOptions] - Options for matching headers and body - * @property {Array} [matchHeaders=null] - Headers to match (null means match all headers) + * @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 @@ -84,7 +84,6 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { - matchHeaders = null, caseSensitive = false } = matchOptions @@ -101,7 +100,7 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { if (ignoreSet.has(headerKey)) continue // If matchHeaders is specified, only include those headers - if (matchHeaders && Array.isArray(matchHeaders)) { + if (matchSet.size !== 0) { if (!matchSet.has(headerKey)) continue } @@ -123,7 +122,7 @@ function filterHeadersForStorage (headers, matchOptions = {}) { } = matchOptions const filtered = {} - const excludeSet = new Set(excludeHeaders.map(h => caseSensitive ? h : h.toLowerCase())) + const excludeSet = new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())) for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -141,14 +140,12 @@ function filterHeadersForStorage (headers, matchOptions = {}) { * Creates cached header sets for performance */ function createHeaderSetsCache (matchOptions = {}) { - const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = null, caseSensitive = false } = matchOptions + const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = [], 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 + ignoreSet: new Set(ignoreHeaders.map(header => caseSensitive ? header : header.toLowerCase())), + excludeSet: new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())), + matchSet: new Set(matchHeaders.map(header => caseSensitive ? header : header.toLowerCase())) } } @@ -291,7 +288,7 @@ class SnapshotRecorder { // Matching configuration 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 From 39c93c66a128cc406790a996016ee9555104e77c Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 11:48:05 +0200 Subject: [PATCH 04/12] remove unnecessary case --- lib/mock/snapshot-agent.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index d35f2a312af..f7e31ca742b 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -107,8 +107,6 @@ 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'`) } } From 679cbebd92bb26d0318246d76bce9a1985dfff45 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 11:55:15 +0200 Subject: [PATCH 05/12] simplify --- lib/mock/snapshot-agent.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index f7e31ca742b..e6327678e2e 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -13,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 { @@ -27,23 +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) - - const mode = opts.mode || 'record' + super(mockAgentOpts) 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], From 323683168ff2828392454ca04b83ce513bf53d57 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 12:24:06 +0200 Subject: [PATCH 06/12] improve --- lib/mock/snapshot-agent.js | 33 +++++++++++++------ lib/mock/snapshot-recorder.js | 61 ++++++++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index e6327678e2e..0913a5bc379 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -84,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) @@ -92,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}`) @@ -107,14 +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) + 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) } @@ -122,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: {}, @@ -177,8 +177,11 @@ 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. */ - _replaySnapshot (snapshot, handler) { + #replaySnapshot (snapshot, handler) { try { const { response } = snapshot @@ -210,6 +213,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]) @@ -217,12 +223,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]) @@ -241,7 +250,7 @@ class SnapshotAgent extends MockAgent { * * @private */ - _setupMockInterceptors () { + #setupMockInterceptors () { for (const snapshot of this[kSnapshotRecorder].getSnapshots()) { const { request, responses, response } = snapshot const url = new URL(request.url) @@ -313,8 +322,12 @@ class SnapshotAgent extends MockAgent { 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 00643ec11a4..e6fd4aca918 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -17,6 +17,30 @@ try { * @returns {string} - The base64url encoded hash of the value */ +/** + * @typedef {Object} SnapshotEntryRequest + * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.) + * @property {string} url - Full URL of the request + * @property {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 {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 + */ + /** * Generates a hash for a given value * @type {HashIdFunction} @@ -25,6 +49,8 @@ const hashId = crypto?.hash ? (value) => crypto.hash('sha256', value, 'base64url') : (value) => Buffer.from(value).toString('base64url') +const alwaysTrue = () => true + /** * @typedef {Object} SnapshotRecorderOptions * @property {string} [snapshotPath] - Path to save/load snapshots @@ -276,6 +302,7 @@ class SnapshotRecorder { * @param {SnapshotRecorderOptions} [options={}] - Configuration options for the recorder */ constructor (options = {}) { + /** @type {Map} */ this.snapshots = new Map() this.snapshotPath = options.snapshotPath this.mode = options.mode || 'record' @@ -284,7 +311,6 @@ class SnapshotRecorder { this.autoFlush = options.autoFlush || false this.flushInterval = options.flushInterval || 30000 // 30 seconds default this._flushTimer = null - this.#flushTimeout = null // Matching configuration this.matchOptions = { @@ -300,15 +326,15 @@ class SnapshotRecorder { this._headerSetsCache = createHeaderSetsCache(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 || alwaysTrue // function(requestOpts) -> boolean + this.shouldPlayback = options.shouldPlayback || alwaysTrue // function(requestOpts) -> boolean // URL pattern filtering this.#excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings // Start auto-flush timer if enabled if (this.autoFlush && this.snapshotPath) { - this._startAutoFlush() + this.#startAutoFlush() } } @@ -317,10 +343,8 @@ class SnapshotRecorder { */ 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 @@ -365,20 +389,21 @@ class SnapshotRecorder { // Auto-flush if enabled if (this.autoFlush && this.snapshotPath) { - this._scheduleFlush() + this.#scheduleFlush() } } /** * Finds a matching snapshot for the given request * Returns the appropriate response based on call count for sequential responses + * + * @param {Object} requestOpts - Request options to match + * @returns {Object|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 @@ -407,6 +432,7 @@ 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 @@ -443,6 +469,9 @@ 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 @@ -517,7 +546,7 @@ class SnapshotRecorder { return { hash, request: snapshot.request, - responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), + responseCount: snapshot.responses.length, callCount: snapshot.callCount || 0, timestamp: snapshot.timestamp } @@ -542,7 +571,7 @@ class SnapshotRecorder { /** * Starts the auto-flush timer */ - _startAutoFlush () { + #startAutoFlush () { if (!this._flushTimer) { this._flushTimer = setInterval(() => { this.saveSnapshots().catch(() => { @@ -566,7 +595,7 @@ class SnapshotRecorder { /** * Schedules a flush (debounced to avoid excessive writes) */ - _scheduleFlush () { + #scheduleFlush () { // Simple debouncing - clear existing timeout and set new one if (this.#flushTimeout) { clearTimeout(this.#flushTimeout) From accf074c4ddd84fab370e7604f71ee1dc9497893 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 13:04:54 +0200 Subject: [PATCH 07/12] reuse sets --- lib/mock/snapshot-recorder.js | 161 +++++++++++++++++++++------------- test/snapshot-recorder.js | 2 +- 2 files changed, 101 insertions(+), 62 deletions(-) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index e6fd4aca918..1719581eb37 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -1,3 +1,5 @@ +// @ts-check + 'use strict' const { writeFile, readFile, mkdir } = require('node:fs/promises') @@ -33,6 +35,16 @@ try { * @property {Object} [trailers] - Optional response trailers */ +/** + * @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 {Headers|UndiciHeaders} headers - Request headers + * @property {NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object + * @property {string|Buffer} [body] - Request body (optional) + */ + /** * @typedef {Object} SnapshotEntry * @property {SnapshotEntryRequest} request - The request object @@ -51,6 +63,16 @@ const hashId = crypto?.hash const alwaysTrue = () => true +/** + * @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 @@ -59,13 +81,6 @@ const alwaysTrue = () => true * @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 {Object} [matchOptions] - Options for matching headers and body - * @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 propertyeters - * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive * @property {function} [shouldRecord=null] - Function to filter requests for recording * @property {function} [shouldPlayback=null] - Function to filter requests */ @@ -74,15 +89,9 @@ const alwaysTrue = () => true * Formats a request for consistent snapshot storage * Caches normalized headers to avoid repeated processing * - * @param {Object} opts - Request options - * @param {string} [opts.method='GET'] - HTTP method (default: 'GET') - * @param {string} opts.path - Request path - * @param {string} opts.origin - Request origin (base URL) - * @param {Object} opts.headers - Request headers - * @param {string|Buffer} [opts.body] - Request body (optional) - * @param {NormalizedHeaders} opts._normalizedHeaders - Request headers + * @param {SnapshotRequestOptions} opts - Request options * @param {Object} cachedSets - Cached header sets for performance - * @param {Object} [matchOptions] - Matching options for headers and body + * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body * @returns {Object} - Formatted request object */ function formatRequestKey (opts, cachedSets, matchOptions = {}) { @@ -105,6 +114,9 @@ function formatRequestKey (opts, cachedSets, matchOptions = {}) { /** * Filters headers based on matching configuration * + * @param {Headers} headers - Headers to filter + * @param {HeaderSets} cachedSets - Cached sets for ignore, exclude, and match headers + * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers */ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} @@ -138,17 +150,20 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { /** * Filters headers for storage (only excludes sensitive headers) + * + * @param {Headers} headers - Headers to filter + * @param {HeaderSets} cachedSets - Cached sets for ignore, exclude, and match headers + * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers */ -function filterHeadersForStorage (headers, matchOptions = {}) { +function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { - excludeHeaders = [], caseSensitive = false } = matchOptions const filtered = {} - const excludeSet = new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())) + const { excludeSet } = cachedSets for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -164,6 +179,9 @@ function filterHeadersForStorage (headers, matchOptions = {}) { /** * Creates cached header sets for performance + * + * @param {SnapshotRecorderMatchOptions} matchOptions - Matching options for headers + * @returns {HeaderSets} - Cached sets for ignore, exclude, and match headers */ function createHeaderSetsCache (matchOptions = {}) { const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = [], caseSensitive = false } = matchOptions @@ -177,6 +195,14 @@ function createHeaderSetsCache (matchOptions = {}) { /** @typedef {{[key: Lowercase]: string}} NormalizedHeaders */ /** @typedef {Array} UndiciHeaders */ +/** @typedef {Record} Headers */ + +/** + * @typedef {Object} HeaderSets + * @property {Set} ignoreSet - Set of headers to ignore for matching + * @property {Set} excludeSet - Set of headers to exclude from matching + * @property {Set} matchSet - Set of headers to match (empty means match + */ /** * @param {*} headers @@ -297,22 +323,34 @@ class SnapshotRecorder { /** @type {Array} */ #excludeUrls = [] + /** @type {Map} */ + #snapshots = new Map() + + /** @type {string|undefined} */ + #snapshotPath + + /** @type {number} */ + #maxSnapshots = Infinity + + /** @type {boolean} */ + #autoFlush = false + + /** @type {HeaderSets} */ + #headerSetsCache + /** * Creates a new SnapshotRecorder instance - * @param {SnapshotRecorderOptions} [options={}] - Configuration options for the recorder + * @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder */ constructor (options = {}) { - /** @type {Map} */ - 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 // Matching configuration + /** @type {Required} */ this.matchOptions = { matchHeaders: options.matchHeaders || [], // empty means match all headers ignoreHeaders: options.ignoreHeaders || [], @@ -323,7 +361,7 @@ class SnapshotRecorder { } // Cache processed header sets to avoid recreating them on every request - this._headerSetsCache = createHeaderSetsCache(this.matchOptions) + this.#headerSetsCache = createHeaderSetsCache(this.matchOptions) // Request filtering callbacks this.shouldRecord = options.shouldRecord || alwaysTrue // function(requestOpts) -> boolean @@ -333,13 +371,14 @@ class SnapshotRecorder { this.#excludeUrls = options.excludeUrls || [] // Array of regex patterns or strings // Start auto-flush timer if enabled - if (this.autoFlush && this.snapshotPath) { + if (this.#autoFlush && this.#snapshotPath) { this.#startAutoFlush() } } /** * Records a request-response interaction + * @param {SnapshotRequestOptions} requestOpts - Request options */ async record (requestOpts, response) { // Check if recording should be filtered out @@ -353,14 +392,14 @@ class SnapshotRecorder { return // Skip recording } - const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) const hash = createRequestHash(request) // Extract response data - always store body as base64 const normalizedHeaders = normalizeHeaders(response.headers) const responseData = { statusCode: response.statusCode, - headers: filterHeadersForStorage(normalizedHeaders, this.matchOptions), + headers: filterHeadersForStorage(normalizedHeaders, this.#headerSetsCache, this.matchOptions), body: Buffer.isBuffer(response.body) ? response.body.toString('base64') : Buffer.from(String(response.body || '')).toString('base64'), @@ -368,18 +407,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, @@ -388,7 +427,7 @@ class SnapshotRecorder { } // Auto-flush if enabled - if (this.autoFlush && this.snapshotPath) { + if (this.#autoFlush && this.#snapshotPath) { this.#scheduleFlush() } } @@ -397,7 +436,7 @@ class SnapshotRecorder { * Finds a matching snapshot for the given request * Returns the appropriate response based on call count for sequential responses * - * @param {Object} requestOpts - Request options to match + * @param {SnapshotRequestOptions} requestOpts - Request options to match * @returns {Object|undefined} - Matching snapshot response or undefined if not found */ findSnapshot (requestOpts) { @@ -412,9 +451,9 @@ class SnapshotRecorder { return undefined // Skip playback } - const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) const hash = createRequestHash(request) - const snapshot = this.snapshots.get(hash) + const snapshot = this.#snapshots.get(hash) if (!snapshot) return undefined @@ -435,7 +474,7 @@ class SnapshotRecorder { * @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') } @@ -446,21 +485,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 }) } @@ -474,7 +510,7 @@ class SnapshotRecorder { * @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') } @@ -485,7 +521,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 })) @@ -498,48 +534,51 @@ class SnapshotRecorder { * @returns {void} */ clear () { - this.snapshots.clear() + this.#snapshots.clear() } /** * Gets all recorded snapshots */ getSnapshots () { - return Array.from(this.snapshots.values()) + return Array.from(this.#snapshots.values()) } /** * Gets snapshot count */ size () { - return this.snapshots.size + return this.#snapshots.size } /** * Resets call counts for all snapshots (useful for test cleanup) */ 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.#headerSetsCache, 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 */ getSnapshotInfo (requestOpts) { - const request = formatRequestKey(requestOpts, this._headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) const hash = createRequestHash(request) - const snapshot = this.snapshots.get(hash) + const snapshot = this.#snapshots.get(hash) if (!snapshot) return null @@ -556,15 +595,15 @@ class SnapshotRecorder { * Replaces all snapshots with new data (full replacement) */ 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)) } } @@ -625,7 +664,7 @@ class SnapshotRecorder { */ 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() } diff --git a/test/snapshot-recorder.js b/test/snapshot-recorder.js index 3491184a623..4448f7006ea 100644 --- a/test/snapshot-recorder.js +++ b/test/snapshot-recorder.js @@ -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'] + excludeSet: new Set(['set-cookie', 'authorization']) }) assert.deepStrictEqual(filtered, { From 3ccb299cb31cb8bdb34a17519a8de2429adc11a3 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 13:06:23 +0200 Subject: [PATCH 08/12] simplify --- lib/mock/snapshot-recorder.js | 44 +++++++++++++++++------------------ test/snapshot-recorder.js | 24 +++++++++---------- test/snapshot-testing.js | 8 +++---- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 1719581eb37..814c9ccc995 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -115,7 +115,7 @@ function formatRequestKey (opts, cachedSets, matchOptions = {}) { * Filters headers based on matching configuration * * @param {Headers} headers - Headers to filter - * @param {HeaderSets} cachedSets - Cached sets for ignore, exclude, and match headers + * @param {HeaderFilterSets} cachedSets - Cached sets for ignore, exclude, and match headers * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers */ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { @@ -126,7 +126,7 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { } = matchOptions const filtered = {} - const { ignoreSet, excludeSet, matchSet } = cachedSets + const { ignore: ignoreSet, exclude: excludeSet, match: matchSet } = cachedSets for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -152,7 +152,7 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { * Filters headers for storage (only excludes sensitive headers) * * @param {Headers} headers - Headers to filter - * @param {HeaderSets} cachedSets - Cached sets for ignore, exclude, and match headers + * @param {HeaderFilterSets} cachedSets - Cached sets for ignore, exclude, and match headers * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers */ function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { @@ -163,7 +163,7 @@ function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { } = matchOptions const filtered = {} - const { excludeSet } = cachedSets + const { exclude: excludeSet } = cachedSets for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -181,15 +181,15 @@ function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { * Creates cached header sets for performance * * @param {SnapshotRecorderMatchOptions} matchOptions - Matching options for headers - * @returns {HeaderSets} - Cached sets for ignore, exclude, and match headers + * @returns {HeaderFilterSets} - Cached sets for ignore, exclude, and match headers */ -function createHeaderSetsCache (matchOptions = {}) { +function createHeaderFilterSets (matchOptions = {}) { const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = [], caseSensitive = false } = matchOptions return { - ignoreSet: new Set(ignoreHeaders.map(header => caseSensitive ? header : header.toLowerCase())), - excludeSet: new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())), - matchSet: new Set(matchHeaders.map(header => caseSensitive ? header : header.toLowerCase())) + 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())) } } @@ -198,10 +198,10 @@ function createHeaderSetsCache (matchOptions = {}) { /** @typedef {Record} Headers */ /** - * @typedef {Object} HeaderSets - * @property {Set} ignoreSet - Set of headers to ignore for matching - * @property {Set} excludeSet - Set of headers to exclude from matching - * @property {Set} matchSet - Set of headers to match (empty means match + * @typedef {Object} HeaderFilterSets + * @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 */ /** @@ -335,8 +335,8 @@ class SnapshotRecorder { /** @type {boolean} */ #autoFlush = false - /** @type {HeaderSets} */ - #headerSetsCache + /** @type {HeaderFilterSets} */ + #headerFilterSets /** * Creates a new SnapshotRecorder instance @@ -361,7 +361,7 @@ class SnapshotRecorder { } // Cache processed header sets to avoid recreating them on every request - this.#headerSetsCache = createHeaderSetsCache(this.matchOptions) + this.#headerFilterSets = createHeaderFilterSets(this.matchOptions) // Request filtering callbacks this.shouldRecord = options.shouldRecord || alwaysTrue // function(requestOpts) -> boolean @@ -392,14 +392,14 @@ class SnapshotRecorder { return // Skip recording } - const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilterSets, this.matchOptions) const hash = createRequestHash(request) // Extract response data - always store body as base64 const normalizedHeaders = normalizeHeaders(response.headers) const responseData = { statusCode: response.statusCode, - headers: filterHeadersForStorage(normalizedHeaders, this.#headerSetsCache, this.matchOptions), + headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilterSets, this.matchOptions), body: Buffer.isBuffer(response.body) ? response.body.toString('base64') : Buffer.from(String(response.body || '')).toString('base64'), @@ -451,7 +451,7 @@ class SnapshotRecorder { return undefined // Skip playback } - const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilterSets, this.matchOptions) const hash = createRequestHash(request) const snapshot = this.#snapshots.get(hash) @@ -566,7 +566,7 @@ class SnapshotRecorder { * @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.#headerFilterSets, this.matchOptions) const hash = createRequestHash(request) return this.#snapshots.delete(hash) } @@ -576,7 +576,7 @@ class SnapshotRecorder { * @param {SnapshotRequestOptions} requestOpts - Request options to match */ getSnapshotInfo (requestOpts) { - const request = formatRequestKey(requestOpts, this.#headerSetsCache, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilterSets, this.matchOptions) const hash = createRequestHash(request) const snapshot = this.#snapshots.get(hash) @@ -673,4 +673,4 @@ class SnapshotRecorder { } } -module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderSetsCache } +module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderFilterSets } diff --git a/test/snapshot-recorder.js b/test/snapshot-recorder.js index 4448f7006ea..36aba5fd109 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, createHeaderFilterSets } = 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 = createHeaderFilterSets({}) const formatted = formatRequestKey(requestOpts, cachedSets) assert.strictEqual(formatted.method, 'POST') @@ -117,7 +117,7 @@ test('SnapshotRecorder - header normalization', (t) => { } } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilterSets({}) 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 = createHeaderFilterSets({}) 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 = createHeaderFilterSets({}) 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 = createHeaderFilterSets(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 = createHeaderFilterSets(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 = createHeaderFilterSets(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, { - excludeSet: new Set(['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 = createHeaderFilterSets(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 = createHeaderFilterSets(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 = createHeaderFilterSets(matchOptions) const formatted = formatRequestKey(requestOpts, cachedSets, matchOptions) assert.strictEqual(formatted.method, 'POST') diff --git a/test/snapshot-testing.js b/test/snapshot-testing.js index a519a5aeaf5..d4eefe6d946 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, createHeaderFilterSets } = 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 = createHeaderFilterSets({}) 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, createHeaderFilterSets } = require('../lib/mock/snapshot-recorder') const mockRequestOpts = { origin, path: '/api/mock', method: 'GET' } - const cachedSets = createHeaderSetsCache({}) + const cachedSets = createHeaderFilterSets({}) const mockRequest = formatRequestKey(mockRequestOpts, cachedSets) const mockHash = createRequestHash(mockRequest) From fd8f814b6565a9347ad931b144e0a95d183ba90e Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 14:00:56 +0200 Subject: [PATCH 09/12] improve --- lib/mock/snapshot-agent.js | 2 - lib/mock/snapshot-recorder.js | 269 +++++++++++----------------------- lib/mock/snapshot-utils.js | 137 +++++++++++++++++ test/snapshot-recorder.js | 24 +-- test/snapshot-testing.js | 8 +- 5 files changed, 240 insertions(+), 200 deletions(-) diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 0913a5bc379..48a0bc4e486 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -247,8 +247,6 @@ class SnapshotAgent extends MockAgent { * response data. * * Called automatically when loading snapshots in playback mode. - * - * @private */ #setupMockInterceptors () { for (const snapshot of this[kSnapshotRecorder].getSnapshots()) { diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 814c9ccc995..9b1a36eb879 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -1,50 +1,36 @@ -// @ts-check - 'use strict' const { writeFile, readFile, mkdir } = require('node:fs/promises') const { dirname, resolve } = require('node:path') const { InvalidArgumentError, UndiciError } = require('../core/errors') - -let crypto -try { - crypto = require('node:crypto') -} catch { - // Fallback if crypto is not available -} +const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils') /** - * @callback HashIdFunction - * @param {string} value - The value to hash - * @returns {string} - The base64url encoded hash of the value + * @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 {NormalizedHeaders} headers - Normalized headers as a lowercase object + * @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 {NormalizedHeaders} headers - Normalized response headers as a lowercase object + * @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} SnapshotRequestOptions - * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.) - * @property {string} path - Request path - * @property {string} origin - Request origin (base URL) - * @property {Headers|UndiciHeaders} headers - Request headers - * @property {NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object - * @property {string|Buffer} [body] - Request body (optional) - */ - /** * @typedef {Object} SnapshotEntry * @property {SnapshotEntryRequest} request - The request object @@ -53,16 +39,6 @@ try { * @property {string} timestamp - ISO timestamp of when the snapshot was created */ -/** - * 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') - -const alwaysTrue = () => true - /** * @typedef {Object} SnapshotRecorderMatchOptions * @property {Array} [matchHeaders=[]] - Headers to match (empty array means match all headers) @@ -85,16 +61,33 @@ const alwaysTrue = () => true * @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 {Object} cachedSets - Cached header sets for performance + * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body - * @returns {Object} - Formatted request object + * @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 @@ -106,19 +99,19 @@ 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 {Headers} headers - Headers to filter - * @param {HeaderFilterSets} cachedSets - Cached sets for ignore, exclude, and match 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 filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { +function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { @@ -126,20 +119,20 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { } = matchOptions const filtered = {} - const { ignore: ignoreSet, exclude: excludeSet, match: 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 (matchSet.size !== 0) { - if (!matchSet.has(headerKey)) continue + if (match.size !== 0) { + if (!match.has(headerKey)) continue } filtered[headerKey] = value @@ -151,11 +144,11 @@ function filterHeadersForMatching (headers, cachedSets, matchOptions = {}) { /** * Filters headers for storage (only excludes sensitive headers) * - * @param {Headers} headers - Headers to filter - * @param {HeaderFilterSets} cachedSets - Cached sets for ignore, exclude, and match 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, cachedSets, matchOptions = {}) { +function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) { if (!headers || typeof headers !== 'object') return {} const { @@ -163,7 +156,7 @@ function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { } = matchOptions const filtered = {} - const { exclude: excludeSet } = cachedSets + const { exclude: excludeSet } = headerFilters for (const [key, value] of Object.entries(headers)) { const headerKey = caseSensitive ? key : key.toLowerCase() @@ -177,103 +170,29 @@ function filterHeadersForStorage (headers, cachedSets, matchOptions = {}) { return filtered } -/** - * Creates cached header sets for performance - * - * @param {SnapshotRecorderMatchOptions} matchOptions - Matching options for headers - * @returns {HeaderFilterSets} - Cached sets for ignore, exclude, and match headers - */ -function createHeaderFilterSets (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())) - } -} - -/** @typedef {{[key: Lowercase]: string}} NormalizedHeaders */ -/** @typedef {Array} UndiciHeaders */ -/** @typedef {Record} Headers */ - -/** - * @typedef {Object} HeaderFilterSets - * @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 - */ - -/** - * @param {*} headers - * @returns {headers is UndiciHeaders} - */ -function isUndiciHeaders (headers) { - return Array.isArray(headers) && (headers.length & 1) === 0 -} - -/** - * 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 -} - /** * Creates a hash key for request matching * Properly orders headers to avoid conflicts and uses crypto hashing when available * - * @param {Request} request - Request object + * @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()) { @@ -283,45 +202,19 @@ function createRequestHash (request) { } // Add body - parts.push(request.body || '') + parts.push(formattedRequest.body) const content = parts.join('|') return hashId(content) } -/** - * Checks if a URL matches any of the exclude patterns - * @param {string} url - The URL to check - * @param {Array} [excludePatterns=[]] - Array of patterns to exclude - * @returns {boolean} - True if the URL matches any exclude pattern, false otherwise - */ -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 - } - } - } - - return false -} - class SnapshotRecorder { /** @type {NodeJS.Timeout | null} */ - #flushTimeout = null + #flushTimeout - /** @type {Array} */ - #excludeUrls = [] + /** @type {import('./snapshot-utils').IsUrlExcluded} */ + #isUrlExcluded /** @type {Map} */ #snapshots = new Map() @@ -335,8 +228,8 @@ class SnapshotRecorder { /** @type {boolean} */ #autoFlush = false - /** @type {HeaderFilterSets} */ - #headerFilterSets + /** @type {import('./snapshot-utils').HeaderFilters} */ + #headerFilters /** * Creates a new SnapshotRecorder instance @@ -361,14 +254,14 @@ class SnapshotRecorder { } // Cache processed header sets to avoid recreating them on every request - this.#headerFilterSets = createHeaderFilterSets(this.matchOptions) + this.#headerFilters = createHeaderFilters(this.matchOptions) // Request filtering callbacks - this.shouldRecord = options.shouldRecord || alwaysTrue // function(requestOpts) -> boolean - this.shouldPlayback = options.shouldPlayback || alwaysTrue // 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) { @@ -379,6 +272,8 @@ class SnapshotRecorder { /** * 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 @@ -388,18 +283,20 @@ class SnapshotRecorder { // 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.#headerFilterSets, 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.#headerFilterSets, 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'), @@ -437,7 +334,7 @@ class SnapshotRecorder { * Returns the appropriate response based on call count for sequential responses * * @param {SnapshotRequestOptions} requestOpts - Request options to match - * @returns {Object|undefined} - Matching snapshot response or undefined if not found + * @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found */ findSnapshot (requestOpts) { // Check if playback should be filtered out @@ -447,11 +344,11 @@ class SnapshotRecorder { // 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.#headerFilterSets, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) const snapshot = this.#snapshots.get(hash) @@ -539,6 +436,7 @@ class SnapshotRecorder { /** * Gets all recorded snapshots + * @return {Array} - Array of all recorded snapshots */ getSnapshots () { return Array.from(this.#snapshots.values()) @@ -546,6 +444,7 @@ class SnapshotRecorder { /** * Gets snapshot count + * @return {number} - Number of recorded snapshots */ size () { return this.#snapshots.size @@ -553,6 +452,7 @@ class SnapshotRecorder { /** * Resets call counts for all snapshots (useful for test cleanup) + * @returns {void} */ resetCallCounts () { for (const snapshot of this.#snapshots.values()) { @@ -566,7 +466,7 @@ class SnapshotRecorder { * @returns {boolean} - True if snapshot was deleted, false if not found */ deleteSnapshot (requestOpts) { - const request = formatRequestKey(requestOpts, this.#headerFilterSets, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) return this.#snapshots.delete(hash) } @@ -574,9 +474,10 @@ class SnapshotRecorder { /** * 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.#headerFilterSets, this.matchOptions) + const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions) const hash = createRequestHash(request) const snapshot = this.#snapshots.get(hash) @@ -585,7 +486,7 @@ class SnapshotRecorder { return { hash, request: snapshot.request, - responseCount: snapshot.responses.length, + responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots callCount: snapshot.callCount || 0, timestamp: snapshot.timestamp } @@ -593,6 +494,8 @@ 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() @@ -609,6 +512,7 @@ class SnapshotRecorder { /** * Starts the auto-flush timer + * @returns {void} */ #startAutoFlush () { if (!this._flushTimer) { @@ -649,6 +553,7 @@ class SnapshotRecorder { /** * Cleanup method to stop timers + * @returns {void} */ destroy () { this.#stopAutoFlush() @@ -673,4 +578,4 @@ class SnapshotRecorder { } } -module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, isUrlExcluded, createHeaderFilterSets } +module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters } diff --git a/lib/mock/snapshot-utils.js b/lib/mock/snapshot-utils.js index 1c5a4a584ed..ebad12e888f 100644 --- a/lib/mock/snapshot-utils.js +++ b/lib/mock/snapshot-utils.js @@ -2,6 +2,138 @@ 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 */ @@ -17,5 +149,10 @@ function validateSnapshotMode (mode) { } module.exports = { + createHeaderFilters, + hashId, + isUndiciHeaders, + normalizeHeaders, + isUrlExcludedFactory, validateSnapshotMode } diff --git a/test/snapshot-recorder.js b/test/snapshot-recorder.js index 36aba5fd109..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, createHeaderFilterSets } = 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 = createHeaderFilterSets({}) + const cachedSets = createHeaderFilters({}) const formatted = formatRequestKey(requestOpts, cachedSets) assert.strictEqual(formatted.method, 'POST') @@ -117,7 +117,7 @@ test('SnapshotRecorder - header normalization', (t) => { } } - const cachedSets = createHeaderFilterSets({}) + 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 = createHeaderFilterSets({}) + 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 = createHeaderFilterSets({}) + 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 = createHeaderFilterSets(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 = createHeaderFilterSets(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 = createHeaderFilterSets(excludeOptions) + const excludeCachedSets = createHeaderFilters(excludeOptions) const excludeSensitive = filterHeadersForMatching(headers, excludeCachedSets, excludeOptions) assert.deepStrictEqual(excludeSensitive, { @@ -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 = createHeaderFilterSets(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 = createHeaderFilterSets(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 = createHeaderFilterSets(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 d4eefe6d946..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, createHeaderFilterSets } = 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 = createHeaderFilterSets({}) + 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, createHeaderFilterSets } = require('../lib/mock/snapshot-recorder') + const { createRequestHash, formatRequestKey, createHeaderFilters } = require('../lib/mock/snapshot-recorder') const mockRequestOpts = { origin, path: '/api/mock', method: 'GET' } - const cachedSets = createHeaderFilterSets({}) + const cachedSets = createHeaderFilters({}) const mockRequest = formatRequestKey(mockRequestOpts, cachedSets) const mockHash = createRequestHash(mockRequest) From 4ac3ab8e69f583326624d3cc4cfc271a9e9cbf8e Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Fri, 15 Aug 2025 14:19:29 +0200 Subject: [PATCH 10/12] more jsdoc --- lib/mock/snapshot-agent.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 48a0bc4e486..dbe53575f1d 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -180,6 +180,7 @@ class SnapshotAgent extends MockAgent { * * @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 { @@ -247,6 +248,8 @@ class SnapshotAgent extends MockAgent { * response data. * * Called automatically when loading snapshots in playback mode. + * + * @returns {void} */ #setupMockInterceptors () { for (const snapshot of this[kSnapshotRecorder].getSnapshots()) { @@ -273,6 +276,7 @@ class SnapshotAgent extends MockAgent { /** * Gets the snapshot recorder + * @return {SnapshotRecorder} - The snapshot recorder instance */ getRecorder () { return this[kSnapshotRecorder] @@ -280,6 +284,7 @@ class SnapshotAgent extends MockAgent { /** * Gets the current mode + * @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode */ getMode () { return this[kSnapshotMode] @@ -287,6 +292,7 @@ class SnapshotAgent extends MockAgent { /** * Clears all snapshots + * @returns {void} */ clearSnapshots () { this[kSnapshotRecorder].clear() @@ -294,6 +300,7 @@ class SnapshotAgent extends MockAgent { /** * Resets call counts for all snapshots (useful for test cleanup) + * @returns {void} */ resetCallCounts () { this[kSnapshotRecorder].resetCallCounts() @@ -301,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) @@ -308,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) @@ -315,6 +325,8 @@ 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) From caf3fb0fb321f60f02eb718c402209929185ef67 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 17 Aug 2025 10:07:10 +0200 Subject: [PATCH 11/12] fix potential race condition --- lib/mock/snapshot-recorder.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 9b1a36eb879..9d5598261c2 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -2,6 +2,7 @@ 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') @@ -515,13 +516,7 @@ class SnapshotRecorder { * @returns {void} */ #startAutoFlush () { - if (!this._flushTimer) { - this._flushTimer = setInterval(() => { - this.saveSnapshots().catch(() => { - // Ignore flush errors - they shouldn't interrupt normal operation - }) - }, this.flushInterval) - } + return this.#scheduleFlush() } /** @@ -529,9 +524,9 @@ class SnapshotRecorder { * @returns {void} */ #stopAutoFlush () { - if (this._flushTimer) { - clearInterval(this._flushTimer) - this._flushTimer = null + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout) + this.#flushTimeout = null } } @@ -539,15 +534,15 @@ class SnapshotRecorder { * 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(() => { this.saveSnapshots().catch(() => { // Ignore flush errors }) - this.#flushTimeout = null + if (this.#autoFlush) { + this.#flushTimeout?.refresh() + } else { + this.#flushTimeout = null + } }, 1000) // 1 second debounce } From 813133eea95be21a4304eaaa8d06f61e55210d7b Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sun, 17 Aug 2025 10:33:07 +0200 Subject: [PATCH 12/12] one last flush --- lib/mock/snapshot-recorder.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 9d5598261c2..e810fe79507 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -526,6 +526,10 @@ class SnapshotRecorder { #stopAutoFlush () { if (this.#flushTimeout) { clearTimeout(this.#flushTimeout) + // Ensure any pending flush is completed + this.saveSnapshots().catch(() => { + // Ignore flush errors + }) this.#flushTimeout = null } }