utils.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import fs from "fs"
  2. import { diff } from "jest-diff"
  3. import _ from "lodash"
  4. import path from "path"
  5. export function loadJson(filePath: string): any {
  6. return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf-8"))
  7. }
  8. export function pretty(obj: any): string {
  9. return JSON.stringify(obj, null, 2)
  10. }
  11. /**
  12. * Recursively normalizes an object or array by:
  13. * 1. Ignoring specified fields.
  14. * 2. Sorting arrays in a stable manner.
  15. * 3. Parsing JSON strings where possible.
  16. *
  17. * This ensures that comparison between objects/arrays is consistent
  18. * and ignores non-deterministic or irrelevant fields.
  19. *
  20. * @param obj - The object/array/value to normalize.
  21. * @param ignoreFields - List of field names or dot-paths to ignore during normalization.
  22. * @param parentPath - Internal use for tracking nested paths (used for ignoreFields).
  23. * @returns A normalized object/array/value suitable for comparison.
  24. */
  25. function normalize(obj: any, ignoreFields: string[] = [], parentPath = ""): any {
  26. if (Array.isArray(obj)) {
  27. // Normalize each element, then sort in a stable way
  28. const normalizedArray = obj.map((item) => normalize(item, ignoreFields, parentPath))
  29. // Sort array by JSON stringification (works for objects & primitives)
  30. return normalizedArray.sort((a, b) => {
  31. const sa = JSON.stringify(a)
  32. const sb = JSON.stringify(b)
  33. return sa < sb ? -1 : sa > sb ? 1 : 0
  34. })
  35. }
  36. if (obj && typeof obj === "object") {
  37. const result: Record<string, any> = {}
  38. for (const [k, v] of Object.entries(obj)) {
  39. const currentPath = parentPath ? `${parentPath}.${k}` : k
  40. if (ignoreFields.includes(currentPath) || ignoreFields.includes(k)) {
  41. continue
  42. }
  43. result[k] = normalize(v, ignoreFields, currentPath)
  44. }
  45. return result
  46. }
  47. if (typeof obj === "string") {
  48. try {
  49. return normalize(JSON.parse(obj), ignoreFields, parentPath)
  50. } catch {
  51. return obj
  52. }
  53. }
  54. return obj
  55. }
  56. /**
  57. * Recursively picks only the keys specified in `filter` from `actual`.
  58. * This is used for partial comparison of objects and arrays.
  59. *
  60. * Behavior:
  61. * 1. For objects: keeps only keys present in `filter`, recursively.
  62. * 2. For arrays: assumes `filter` is an array and picks corresponding keys
  63. * from each element in `actual` array.
  64. * 3. For primitives: returns the actual value.
  65. *
  66. * @param actual - The full object/array/value received.
  67. * @param filter - The subset of keys/structure to keep from `actual`.
  68. * @returns A new object/array/value that only contains keys from `filter`.
  69. *
  70. * Example:
  71. * actual = {
  72. * a: 1,
  73. * b: { x: 10, y: 20 },
  74. * c: [{ id: 1, val: "x" }, { id: 2, val: "y" }]
  75. * }
  76. *
  77. * filter = {
  78. * b: { y: 20 },
  79. * c: [{ val: "x" }]
  80. * }
  81. *
  82. * Result:
  83. * {
  84. * b: { y: 20 },
  85. * c: [{ val: "x" }, undefined]
  86. * }
  87. */
  88. function pickDeep(actual: any, filter: any): any {
  89. if (_.isArray(filter)) {
  90. if (!_.isArray(actual)) return actual
  91. // Compare arrays element by element, picking only keys from filter
  92. return filter.map((f, i) => pickDeep(actual[i], f))
  93. }
  94. if (_.isPlainObject(filter)) {
  95. return _.mapValues(filter, (v, k) => (actual && k in actual ? pickDeep(actual[k], v) : undefined))
  96. }
  97. return actual
  98. }
  99. /**
  100. * Compares an actual response against an expected response, with optional:
  101. * - Ignored fields
  102. * - Partial comparison (via expectedSubset)
  103. *
  104. * Behavior:
  105. * 1. Normalizes actual and expected objects (sorting arrays, parsing JSON strings).
  106. * 2. If expectedSubset is provided, only compares the keys/structure defined in it.
  107. * 3. Returns a boolean success flag and an array of diffs (for reporting mismatches).
  108. *
  109. * @param actual - The response received from gRPC call.
  110. * @param expected - The full expected response from the spec file.
  111. * @param ignoreFields - Fields or paths to ignore in comparison.
  112. * @param expectedSubset - Optional subset of fields to validate (for meta.expected).
  113. * @returns Object with `success` (true/false) and `diffs` (array of string diffs).
  114. */
  115. export function compareResponse(
  116. actual: any,
  117. expected: any,
  118. ignoreFields: string[] = [],
  119. expectedSubset?: any,
  120. ): { success: boolean; diffs: string[] } {
  121. const actualToCompare = normalize(actual, ignoreFields)
  122. const expectedToCompare = normalize(expected, ignoreFields)
  123. if (expectedSubset) {
  124. // Extract only the subset we care about
  125. const actualSubset = pickDeep(actualToCompare, expectedSubset)
  126. const success = _.isEqual(actualSubset, expectedSubset)
  127. if (!success) {
  128. const difference = diff(expectedSubset, actualSubset, { expand: false })
  129. return { success: false, diffs: [difference || "Objects differ"] }
  130. }
  131. return { success: true, diffs: [] }
  132. }
  133. // Fallback: full comparison
  134. const success = _.isEqual(actualToCompare, expectedToCompare)
  135. if (!success) {
  136. const difference = diff(expectedToCompare, actualToCompare, { expand: false })
  137. return { success: false, diffs: [difference || "Objects differ"] }
  138. }
  139. return { success: true, diffs: [] }
  140. }
  141. /**
  142. * Retries a given asynchronous function up to a specified number of times.
  143. *
  144. * @template T - The type of the resolved value.
  145. * @param fn - The async function to execute.
  146. * @param retries - Maximum number of attempts before throwing the last error (default: 3).
  147. * @param delayMs - Delay (in milliseconds) between retries (default: 100).
  148. * @returns A promise that resolves with the function result if successful.
  149. * @throws The last encountered error if all retries fail.
  150. *
  151. * @example
  152. * await retry(() => fetchData(), 5, 200)
  153. */
  154. export async function retry<T>(fn: () => Promise<T>, retries = 3, delayMs = 100): Promise<T> {
  155. let lastError: any
  156. for (let attempt = 1; attempt <= retries; attempt++) {
  157. try {
  158. return await fn()
  159. } catch (err) {
  160. lastError = err
  161. if (attempt < retries) {
  162. console.warn(`⚠️ Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
  163. await new Promise((r) => setTimeout(r, delayMs))
  164. }
  165. }
  166. }
  167. throw lastError
  168. }