matching.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { Option, Schema } from "effect"
  2. import { REDACTED, secretFindings } from "./redaction.js"
  3. import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
  4. const JsonValue = Schema.fromJsonString(Schema.Unknown)
  5. export const decodeJson = Schema.decodeUnknownOption(JsonValue)
  6. const isRecord = (value: unknown): value is Record<string, unknown> =>
  7. value !== null && typeof value === "object" && !Array.isArray(value)
  8. export const canonicalizeJson = (value: unknown): unknown => {
  9. if (Array.isArray(value)) return value.map(canonicalizeJson)
  10. if (isRecord(value)) {
  11. return Object.fromEntries(
  12. Object.keys(value)
  13. .toSorted()
  14. .map((key) => [key, canonicalizeJson(value[key])]),
  15. )
  16. }
  17. return value
  18. }
  19. export type { RequestMatcher } from "./types.js"
  20. export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
  21. JSON.stringify({
  22. method: snapshot.method,
  23. url: snapshot.url,
  24. headers: canonicalizeJson(snapshot.headers),
  25. body: Option.match(decodeJson(snapshot.body), {
  26. onNone: () => snapshot.body,
  27. onSome: canonicalizeJson,
  28. }),
  29. })
  30. export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
  31. canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
  32. export const safeText = (value: unknown) => {
  33. if (value === undefined) return "undefined"
  34. if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
  35. const text = JSON.stringify(value)
  36. if (!text) return typeof value
  37. return text.length > 300 ? `${text.slice(0, 300)}...` : text
  38. }
  39. const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
  40. const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
  41. if (Object.is(expected, received)) return []
  42. if (isRecord(expected) && isRecord(received)) {
  43. return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
  44. .toSorted()
  45. .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
  46. .slice(0, limit)
  47. }
  48. if (Array.isArray(expected) && Array.isArray(received)) {
  49. return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
  50. .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
  51. .slice(0, limit)
  52. }
  53. return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
  54. }
  55. const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
  56. [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
  57. if (expected[key] === received[key]) return []
  58. if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
  59. if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
  60. return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
  61. })
  62. export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
  63. const lines: string[] = []
  64. if (expected.method !== received.method) {
  65. lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
  66. }
  67. if (expected.url !== received.url) {
  68. lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
  69. }
  70. const headers = headerDiffs(expected.headers, received.headers)
  71. if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
  72. const expectedBody = jsonBody(expected.body)
  73. const receivedBody = jsonBody(received.body)
  74. const body =
  75. expectedBody !== undefined && receivedBody !== undefined
  76. ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
  77. : expected.body === received.body
  78. ? []
  79. : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
  80. if (body.length > 0) lines.push("body:", ...body)
  81. return lines
  82. }
  83. export const selectSequential = (
  84. interactions: ReadonlyArray<HttpInteraction>,
  85. incoming: RequestSnapshot,
  86. match: RequestMatcher,
  87. index: number,
  88. ): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
  89. const interaction = interactions[index]
  90. if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
  91. if (!match(incoming, interaction.request))
  92. return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
  93. return { interaction, detail: "" }
  94. }