aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/mock/snapshot-agent.js
diff options
context:
space:
mode:
Diffstat (limited to 'vanilla/node_modules/undici/lib/mock/snapshot-agent.js')
-rw-r--r--vanilla/node_modules/undici/lib/mock/snapshot-agent.js353
1 files changed, 353 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/mock/snapshot-agent.js b/vanilla/node_modules/undici/lib/mock/snapshot-agent.js
new file mode 100644
index 0000000..8028011
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/mock/snapshot-agent.js
@@ -0,0 +1,353 @@
+'use strict'
+
+const Agent = require('../dispatcher/agent')
+const MockAgent = require('./mock-agent')
+const { SnapshotRecorder } = require('./snapshot-recorder')
+const WrapHandler = require('../handler/wrap-handler')
+const { InvalidArgumentError, UndiciError } = require('../core/errors')
+const { validateSnapshotMode } = require('./snapshot-utils')
+
+const kSnapshotRecorder = Symbol('kSnapshotRecorder')
+const kSnapshotMode = Symbol('kSnapshotMode')
+const kSnapshotPath = Symbol('kSnapshotPath')
+const kSnapshotLoaded = Symbol('kSnapshotLoaded')
+const kRealAgent = Symbol('kRealAgent')
+
+// Static flag to ensure warning is only emitted once per process
+let warningEmitted = false
+
+class SnapshotAgent extends MockAgent {
+ constructor (opts = {}) {
+ // Emit experimental warning only once
+ if (!warningEmitted) {
+ process.emitWarning(
+ 'SnapshotAgent is experimental and subject to change',
+ 'ExperimentalWarning'
+ )
+ warningEmitted = true
+ }
+
+ const {
+ mode = 'record',
+ snapshotPath = null,
+ ...mockAgentOpts
+ } = opts
+
+ super(mockAgentOpts)
+
+ validateSnapshotMode(mode)
+
+ // Validate snapshotPath is provided when required
+ if ((mode === 'playback' || mode === 'update') && !snapshotPath) {
+ throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`)
+ }
+
+ this[kSnapshotMode] = mode
+ this[kSnapshotPath] = snapshotPath
+
+ this[kSnapshotRecorder] = new SnapshotRecorder({
+ snapshotPath: this[kSnapshotPath],
+ mode: this[kSnapshotMode],
+ maxSnapshots: opts.maxSnapshots,
+ autoFlush: opts.autoFlush,
+ flushInterval: opts.flushInterval,
+ matchHeaders: opts.matchHeaders,
+ ignoreHeaders: opts.ignoreHeaders,
+ excludeHeaders: opts.excludeHeaders,
+ matchBody: opts.matchBody,
+ matchQuery: opts.matchQuery,
+ caseSensitive: opts.caseSensitive,
+ shouldRecord: opts.shouldRecord,
+ shouldPlayback: opts.shouldPlayback,
+ excludeUrls: opts.excludeUrls
+ })
+ this[kSnapshotLoaded] = false
+
+ // For recording/update mode, we need a real agent to make actual requests
+ // For playback mode, we need a real agent if there are excluded URLs
+ if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update' ||
+ (this[kSnapshotMode] === 'playback' && opts.excludeUrls && opts.excludeUrls.length > 0)) {
+ this[kRealAgent] = new Agent(opts)
+ }
+
+ // Auto-load snapshots in playback/update mode
+ if ((this[kSnapshotMode] === 'playback' || this[kSnapshotMode] === 'update') && this[kSnapshotPath]) {
+ this.loadSnapshots().catch(() => {
+ // Ignore load errors - file might not exist yet
+ })
+ }
+ }
+
+ dispatch (opts, handler) {
+ handler = WrapHandler.wrap(handler)
+ const mode = this[kSnapshotMode]
+
+ // Check if URL should be excluded (pass through without mocking/recording)
+ if (this[kSnapshotRecorder].isUrlExcluded(opts)) {
+ // Real agent is guaranteed by constructor when excludeUrls is configured
+ return this[kRealAgent].dispatch(opts, handler)
+ }
+
+ if (mode === 'playback' || mode === 'update') {
+ // Ensure snapshots are loaded
+ if (!this[kSnapshotLoaded]) {
+ // Need to load asynchronously, delegate to async version
+ return this.#asyncDispatch(opts, handler)
+ }
+
+ // Try to find existing snapshot (synchronous)
+ const snapshot = this[kSnapshotRecorder].findSnapshot(opts)
+
+ if (snapshot) {
+ // Use recorded response (synchronous)
+ return this.#replaySnapshot(snapshot, handler)
+ } else if (mode === 'update') {
+ // Make real request and record it (async required)
+ return this.#recordAndReplay(opts, handler)
+ } else {
+ // Playback mode but no snapshot found
+ const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`)
+ if (handler.onError) {
+ handler.onError(error)
+ return
+ }
+ throw error
+ }
+ } else if (mode === 'record') {
+ // Record mode - make real request and save response (async required)
+ return this.#recordAndReplay(opts, handler)
+ }
+ }
+
+ /**
+ * Async version of dispatch for when we need to load snapshots first
+ */
+ async #asyncDispatch (opts, handler) {
+ await this.loadSnapshots()
+ return this.dispatch(opts, handler)
+ }
+
+ /**
+ * Records a real request and replays the response
+ */
+ #recordAndReplay (opts, handler) {
+ const responseData = {
+ statusCode: null,
+ headers: {},
+ trailers: {},
+ body: []
+ }
+
+ const self = this // Capture 'this' context for use within nested handler callbacks
+
+ const recordingHandler = {
+ onRequestStart (controller, context) {
+ return handler.onRequestStart(controller, { ...context, history: this.history })
+ },
+
+ onRequestUpgrade (controller, statusCode, headers, socket) {
+ return handler.onRequestUpgrade(controller, statusCode, headers, socket)
+ },
+
+ onResponseStart (controller, statusCode, headers, statusMessage) {
+ responseData.statusCode = statusCode
+ responseData.headers = headers
+ return handler.onResponseStart(controller, statusCode, headers, statusMessage)
+ },
+
+ onResponseData (controller, chunk) {
+ responseData.body.push(chunk)
+ return handler.onResponseData(controller, chunk)
+ },
+
+ onResponseEnd (controller, trailers) {
+ responseData.trailers = trailers
+
+ // Record the interaction using captured 'self' context (fire and forget)
+ const responseBody = Buffer.concat(responseData.body)
+ self[kSnapshotRecorder].record(opts, {
+ statusCode: responseData.statusCode,
+ headers: responseData.headers,
+ body: responseBody,
+ trailers: responseData.trailers
+ })
+ .then(() => handler.onResponseEnd(controller, trailers))
+ .catch((error) => handler.onResponseError(controller, error))
+ }
+ }
+
+ // Use composed agent if available (includes interceptors), otherwise use real agent
+ const agent = this[kRealAgent]
+ return agent.dispatch(opts, recordingHandler)
+ }
+
+ /**
+ * Replays a recorded response
+ *
+ * @param {Object} snapshot - The recorded snapshot to replay.
+ * @param {Object} handler - The handler to call with the response data.
+ * @returns {void}
+ */
+ #replaySnapshot (snapshot, handler) {
+ try {
+ const { response } = snapshot
+
+ const controller = {
+ pause () { },
+ resume () { },
+ abort (reason) {
+ this.aborted = true
+ this.reason = reason
+ },
+
+ aborted: false,
+ paused: false
+ }
+
+ handler.onRequestStart(controller)
+
+ handler.onResponseStart(controller, response.statusCode, response.headers)
+
+ // Body is always stored as base64 string
+ const body = Buffer.from(response.body, 'base64')
+ handler.onResponseData(controller, body)
+
+ handler.onResponseEnd(controller, response.trailers)
+ } catch (error) {
+ handler.onError?.(error)
+ }
+ }
+
+ /**
+ * Loads snapshots from file
+ *
+ * @param {string} [filePath] - Optional file path to load snapshots from.
+ * @returns {Promise<void>} - Resolves when snapshots are loaded.
+ */
+ async loadSnapshots (filePath) {
+ await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath])
+ this[kSnapshotLoaded] = true
+
+ // In playback mode, set up MockAgent interceptors for all snapshots
+ if (this[kSnapshotMode] === 'playback') {
+ this.#setupMockInterceptors()
+ }
+ }
+
+ /**
+ * Saves snapshots to file
+ *
+ * @param {string} [filePath] - Optional file path to save snapshots to.
+ * @returns {Promise<void>} - Resolves when snapshots are saved.
+ */
+ async saveSnapshots (filePath) {
+ return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath])
+ }
+
+ /**
+ * Sets up MockAgent interceptors based on recorded snapshots.
+ *
+ * This method creates MockAgent interceptors for each recorded snapshot,
+ * allowing the SnapshotAgent to fall back to MockAgent's standard intercept
+ * mechanism in playback mode. Each interceptor is configured to persist
+ * (remain active for multiple requests) and responds with the recorded
+ * response data.
+ *
+ * Called automatically when loading snapshots in playback mode.
+ *
+ * @returns {void}
+ */
+ #setupMockInterceptors () {
+ for (const snapshot of this[kSnapshotRecorder].getSnapshots()) {
+ const { request, responses, response } = snapshot
+ const url = new URL(request.url)
+
+ const mockPool = this.get(url.origin)
+
+ // Handle both new format (responses array) and legacy format (response object)
+ const responseData = responses ? responses[0] : response
+ if (!responseData) continue
+
+ mockPool.intercept({
+ path: url.pathname + url.search,
+ method: request.method,
+ headers: request.headers,
+ body: request.body
+ }).reply(responseData.statusCode, responseData.body, {
+ headers: responseData.headers,
+ trailers: responseData.trailers
+ }).persist()
+ }
+ }
+
+ /**
+ * Gets the snapshot recorder
+ * @return {SnapshotRecorder} - The snapshot recorder instance
+ */
+ getRecorder () {
+ return this[kSnapshotRecorder]
+ }
+
+ /**
+ * Gets the current mode
+ * @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode
+ */
+ getMode () {
+ return this[kSnapshotMode]
+ }
+
+ /**
+ * Clears all snapshots
+ * @returns {void}
+ */
+ clearSnapshots () {
+ this[kSnapshotRecorder].clear()
+ }
+
+ /**
+ * Resets call counts for all snapshots (useful for test cleanup)
+ * @returns {void}
+ */
+ resetCallCounts () {
+ this[kSnapshotRecorder].resetCallCounts()
+ }
+
+ /**
+ * Deletes a specific snapshot by request options
+ * @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot
+ * @return {Promise<boolean>} - Returns true if the snapshot was deleted, false if not found
+ */
+ deleteSnapshot (requestOpts) {
+ return this[kSnapshotRecorder].deleteSnapshot(requestOpts)
+ }
+
+ /**
+ * Gets information about a specific snapshot
+ * @returns {import('./snapshot-recorder').SnapshotInfo|null} - Snapshot information or null if not found
+ */
+ getSnapshotInfo (requestOpts) {
+ return this[kSnapshotRecorder].getSnapshotInfo(requestOpts)
+ }
+
+ /**
+ * Replaces all snapshots with new data (full replacement)
+ * @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record<string, import('./snapshot-recorder').SnapshotEntry>} snapshotData - New snapshot data to replace existing snapshots
+ * @returns {void}
+ */
+ replaceSnapshots (snapshotData) {
+ this[kSnapshotRecorder].replaceSnapshots(snapshotData)
+ }
+
+ /**
+ * Closes the agent, saving snapshots and cleaning up resources.
+ *
+ * @returns {Promise<void>}
+ */
+ async close () {
+ await this[kSnapshotRecorder].close()
+ await this[kRealAgent]?.close()
+ await super.close()
+ }
+}
+
+module.exports = SnapshotAgent