snapshot-utils.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. 'use strict'
  2. const { InvalidArgumentError } = require('../core/errors')
  3. const { runtimeFeatures } = require('../util/runtime-features.js')
  4. /**
  5. * @typedef {Object} HeaderFilters
  6. * @property {Set<string>} ignore - Set of headers to ignore for matching
  7. * @property {Set<string>} exclude - Set of headers to exclude from matching
  8. * @property {Set<string>} match - Set of headers to match (empty means match
  9. */
  10. /**
  11. * Creates cached header sets for performance
  12. *
  13. * @param {import('./snapshot-recorder').SnapshotRecorderMatchOptions} matchOptions - Matching options for headers
  14. * @returns {HeaderFilters} - Cached sets for ignore, exclude, and match headers
  15. */
  16. function createHeaderFilters (matchOptions = {}) {
  17. const { ignoreHeaders = [], excludeHeaders = [], matchHeaders = [], caseSensitive = false } = matchOptions
  18. return {
  19. ignore: new Set(ignoreHeaders.map(header => caseSensitive ? header : header.toLowerCase())),
  20. exclude: new Set(excludeHeaders.map(header => caseSensitive ? header : header.toLowerCase())),
  21. match: new Set(matchHeaders.map(header => caseSensitive ? header : header.toLowerCase()))
  22. }
  23. }
  24. const crypto = runtimeFeatures.has('crypto')
  25. ? require('node:crypto')
  26. : null
  27. /**
  28. * @callback HashIdFunction
  29. * @param {string} value - The value to hash
  30. * @returns {string} - The base64url encoded hash of the value
  31. */
  32. /**
  33. * Generates a hash for a given value
  34. * @type {HashIdFunction}
  35. */
  36. const hashId = crypto?.hash
  37. ? (value) => crypto.hash('sha256', value, 'base64url')
  38. : (value) => Buffer.from(value).toString('base64url')
  39. /**
  40. * @typedef {(url: string) => boolean} IsUrlExcluded Checks if a URL matches any of the exclude patterns
  41. */
  42. /** @typedef {{[key: Lowercase<string>]: string}} NormalizedHeaders */
  43. /** @typedef {Array<string>} UndiciHeaders */
  44. /** @typedef {Record<string, string|string[]>} Headers */
  45. /**
  46. * @param {*} headers
  47. * @returns {headers is UndiciHeaders}
  48. */
  49. function isUndiciHeaders (headers) {
  50. return Array.isArray(headers) && (headers.length & 1) === 0
  51. }
  52. /**
  53. * Factory function to create a URL exclusion checker
  54. * @param {Array<string| RegExp>} [excludePatterns=[]] - Array of patterns to exclude
  55. * @returns {IsUrlExcluded} - A function that checks if a URL matches any of the exclude patterns
  56. */
  57. function isUrlExcludedFactory (excludePatterns = []) {
  58. if (excludePatterns.length === 0) {
  59. return () => false
  60. }
  61. return function isUrlExcluded (url) {
  62. let urlLowerCased
  63. for (const pattern of excludePatterns) {
  64. if (typeof pattern === 'string') {
  65. if (!urlLowerCased) {
  66. // Convert URL to lowercase only once
  67. urlLowerCased = url.toLowerCase()
  68. }
  69. // Simple string match (case-insensitive)
  70. if (urlLowerCased.includes(pattern.toLowerCase())) {
  71. return true
  72. }
  73. } else if (pattern instanceof RegExp) {
  74. // Regex pattern match
  75. if (pattern.test(url)) {
  76. return true
  77. }
  78. }
  79. }
  80. return false
  81. }
  82. }
  83. /**
  84. * Normalizes headers for consistent comparison
  85. *
  86. * @param {Object|UndiciHeaders} headers - Headers to normalize
  87. * @returns {NormalizedHeaders} - Normalized headers as a lowercase object
  88. */
  89. function normalizeHeaders (headers) {
  90. /** @type {NormalizedHeaders} */
  91. const normalizedHeaders = {}
  92. if (!headers) return normalizedHeaders
  93. // Handle array format (undici internal format: [name, value, name, value, ...])
  94. if (isUndiciHeaders(headers)) {
  95. for (let i = 0; i < headers.length; i += 2) {
  96. const key = headers[i]
  97. const value = headers[i + 1]
  98. if (key && value !== undefined) {
  99. // Convert Buffers to strings if needed
  100. const keyStr = Buffer.isBuffer(key) ? key.toString() : key
  101. const valueStr = Buffer.isBuffer(value) ? value.toString() : value
  102. normalizedHeaders[keyStr.toLowerCase()] = valueStr
  103. }
  104. }
  105. return normalizedHeaders
  106. }
  107. // Handle object format
  108. if (headers && typeof headers === 'object') {
  109. for (const [key, value] of Object.entries(headers)) {
  110. if (key && typeof key === 'string') {
  111. normalizedHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
  112. }
  113. }
  114. }
  115. return normalizedHeaders
  116. }
  117. const validSnapshotModes = /** @type {const} */ (['record', 'playback', 'update'])
  118. /** @typedef {typeof validSnapshotModes[number]} SnapshotMode */
  119. /**
  120. * @param {*} mode - The snapshot mode to validate
  121. * @returns {asserts mode is SnapshotMode}
  122. */
  123. function validateSnapshotMode (mode) {
  124. if (!validSnapshotModes.includes(mode)) {
  125. throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be one of: ${validSnapshotModes.join(', ')}`)
  126. }
  127. }
  128. module.exports = {
  129. createHeaderFilters,
  130. hashId,
  131. isUndiciHeaders,
  132. normalizeHeaders,
  133. isUrlExcludedFactory,
  134. validateSnapshotMode
  135. }