diff --git a/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md b/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md new file mode 100644 index 00000000000..9e578f3791b --- /dev/null +++ b/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/spec-api" + - "@typespec/spector" + - "@typespec/http-specs" +--- + +Add matcher framework for flexible value comparison in scenarios. `match.dateTime()` enables semantic datetime comparison that handles precision and timezone differences across languages. diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index 00d21397026..1472f9ac2f3 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,244 +1,149 @@ -import { - CollectionFormat, - json, - MockRequest, - passOnSuccess, - ScenarioMockApi, - validateValueFormat, - ValidationError, -} from "@typespec/spec-api"; +import { json, match, MockRequest, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api"; export const Scenarios: Record = {}; function createQueryServerTests( uri: string, - paramData: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, - collectionFormat?: CollectionFormat, + format: "rfc7231" | "rfc3339" | undefined, ) { return passOnSuccess({ uri, method: "get", request: { - query: paramData, + query: { value: format ? match.dateTime[format](value) : value }, }, response: { status: 204, }, - handler(req: MockRequest) { - if (format) { - validateValueFormat(req.query["value"] as string, format); - if (Date.parse(req.query["value"] as string) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.query["value"]); - } - } else { - req.expect.containsQueryParam("value", value, collectionFormat); - } - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Query_default = createQueryServerTests( "/encode/datetime/query/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Query_rfc3339 = createQueryServerTests( "/encode/datetime/query/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Query_rfc7231 = createQueryServerTests( "/encode/datetime/query/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Query_unixTimestamp = createQueryServerTests( "/encode/datetime/query/unix-timestamp", - { - value: 1686566864, - }, - undefined, "1686566864", + undefined, ); Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests( "/encode/datetime/query/unix-timestamp-array", - { - value: [1686566864, 1686734256].join(","), - }, + [1686566864, 1686734256].join(","), undefined, - ["1686566864", "1686734256"], - "csv", ); function createPropertyServerTests( uri: string, - data: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | undefined, ) { + const matcherBody = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "post", request: { - body: json(data), + body: json(matcherBody), }, response: { status: 200, - }, - handler: (req: MockRequest) => { - if (format) { - validateValueFormat(req.body["value"], format); - if (Date.parse(req.body["value"]) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.body["value"]); - } - } else { - req.expect.coercedBodyEquals({ value: value }); - } - return { - status: 200, - body: json({ value: value }), - }; + body: json(matcherBody), }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Property_default = createPropertyServerTests( "/encode/datetime/property/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Property_rfc3339 = createPropertyServerTests( "/encode/datetime/property/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Property_rfc7231 = createPropertyServerTests( "/encode/datetime/property/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Property_unixTimestamp = createPropertyServerTests( "/encode/datetime/property/unix-timestamp", - { - value: 1686566864, - }, - undefined, 1686566864, + undefined, ); Scenarios.Encode_Datetime_Property_unixTimestampArray = createPropertyServerTests( "/encode/datetime/property/unix-timestamp-array", - { - value: [1686566864, 1686734256], - }, - undefined, [1686566864, 1686734256], + undefined, ); function createHeaderServerTests( uri: string, - data: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | undefined, ) { + const matcherHeaders = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "get", request: { - headers: data, + headers: matcherHeaders, }, response: { status: 204, }, - handler(req: MockRequest) { - if (format) { - validateValueFormat(req.headers["value"], format); - if (Date.parse(req.headers["value"]) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.headers["value"]); - } - } else { - req.expect.containsHeader("value", value); - } - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Header_default = createHeaderServerTests( "/encode/datetime/header/default", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Header_rfc3339 = createHeaderServerTests( "/encode/datetime/header/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Header_rfc7231 = createHeaderServerTests( "/encode/datetime/header/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Header_unixTimestamp = createHeaderServerTests( "/encode/datetime/header/unix-timestamp", - { - value: 1686566864, - }, + 1686566864, undefined, - "1686566864", ); Scenarios.Encode_Datetime_Header_unixTimestampArray = createHeaderServerTests( "/encode/datetime/header/unix-timestamp-array", - { - value: [1686566864, 1686734256].join(","), - }, + [1686566864, 1686734256].join(","), undefined, - "1686566864,1686734256", ); -function createResponseHeaderServerTests(uri: string, data: any, value: any) { +function createResponseHeaderServerTests(uri: string, value: any) { return passOnSuccess({ uri, method: "get", request: {}, response: { status: 204, - headers: data, + headers: { value }, }, handler: (req: MockRequest) => { return { status: 204, - headers: { value: value }, + headers: { value }, }; }, kind: "MockApiDefinition", @@ -246,29 +151,17 @@ function createResponseHeaderServerTests(uri: string, data: any, value: any) { } Scenarios.Encode_Datetime_ResponseHeader_default = createResponseHeaderServerTests( "/encode/datetime/responseheader/default", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, "Fri, 26 Aug 2022 14:38:00 GMT", ); Scenarios.Encode_Datetime_ResponseHeader_rfc3339 = createResponseHeaderServerTests( "/encode/datetime/responseheader/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, "2022-08-26T18:38:00.000Z", ); Scenarios.Encode_Datetime_ResponseHeader_rfc7231 = createResponseHeaderServerTests( "/encode/datetime/responseheader/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, "Fri, 26 Aug 2022 14:38:00 GMT", ); Scenarios.Encode_Datetime_ResponseHeader_unixTimestamp = createResponseHeaderServerTests( "/encode/datetime/responseheader/unix-timestamp", - { - value: "1686566864", - }, - 1686566864, + "1686566864", ); diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index 47176f7a53f..f8a97e5d341 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -1,4 +1,4 @@ -import deepEqual from "deep-equal"; +import { matchValues } from "./matchers.js"; import { validateBodyEmpty, validateBodyEquals, @@ -89,8 +89,9 @@ export class RequestExpectation { * @param expected Expected value */ public deepEqual(actual: unknown, expected: unknown, message = "Values not deep equal"): void { - if (!deepEqual(actual, expected, { strict: true })) { - throw new ValidationError(message, expected, actual); + const result = matchValues(actual, expected); + if (!result.pass) { + throw new ValidationError(`${message}: ${result.message}`, expected, actual); } } diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 68e8d112df5..6e374850193 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,3 +1,5 @@ +export { match } from "./match.js"; +export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts new file mode 100644 index 00000000000..2bca55de4f5 --- /dev/null +++ b/packages/spec-api/src/match.ts @@ -0,0 +1,73 @@ +import { err, MatcherSymbol, type MatchResult, type MockValueMatcher, ok } from "./matchers.js"; + +const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; +const rfc7231Pattern = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s\d{2}\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT$/i; + +function createDateTimeMatcher( + value: string, + label: string, + formatName: string, + formatPattern: RegExp, +): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`${label}: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!formatPattern.test(actual)) { + return err(`${label}: expected ${formatName} format but got "${actual}"`); + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return err( + `${label}: value "${actual}" matches ${formatName} format but is not a valid date`, + ); + } + if (actualMs !== expectedMs) { + return err( + `${label}: timestamps differ — expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`, + ); + } + return ok(); + }, + toJSON(): string { + return value; + }, + toString(): string { + return `${label}(${value})`; + }, + }; +} + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Matchers for comparing datetime values semantically. + * Validates that the actual value is in the correct format and represents + * the same point in time as the expected value. + * + * @example + * ```ts + * match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") + * match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT") + * ``` + */ + dateTime: { + rfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern); + }, + rfc7231(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); + }, + }, +}; diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts new file mode 100644 index 00000000000..4ec08e53d40 --- /dev/null +++ b/packages/spec-api/src/matchers.ts @@ -0,0 +1,156 @@ +/** + * Matcher framework for Spector mock API validation. + * + * Matchers are special objects that can be placed anywhere in an expected value tree. + * The comparison engine recognizes them and delegates to `matcher.check(actual)` + * instead of doing strict equality — enabling flexible comparisons for types like + * datetime that serialize differently across languages. + */ + +/** Symbol used to identify matcher objects */ +export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); + +/** Result of a match operation */ +export type MatchResult = { pass: true } | { pass: false; message: string }; + +const OK: MatchResult = Object.freeze({ pass: true }); + +/** Create a passing match result */ +export function ok(): MatchResult { + return OK; +} + +/** Create a failing match result with a message */ +export function err(message: string): MatchResult { + return { pass: false, message }; +} + +/** + * Interface for custom value matchers. + * Implement this to create new matcher types. + */ +export interface MockValueMatcher { + readonly [MatcherSymbol]: true; + /** Check whether the actual value matches the expectation */ + check(actual: unknown): MatchResult; + /** The raw value to use when serializing (e.g., in JSON.stringify) */ + toJSON(): T; + /** Human-readable description for error messages */ + toString(): string; +} + +/** Type guard to check if a value is a MockValueMatcher */ +export function isMatcher(value: unknown): value is MockValueMatcher { + return ( + typeof value === "object" && + value !== null && + MatcherSymbol in value && + (value as any)[MatcherSymbol] === true + ); +} + +function formatValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return `"${value}"`; + if (Buffer.isBuffer(value)) return `Buffer(${value.length})`; + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function pathErr(message: string, path: string): MatchResult { + const prefix = path ? `at ${path}: ` : ""; + return err(`${prefix}${message}`); +} + +/** + * Recursively compares actual vs expected values. + * When a MockValueMatcher is encountered in the expected tree, delegates to matcher.check(). + * Otherwise uses strict equality semantics (same as deep-equal with strict: true). + */ +export function matchValues(actual: unknown, expected: unknown, path: string = "$"): MatchResult { + if (expected === actual) { + return ok(); + } + + if (isMatcher(expected)) { + const result = expected.check(actual); + if (!result.pass) { + return pathErr(result.message, path); + } + return result; + } + + if (typeof expected !== typeof actual) { + return pathErr( + `Type mismatch: expected ${typeof expected} but got ${typeof actual} (${formatValue(actual)})`, + path, + ); + } + + if (expected === null || actual === null) { + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return pathErr(`Expected an array but got ${formatValue(actual)}`, path); + } + if (expected.length !== actual.length) { + return pathErr( + `Array length mismatch: expected ${expected.length} but got ${actual.length}`, + path, + ); + } + for (let i = 0; i < expected.length; i++) { + const result = matchValues(actual[i], expected[i], `${path}[${i}]`); + if (!result.pass) { + return result; + } + } + return ok(); + } + + if (Buffer.isBuffer(expected)) { + if (!Buffer.isBuffer(actual)) { + return pathErr(`Expected a Buffer but got ${typeof actual}`, path); + } + if (!expected.equals(actual)) { + return pathErr(`Buffer contents differ`, path); + } + return ok(); + } + + if (typeof expected === "object") { + const expectedObj = expected as Record; + const actualObj = actual as Record; + + const expectedKeys = Object.keys(expectedObj); + const actualKeys = Object.keys(actualObj); + + if (expectedKeys.length !== actualKeys.length) { + const missing = expectedKeys.filter((k) => !(k in actualObj)); + const extra = actualKeys.filter((k) => !(k in expectedObj)); + const parts: string[] = [ + `Key count mismatch: expected ${expectedKeys.length} but got ${actualKeys.length}`, + ]; + if (missing.length > 0) parts.push(`missing: [${missing.join(", ")}]`); + if (extra.length > 0) parts.push(`extra: [${extra.join(", ")}]`); + return pathErr(parts.join(". "), path); + } + + for (const key of expectedKeys) { + if (!(key in actualObj)) { + return pathErr(`Missing key "${key}"`, path); + } + const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`); + if (!result.pass) { + return result; + } + } + return ok(); + } + + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); +} diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index 223b439ce2a..1b40f705ded 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -1,6 +1,7 @@ import deepEqual from "deep-equal"; import * as prettier from "prettier"; import { parseString } from "xml2js"; +import { matchValues } from "./matchers.js"; import { CollectionFormat, RequestExt } from "./types.js"; import { ValidationError } from "./validation-error.js"; @@ -37,8 +38,13 @@ export const validateBodyEquals = ( return; } - if (!deepEqual(request.body, expectedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); + const result = matchValues(request.body, expectedBody); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedBody, + request.body, + ); } }; @@ -85,8 +91,13 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!deepEqual(coerceDate(request.body), expectedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); + const result = matchValues(coerceDate(request.body), expectedBody); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedBody, + request.body, + ); } }; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 49a8bf797cc..8e64c7a1196 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -1,3 +1,4 @@ +import { isMatcher } from "./matchers.js"; import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js"; /** @@ -18,6 +19,9 @@ function createResolver(content: unknown): Resolver { const expanded = expandDyns(content, config); return JSON.stringify(expanded); }, + resolve: (config: ResolverConfig) => { + return expandDyns(content, config); + }, }; } @@ -95,6 +99,9 @@ export function expandDyns(value: T, config: ResolverConfig): T { } else if (Array.isArray(value)) { return value.map((v) => expandDyns(v, config)) as any; } else if (typeof value === "object" && value !== null) { + if (isMatcher(value)) { + return value as any; + } const obj = value as Record; return Object.fromEntries( Object.entries(obj).map(([key, v]) => [key, expandDyns(v, config)]), diff --git a/packages/spec-api/src/types.ts b/packages/spec-api/src/types.ts index 4841caf8886..c04347a476c 100644 --- a/packages/spec-api/src/types.ts +++ b/packages/spec-api/src/types.ts @@ -110,6 +110,8 @@ export interface ResolverConfig { export interface Resolver { serialize(config: ResolverConfig): string; + /** Returns the expanded content with matchers preserved (for comparison). */ + resolve(config: ResolverConfig): unknown; } export interface MockMultipartBody { diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts new file mode 100644 index 00000000000..4f48d0c7d84 --- /dev/null +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../src/match.js"; +import { + err, + isMatcher, + type MatchResult, + matchValues, + MockValueMatcher, + ok, +} from "../src/matchers.js"; +import { expandDyns, json } from "../src/response-utils.js"; +import { ResolverConfig } from "../src/types.js"; + +describe("isMatcher", () => { + it("should return true for a matcher", () => { + expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); + }); + + it("should return false for plain values", () => { + expect(isMatcher("hello")).toBe(false); + expect(isMatcher(42)).toBe(false); + expect(isMatcher(null)).toBe(false); + expect(isMatcher(undefined)).toBe(false); + expect(isMatcher({ a: 1 })).toBe(false); + expect(isMatcher([1, 2])).toBe(false); + }); +}); + +function expectPass(result: MatchResult) { + expect(result).toEqual({ pass: true }); +} + +function expectFail(result: MatchResult, messagePattern?: string | RegExp) { + expect(result.pass).toBe(false); + if (!result.pass && messagePattern) { + if (typeof messagePattern === "string") { + expect(result.message).toContain(messagePattern); + } else { + expect(result.message).toMatch(messagePattern); + } + } +} + +describe("matchValues", () => { + describe("plain values (same as deepEqual)", () => { + it("should match identical primitives", () => { + expectPass(matchValues("hello", "hello")); + expectPass(matchValues(42, 42)); + expectPass(matchValues(true, true)); + expectPass(matchValues(null, null)); + }); + + it("should not match different primitives", () => { + expectFail(matchValues("hello", "world")); + expectFail(matchValues(42, 43)); + expectFail(matchValues(true, false)); + expectFail(matchValues(null, undefined)); + }); + + it("should not match different types", () => { + expectFail(matchValues("42", 42), "Type mismatch"); + expectFail(matchValues(0, false), "Type mismatch"); + expectFail(matchValues("", null)); + }); + + it("should match identical objects", () => { + expectPass(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })); + }); + + it("should not match objects with different keys", () => { + expectFail(matchValues({ a: 1 }, { a: 1, b: 2 }), "Key count mismatch"); + expectFail(matchValues({ a: 1, b: 2 }, { a: 1 }), "Key count mismatch"); + }); + + it("should match identical arrays", () => { + expectPass(matchValues([1, 2, 3], [1, 2, 3])); + }); + + it("should not match arrays of different lengths", () => { + expectFail(matchValues([1, 2], [1, 2, 3]), "Array length mismatch"); + }); + + it("should match nested objects", () => { + expectPass(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })); + }); + + it("should not match nested objects with differences", () => { + expectFail(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })); + }); + }); + + describe("error messages include path", () => { + it("should include path for nested object mismatch", () => { + const result = matchValues({ a: { b: "wrong" } }, { a: { b: "right" } }); + expectFail(result, "at $.a.b:"); + }); + + it("should include path for array element mismatch", () => { + const result = matchValues([1, 2, "wrong"], [1, 2, "right"]); + expectFail(result, "at $[2]:"); + }); + + it("should include path for deeply nested mismatch", () => { + const result = matchValues( + { data: { items: [{ name: "wrong" }] } }, + { data: { items: [{ name: "right" }] } }, + ); + expectFail(result, "at $.data.items[0].name:"); + }); + + it("should report missing keys", () => { + const result = matchValues({ a: 1 }, { a: 1, b: 2 }); + expectFail(result, "missing: [b]"); + }); + + it("should report extra keys", () => { + const result = matchValues({ a: 1, b: 2 }, { a: 1 }); + expectFail(result, "extra: [b]"); + }); + }); + + describe("with matchers", () => { + it("should delegate to matcher.check() in top-level position", () => { + const matcher: MockValueMatcher = { + [Symbol.for("SpectorMatcher")]: true as const, + check: (actual: any) => + actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`), + toJSON: () => "raw", + toString: () => "custom", + } as any; + expectPass(matchValues("matched", matcher)); + expectFail(matchValues("not-matched", matcher)); + }); + + it("should handle matchers nested in objects", () => { + const expected = { + name: "test", + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), + }; + expectPass(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)); + }); + + it("should handle matchers nested in arrays", () => { + const expected = [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), "plain"]; + expectPass(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)); + }); + + it("should handle deeply nested matchers", () => { + const expected = { + data: { + items: [{ created: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), name: "item1" }], + }, + }; + const actual = { + data: { + items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], + }, + }; + expectPass(matchValues(actual, expected)); + }); + + it("should include path in matcher failure message", () => { + const expected = { + data: { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }, + }; + const actual = { data: { timestamp: "not-rfc3339" } }; + const result = matchValues(actual, expected); + expectFail(result, "at $.data.timestamp:"); + expectFail(result, "rfc3339 format"); + }); + }); +}); + +describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should preserve matchers through expandDyns", () => { + const content = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.value)).toBe(true); + }); + + it("should preserve matchers in arrays through expandDyns", () => { + const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.items[0])).toBe(true); + }); +}); + +describe("integration with json() Resolver", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should serialize matchers to their raw value via serialize()", () => { + const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }); + const raw = (body.rawContent as any).serialize(config); + expect(raw).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + + it("should preserve matchers via resolve()", () => { + const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }); + const resolved = (body.rawContent as any).resolve(config) as Record; + expect(isMatcher(resolved.value)).toBe(true); + }); +}); diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts new file mode 100644 index 00000000000..447d5cc8787 --- /dev/null +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/match.js"; +import { type MatchResult } from "../../src/matchers.js"; + +function expectPass(result: MatchResult) { + expect(result).toEqual({ pass: true }); +} + +function expectFail(result: MatchResult, messagePattern?: string | RegExp) { + expect(result.pass).toBe(false); + if (!result.pass && messagePattern) { + if (typeof messagePattern === "string") { + expect(result.message).toContain(messagePattern); + } else { + expect(result.message).toMatch(messagePattern); + } + } +} + +describe("match.dateTime.rfc3339()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.rfc3339("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime.rfc3339("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expectPass(matcher.check("2022-08-26T18:38:00.000Z")); + }); + + it("should match without fractional seconds", () => { + expectPass(matcher.check("2022-08-26T18:38:00Z")); + }); + + it("should match with extra precision", () => { + expectPass(matcher.check("2022-08-26T18:38:00.0000000Z")); + }); + + it("should match with 1 fractional digit", () => { + expectPass(matcher.check("2022-08-26T18:38:00.0Z")); + }); + + it("should match with 2 fractional digits", () => { + expectPass(matcher.check("2022-08-26T18:38:00.00Z")); + }); + + it("should match with +00:00 offset instead of Z", () => { + expectPass(matcher.check("2022-08-26T18:38:00.000+00:00")); + }); + + it("should match equivalent time in a different timezone offset", () => { + expectPass(matcher.check("2022-08-26T14:38:00.000-04:00")); + }); + + it("should reject RFC 7231 format even if same point in time", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "rfc3339 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ"); + }); + + it("should not match off by one second", () => { + expectFail(matcher.check("2022-08-26T18:38:01.000Z"), "timestamps differ"); + }); + + it("should not match different date same time", () => { + expectFail(matcher.check("2022-08-27T18:38:00.000Z"), "timestamps differ"); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); + expectFail(matcher.check(undefined), "expected a string but got undefined"); + expectFail(matcher.check(true), "expected a string but got boolean"); + expectFail(matcher.check({}), "expected a string but got object"); + expectFail(matcher.check([]), "expected a string but got object"); + }); + + it("should not match empty string", () => { + expectFail(matcher.check(""), "rfc3339 format"); + }); + + it("should not match invalid datetime strings", () => { + expectFail(matcher.check("not-a-date"), "rfc3339 format"); + }); + }); + + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); + + it("should match exact milliseconds", () => { + expectPass(matcher.check("2022-08-26T18:38:00.123Z")); + }); + + it("should match with trailing zeros", () => { + expectPass(matcher.check("2022-08-26T18:38:00.1230000Z")); + }); + + it("should not match truncated milliseconds", () => { + expectFail(matcher.check("2022-08-26T18:38:00Z"), "timestamps differ"); + }); + + it("should not match different milliseconds", () => { + expectFail(matcher.check("2022-08-26T18:38:00.124Z"), "timestamps differ"); + }); + }); + + describe("with midnight edge case", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expectPass(matcher.check("2022-08-26T00:00:00Z")); + }); + + it("should match midnight with offset expressing previous day", () => { + expectPass(matcher.check("2022-08-25T20:00:00-04:00")); + }); + }); + + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toJSON()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + }); + + describe("toString()", () => { + it("should include rfc3339 in toString()", () => { + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime.rfc3339(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); + +describe("match.dateTime.rfc7231()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.rfc7231("not-a-date")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); + + it("should match exact same string", () => { + expectPass(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")); + }); + + it("should reject RFC 3339 format even if same point in time", () => { + expectFail(matcher.check("2022-08-26T14:38:00.000Z"), "rfc7231 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT"), "timestamps differ"); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); + }); + }); + + describe("toJSON()", () => { + it("should preserve RFC 7231 format", () => { + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); + }); + }); + + describe("toString()", () => { + it("should include rfc7231 in toString()", () => { + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").toString()).toBe( + "match.dateTime.rfc7231(Fri, 26 Aug 2022 14:38:00 GMT)", + ); + }); + }); +}); diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index 6cd60ddbdf3..6bb41a9f1d3 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -1,11 +1,11 @@ import { expandDyns, + matchValues, MockApiDefinition, MockBody, ResolverConfig, ValidationError, } from "@typespec/spec-api"; -import deepEqual from "deep-equal"; import micromatch from "micromatch"; import { inspect } from "node:util"; import pc from "picocolors"; @@ -79,28 +79,44 @@ class ServerTestsGenerator { async #validateBody(response: Response, body: MockBody) { if (Buffer.isBuffer(body.rawContent)) { const responseData = Buffer.from(await response.arrayBuffer()); - if (!deepEqual(responseData, body.rawContent)) { - throw new ValidationError(`Raw body mismatch`, body.rawContent, responseData); + const result = matchValues(responseData, body.rawContent); + if (!result.pass) { + throw new ValidationError( + `Raw body mismatch: ${result.message}`, + body.rawContent, + responseData, + ); } } else { const responseData = await response.text(); - const raw = - typeof body.rawContent === "string" - ? body.rawContent - : body.rawContent?.serialize(this.resolverConfig); switch (body.contentType) { case "application/xml": - case "text/plain": - if (body.rawContent !== responseData) { + case "text/plain": { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(this.resolverConfig); + if (raw !== responseData) { throw new ValidationError("Response data mismatch", raw, responseData); } break; - case "application/json": - const expected = JSON.parse(raw as any); + } + case "application/json": { + const expected = + typeof body.rawContent === "string" + ? JSON.parse(body.rawContent) + : body.rawContent?.resolve(this.resolverConfig); const actual = JSON.parse(responseData); - if (!deepEqual(actual, expected, { strict: true })) { - throw new ValidationError("Response data mismatch", expected, actual); + const result = matchValues(actual, expected); + if (!result.pass) { + throw new ValidationError( + `Response data mismatch: ${result.message}`, + expected, + actual, + ); } + break; + } } } } diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 329e08de767..36949904466 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -1,5 +1,6 @@ import { expandDyns, + isMatcher, MockApiDefinition, MockBody, MockMultipartBody, @@ -7,6 +8,7 @@ import { RequestExt, ResolverConfig, ScenarioMockApi, + ValidationError, } from "@typespec/spec-api"; import { ScenariosMetadata } from "@typespec/spec-coverage-sdk"; import { Response, Router } from "express"; @@ -105,19 +107,32 @@ function validateBody( if (Buffer.isBuffer(body.rawContent)) { req.expect.rawBodyEquals(body.rawContent); } else { - const raw = - typeof body.rawContent === "string" ? body.rawContent : body.rawContent?.serialize(config); switch (body.contentType) { - case "application/json": - req.expect.coercedBodyEquals(JSON.parse(raw as any)); + case "application/json": { + const expected = + typeof body.rawContent === "string" + ? JSON.parse(body.rawContent) + : body.rawContent?.resolve(config); + req.expect.coercedBodyEquals(expected); break; - case "application/xml": + } + case "application/xml": { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(config); req.expect.xmlBodyEquals( (raw as any).replace(``, ""), ); break; - default: + } + default: { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(config); req.expect.rawBodyEquals(raw); + } } } } @@ -136,7 +151,17 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) const headers = expandDyns(apiDefinition.request.headers, config); Object.entries(headers).forEach(([key, value]) => { if (key.toLowerCase() !== "content-type") { - if (Array.isArray(value)) { + if (isMatcher(value)) { + const actual = req.headers[key.toLowerCase()]; + const result = value.check(actual); + if (!result.pass) { + throw new ValidationError( + `Header "${key}": ${result.message}`, + value.toString(), + actual, + ); + } + } else if (Array.isArray(value)) { req.expect.deepEqual(req.headers[key], value); } else { req.expect.containsHeader(key.toLowerCase(), String(value)); @@ -146,8 +171,19 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) } if (apiDefinition.request?.query) { - Object.entries(apiDefinition.request.query).forEach(([key, value]) => { - if (Array.isArray(value)) { + const query = expandDyns(apiDefinition.request.query, config); + Object.entries(query).forEach(([key, value]) => { + if (isMatcher(value)) { + const actual = req.query[key]; + const result = value.check(actual); + if (!result.pass) { + throw new ValidationError( + `Query param "${key}": ${result.message}`, + value.toString(), + actual, + ); + } + } else if (Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { req.expect.containsQueryParam(key, String(value));