aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
commit76cb9c2a39d477a64824a985ade40507e3bbade1 (patch)
tree41e997aa9c6f538d3a136af61dae9424db2005a9 /vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
parent819a39a21ac992b1393244a4c283bbb125208c69 (diff)
downloadneko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.gz
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.bz2
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.zip
feat(vanilla): add testing infrastructure and tests (NK-wjnczv)
Diffstat (limited to 'vanilla/node_modules/undici/lib/mock/snapshot-recorder.js')
-rw-r--r--vanilla/node_modules/undici/lib/mock/snapshot-recorder.js588
1 files changed, 588 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js b/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
new file mode 100644
index 0000000..b5d07fa
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/snapshot-recorder.js
@@ -0,0 +1,588 @@
+'use strict'
+
+const { writeFile, readFile, mkdir } = require('node:fs/promises')
+const { dirname, resolve } = require('node:path')
+const { setTimeout, clearTimeout } = require('node:timers')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils')
+
+/**
+ * @typedef {Object} SnapshotRequestOptions
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} path - Request path
+ * @property {string} origin - Request origin (base URL)
+ * @property {import('./snapshot-utils').Headers|import('./snapshot-utils').UndiciHeaders} headers - Request headers
+ * @property {import('./snapshot-utils').NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object
+ * @property {string|Buffer} [body] - Request body (optional)
+ */
+
+/**
+ * @typedef {Object} SnapshotEntryRequest
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} url - Full URL of the request
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
+ * @property {string|Buffer} [body] - Request body (optional)
+ */
+
+/**
+ * @typedef {Object} SnapshotEntryResponse
+ * @property {number} statusCode - HTTP status code of the response
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized response headers as a lowercase object
+ * @property {string} body - Response body as a base64url encoded string
+ * @property {Object} [trailers] - Optional response trailers
+ */
+
+/**
+ * @typedef {Object} SnapshotEntry
+ * @property {SnapshotEntryRequest} request - The request object
+ * @property {Array<SnapshotEntryResponse>} responses - Array of response objects
+ * @property {number} callCount - Number of times this snapshot has been called
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
+ */
+
+/**
+ * @typedef {Object} SnapshotRecorderMatchOptions
+ * @property {Array<string>} [matchHeaders=[]] - Headers to match (empty array means match all headers)
+ * @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
+ * @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
+ * @property {boolean} [matchBody=true] - Whether to match request body
+ * @property {boolean} [matchQuery=true] - Whether to match query properties
+ * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
+ */
+
+/**
+ * @typedef {Object} SnapshotRecorderOptions
+ * @property {string} [snapshotPath] - Path to save/load snapshots
+ * @property {import('./snapshot-utils').SnapshotMode} [mode='record'] - Mode: 'record' or 'playback'
+ * @property {number} [maxSnapshots=Infinity] - Maximum number of snapshots to keep
+ * @property {boolean} [autoFlush=false] - Whether to automatically flush snapshots to disk
+ * @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds)
+ * @property {Array<string|RegExp>} [excludeUrls=[]] - URLs to exclude from recording
+ * @property {function} [shouldRecord=null] - Function to filter requests for recording
+ * @property {function} [shouldPlayback=null] - Function to filter requests
+ */
+
+/**
+ * @typedef {Object} SnapshotFormattedRequest
+ * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
+ * @property {string} url - Full URL of the request (with query parameters if matchQuery is true)
+ * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
+ * @property {string} body - Request body (optional, only if matchBody is true)
+ */
+
+/**
+ * @typedef {Object} SnapshotInfo
+ * @property {string} hash - Hash key for the snapshot
+ * @property {SnapshotEntryRequest} request - The request object
+ * @property {number} responseCount - Number of responses recorded for this request
+ * @property {number} callCount - Number of times this snapshot has been called
+ * @property {string} timestamp - ISO timestamp of when the snapshot was created
+ */
+
+/**
+ * Formats a request for consistent snapshot storage
+ * Caches normalized headers to avoid repeated processing
+ *
+ * @param {SnapshotRequestOptions} opts - Request options
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body
+ * @returns {SnapshotFormattedRequest} - Formatted request object
+ */
+function formatRequestKey (opts, headerFilters, matchOptions = {}) {
+ const url = new URL(opts.path, opts.origin)
+
+ // Cache normalized headers if not already done
+ const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
+ if (!opts._normalizedHeaders) {
+ opts._normalizedHeaders = normalized
+ }
+
+ return {
+ method: opts.method || 'GET',
+ url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
+ headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
+ body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
+ }
+}
+
+/**
+ * Filters headers based on matching configuration
+ *
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
+ */
+function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
+ if (!headers || typeof headers !== 'object') return {}
+
+ const {
+ caseSensitive = false
+ } = matchOptions
+
+ const filtered = {}
+ const { ignore, exclude, match } = headerFilters
+
+ for (const [key, value] of Object.entries(headers)) {
+ const headerKey = caseSensitive ? key : key.toLowerCase()
+
+ // Skip if in exclude list (for security)
+ if (exclude.has(headerKey)) continue
+
+ // Skip if in ignore list (for matching)
+ if (ignore.has(headerKey)) continue
+
+ // If matchHeaders is specified, only include those headers
+ if (match.size !== 0) {
+ if (!match.has(headerKey)) continue
+ }
+
+ filtered[headerKey] = value
+ }
+
+ return filtered
+}
+
+/**
+ * Filters headers for storage (only excludes sensitive headers)
+ *
+ * @param {import('./snapshot-utils').Headers} headers - Headers to filter
+ * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
+ * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
+ */
+function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
+ if (!headers || typeof headers !== 'object') return {}
+
+ const {
+ caseSensitive = false
+ } = matchOptions
+
+ const filtered = {}
+ const { exclude: excludeSet } = headerFilters
+
+ for (const [key, value] of Object.entries(headers)) {
+ const headerKey = caseSensitive ? key : key.toLowerCase()
+
+ // Skip if in exclude list (for security)
+ if (excludeSet.has(headerKey)) continue
+
+ filtered[headerKey] = value
+ }
+
+ return filtered
+}
+
+/**
+ * Creates a hash key for request matching
+ * Properly orders headers to avoid conflicts and uses crypto hashing when available
+ *
+ * @param {SnapshotFormattedRequest} formattedRequest - Request object
+ * @returns {string} - Base64url encoded hash of the request
+ */
+function createRequestHash (formattedRequest) {
+ const parts = [
+ formattedRequest.method,
+ formattedRequest.url
+ ]
+
+ // Process headers in a deterministic way to avoid conflicts
+ if (formattedRequest.headers && typeof formattedRequest.headers === 'object') {
+ const headerKeys = Object.keys(formattedRequest.headers).sort()
+ for (const key of headerKeys) {
+ const values = Array.isArray(formattedRequest.headers[key])
+ ? formattedRequest.headers[key]
+ : [formattedRequest.headers[key]]
+
+ // Add header name
+ parts.push(key)
+
+ // Add all values for this header, sorted for consistency
+ for (const value of values.sort()) {
+ parts.push(String(value))
+ }
+ }
+ }
+
+ // Add body
+ parts.push(formattedRequest.body)
+
+ const content = parts.join('|')
+
+ return hashId(content)
+}
+
+class SnapshotRecorder {
+ /** @type {NodeJS.Timeout | null} */
+ #flushTimeout
+
+ /** @type {import('./snapshot-utils').IsUrlExcluded} */
+ #isUrlExcluded
+
+ /** @type {Map<string, SnapshotEntry>} */
+ #snapshots = new Map()
+
+ /** @type {string|undefined} */
+ #snapshotPath
+
+ /** @type {number} */
+ #maxSnapshots = Infinity
+
+ /** @type {boolean} */
+ #autoFlush = false
+
+ /** @type {import('./snapshot-utils').HeaderFilters} */
+ #headerFilters
+
+ /**
+ * Creates a new SnapshotRecorder instance
+ * @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder
+ */
+ constructor (options = {}) {
+ this.#snapshotPath = options.snapshotPath
+ this.#maxSnapshots = options.maxSnapshots || Infinity
+ this.#autoFlush = options.autoFlush || false
+ this.flushInterval = options.flushInterval || 30000 // 30 seconds default
+ this._flushTimer = null
+
+ // Matching configuration
+ /** @type {Required<SnapshotRecorderMatchOptions>} */
+ this.matchOptions = {
+ matchHeaders: options.matchHeaders || [], // empty means match all headers
+ ignoreHeaders: options.ignoreHeaders || [],
+ excludeHeaders: options.excludeHeaders || [],
+ matchBody: options.matchBody !== false, // default: true
+ matchQuery: options.matchQuery !== false, // default: true
+ caseSensitive: options.caseSensitive || false
+ }
+
+ // Cache processed header sets to avoid recreating them on every request
+ this.#headerFilters = createHeaderFilters(this.matchOptions)
+
+ // Request filtering callbacks
+ this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
+ this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
+
+ // URL pattern filtering
+ this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
+
+ // Start auto-flush timer if enabled
+ if (this.#autoFlush && this.#snapshotPath) {
+ this.#startAutoFlush()
+ }
+ }
+
+ /**
+ * Records a request-response interaction
+ * @param {SnapshotRequestOptions} requestOpts - Request options
+ * @param {SnapshotEntryResponse} response - Response data to record
+ * @return {Promise<void>} - Resolves when the recording is complete
+ */
+ async record (requestOpts, response) {
+ // Check if recording should be filtered out
+ if (!this.shouldRecord(requestOpts)) {
+ return // Skip recording
+ }
+
+ // Check URL exclusion patterns
+ if (this.isUrlExcluded(requestOpts)) {
+ return // Skip recording
+ }
+
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+
+ // Extract response data - always store body as base64
+ const normalizedHeaders = normalizeHeaders(response.headers)
+
+ /** @type {SnapshotEntryResponse} */
+ const responseData = {
+ statusCode: response.statusCode,
+ headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
+ body: Buffer.isBuffer(response.body)
+ ? response.body.toString('base64')
+ : Buffer.from(String(response.body || '')).toString('base64'),
+ trailers: response.trailers
+ }
+
+ // Remove oldest snapshot if we exceed maxSnapshots limit
+ if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
+ const oldestKey = this.#snapshots.keys().next().value
+ this.#snapshots.delete(oldestKey)
+ }
+
+ // Support sequential responses - if snapshot exists, add to responses array
+ const existingSnapshot = this.#snapshots.get(hash)
+ if (existingSnapshot && existingSnapshot.responses) {
+ existingSnapshot.responses.push(responseData)
+ existingSnapshot.timestamp = new Date().toISOString()
+ } else {
+ this.#snapshots.set(hash, {
+ request,
+ responses: [responseData], // Always store as array for consistency
+ callCount: 0,
+ timestamp: new Date().toISOString()
+ })
+ }
+
+ // Auto-flush if enabled
+ if (this.#autoFlush && this.#snapshotPath) {
+ this.#scheduleFlush()
+ }
+ }
+
+ /**
+ * Checks if a URL should be excluded from recording/playback
+ * @param {SnapshotRequestOptions} requestOpts - Request options to check
+ * @returns {boolean} - True if URL is excluded
+ */
+ isUrlExcluded (requestOpts) {
+ const url = new URL(requestOpts.path, requestOpts.origin).toString()
+ return this.#isUrlExcluded(url)
+ }
+
+ /**
+ * Finds a matching snapshot for the given request
+ * Returns the appropriate response based on call count for sequential responses
+ *
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found
+ */
+ findSnapshot (requestOpts) {
+ // Check if playback should be filtered out
+ if (!this.shouldPlayback(requestOpts)) {
+ return undefined // Skip playback
+ }
+
+ // Check URL exclusion patterns
+ if (this.isUrlExcluded(requestOpts)) {
+ return undefined // Skip playback
+ }
+
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ const snapshot = this.#snapshots.get(hash)
+
+ if (!snapshot) return undefined
+
+ // Handle sequential responses
+ const currentCallCount = snapshot.callCount || 0
+ const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
+ snapshot.callCount = currentCallCount + 1
+
+ return {
+ ...snapshot,
+ response: snapshot.responses[responseIndex]
+ }
+ }
+
+ /**
+ * Loads snapshots from file
+ * @param {string} [filePath] - Optional file path to load snapshots from
+ * @return {Promise<void>} - Resolves when snapshots are loaded
+ */
+ async loadSnapshots (filePath) {
+ const path = filePath || this.#snapshotPath
+ if (!path) {
+ throw new InvalidArgumentError('Snapshot path is required')
+ }
+
+ try {
+ const data = await readFile(resolve(path), 'utf8')
+ const parsed = JSON.parse(data)
+
+ // Convert array format back to Map
+ if (Array.isArray(parsed)) {
+ this.#snapshots.clear()
+ for (const { hash, snapshot } of parsed) {
+ this.#snapshots.set(hash, snapshot)
+ }
+ } else {
+ // Legacy object format
+ this.#snapshots = new Map(Object.entries(parsed))
+ }
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ // File doesn't exist yet - that's ok for recording mode
+ this.#snapshots.clear()
+ } else {
+ throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
+ }
+ }
+ }
+
+ /**
+ * Saves snapshots to file
+ *
+ * @param {string} [filePath] - Optional file path to save snapshots
+ * @returns {Promise<void>} - Resolves when snapshots are saved
+ */
+ async saveSnapshots (filePath) {
+ const path = filePath || this.#snapshotPath
+ if (!path) {
+ throw new InvalidArgumentError('Snapshot path is required')
+ }
+
+ const resolvedPath = resolve(path)
+
+ // Ensure directory exists
+ await mkdir(dirname(resolvedPath), { recursive: true })
+
+ // Convert Map to serializable format
+ const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
+ hash,
+ snapshot
+ }))
+
+ await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
+ }
+
+ /**
+ * Clears all recorded snapshots
+ * @returns {void}
+ */
+ clear () {
+ this.#snapshots.clear()
+ }
+
+ /**
+ * Gets all recorded snapshots
+ * @return {Array<SnapshotEntry>} - Array of all recorded snapshots
+ */
+ getSnapshots () {
+ return Array.from(this.#snapshots.values())
+ }
+
+ /**
+ * Gets snapshot count
+ * @return {number} - Number of recorded snapshots
+ */
+ size () {
+ return this.#snapshots.size
+ }
+
+ /**
+ * Resets call counts for all snapshots (useful for test cleanup)
+ * @returns {void}
+ */
+ resetCallCounts () {
+ for (const snapshot of this.#snapshots.values()) {
+ snapshot.callCount = 0
+ }
+ }
+
+ /**
+ * Deletes a specific snapshot by request options
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {boolean} - True if snapshot was deleted, false if not found
+ */
+ deleteSnapshot (requestOpts) {
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ return this.#snapshots.delete(hash)
+ }
+
+ /**
+ * Gets information about a specific snapshot
+ * @param {SnapshotRequestOptions} requestOpts - Request options to match
+ * @returns {SnapshotInfo|null} - Snapshot information or null if not found
+ */
+ getSnapshotInfo (requestOpts) {
+ const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
+ const hash = createRequestHash(request)
+ const snapshot = this.#snapshots.get(hash)
+
+ if (!snapshot) return null
+
+ return {
+ hash,
+ request: snapshot.request,
+ responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
+ callCount: snapshot.callCount || 0,
+ timestamp: snapshot.timestamp
+ }
+ }
+
+ /**
+ * Replaces all snapshots with new data (full replacement)
+ * @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record<string, SnapshotEntry>} snapshotData - New snapshot data to replace existing ones
+ * @returns {void}
+ */
+ replaceSnapshots (snapshotData) {
+ this.#snapshots.clear()
+
+ if (Array.isArray(snapshotData)) {
+ for (const { hash, snapshot } of snapshotData) {
+ this.#snapshots.set(hash, snapshot)
+ }
+ } else if (snapshotData && typeof snapshotData === 'object') {
+ // Legacy object format
+ this.#snapshots = new Map(Object.entries(snapshotData))
+ }
+ }
+
+ /**
+ * Starts the auto-flush timer
+ * @returns {void}
+ */
+ #startAutoFlush () {
+ return this.#scheduleFlush()
+ }
+
+ /**
+ * Stops the auto-flush timer
+ * @returns {void}
+ */
+ #stopAutoFlush () {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout)
+ // Ensure any pending flush is completed
+ this.saveSnapshots().catch(() => {
+ // Ignore flush errors
+ })
+ this.#flushTimeout = null
+ }
+ }
+
+ /**
+ * Schedules a flush (debounced to avoid excessive writes)
+ */
+ #scheduleFlush () {
+ this.#flushTimeout = setTimeout(() => {
+ this.saveSnapshots().catch(() => {
+ // Ignore flush errors
+ })
+ if (this.#autoFlush) {
+ this.#flushTimeout?.refresh()
+ } else {
+ this.#flushTimeout = null
+ }
+ }, 1000) // 1 second debounce
+ }
+
+ /**
+ * Cleanup method to stop timers
+ * @returns {void}
+ */
+ destroy () {
+ this.#stopAutoFlush()
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout)
+ this.#flushTimeout = null
+ }
+ }
+
+ /**
+ * Async close method that saves all recordings and performs cleanup
+ * @returns {Promise<void>}
+ */
+ async close () {
+ // Save any pending recordings if we have a snapshot path
+ if (this.#snapshotPath && this.#snapshots.size !== 0) {
+ await this.saveSnapshots()
+ }
+
+ // Perform cleanup
+ this.destroy()
+ }
+}
+
+module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }