| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- import fs from "fs"
- import { diff } from "jest-diff"
- import _ from "lodash"
- import path from "path"
- export function loadJson(filePath: string): any {
- return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf-8"))
- }
- export function pretty(obj: any): string {
- return JSON.stringify(obj, null, 2)
- }
- /**
- * Recursively normalizes an object or array by:
- * 1. Ignoring specified fields.
- * 2. Sorting arrays in a stable manner.
- * 3. Parsing JSON strings where possible.
- *
- * This ensures that comparison between objects/arrays is consistent
- * and ignores non-deterministic or irrelevant fields.
- *
- * @param obj - The object/array/value to normalize.
- * @param ignoreFields - List of field names or dot-paths to ignore during normalization.
- * @param parentPath - Internal use for tracking nested paths (used for ignoreFields).
- * @returns A normalized object/array/value suitable for comparison.
- */
- function normalize(obj: any, ignoreFields: string[] = [], parentPath = ""): any {
- if (Array.isArray(obj)) {
- // Normalize each element, then sort in a stable way
- const normalizedArray = obj.map((item) => normalize(item, ignoreFields, parentPath))
- // Sort array by JSON stringification (works for objects & primitives)
- return normalizedArray.sort((a, b) => {
- const sa = JSON.stringify(a)
- const sb = JSON.stringify(b)
- return sa < sb ? -1 : sa > sb ? 1 : 0
- })
- }
- if (obj && typeof obj === "object") {
- const result: Record<string, any> = {}
- for (const [k, v] of Object.entries(obj)) {
- const currentPath = parentPath ? `${parentPath}.${k}` : k
- if (ignoreFields.includes(currentPath) || ignoreFields.includes(k)) {
- continue
- }
- result[k] = normalize(v, ignoreFields, currentPath)
- }
- return result
- }
- if (typeof obj === "string") {
- try {
- return normalize(JSON.parse(obj), ignoreFields, parentPath)
- } catch {
- return obj
- }
- }
- return obj
- }
- /**
- * Recursively picks only the keys specified in `filter` from `actual`.
- * This is used for partial comparison of objects and arrays.
- *
- * Behavior:
- * 1. For objects: keeps only keys present in `filter`, recursively.
- * 2. For arrays: assumes `filter` is an array and picks corresponding keys
- * from each element in `actual` array.
- * 3. For primitives: returns the actual value.
- *
- * @param actual - The full object/array/value received.
- * @param filter - The subset of keys/structure to keep from `actual`.
- * @returns A new object/array/value that only contains keys from `filter`.
- *
- * Example:
- * actual = {
- * a: 1,
- * b: { x: 10, y: 20 },
- * c: [{ id: 1, val: "x" }, { id: 2, val: "y" }]
- * }
- *
- * filter = {
- * b: { y: 20 },
- * c: [{ val: "x" }]
- * }
- *
- * Result:
- * {
- * b: { y: 20 },
- * c: [{ val: "x" }, undefined]
- * }
- */
- function pickDeep(actual: any, filter: any): any {
- if (_.isArray(filter)) {
- if (!_.isArray(actual)) return actual
- // Compare arrays element by element, picking only keys from filter
- return filter.map((f, i) => pickDeep(actual[i], f))
- }
- if (_.isPlainObject(filter)) {
- return _.mapValues(filter, (v, k) => (actual && k in actual ? pickDeep(actual[k], v) : undefined))
- }
- return actual
- }
- /**
- * Compares an actual response against an expected response, with optional:
- * - Ignored fields
- * - Partial comparison (via expectedSubset)
- *
- * Behavior:
- * 1. Normalizes actual and expected objects (sorting arrays, parsing JSON strings).
- * 2. If expectedSubset is provided, only compares the keys/structure defined in it.
- * 3. Returns a boolean success flag and an array of diffs (for reporting mismatches).
- *
- * @param actual - The response received from gRPC call.
- * @param expected - The full expected response from the spec file.
- * @param ignoreFields - Fields or paths to ignore in comparison.
- * @param expectedSubset - Optional subset of fields to validate (for meta.expected).
- * @returns Object with `success` (true/false) and `diffs` (array of string diffs).
- */
- export function compareResponse(
- actual: any,
- expected: any,
- ignoreFields: string[] = [],
- expectedSubset?: any,
- ): { success: boolean; diffs: string[] } {
- const actualToCompare = normalize(actual, ignoreFields)
- const expectedToCompare = normalize(expected, ignoreFields)
- if (expectedSubset) {
- // Extract only the subset we care about
- const actualSubset = pickDeep(actualToCompare, expectedSubset)
- const success = _.isEqual(actualSubset, expectedSubset)
- if (!success) {
- const difference = diff(expectedSubset, actualSubset, { expand: false })
- return { success: false, diffs: [difference || "Objects differ"] }
- }
- return { success: true, diffs: [] }
- }
- // Fallback: full comparison
- const success = _.isEqual(actualToCompare, expectedToCompare)
- if (!success) {
- const difference = diff(expectedToCompare, actualToCompare, { expand: false })
- return { success: false, diffs: [difference || "Objects differ"] }
- }
- return { success: true, diffs: [] }
- }
- /**
- * Retries a given asynchronous function up to a specified number of times.
- *
- * @template T - The type of the resolved value.
- * @param fn - The async function to execute.
- * @param retries - Maximum number of attempts before throwing the last error (default: 3).
- * @param delayMs - Delay (in milliseconds) between retries (default: 100).
- * @returns A promise that resolves with the function result if successful.
- * @throws The last encountered error if all retries fail.
- *
- * @example
- * await retry(() => fetchData(), 5, 200)
- */
- export async function retry<T>(fn: () => Promise<T>, retries = 3, delayMs = 100): Promise<T> {
- let lastError: any
- for (let attempt = 1; attempt <= retries; attempt++) {
- try {
- return await fn()
- } catch (err) {
- lastError = err
- if (attempt < retries) {
- console.warn(`⚠️ Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
- await new Promise((r) => setTimeout(r, delayMs))
- }
- }
- }
- throw lastError
- }
|