snapshot-agent.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. 'use strict'
  2. const Agent = require('../dispatcher/agent')
  3. const MockAgent = require('./mock-agent')
  4. const { SnapshotRecorder } = require('./snapshot-recorder')
  5. const WrapHandler = require('../handler/wrap-handler')
  6. const { InvalidArgumentError, UndiciError } = require('../core/errors')
  7. const { validateSnapshotMode } = require('./snapshot-utils')
  8. const kSnapshotRecorder = Symbol('kSnapshotRecorder')
  9. const kSnapshotMode = Symbol('kSnapshotMode')
  10. const kSnapshotPath = Symbol('kSnapshotPath')
  11. const kSnapshotLoaded = Symbol('kSnapshotLoaded')
  12. const kRealAgent = Symbol('kRealAgent')
  13. // Static flag to ensure warning is only emitted once per process
  14. let warningEmitted = false
  15. class SnapshotAgent extends MockAgent {
  16. constructor (opts = {}) {
  17. // Emit experimental warning only once
  18. if (!warningEmitted) {
  19. process.emitWarning(
  20. 'SnapshotAgent is experimental and subject to change',
  21. 'ExperimentalWarning'
  22. )
  23. warningEmitted = true
  24. }
  25. const {
  26. mode = 'record',
  27. snapshotPath = null,
  28. ...mockAgentOpts
  29. } = opts
  30. super(mockAgentOpts)
  31. validateSnapshotMode(mode)
  32. // Validate snapshotPath is provided when required
  33. if ((mode === 'playback' || mode === 'update') && !snapshotPath) {
  34. throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`)
  35. }
  36. this[kSnapshotMode] = mode
  37. this[kSnapshotPath] = snapshotPath
  38. this[kSnapshotRecorder] = new SnapshotRecorder({
  39. snapshotPath: this[kSnapshotPath],
  40. mode: this[kSnapshotMode],
  41. maxSnapshots: opts.maxSnapshots,
  42. autoFlush: opts.autoFlush,
  43. flushInterval: opts.flushInterval,
  44. matchHeaders: opts.matchHeaders,
  45. ignoreHeaders: opts.ignoreHeaders,
  46. excludeHeaders: opts.excludeHeaders,
  47. matchBody: opts.matchBody,
  48. matchQuery: opts.matchQuery,
  49. caseSensitive: opts.caseSensitive,
  50. shouldRecord: opts.shouldRecord,
  51. shouldPlayback: opts.shouldPlayback,
  52. excludeUrls: opts.excludeUrls
  53. })
  54. this[kSnapshotLoaded] = false
  55. // For recording/update mode, we need a real agent to make actual requests
  56. // For playback mode, we need a real agent if there are excluded URLs
  57. if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update' ||
  58. (this[kSnapshotMode] === 'playback' && opts.excludeUrls && opts.excludeUrls.length > 0)) {
  59. this[kRealAgent] = new Agent(opts)
  60. }
  61. // Auto-load snapshots in playback/update mode
  62. if ((this[kSnapshotMode] === 'playback' || this[kSnapshotMode] === 'update') && this[kSnapshotPath]) {
  63. this.loadSnapshots().catch(() => {
  64. // Ignore load errors - file might not exist yet
  65. })
  66. }
  67. }
  68. dispatch (opts, handler) {
  69. handler = WrapHandler.wrap(handler)
  70. const mode = this[kSnapshotMode]
  71. // Check if URL should be excluded (pass through without mocking/recording)
  72. if (this[kSnapshotRecorder].isUrlExcluded(opts)) {
  73. // Real agent is guaranteed by constructor when excludeUrls is configured
  74. return this[kRealAgent].dispatch(opts, handler)
  75. }
  76. if (mode === 'playback' || mode === 'update') {
  77. // Ensure snapshots are loaded
  78. if (!this[kSnapshotLoaded]) {
  79. // Need to load asynchronously, delegate to async version
  80. return this.#asyncDispatch(opts, handler)
  81. }
  82. // Try to find existing snapshot (synchronous)
  83. const snapshot = this[kSnapshotRecorder].findSnapshot(opts)
  84. if (snapshot) {
  85. // Use recorded response (synchronous)
  86. return this.#replaySnapshot(snapshot, handler)
  87. } else if (mode === 'update') {
  88. // Make real request and record it (async required)
  89. return this.#recordAndReplay(opts, handler)
  90. } else {
  91. // Playback mode but no snapshot found
  92. const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`)
  93. if (handler.onError) {
  94. handler.onError(error)
  95. return
  96. }
  97. throw error
  98. }
  99. } else if (mode === 'record') {
  100. // Record mode - make real request and save response (async required)
  101. return this.#recordAndReplay(opts, handler)
  102. }
  103. }
  104. /**
  105. * Async version of dispatch for when we need to load snapshots first
  106. */
  107. async #asyncDispatch (opts, handler) {
  108. await this.loadSnapshots()
  109. return this.dispatch(opts, handler)
  110. }
  111. /**
  112. * Records a real request and replays the response
  113. */
  114. #recordAndReplay (opts, handler) {
  115. const responseData = {
  116. statusCode: null,
  117. headers: {},
  118. trailers: {},
  119. body: []
  120. }
  121. const self = this // Capture 'this' context for use within nested handler callbacks
  122. const recordingHandler = {
  123. onRequestStart (controller, context) {
  124. return handler.onRequestStart(controller, { ...context, history: this.history })
  125. },
  126. onRequestUpgrade (controller, statusCode, headers, socket) {
  127. return handler.onRequestUpgrade(controller, statusCode, headers, socket)
  128. },
  129. onResponseStart (controller, statusCode, headers, statusMessage) {
  130. responseData.statusCode = statusCode
  131. responseData.headers = headers
  132. return handler.onResponseStart(controller, statusCode, headers, statusMessage)
  133. },
  134. onResponseData (controller, chunk) {
  135. responseData.body.push(chunk)
  136. return handler.onResponseData(controller, chunk)
  137. },
  138. onResponseEnd (controller, trailers) {
  139. responseData.trailers = trailers
  140. // Record the interaction using captured 'self' context (fire and forget)
  141. const responseBody = Buffer.concat(responseData.body)
  142. self[kSnapshotRecorder].record(opts, {
  143. statusCode: responseData.statusCode,
  144. headers: responseData.headers,
  145. body: responseBody,
  146. trailers: responseData.trailers
  147. })
  148. .then(() => handler.onResponseEnd(controller, trailers))
  149. .catch((error) => handler.onResponseError(controller, error))
  150. }
  151. }
  152. // Use composed agent if available (includes interceptors), otherwise use real agent
  153. const agent = this[kRealAgent]
  154. return agent.dispatch(opts, recordingHandler)
  155. }
  156. /**
  157. * Replays a recorded response
  158. *
  159. * @param {Object} snapshot - The recorded snapshot to replay.
  160. * @param {Object} handler - The handler to call with the response data.
  161. * @returns {void}
  162. */
  163. #replaySnapshot (snapshot, handler) {
  164. try {
  165. const { response } = snapshot
  166. const controller = {
  167. pause () { },
  168. resume () { },
  169. abort (reason) {
  170. this.aborted = true
  171. this.reason = reason
  172. },
  173. aborted: false,
  174. paused: false
  175. }
  176. handler.onRequestStart(controller)
  177. handler.onResponseStart(controller, response.statusCode, response.headers)
  178. // Body is always stored as base64 string
  179. const body = Buffer.from(response.body, 'base64')
  180. handler.onResponseData(controller, body)
  181. handler.onResponseEnd(controller, response.trailers)
  182. } catch (error) {
  183. handler.onError?.(error)
  184. }
  185. }
  186. /**
  187. * Loads snapshots from file
  188. *
  189. * @param {string} [filePath] - Optional file path to load snapshots from.
  190. * @returns {Promise<void>} - Resolves when snapshots are loaded.
  191. */
  192. async loadSnapshots (filePath) {
  193. await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath])
  194. this[kSnapshotLoaded] = true
  195. // In playback mode, set up MockAgent interceptors for all snapshots
  196. if (this[kSnapshotMode] === 'playback') {
  197. this.#setupMockInterceptors()
  198. }
  199. }
  200. /**
  201. * Saves snapshots to file
  202. *
  203. * @param {string} [filePath] - Optional file path to save snapshots to.
  204. * @returns {Promise<void>} - Resolves when snapshots are saved.
  205. */
  206. async saveSnapshots (filePath) {
  207. return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath])
  208. }
  209. /**
  210. * Sets up MockAgent interceptors based on recorded snapshots.
  211. *
  212. * This method creates MockAgent interceptors for each recorded snapshot,
  213. * allowing the SnapshotAgent to fall back to MockAgent's standard intercept
  214. * mechanism in playback mode. Each interceptor is configured to persist
  215. * (remain active for multiple requests) and responds with the recorded
  216. * response data.
  217. *
  218. * Called automatically when loading snapshots in playback mode.
  219. *
  220. * @returns {void}
  221. */
  222. #setupMockInterceptors () {
  223. for (const snapshot of this[kSnapshotRecorder].getSnapshots()) {
  224. const { request, responses, response } = snapshot
  225. const url = new URL(request.url)
  226. const mockPool = this.get(url.origin)
  227. // Handle both new format (responses array) and legacy format (response object)
  228. const responseData = responses ? responses[0] : response
  229. if (!responseData) continue
  230. mockPool.intercept({
  231. path: url.pathname + url.search,
  232. method: request.method,
  233. headers: request.headers,
  234. body: request.body
  235. }).reply(responseData.statusCode, responseData.body, {
  236. headers: responseData.headers,
  237. trailers: responseData.trailers
  238. }).persist()
  239. }
  240. }
  241. /**
  242. * Gets the snapshot recorder
  243. * @return {SnapshotRecorder} - The snapshot recorder instance
  244. */
  245. getRecorder () {
  246. return this[kSnapshotRecorder]
  247. }
  248. /**
  249. * Gets the current mode
  250. * @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode
  251. */
  252. getMode () {
  253. return this[kSnapshotMode]
  254. }
  255. /**
  256. * Clears all snapshots
  257. * @returns {void}
  258. */
  259. clearSnapshots () {
  260. this[kSnapshotRecorder].clear()
  261. }
  262. /**
  263. * Resets call counts for all snapshots (useful for test cleanup)
  264. * @returns {void}
  265. */
  266. resetCallCounts () {
  267. this[kSnapshotRecorder].resetCallCounts()
  268. }
  269. /**
  270. * Deletes a specific snapshot by request options
  271. * @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot
  272. * @return {Promise<boolean>} - Returns true if the snapshot was deleted, false if not found
  273. */
  274. deleteSnapshot (requestOpts) {
  275. return this[kSnapshotRecorder].deleteSnapshot(requestOpts)
  276. }
  277. /**
  278. * Gets information about a specific snapshot
  279. * @returns {import('./snapshot-recorder').SnapshotInfo|null} - Snapshot information or null if not found
  280. */
  281. getSnapshotInfo (requestOpts) {
  282. return this[kSnapshotRecorder].getSnapshotInfo(requestOpts)
  283. }
  284. /**
  285. * Replaces all snapshots with new data (full replacement)
  286. * @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record<string, import('./snapshot-recorder').SnapshotEntry>} snapshotData - New snapshot data to replace existing snapshots
  287. * @returns {void}
  288. */
  289. replaceSnapshots (snapshotData) {
  290. this[kSnapshotRecorder].replaceSnapshots(snapshotData)
  291. }
  292. /**
  293. * Closes the agent, saving snapshots and cleaning up resources.
  294. *
  295. * @returns {Promise<void>}
  296. */
  297. async close () {
  298. await this[kSnapshotRecorder].close()
  299. await this[kRealAgent]?.close()
  300. await super.close()
  301. }
  302. }
  303. module.exports = SnapshotAgent