From 79e879d351353fc6654f7a4381c773880073bece Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:15:23 -0400 Subject: [PATCH 01/26] Spector datetime matchers --- ...eat-spector-matchers-2026-2-12-14-17-25.md | 9 + .../specs/encode/datetime/mockapi.ts | 31 +-- packages/spec-api/src/expectation.ts | 4 +- packages/spec-api/src/index.ts | 1 + packages/spec-api/src/matchers.ts | 136 ++++++++++++ packages/spec-api/src/request-validations.ts | 5 +- packages/spec-api/src/response-utils.ts | 7 + packages/spec-api/src/types.ts | 2 + packages/spec-api/test/matchers.test.ts | 201 ++++++++++++++++++ packages/spector/src/actions/server-test.ts | 28 ++- packages/spector/src/app/app.ts | 25 ++- 11 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 .chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md create mode 100644 packages/spec-api/src/matchers.ts create mode 100644 packages/spec-api/test/matchers.test.ts 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..c82870bc7b1 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,6 +1,7 @@ import { CollectionFormat, json, + match, MockRequest, passOnSuccess, ScenarioMockApi, @@ -89,6 +90,22 @@ function createPropertyServerTests( format: "rfc7231" | "rfc3339" | undefined, value: any, ) { + if (format) { + const matcherBody = { value: match.dateTime(data.value) }; + return passOnSuccess({ + uri, + method: "post", + request: { + body: json(matcherBody), + }, + response: { + status: 200, + body: json(matcherBody), + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "post", @@ -98,20 +115,6 @@ function createPropertyServerTests( 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 }), - }; - }, kind: "MockApiDefinition", }); } diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index 47176f7a53f..d273c724e82 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,7 +89,7 @@ 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 })) { + if (!matchValues(actual, expected)) { throw new ValidationError(message, expected, actual); } } diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 68e8d112df5..056ce90b673 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,3 +1,4 @@ +export { isMatcher, match, matchValues, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts new file mode 100644 index 00000000000..ad0a8cf1152 --- /dev/null +++ b/packages/spec-api/src/matchers.ts @@ -0,0 +1,136 @@ +/** + * 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 */ +const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); + +/** + * 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): boolean; + /** 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 + ); +} + +/** + * 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). + * + * @returns `true` if values match, `false` otherwise + */ +export function matchValues(actual: unknown, expected: unknown): boolean { + if (expected === actual) { + return true; + } + + if (isMatcher(expected)) { + return expected.check(actual); + } + + if (typeof expected !== typeof actual) { + return false; + } + + if (expected === null || actual === null) { + return false; + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return false; + } + if (expected.length !== actual.length) { + return false; + } + return expected.every((item, index) => matchValues(actual[index], item)); + } + + if (Buffer.isBuffer(expected)) { + return Buffer.isBuffer(actual) && expected.equals(actual); + } + + 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) { + return false; + } + + return expectedKeys.every( + (key) => key in actualObj && matchValues(actualObj[key], expectedObj[key]), + ); + } + + return false; +} + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Creates a matcher that compares datetime values semantically. + * Accepts any datetime string that represents the same point in time, + * regardless of precision or timezone format. + * + * @example + * ```ts + * match.dateTime("2022-08-26T18:38:00.000Z") + * // matches "2022-08-26T18:38:00Z" + * // matches "2022-08-26T18:38:00.000Z" + * // matches "2022-08-26T18:38:00.0000000Z" + * ``` + */ + dateTime(value: string): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`match.dateTime: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): boolean { + if (typeof actual !== "string") { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `match.dateTime(${value})`; + }, + }; + }, +}; diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index 223b439ce2a..e79f5d2603c 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,7 +38,7 @@ export const validateBodyEquals = ( return; } - if (!deepEqual(request.body, expectedBody, { strict: true })) { + if (!matchValues(request.body, expectedBody)) { throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); } }; @@ -85,7 +86,7 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!deepEqual(coerceDate(request.body), expectedBody, { strict: true })) { + if (!matchValues(coerceDate(request.body), expectedBody)) { throw new ValidationError(BODY_NOT_EQUAL_ERROR_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.test.ts b/packages/spec-api/test/matchers.test.ts new file mode 100644 index 00000000000..39bccad49d0 --- /dev/null +++ b/packages/spec-api/test/matchers.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { isMatcher, matchValues, match, MockValueMatcher } 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("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); + }); +}); + +describe("matchValues", () => { + describe("plain values (same as deepEqual)", () => { + it("should match identical primitives", () => { + expect(matchValues("hello", "hello")).toBe(true); + expect(matchValues(42, 42)).toBe(true); + expect(matchValues(true, true)).toBe(true); + expect(matchValues(null, null)).toBe(true); + }); + + it("should not match different primitives", () => { + expect(matchValues("hello", "world")).toBe(false); + expect(matchValues(42, 43)).toBe(false); + expect(matchValues(true, false)).toBe(false); + expect(matchValues(null, undefined)).toBe(false); + }); + + it("should not match different types", () => { + expect(matchValues("42", 42)).toBe(false); + expect(matchValues(0, false)).toBe(false); + expect(matchValues("", null)).toBe(false); + }); + + it("should match identical objects", () => { + expect(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })).toBe(true); + }); + + it("should not match objects with different keys", () => { + expect(matchValues({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(matchValues({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + it("should match identical arrays", () => { + expect(matchValues([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it("should not match arrays of different lengths", () => { + expect(matchValues([1, 2], [1, 2, 3])).toBe(false); + }); + + it("should match nested objects", () => { + expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })).toBe(true); + }); + + it("should not match nested objects with differences", () => { + expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })).toBe(false); + }); + }); + + describe("with matchers", () => { + it("should delegate to matcher.check() in top-level position", () => { + const matcher: MockValueMatcher = { + [Symbol.for("SpectorMatcher")]: true as const, + check: (actual) => actual === "matched", + toJSON: () => "raw", + toString: () => "custom", + }; + expect(matchValues("matched", matcher)).toBe(true); + expect(matchValues("not-matched", matcher)).toBe(false); + }); + + it("should handle matchers nested in objects", () => { + const expected = { + name: "test", + timestamp: match.dateTime("2022-08-26T18:38:00.000Z"), + }; + expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); + }); + + it("should handle matchers nested in arrays", () => { + const expected = [match.dateTime("2022-08-26T18:38:00.000Z"), "plain"]; + expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); + }); + + it("should handle deeply nested matchers", () => { + const expected = { + data: { + items: [{ created: match.dateTime("2022-08-26T18:38:00.000Z"), name: "item1" }], + }, + }; + const actual = { + data: { + items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], + }, + }; + expect(matchValues(actual, expected)).toBe(true); + }); + }); +}); + +describe("match.dateTime", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); + + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); + + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); + + it("should match with different fractional precision", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); + + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + }); + + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); + }); + + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("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("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); + +describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should preserve matchers through expandDyns", () => { + const content = { value: match.dateTime("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("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("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("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/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index 6cd60ddbdf3..739ecb8ebb3 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,34 @@ 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)) { + if (!matchValues(responseData, body.rawContent)) { throw new ValidationError(`Raw body mismatch`, 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 })) { + if (!matchValues(actual, expected)) { throw new ValidationError("Response data mismatch", expected, actual); } + break; + } } } } diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 329e08de767..918b238e2a4 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -105,19 +105,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); + } } } } From 91c88fff64bc8930580fb0c9941a4925f426af18 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:30:51 -0400 Subject: [PATCH 02/26] organize --- packages/spec-api/src/index.ts | 3 +- packages/spec-api/src/match.ts | 45 ++++++++++++++++++++++++ packages/spec-api/src/matchers.ts | 46 +------------------------ packages/spec-api/test/matchers.test.ts | 3 +- 4 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 packages/spec-api/src/match.ts diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 056ce90b673..1e6f79b7a9f 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,5 @@ -export { isMatcher, match, matchValues, type MockValueMatcher } from "./matchers.js"; +export { isMatcher, matchValues, type MockValueMatcher } from "./matchers.js"; +export { match } from "./match.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..6c4f5374285 --- /dev/null +++ b/packages/spec-api/src/match.ts @@ -0,0 +1,45 @@ +import { type MockValueMatcher, MatcherSymbol } from "./matchers.js"; + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Creates a matcher that compares datetime values semantically. + * Accepts any datetime string that represents the same point in time, + * regardless of precision or timezone format. + * + * @example + * ```ts + * match.dateTime("2022-08-26T18:38:00.000Z") + * // matches "2022-08-26T18:38:00Z" + * // matches "2022-08-26T18:38:00.000Z" + * // matches "2022-08-26T18:38:00.0000000Z" + * ``` + */ + dateTime(value: string): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`match.dateTime: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): boolean { + if (typeof actual !== "string") { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `match.dateTime(${value})`; + }, + }; + }, +}; diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index ad0a8cf1152..829ff0c50ef 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -8,7 +8,7 @@ */ /** Symbol used to identify matcher objects */ -const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); +export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); /** * Interface for custom value matchers. @@ -90,47 +90,3 @@ export function matchValues(actual: unknown, expected: unknown): boolean { return false; } - -/** - * Namespace for built-in matchers. - */ -export const match = { - /** - * Creates a matcher that compares datetime values semantically. - * Accepts any datetime string that represents the same point in time, - * regardless of precision or timezone format. - * - * @example - * ```ts - * match.dateTime("2022-08-26T18:38:00.000Z") - * // matches "2022-08-26T18:38:00Z" - * // matches "2022-08-26T18:38:00.000Z" - * // matches "2022-08-26T18:38:00.0000000Z" - * ``` - */ - dateTime(value: string): MockValueMatcher { - const expectedMs = Date.parse(value); - if (isNaN(expectedMs)) { - throw new Error(`match.dateTime: invalid datetime value: ${value}`); - } - return { - [MatcherSymbol]: true, - check(actual: unknown): boolean { - if (typeof actual !== "string") { - return false; - } - const actualMs = Date.parse(actual); - if (isNaN(actualMs)) { - return false; - } - return actualMs === expectedMs; - }, - toJSON(): string { - return value; - }, - toString(): string { - return `match.dateTime(${value})`; - }, - }; - }, -}; diff --git a/packages/spec-api/test/matchers.test.ts b/packages/spec-api/test/matchers.test.ts index 39bccad49d0..78ce63dd52e 100644 --- a/packages/spec-api/test/matchers.test.ts +++ b/packages/spec-api/test/matchers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isMatcher, matchValues, match, MockValueMatcher } from "../src/matchers.js"; +import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; +import { match } from "../src/match.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; From 280ca8a865c1b3ba2283e5c64460f28157daf344 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:42:54 -0400 Subject: [PATCH 03/26] tweaks --- ...tchers.test.ts => matchers-engine.test.ts} | 67 +-------- .../test/matchers/match-datetime.test.ts | 140 ++++++++++++++++++ 2 files changed, 143 insertions(+), 64 deletions(-) rename packages/spec-api/test/{matchers.test.ts => matchers-engine.test.ts} (71%) create mode 100644 packages/spec-api/test/matchers/match-datetime.test.ts diff --git a/packages/spec-api/test/matchers.test.ts b/packages/spec-api/test/matchers-engine.test.ts similarity index 71% rename from packages/spec-api/test/matchers.test.ts rename to packages/spec-api/test/matchers-engine.test.ts index 78ce63dd52e..1edf93908d4 100644 --- a/packages/spec-api/test/matchers.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; import { match } from "../src/match.js"; +import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; @@ -71,10 +71,10 @@ describe("matchValues", () => { it("should delegate to matcher.check() in top-level position", () => { const matcher: MockValueMatcher = { [Symbol.for("SpectorMatcher")]: true as const, - check: (actual) => actual === "matched", + check: (actual: any) => actual === "matched", toJSON: () => "raw", toString: () => "custom", - }; + } as any; expect(matchValues("matched", matcher)).toBe(true); expect(matchValues("not-matched", matcher)).toBe(false); }); @@ -108,67 +108,6 @@ describe("matchValues", () => { }); }); -describe("match.dateTime", () => { - it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); - }); - - describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); - - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); - - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); - - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); - - it("should match with different fractional precision", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); - - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); - - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - }); - - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); - }); - - describe("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("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("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); - }); - }); - - describe("toString()", () => { - it("should return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); - }); - }); -}); - describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; 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..2fcd8745381 --- /dev/null +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/match.js"; + +describe("match.dateTime", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); + + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); + + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); + + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); + + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); + + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); + + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); + + it("should match RFC 7231 format", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); + }); + + it("should match ISO 8601 against RFC 7231 expected", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); + + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); + + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); + + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); + + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); + + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); + }); + + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); + + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); + + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); + + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); + + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + }); + }); + + describe("with midnight edge case", () => { + const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); + + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + }); + }); + + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("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("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + + it("should preserve original format in toJSON for RFC 7231", () => { + expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); From 881e1ec977e9955eb4db0c924fffd47d33ecf5db Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:43:26 -0400 Subject: [PATCH 04/26] cleanup --- .../test/matchers/match-datetime.test.ts | 202 +++++++++--------- 1 file changed, 100 insertions(+), 102 deletions(-) diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 2fcd8745381..550cfd24778 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,140 +1,138 @@ import { describe, expect, it } from "vitest"; import { match } from "../../src/match.js"; -describe("match.dateTime", () => { - it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); - }); +it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); +}); - it("should throw for empty string", () => { - expect(() => match.dateTime("")).toThrow("invalid datetime value"); - }); +it("should throw for empty string", () => { + expect(() => match.dateTime("")).toThrow("invalid datetime value"); +}); - describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); +describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); - it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); - }); + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); - it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); - it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); - }); + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); - it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); - }); + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); - it("should match RFC 7231 format", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); - }); + it("should match RFC 7231 format", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); + }); - it("should match ISO 8601 against RFC 7231 expected", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); - }); + it("should match ISO 8601 against RFC 7231 expected", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); - it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); - }); + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); - it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); - }); + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); - }); + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); - it("should not match empty string", () => { - expect(matcher.check("")).toBe(false); - }); + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); }); +}); - describe("with non-zero milliseconds", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); +describe("with non-zero milliseconds", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); - it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); - }); + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); - it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); - }); + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); - it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); - }); + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); - it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); - }); + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); }); +}); - describe("with midnight edge case", () => { - const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); +describe("with midnight edge case", () => { + const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); - it("should match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); - }); + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); - it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); - }); + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); }); +}); - describe("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("2022-08-26T18:38:00.000Z"); - }); +describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("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("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); - }); + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); - it("should preserve original format in toJSON for RFC 7231", () => { - expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( - "Fri, 26 Aug 2022 14:38:00 GMT", - ); - }); + it("should preserve original format in toJSON for RFC 7231", () => { + expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); }); +}); - describe("toString()", () => { - it("should return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); - }); +describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); }); }); From 121d5ebabf8cd8733ae431f0b06e80b67c4e3073 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:16:55 -0400 Subject: [PATCH 05/26] cleanup --- .../specs/encode/datetime/mockapi.ts | 160 ++++++------------ 1 file changed, 56 insertions(+), 104 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index c82870bc7b1..baf68a8d473 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -5,37 +5,41 @@ import { MockRequest, passOnSuccess, ScenarioMockApi, - validateValueFormat, - ValidationError, } from "@typespec/spec-api"; export const Scenarios: Record = {}; function createQueryServerTests( uri: string, - paramData: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | undefined, collectionFormat?: CollectionFormat, ) { + if (format) { + return passOnSuccess({ + uri, + method: "get", + request: { + query: { value: match.dateTime(value) }, + }, + response: { + status: 204, + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "get", request: { - query: paramData, + query: { 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); - } + req.expect.containsQueryParam("value", value, collectionFormat); return { status: 204, }; @@ -45,53 +49,37 @@ function createQueryServerTests( } 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, ) { if (format) { - const matcherBody = { value: match.dateTime(data.value) }; + const matcherBody = { value: match.dateTime(value) }; return passOnSuccess({ uri, method: "post", @@ -110,7 +98,7 @@ function createPropertyServerTests( uri, method: "post", request: { - body: json(data), + body: json({ value }), }, response: { status: 200, @@ -120,68 +108,59 @@ function createPropertyServerTests( } 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, ) { + if (format) { + return passOnSuccess({ + uri, + method: "get", + request: { + headers: { value: match.dateTime(value) }, + }, + response: { + status: 204, + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "get", request: { - headers: data, + headers: { value }, }, 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); - } + req.expect.containsHeader("value", String(value)); return { status: 204, }; @@ -191,57 +170,42 @@ function createHeaderServerTests( } 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", @@ -249,29 +213,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", ); From ff3f3d20c19152ce8228cfc9351b1ddaf47d0f56 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:31:35 -0400 Subject: [PATCH 06/26] rfc7231 --- .../specs/encode/datetime/mockapi.ts | 6 +- packages/spec-api/src/match.ts | 80 ++++--- .../spec-api/test/matchers-engine.test.ts | 16 +- .../test/matchers/match-datetime.test.ts | 225 ++++++++++-------- 4 files changed, 190 insertions(+), 137 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index baf68a8d473..1e774e037d5 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -20,7 +20,7 @@ function createQueryServerTests( uri, method: "get", request: { - query: { value: match.dateTime(value) }, + query: { value: match.dateTime[format](value) }, }, response: { status: 204, @@ -79,7 +79,7 @@ function createPropertyServerTests( format: "rfc7231" | "rfc3339" | undefined, ) { if (format) { - const matcherBody = { value: match.dateTime(value) }; + const matcherBody = { value: match.dateTime[format](value) }; return passOnSuccess({ uri, method: "post", @@ -141,7 +141,7 @@ function createHeaderServerTests( uri, method: "get", request: { - headers: { value: match.dateTime(value) }, + headers: { value: match.dateTime[format](value) }, }, response: { status: 204, diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 6c4f5374285..4f1a1315707 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,45 +1,63 @@ import { type MockValueMatcher, MatcherSymbol } 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, + 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): boolean { + if (typeof actual !== "string") { + return false; + } + if (!formatPattern.test(actual)) { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `${label}(${value})`; + }, + }; +} + /** * Namespace for built-in matchers. */ export const match = { /** - * Creates a matcher that compares datetime values semantically. - * Accepts any datetime string that represents the same point in time, - * regardless of precision or timezone format. + * 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("2022-08-26T18:38:00.000Z") - * // matches "2022-08-26T18:38:00Z" - * // matches "2022-08-26T18:38:00.000Z" - * // matches "2022-08-26T18:38:00.0000000Z" + * match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") + * match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT") * ``` */ - dateTime(value: string): MockValueMatcher { - const expectedMs = Date.parse(value); - if (isNaN(expectedMs)) { - throw new Error(`match.dateTime: invalid datetime value: ${value}`); - } - return { - [MatcherSymbol]: true, - check(actual: unknown): boolean { - if (typeof actual !== "string") { - return false; - } - const actualMs = Date.parse(actual); - if (isNaN(actualMs)) { - return false; - } - return actualMs === expectedMs; - }, - toJSON(): string { - return value; - }, - toString(): string { - return `match.dateTime(${value})`; - }, - }; + dateTime: { + rfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc3339", rfc3339Pattern); + }, + rfc7231(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc7231", rfc7231Pattern); + }, }, }; diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index 1edf93908d4..da7b997f92f 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -6,7 +6,7 @@ import { ResolverConfig } from "../src/types.js"; describe("isMatcher", () => { it("should return true for a matcher", () => { - expect(isMatcher(match.dateTime("2022-08-26T18:38:00.000Z"))).toBe(true); + expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); }); it("should return false for plain values", () => { @@ -82,20 +82,20 @@ describe("matchValues", () => { it("should handle matchers nested in objects", () => { const expected = { name: "test", - timestamp: match.dateTime("2022-08-26T18:38:00.000Z"), + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), }; expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); }); it("should handle matchers nested in arrays", () => { - const expected = [match.dateTime("2022-08-26T18:38:00.000Z"), "plain"]; + const expected = [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), "plain"]; expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); }); it("should handle deeply nested matchers", () => { const expected = { data: { - items: [{ created: match.dateTime("2022-08-26T18:38:00.000Z"), name: "item1" }], + items: [{ created: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), name: "item1" }], }, }; const actual = { @@ -112,13 +112,13 @@ describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; it("should preserve matchers through expandDyns", () => { - const content = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + 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("2022-08-26T18:38:00.000Z")] }; + const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] }; const expanded = expandDyns(content, config); expect(isMatcher(expanded.items[0])).toBe(true); }); @@ -128,13 +128,13 @@ 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("2022-08-26T18:38:00.000Z") }); + 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("2022-08-26T18:38:00.000Z") }); + 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 index 550cfd24778..8de9876a612 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,138 +1,173 @@ import { describe, expect, it } from "vitest"; import { match } from "../../src/match.js"; -it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); -}); +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("")).toThrow("invalid datetime value"); -}); + it("should throw for empty string", () => { + expect(() => match.dateTime.rfc3339("")).toThrow("invalid datetime value"); + }); -describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + describe("check()", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); - it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); - }); + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); - it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); - it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); - }); + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); - it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); - }); + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); - it("should match RFC 7231 format", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); - }); + it("should reject RFC 7231 format even if same point in time", () => { + expect(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT")).toBe(false); + }); - it("should match ISO 8601 against RFC 7231 expected", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); - }); + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); - it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); - }); + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); - it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); - }); + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); - }); + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); - it("should not match empty string", () => { - expect(matcher.check("")).toBe(false); + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); }); - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); -}); + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); -describe("with non-zero milliseconds", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); - it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); - }); + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); - it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); - }); + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); - it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + }); }); - it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + describe("with midnight edge case", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); + + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + }); }); -}); -describe("with midnight edge case", () => { - const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); + 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 match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + 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"}'); + }); }); - it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + 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("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("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"); }); - it("should serialize correctly in JSON.stringify", () => { - const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + describe("check()", () => { + const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); + + it("should match exact same string", () => { + expect(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); + + it("should reject RFC 3339 format even if same point in time", () => { + expect(matcher.check("2022-08-26T14:38:00.000Z")).toBe(false); + }); + + it("should not match different time", () => { + expect(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + }); }); - it("should preserve original format in toJSON for RFC 7231", () => { - expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( - "Fri, 26 Aug 2022 14:38:00 GMT", - ); + 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 return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); + 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)", + ); + }); }); }); From b1e207d1dd592ce6f8c6c49789bf96f223cc7eb1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:34:12 -0400 Subject: [PATCH 07/26] Simplify some more --- .../specs/encode/datetime/mockapi.ts | 76 ++----------------- 1 file changed, 7 insertions(+), 69 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index 1e774e037d5..1472f9ac2f3 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,11 +1,4 @@ -import { - CollectionFormat, - json, - match, - MockRequest, - passOnSuccess, - ScenarioMockApi, -} from "@typespec/spec-api"; +import { json, match, MockRequest, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -13,37 +6,16 @@ function createQueryServerTests( uri: string, value: any, format: "rfc7231" | "rfc3339" | undefined, - collectionFormat?: CollectionFormat, ) { - if (format) { - return passOnSuccess({ - uri, - method: "get", - request: { - query: { value: match.dateTime[format](value) }, - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", - }); - } - return passOnSuccess({ uri, method: "get", request: { - query: { value }, + query: { value: format ? match.dateTime[format](value) : value }, }, response: { status: 204, }, - handler(req: MockRequest) { - req.expect.containsQueryParam("value", value, collectionFormat); - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } @@ -71,37 +43,22 @@ Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests( "/encode/datetime/query/unix-timestamp-array", [1686566864, 1686734256].join(","), undefined, - "csv", ); function createPropertyServerTests( uri: string, value: any, format: "rfc7231" | "rfc3339" | undefined, ) { - if (format) { - const matcherBody = { value: match.dateTime[format](value) }; - return passOnSuccess({ - uri, - method: "post", - request: { - body: json(matcherBody), - }, - response: { - status: 200, - body: json(matcherBody), - }, - kind: "MockApiDefinition", - }); - } - + const matcherBody = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "post", request: { - body: json({ value }), + body: json(matcherBody), }, response: { status: 200, + body: json(matcherBody), }, kind: "MockApiDefinition", }); @@ -136,35 +93,16 @@ function createHeaderServerTests( value: any, format: "rfc7231" | "rfc3339" | undefined, ) { - if (format) { - return passOnSuccess({ - uri, - method: "get", - request: { - headers: { value: match.dateTime[format](value) }, - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", - }); - } - + const matcherHeaders = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "get", request: { - headers: { value }, + headers: matcherHeaders, }, response: { status: 204, }, - handler(req: MockRequest) { - req.expect.containsHeader("value", String(value)); - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } From 34d4e6f45277e43282ee5775eb0aeff95aaaf88e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 18:29:27 -0400 Subject: [PATCH 08/26] better errors --- packages/spec-api/src/expectation.ts | 5 +- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 26 +++-- packages/spec-api/src/matchers.ts | 96 +++++++++++++--- packages/spec-api/src/request-validations.ts | 18 ++- .../spec-api/test/matchers-engine.test.ts | 106 +++++++++++++----- .../test/matchers/match-datetime.test.ts | 76 ++++++++----- packages/spector/src/actions/server-test.ts | 10 +- 8 files changed, 248 insertions(+), 91 deletions(-) diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index d273c724e82..f8a97e5d341 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -89,8 +89,9 @@ export class RequestExpectation { * @param expected Expected value */ public deepEqual(actual: unknown, expected: unknown, message = "Values not deep equal"): void { - if (!matchValues(actual, expected)) { - 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 1e6f79b7a9f..a3b52441519 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,4 @@ -export { isMatcher, matchValues, type MockValueMatcher } from "./matchers.js"; +export { isMatcher, matchValues, ok, err, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { match } from "./match.js"; export { MockRequest } from "./mock-request.js"; export { diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 4f1a1315707..16f97f2a600 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,4 @@ -import { type MockValueMatcher, MatcherSymbol } from "./matchers.js"; +import { type MockValueMatcher, type MatchResult, ok, err, MatcherSymbol } 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 = @@ -7,6 +7,7 @@ const rfc7231Pattern = function createDateTimeMatcher( value: string, label: string, + formatName: string, formatPattern: RegExp, ): MockValueMatcher { const expectedMs = Date.parse(value); @@ -15,18 +16,27 @@ function createDateTimeMatcher( } return { [MatcherSymbol]: true, - check(actual: unknown): boolean { + check(actual: unknown): MatchResult { if (typeof actual !== "string") { - return false; + return err( + `${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); } if (!formatPattern.test(actual)) { - return false; + return err(`${label}: expected ${formatName} format but got "${actual}"`); } const actualMs = Date.parse(actual); if (isNaN(actualMs)) { - return false; + return err( + `${label}: value "${actual}" matches ${formatName} format but is not a valid date`, + ); } - return actualMs === expectedMs; + 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; @@ -54,10 +64,10 @@ export const match = { */ dateTime: { rfc3339(value: string): MockValueMatcher { - return createDateTimeMatcher(value, "match.dateTime.rfc3339", rfc3339Pattern); + return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern); }, rfc7231(value: string): MockValueMatcher { - return createDateTimeMatcher(value, "match.dateTime.rfc7231", rfc7231Pattern); + return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); }, }, }; diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index 829ff0c50ef..cc399bfc9d3 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -10,6 +10,21 @@ /** 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. @@ -17,7 +32,7 @@ export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); export interface MockValueMatcher { readonly [MatcherSymbol]: true; /** Check whether the actual value matches the expectation */ - check(actual: unknown): boolean; + check(actual: unknown): MatchResult; /** The raw value to use when serializing (e.g., in JSON.stringify) */ toJSON(): T; /** Human-readable description for error messages */ @@ -34,42 +49,77 @@ export function isMatcher(value: unknown): value is MockValueMatcher { ); } +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). - * - * @returns `true` if values match, `false` otherwise */ -export function matchValues(actual: unknown, expected: unknown): boolean { +export function matchValues(actual: unknown, expected: unknown, path: string = "$"): MatchResult { if (expected === actual) { - return true; + return ok(); } if (isMatcher(expected)) { - return expected.check(actual); + const result = expected.check(actual); + if (!result.pass) { + return pathErr(result.message, path); + } + return result; } if (typeof expected !== typeof actual) { - return false; + return pathErr( + `Type mismatch: expected ${typeof expected} but got ${typeof actual} (${formatValue(actual)})`, + path, + ); } if (expected === null || actual === null) { - return false; + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); } if (Array.isArray(expected)) { if (!Array.isArray(actual)) { - return false; + return pathErr(`Expected an array but got ${formatValue(actual)}`, path); } if (expected.length !== actual.length) { - return false; + 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 expected.every((item, index) => matchValues(actual[index], item)); + return ok(); } if (Buffer.isBuffer(expected)) { - return Buffer.isBuffer(actual) && expected.equals(actual); + 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") { @@ -80,13 +130,25 @@ export function matchValues(actual: unknown, expected: unknown): boolean { const actualKeys = Object.keys(actualObj); if (expectedKeys.length !== actualKeys.length) { - return false; + 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); } - return expectedKeys.every( - (key) => key in actualObj && matchValues(actualObj[key], expectedObj[key]), - ); + 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 false; + 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 e79f5d2603c..1b40f705ded 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -38,8 +38,13 @@ export const validateBodyEquals = ( return; } - if (!matchValues(request.body, expectedBody)) { - 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, + ); } }; @@ -86,8 +91,13 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!matchValues(coerceDate(request.body), expectedBody)) { - 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/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index da7b997f92f..f4347707431 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { match } from "../src/match.js"; -import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; +import { isMatcher, matchValues, ok, err, type MatchResult, MockValueMatcher } from "../src/matchers.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; @@ -19,51 +19,96 @@ describe("isMatcher", () => { }); }); +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", () => { - expect(matchValues("hello", "hello")).toBe(true); - expect(matchValues(42, 42)).toBe(true); - expect(matchValues(true, true)).toBe(true); - expect(matchValues(null, null)).toBe(true); + expectPass(matchValues("hello", "hello")); + expectPass(matchValues(42, 42)); + expectPass(matchValues(true, true)); + expectPass(matchValues(null, null)); }); it("should not match different primitives", () => { - expect(matchValues("hello", "world")).toBe(false); - expect(matchValues(42, 43)).toBe(false); - expect(matchValues(true, false)).toBe(false); - expect(matchValues(null, undefined)).toBe(false); + expectFail(matchValues("hello", "world")); + expectFail(matchValues(42, 43)); + expectFail(matchValues(true, false)); + expectFail(matchValues(null, undefined)); }); it("should not match different types", () => { - expect(matchValues("42", 42)).toBe(false); - expect(matchValues(0, false)).toBe(false); - expect(matchValues("", null)).toBe(false); + expectFail(matchValues("42", 42), "Type mismatch"); + expectFail(matchValues(0, false), "Type mismatch"); + expectFail(matchValues("", null)); }); it("should match identical objects", () => { - expect(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })).toBe(true); + expectPass(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })); }); it("should not match objects with different keys", () => { - expect(matchValues({ a: 1 }, { a: 1, b: 2 })).toBe(false); - expect(matchValues({ a: 1, b: 2 }, { a: 1 })).toBe(false); + 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", () => { - expect(matchValues([1, 2, 3], [1, 2, 3])).toBe(true); + expectPass(matchValues([1, 2, 3], [1, 2, 3])); }); it("should not match arrays of different lengths", () => { - expect(matchValues([1, 2], [1, 2, 3])).toBe(false); + expectFail(matchValues([1, 2], [1, 2, 3]), "Array length mismatch"); }); it("should match nested objects", () => { - expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })).toBe(true); + expectPass(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })); }); it("should not match nested objects with differences", () => { - expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })).toBe(false); + 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]"); }); }); @@ -71,12 +116,13 @@ describe("matchValues", () => { it("should delegate to matcher.check() in top-level position", () => { const matcher: MockValueMatcher = { [Symbol.for("SpectorMatcher")]: true as const, - check: (actual: any) => actual === "matched", + check: (actual: any) => + actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`), toJSON: () => "raw", toString: () => "custom", } as any; - expect(matchValues("matched", matcher)).toBe(true); - expect(matchValues("not-matched", matcher)).toBe(false); + expectPass(matchValues("matched", matcher)); + expectFail(matchValues("not-matched", matcher)); }); it("should handle matchers nested in objects", () => { @@ -84,12 +130,12 @@ describe("matchValues", () => { name: "test", timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), }; - expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); + 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"]; - expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); + expectPass(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)); }); it("should handle deeply nested matchers", () => { @@ -103,7 +149,17 @@ describe("matchValues", () => { items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], }, }; - expect(matchValues(actual, expected)).toBe(true); + 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"); }); }); }); diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 8de9876a612..447d5cc8787 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,5 +1,21 @@ 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", () => { @@ -14,64 +30,64 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.000Z")); }); it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00Z")); }); it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.0000000Z")); }); it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.0Z")); }); it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.00Z")); }); it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.000+00:00")); }); it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + expectPass(matcher.check("2022-08-26T14:38:00.000-04:00")); }); it("should reject RFC 7231 format even if same point in time", () => { - expect(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT")).toBe(false); + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "rfc3339 format"); }); it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ"); }); it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:01.000Z"), "timestamps differ"); }); it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-27T18:38:00.000Z"), "timestamps differ"); }); it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); + 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", () => { - expect(matcher.check("")).toBe(false); + expectFail(matcher.check(""), "rfc3339 format"); }); it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); + expectFail(matcher.check("not-a-date"), "rfc3339 format"); }); }); @@ -79,19 +95,19 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.123Z")); }); it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.1230000Z")); }); it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:00Z"), "timestamps differ"); }); it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:00.124Z"), "timestamps differ"); }); }); @@ -99,11 +115,11 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); it("should match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T00:00:00Z")); }); it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + expectPass(matcher.check("2022-08-25T20:00:00-04:00")); }); }); @@ -138,20 +154,20 @@ describe("match.dateTime.rfc7231()", () => { const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); it("should match exact same string", () => { - expect(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + expectPass(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")); }); it("should reject RFC 3339 format even if same point in time", () => { - expect(matcher.check("2022-08-26T14:38:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T14:38:00.000Z"), "rfc7231 format"); }); it("should not match different time", () => { - expect(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT")).toBe(false); + expectFail(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT"), "timestamps differ"); }); it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); }); }); diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index 739ecb8ebb3..f9765ac7029 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -79,8 +79,9 @@ class ServerTestsGenerator { async #validateBody(response: Response, body: MockBody) { if (Buffer.isBuffer(body.rawContent)) { const responseData = Buffer.from(await response.arrayBuffer()); - if (!matchValues(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(); @@ -102,8 +103,9 @@ class ServerTestsGenerator { ? JSON.parse(body.rawContent) : body.rawContent?.resolve(this.resolverConfig); const actual = JSON.parse(responseData); - if (!matchValues(actual, expected)) { - 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; } From 72741ab017b494e9b2467a7526b9f4e3d9123fe3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 18:35:52 -0400 Subject: [PATCH 09/26] format --- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 2 +- packages/spec-api/src/matchers.ts | 4 +++- packages/spec-api/test/matchers-engine.test.ts | 9 ++++++++- packages/spector/src/actions/server-test.ts | 12 ++++++++++-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index a3b52441519..6e374850193 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,5 +1,5 @@ -export { isMatcher, matchValues, ok, err, type MatchResult, type MockValueMatcher } from "./matchers.js"; 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 index 16f97f2a600..2bca55de4f5 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,4 @@ -import { type MockValueMatcher, type MatchResult, ok, err, MatcherSymbol } from "./matchers.js"; +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 = diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index cc399bfc9d3..4ec08e53d40 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -132,7 +132,9 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " 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}`]; + 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); diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index f4347707431..4f48d0c7d84 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { match } from "../src/match.js"; -import { isMatcher, matchValues, ok, err, type MatchResult, MockValueMatcher } from "../src/matchers.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"; diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index f9765ac7029..6bb41a9f1d3 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -81,7 +81,11 @@ class ServerTestsGenerator { const responseData = Buffer.from(await response.arrayBuffer()); const result = matchValues(responseData, body.rawContent); if (!result.pass) { - throw new ValidationError(`Raw body mismatch: ${result.message}`, body.rawContent, responseData); + throw new ValidationError( + `Raw body mismatch: ${result.message}`, + body.rawContent, + responseData, + ); } } else { const responseData = await response.text(); @@ -105,7 +109,11 @@ class ServerTestsGenerator { const actual = JSON.parse(responseData); const result = matchValues(actual, expected); if (!result.pass) { - throw new ValidationError(`Response data mismatch: ${result.message}`, expected, actual); + throw new ValidationError( + `Response data mismatch: ${result.message}`, + expected, + actual, + ); } break; } From 61fc55714ab802912a055964e64898327f4fb2c9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 18:48:00 -0400 Subject: [PATCH 10/26] Fix headers and query --- packages/spector/src/app/app.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 918b238e2a4..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"; @@ -149,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)); @@ -159,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)); From d7f002496a31b8720f0b87029e0811f5ac349e06 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 10:53:09 -0400 Subject: [PATCH 11/26] base url too --- .../specs/payload/pageable/mockapi.ts | 49 ++---- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 77 ++++++++ packages/spec-api/src/response-utils.ts | 69 +++++++- .../spec-api/test/matchers-engine.test.ts | 66 +++++++ .../test/matchers/match-base-url.test.ts | 164 ++++++++++++++++++ 6 files changed, 388 insertions(+), 39 deletions(-) create mode 100644 packages/spec-api/test/matchers/match-base-url.test.ts diff --git a/packages/http-specs/specs/payload/pageable/mockapi.ts b/packages/http-specs/specs/payload/pageable/mockapi.ts index c77b1328a1e..ce2e69a3c86 100644 --- a/packages/http-specs/specs/payload/pageable/mockapi.ts +++ b/packages/http-specs/specs/payload/pageable/mockapi.ts @@ -2,9 +2,9 @@ import { dyn, dynItem, json, + match, MockRequest, passOnSuccess, - ResolverConfig, ScenarioMockApi, ValidationError, xml, @@ -622,22 +622,6 @@ Scenarios.Payload_Pageable_XmlPagination_listWithContinuation = passOnSuccess([ }, ]); -const xmlNextLinkFirstPage = (baseUrl: string) => ` - - - - 1 - dog - - - 2 - cat - - - ${baseUrl}/payload/pageable/xml/list-with-next-link/nextPage - -`; - const XmlNextLinkSecondPage = ` @@ -660,26 +644,25 @@ Scenarios.Payload_Pageable_XmlPagination_listWithNextLink = passOnSuccess([ request: {}, response: { status: 200, - body: { - contentType: "application/xml", - rawContent: { - serialize: (config: ResolverConfig) => - `` + xmlNextLinkFirstPage(config.baseUrl), - }, - }, + body: xml` + + + + 1 + dog + + + 2 + cat + + + ${match.baseUrl("/payload/pageable/xml/list-with-next-link/nextPage")} + +`, headers: { "content-type": "application/xml; charset=utf-8", }, }, - handler: (req: MockRequest) => { - return { - status: 200, - body: xml(xmlNextLinkFirstPage(req.baseUrl)), - headers: { - "content-type": "application/xml", - }, - }; - }, kind: "MockApiDefinition", }, { diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 6e374850193..dd0c949f22e 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,4 @@ -export { match } from "./match.js"; +export { match, type ResolvableMockValueMatcher } from "./match.js"; export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 2bca55de4f5..45e38d6bd73 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,5 @@ import { err, MatcherSymbol, type MatchResult, type MockValueMatcher, ok } from "./matchers.js"; +import type { ResolverConfig } from "./types.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 = @@ -47,6 +48,61 @@ function createDateTimeMatcher( }; } +/** + * A MockValueMatcher that also carries a `resolve` method. + * Before resolution, `check()` performs a loose path-suffix validation. + * After resolution via `expandDyns`, the returned matcher does exact equality. + */ +export interface ResolvableMockValueMatcher extends MockValueMatcher { + resolve(config: ResolverConfig): MockValueMatcher; +} + +function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher { + return { + [MatcherSymbol]: true, + resolve(config: ResolverConfig): MockValueMatcher { + const expected = config.baseUrl + path; + return { + [MatcherSymbol]: true, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (actual !== expected) { + return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return expected; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; + }, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!actual.endsWith(path)) { + return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return path; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; +} + /** * Namespace for built-in matchers. */ @@ -70,4 +126,25 @@ export const match = { return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); }, }, + + /** + * Matcher for URL values that include the server's base URL. + * + * The matcher is created with just the path portion. At runtime, `expandDyns()` + * resolves it by injecting the server's actual base URL (e.g. `http://localhost:3000`). + * The resolved matcher validates that the actual value equals `baseUrl + path`. + * + * Works in both request validation (via `check()`) and response serialization (via `toJSON()`). + * + * @example + * ```ts + * match.baseUrl("/payload/pageable/next-page") + * // After resolution with baseUrl "http://localhost:3000": + * // check("http://localhost:3000/payload/pageable/next-page") → pass + * // toJSON() → "http://localhost:3000/payload/pageable/next-page" + * ``` + */ + baseUrl(path: string): ResolvableMockValueMatcher { + return createBaseUrlMatcher(path); + }, }; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 8e64c7a1196..717e4d8be98 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -25,16 +25,72 @@ function createResolver(content: unknown): Resolver { }; } +const XML_DECLARATION = ``; + /** - * Sends the provided XML string in a MockResponse body. - * The XML declaration prefix will automatically be added to xmlString. - * @content Object to return as XML. + * Sends the provided XML content in a MockResponse body. + * The XML declaration prefix is automatically prepended. + * + * Can be used as a plain function or as a tagged template literal. + * When used as a tagged template, interpolated matchers (e.g. `match.baseUrl`) + * are resolved at serialization time via `expandDyns`. + * + * @example + * ```ts + * // Plain string + * xml("hello") + * + * // Tagged template with matcher + * xml`${match.baseUrl("/next")}` + * ``` + * * @returns {MockBody} response body with application/xml content type. */ -export function xml(xmlString: string): MockBody { +export function xml(content: string): MockBody; +export function xml(strings: TemplateStringsArray, ...values: unknown[]): MockBody; +export function xml( + content: string | TemplateStringsArray, + ...values: unknown[] +): MockBody { + // Tagged template literal: xml`...${match.baseUrl("/path")}...` + if (typeof content !== "string") { + const strings = content; + const hasDynamic = values.some((v) => isMatcher(v)); + + if (!hasDynamic) { + // No matchers — concatenate to a static string + let result = strings[0]; + values.forEach((v, i) => { + result += String(v) + strings[i + 1]; + }); + return { + contentType: "application/xml", + rawContent: XML_DECLARATION + result, + }; + } + + // Has matchers — create a resolver that resolves them at serialization time + const resolveTemplate = (config: ResolverConfig): string => { + let result = strings[0]; + values.forEach((v, i) => { + const expanded = expandDyns(v, config); + result += (isMatcher(expanded) ? String(expanded.toJSON()) : String(expanded)) + strings[i + 1]; + }); + return XML_DECLARATION + result; + }; + return { + contentType: "application/xml", + rawContent: { + serialize: resolveTemplate, + resolve: resolveTemplate, + }, + }; + } + + // Plain string return { contentType: "application/xml", - rawContent: `` + xmlString, + rawContent: XML_DECLARATION + content, }; } @@ -100,6 +156,9 @@ export function expandDyns(value: T, config: ResolverConfig): T { return value.map((v) => expandDyns(v, config)) as any; } else if (typeof value === "object" && value !== null) { if (isMatcher(value)) { + if ("resolve" in value && typeof (value as any).resolve === "function") { + return (value as any).resolve(config) as any; + } return value as any; } const obj = value as Record; diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index 4f48d0c7d84..a9905901cc3 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -16,6 +16,12 @@ describe("isMatcher", () => { expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); }); + it("should return true for baseUrl matchers (both unresolved and resolved)", () => { + expect(isMatcher(match.baseUrl("/path"))).toBe(true); + const resolved = match.baseUrl("/path").resolve({ baseUrl: "http://localhost:3000" }); + expect(isMatcher(resolved)).toBe(true); + }); + it("should return false for plain values", () => { expect(isMatcher("hello")).toBe(false); expect(isMatcher(42)).toBe(false); @@ -168,6 +174,34 @@ describe("matchValues", () => { expectFail(result, "at $.data.timestamp:"); expectFail(result, "rfc3339 format"); }); + + it("should handle resolved baseUrl matchers", () => { + const resolved = match + .baseUrl("/next-page") + .resolve({ baseUrl: "http://localhost:3000" }); + const expected = { link: resolved }; + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); + }); + + it("should fail resolved baseUrl matchers on wrong value", () => { + const resolved = match + .baseUrl("/next-page") + .resolve({ baseUrl: "http://localhost:3000" }); + const expected = { link: resolved }; + expectFail( + matchValues({ link: "http://localhost:4000/next-page" }, expected), + "match.baseUrl", + ); + }); + + it("should use loose path-suffix check for unresolved baseUrl matchers", () => { + const expected = { link: match.baseUrl("/next-page") }; + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); + expectFail( + matchValues({ link: "http://localhost:3000/other-page" }, expected), + 'ending with "/next-page"', + ); + }); }); }); @@ -185,6 +219,25 @@ describe("integration with expandDyns", () => { const expanded = expandDyns(content, config); expect(isMatcher(expanded.items[0])).toBe(true); }); + + it("should resolve baseUrl matchers through expandDyns", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + // After resolution, it's still a matcher but now does exact matching + expect(isMatcher(expanded.next)).toBe(true); + expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + }); + + it("should resolve baseUrl matchers while preserving other matchers", () => { + const content = { + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), + next: match.baseUrl("/next-page"), + }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.timestamp)).toBe(true); + expect(isMatcher(expanded.next)).toBe(true); + expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + }); }); describe("integration with json() Resolver", () => { @@ -201,4 +254,17 @@ describe("integration with json() Resolver", () => { const resolved = (body.rawContent as any).resolve(config) as Record; expect(isMatcher(resolved.value)).toBe(true); }); + + it("should serialize baseUrl matchers to their resolved value via serialize()", () => { + const body = json({ next: match.baseUrl("/items/page2") }); + const raw = (body.rawContent as any).serialize(config); + expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}'); + }); + + it("should resolve baseUrl matchers via resolve()", () => { + const body = json({ next: match.baseUrl("/items/page2") }); + const resolved = (body.rawContent as any).resolve(config) as Record; + expect(isMatcher(resolved.next)).toBe(true); + expectPass((resolved.next as any).check("http://localhost:3000/items/page2")); + }); }); diff --git a/packages/spec-api/test/matchers/match-base-url.test.ts b/packages/spec-api/test/matchers/match-base-url.test.ts new file mode 100644 index 00000000000..839828adc55 --- /dev/null +++ b/packages/spec-api/test/matchers/match-base-url.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/match.js"; +import { isMatcher, type MatchResult } from "../../src/matchers.js"; +import { expandDyns } from "../../src/response-utils.js"; +import { ResolverConfig } from "../../src/types.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.baseUrl()", () => { + it("should be identified by isMatcher", () => { + expect(isMatcher(match.baseUrl("/some/path"))).toBe(true); + }); + + describe("unresolved check() — loose path-suffix validation", () => { + const matcher = match.baseUrl("/payload/pageable/next-page"); + + it("should match any URL ending with the path", () => { + expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page")); + expectPass(matcher.check("https://example.com/payload/pageable/next-page")); + }); + + it("should not match a different path", () => { + expectFail( + matcher.check("http://localhost:3000/payload/pageable/other-page"), + 'ending with "/payload/pageable/next-page"', + ); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(42), "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"); + }); + }); + + describe("unresolved toJSON / toString", () => { + it("toJSON should return the path", () => { + expect(match.baseUrl("/some/path").toJSON()).toBe("/some/path"); + }); + + it("toString should return a descriptive string", () => { + expect(match.baseUrl("/some/path").toString()).toBe('match.baseUrl("/some/path")'); + }); + }); + + describe("resolved matcher", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + const resolved = match.baseUrl("/payload/pageable/next-page").resolve(config); + + describe("check()", () => { + it("should match the exact full URL (baseUrl + path)", () => { + expectPass(resolved.check("http://localhost:3000/payload/pageable/next-page")); + }); + + it("should not match a different base URL", () => { + expectFail( + resolved.check("http://localhost:4000/payload/pageable/next-page"), + "match.baseUrl", + ); + }); + + it("should not match a different path", () => { + expectFail( + resolved.check("http://localhost:3000/payload/pageable/other-page"), + "match.baseUrl", + ); + }); + + it("should not match a partial URL", () => { + expectFail(resolved.check("/payload/pageable/next-page"), "match.baseUrl"); + }); + + it("should not match non-string values", () => { + expectFail(resolved.check(42), "expected a string but got number"); + expectFail(resolved.check(null), "expected a string but got object"); + }); + }); + + describe("toJSON()", () => { + it("should return the full URL", () => { + expect(resolved.toJSON()).toBe("http://localhost:3000/payload/pageable/next-page"); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { nextLink: resolved }; + expect(JSON.stringify(obj)).toBe( + '{"nextLink":"http://localhost:3000/payload/pageable/next-page"}', + ); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(resolved.toString()).toBe('match.baseUrl("/payload/pageable/next-page")'); + }); + }); + }); + + describe("resolution with different base URLs", () => { + const unresolved = match.baseUrl("/api/items"); + + it("should resolve with localhost", () => { + const resolved = unresolved.resolve({ baseUrl: "http://localhost:3000" }); + expectPass(resolved.check("http://localhost:3000/api/items")); + }); + + it("should resolve with https URL", () => { + const resolved = unresolved.resolve({ baseUrl: "https://example.com" }); + expectPass(resolved.check("https://example.com/api/items")); + }); + + it("should resolve with URL including port", () => { + const resolved = unresolved.resolve({ baseUrl: "http://127.0.0.1:8080" }); + expectPass(resolved.check("http://127.0.0.1:8080/api/items")); + }); + }); + + describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should resolve baseUrl matchers in expandDyns", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.next)).toBe(true); + expectPass((expanded.next as any).check("http://localhost:3000/next-page")); + }); + + it("should resolve baseUrl matchers nested in objects", () => { + const content = { + data: { + nextLink: match.baseUrl("/items/page2"), + }, + }; + const expanded = expandDyns(content, config); + expectPass((expanded.data.nextLink as any).check("http://localhost:3000/items/page2")); + }); + + it("should resolve baseUrl matchers in arrays", () => { + const content = { links: [match.baseUrl("/page1"), match.baseUrl("/page2")] }; + const expanded = expandDyns(content, config); + expectPass((expanded.links[0] as any).check("http://localhost:3000/page1")); + expectPass((expanded.links[1] as any).check("http://localhost:3000/page2")); + }); + + it("should serialize resolved matcher in JSON.stringify", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + expect(JSON.stringify(expanded)).toBe('{"next":"http://localhost:3000/next-page"}'); + }); + }); +}); From a830dbebdf7a368875702825bc1d07cf19c04abe Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 12:24:06 -0400 Subject: [PATCH 12/26] progress --- packages/spec-api/src/matchers.ts | 25 +++- packages/spec-api/src/response-utils.ts | 107 ++++++++++-------- .../spec-api/test/matchers-engine.test.ts | 21 ++-- .../test/matchers/match-base-url.test.ts | 11 +- packages/spector/src/actions/helper.ts | 13 ++- packages/spector/src/app/app.ts | 25 +--- 6 files changed, 102 insertions(+), 100 deletions(-) diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index 4ec08e53d40..657523847bb 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -126,21 +126,34 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " const expectedObj = expected as Record; const actualObj = actual as Record; - const expectedKeys = Object.keys(expectedObj); + // Keys with undefined values in expected mean "must not be present in actual" + const expectedPresentKeys = Object.keys(expectedObj).filter( + (k) => expectedObj[k] !== undefined, + ); + const expectedAbsentKeys = Object.keys(expectedObj).filter( + (k) => expectedObj[k] === undefined, + ); 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)); + // Verify keys that should be absent are not in actual + for (const key of expectedAbsentKeys) { + if (key in actualObj && actualObj[key] !== undefined) { + return pathErr(`Key "${key}" should not be present but got ${formatValue(actualObj[key])}`, path); + } + } + + if (expectedPresentKeys.length !== actualKeys.length) { + const missing = expectedPresentKeys.filter((k) => !(k in actualObj)); + const extra = actualKeys.filter((k) => !expectedPresentKeys.includes(k) && !expectedAbsentKeys.includes(k)); const parts: string[] = [ - `Key count mismatch: expected ${expectedKeys.length} but got ${actualKeys.length}`, + `Key count mismatch: expected ${expectedPresentKeys.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) { + for (const key of expectedPresentKeys) { if (!(key in actualObj)) { return pathErr(`Missing key "${key}"`, path); } diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 717e4d8be98..ad250d76e97 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -20,7 +20,8 @@ function createResolver(content: unknown): Resolver { return JSON.stringify(expanded); }, resolve: (config: ResolverConfig) => { - return expandDyns(content, config); + // Preserve matchers so matchValues can use them for flexible validation + return expandDyns(content, config, { resolveMatchers: false }); }, }; } @@ -48,17 +49,16 @@ const XML_DECLARATION = ``; */ export function xml(content: string): MockBody; export function xml(strings: TemplateStringsArray, ...values: unknown[]): MockBody; -export function xml( - content: string | TemplateStringsArray, - ...values: unknown[] -): MockBody { +export function xml(content: string | TemplateStringsArray, ...values: unknown[]): MockBody { // Tagged template literal: xml`...${match.baseUrl("/path")}...` if (typeof content !== "string") { const strings = content; - const hasDynamic = values.some((v) => isMatcher(v)); + const hasDynamic = values.some( + (v) => isMatcher(v) || (typeof v === "object" && v !== null && "isDyn" in v), + ); if (!hasDynamic) { - // No matchers — concatenate to a static string + // No dynamic values — concatenate to a static string let result = strings[0]; values.forEach((v, i) => { result += String(v) + strings[i + 1]; @@ -69,20 +69,14 @@ export function xml( }; } - // Has matchers — create a resolver that resolves them at serialization time - const resolveTemplate = (config: ResolverConfig): string => { - let result = strings[0]; - values.forEach((v, i) => { - const expanded = expandDyns(v, config); - result += (isMatcher(expanded) ? String(expanded.toJSON()) : String(expanded)) + strings[i + 1]; - }); - return XML_DECLARATION + result; - }; + // Delegate to dyn for deferred resolution + const template = dyn(strings, ...values); + const resolve = (config: ResolverConfig): string => XML_DECLARATION + template(config); return { contentType: "application/xml", rawContent: { - serialize: resolveTemplate, - resolve: resolveTemplate, + serialize: resolve, + resolve, }, }; } @@ -104,10 +98,9 @@ export function multipart( }; } -export interface DynValue { +export interface DynValue { readonly isDyn: true; - readonly keys: T; - (dict: Record): string; + (config: ResolverConfig): string; } export interface DynItem { @@ -122,53 +115,73 @@ export function dynItem(name: T): DynItem< }; } -/** Specify that this value is dynamic and needs to be interpolated with the given keys */ -export function dyn( - strings: readonly string[], - ...keys: (DynItem | string)[] -): DynValue { - const dynKeys: T = [] as any; - const template = (dict: Record) => { - const result = [strings[0]]; - keys.forEach((key, i) => { - if (typeof key === "string") { - result.push(key); +/** + * Tagged template for building strings with deferred resolution. + * Interpolated values can be: + * - `dynItem("baseUrl")` — resolved from `ResolverConfig` at call time + * - Matchers (e.g. `match.baseUrl(...)`) — resolved via `expandDyns` at call time + * - Plain strings/numbers — used as-is + */ +export function dyn(strings: readonly string[], ...values: unknown[]): DynValue { + const template = (config: ResolverConfig) => { + let result = strings[0]; + values.forEach((v, i) => { + if (typeof v === "object" && v !== null && "isDyn" in v && (v as any).isDyn) { + const name = (v as DynItem).name; + result += String(config[name] ?? ""); } else { - dynKeys.push(key.name); - const value = (dict as any)[key.name]; - if (value !== undefined) { - result.push(value); - } + result += String(expandDyns(v, config)); } - result.push(strings[i + 1]); + result += strings[i + 1]; }); - return result.join(""); + return result; }; - template.keys = dynKeys; template.isDyn = true as const; return template; } -export function expandDyns(value: T, config: ResolverConfig): T { +export interface ExpandDynsOptions { + /** When true, matchers are resolved to their `toJSON()` value. Default: true. */ + resolveMatchers?: boolean; +} + +/** + * Recursively expands all dynamic values. + * - Dyn functions are called with the config. + * - Resolvable matchers (e.g. `match.baseUrl`) are resolved via `resolve(config)`. + * - By default, matchers are resolved to their `toJSON()` plain value. + * Pass `{ resolveMatchers: false }` to preserve matchers for use with `matchValues`. + */ +export function expandDyns( + value: T, + config: ResolverConfig, + options?: ExpandDynsOptions, +): T { + const resolve = options?.resolveMatchers ?? true; + return _expandDyns(value, config, resolve); +} + +function _expandDyns(value: T, config: ResolverConfig, resolveMatchers: boolean): T { if (typeof value === "string") { return value; } else if (Array.isArray(value)) { - return value.map((v) => expandDyns(v, config)) as any; + return value.map((v) => _expandDyns(v, config, resolveMatchers)) as any; } else if (typeof value === "object" && value !== null) { if (isMatcher(value)) { if ("resolve" in value && typeof (value as any).resolve === "function") { - return (value as any).resolve(config) as any; + const resolved = (value as any).resolve(config); + return resolveMatchers ? (resolved.toJSON() as any) : (resolved as any); } - return value as any; + return resolveMatchers ? (value.toJSON() as any) : (value as any); } const obj = value as Record; return Object.fromEntries( - Object.entries(obj).map(([key, v]) => [key, expandDyns(v, config)]), + Object.entries(obj).map(([key, v]) => [key, _expandDyns(v, config, resolveMatchers)]), ) as any; } else if (typeof value === "function") { if ("isDyn" in value && value.isDyn) { - const dynValue = value as any as DynValue; - return dynValue(config as any) as any; + const dynValue = value as any as DynValue; + return dynValue(config) as any; } else { throw new Error("Invalid function value"); } diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index a9905901cc3..800f0b87684 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -208,35 +208,32 @@ describe("matchValues", () => { describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; - it("should preserve matchers through expandDyns", () => { + it("should resolve matchers to their plain values", () => { const content = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; const expanded = expandDyns(content, config); - expect(isMatcher(expanded.value)).toBe(true); + expect(expanded.value).toBe("2022-08-26T18:38:00.000Z"); }); - it("should preserve matchers in arrays through expandDyns", () => { + it("should resolve matchers in arrays to their plain values", () => { const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] }; const expanded = expandDyns(content, config); - expect(isMatcher(expanded.items[0])).toBe(true); + expect(expanded.items[0]).toBe("2022-08-26T18:38:00.000Z"); }); - it("should resolve baseUrl matchers through expandDyns", () => { + it("should resolve baseUrl matchers to their full URL", () => { const content = { next: match.baseUrl("/next-page") }; const expanded = expandDyns(content, config); - // After resolution, it's still a matcher but now does exact matching - expect(isMatcher(expanded.next)).toBe(true); - expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + expect(expanded.next).toBe("http://localhost:3000/next-page"); }); - it("should resolve baseUrl matchers while preserving other matchers", () => { + it("should resolve all matchers to their plain values", () => { const content = { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), next: match.baseUrl("/next-page"), }; const expanded = expandDyns(content, config); - expect(isMatcher(expanded.timestamp)).toBe(true); - expect(isMatcher(expanded.next)).toBe(true); - expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + expect(expanded.timestamp).toBe("2022-08-26T18:38:00.000Z"); + expect(expanded.next).toBe("http://localhost:3000/next-page"); }); }); diff --git a/packages/spec-api/test/matchers/match-base-url.test.ts b/packages/spec-api/test/matchers/match-base-url.test.ts index 839828adc55..61b479f7076 100644 --- a/packages/spec-api/test/matchers/match-base-url.test.ts +++ b/packages/spec-api/test/matchers/match-base-url.test.ts @@ -131,11 +131,10 @@ describe("match.baseUrl()", () => { describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; - it("should resolve baseUrl matchers in expandDyns", () => { + it("should resolve baseUrl matchers to their full URL", () => { const content = { next: match.baseUrl("/next-page") }; const expanded = expandDyns(content, config); - expect(isMatcher(expanded.next)).toBe(true); - expectPass((expanded.next as any).check("http://localhost:3000/next-page")); + expect(expanded.next).toBe("http://localhost:3000/next-page"); }); it("should resolve baseUrl matchers nested in objects", () => { @@ -145,14 +144,14 @@ describe("match.baseUrl()", () => { }, }; const expanded = expandDyns(content, config); - expectPass((expanded.data.nextLink as any).check("http://localhost:3000/items/page2")); + expect(expanded.data.nextLink).toBe("http://localhost:3000/items/page2"); }); it("should resolve baseUrl matchers in arrays", () => { const content = { links: [match.baseUrl("/page1"), match.baseUrl("/page2")] }; const expanded = expandDyns(content, config); - expectPass((expanded.links[0] as any).check("http://localhost:3000/page1")); - expectPass((expanded.links[1] as any).check("http://localhost:3000/page2")); + expect(expanded.links[0]).toBe("http://localhost:3000/page1"); + expect(expanded.links[1]).toBe("http://localhost:3000/page2"); }); it("should serialize resolved matcher in JSON.stringify", () => { diff --git a/packages/spector/src/actions/helper.ts b/packages/spector/src/actions/helper.ts index 14a855a95c6..7ce381abdc4 100644 --- a/packages/spector/src/actions/helper.ts +++ b/packages/spector/src/actions/helper.ts @@ -35,7 +35,7 @@ function renderMultipartRequest(body: MockMultipartBody) { return formData; } -function resolveUrl(request: ServiceRequest) { +function resolveUrl(request: ServiceRequest, config: ResolverConfig) { let endpoint = request.url; if (request.pathParams) { @@ -47,14 +47,15 @@ function resolveUrl(request: ServiceRequest) { endpoint = endpoint.replaceAll("\\:", ":"); if (request.query) { + const resolved = expandDyns(request.query, config); const query = new URLSearchParams(); - for (const [key, value] of Object.entries(request.query)) { + for (const [key, value] of Object.entries(resolved)) { if (Array.isArray(value)) { for (const v of value) { - query.append(key, v); + query.append(key, String(v)); } } else { - query.append(key, value as any); + query.append(key, String(value)); } } endpoint = `${endpoint}?${query.toString()}`; @@ -66,9 +67,9 @@ export async function makeServiceCall( request: ServiceRequest, config: ResolverConfig, ): Promise { - const url = resolveUrl(request); + const url = resolveUrl(request, config); let body; - let headers = expandDyns(request.headers, config) as Record; + let headers = expandDyns(request.headers, config) as Record | undefined; if (request.body) { if ("kind" in request.body) { const formData = renderMultipartRequest(request.body); diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 36949904466..0d5d320bb7b 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -1,6 +1,5 @@ import { expandDyns, - isMatcher, MockApiDefinition, MockBody, MockMultipartBody, @@ -151,17 +150,7 @@ 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 (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)) { + if (Array.isArray(value)) { req.expect.deepEqual(req.headers[key], value); } else { req.expect.containsHeader(key.toLowerCase(), String(value)); @@ -173,17 +162,7 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) if (apiDefinition.request?.query) { 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)) { + if (Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { req.expect.containsQueryParam(key, String(value)); From fec25f07ecea49df3ecc17b6f92efded2fb8c808 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 12:32:56 -0400 Subject: [PATCH 13/26] simplify --- packages/spec-api/src/response-utils.ts | 53 ++++++------------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index ad250d76e97..ff197932a0b 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -50,38 +50,13 @@ const XML_DECLARATION = ``; export function xml(content: string): MockBody; export function xml(strings: TemplateStringsArray, ...values: unknown[]): MockBody; export function xml(content: string | TemplateStringsArray, ...values: unknown[]): MockBody { - // Tagged template literal: xml`...${match.baseUrl("/path")}...` if (typeof content !== "string") { - const strings = content; - const hasDynamic = values.some( - (v) => isMatcher(v) || (typeof v === "object" && v !== null && "isDyn" in v), - ); - - if (!hasDynamic) { - // No dynamic values — concatenate to a static string - let result = strings[0]; - values.forEach((v, i) => { - result += String(v) + strings[i + 1]; - }); - return { - contentType: "application/xml", - rawContent: XML_DECLARATION + result, - }; - } - - // Delegate to dyn for deferred resolution - const template = dyn(strings, ...values); - const resolve = (config: ResolverConfig): string => XML_DECLARATION + template(config); return { contentType: "application/xml", - rawContent: { - serialize: resolve, - resolve, - }, + rawContent: dyn`${XML_DECLARATION}${dyn(content, ...values)}`, }; } - // Plain string return { contentType: "application/xml", rawContent: XML_DECLARATION + content, @@ -98,7 +73,7 @@ export function multipart( }; } -export interface DynValue { +export interface DynValue extends Resolver { readonly isDyn: true; (config: ResolverConfig): string; } @@ -118,25 +93,23 @@ export function dynItem(name: T): DynItem< /** * Tagged template for building strings with deferred resolution. * Interpolated values can be: - * - `dynItem("baseUrl")` — resolved from `ResolverConfig` at call time - * - Matchers (e.g. `match.baseUrl(...)`) — resolved via `expandDyns` at call time + * - `dynItem("baseUrl")` — resolved from `ResolverConfig` + * - Matchers (e.g. `match.baseUrl(...)`) — resolved via `expandDyns` + * - Other `dyn` templates — recursively resolved * - Plain strings/numbers — used as-is */ export function dyn(strings: readonly string[], ...values: unknown[]): DynValue { const template = (config: ResolverConfig) => { let result = strings[0]; values.forEach((v, i) => { - if (typeof v === "object" && v !== null && "isDyn" in v && (v as any).isDyn) { - const name = (v as DynItem).name; - result += String(config[name] ?? ""); - } else { - result += String(expandDyns(v, config)); - } + result += String(expandDyns(v, config)); result += strings[i + 1]; }); return result; }; template.isDyn = true as const; + template.serialize = template; + template.resolve = template; return template; } @@ -152,11 +125,7 @@ export interface ExpandDynsOptions { * - By default, matchers are resolved to their `toJSON()` plain value. * Pass `{ resolveMatchers: false }` to preserve matchers for use with `matchValues`. */ -export function expandDyns( - value: T, - config: ResolverConfig, - options?: ExpandDynsOptions, -): T { +export function expandDyns(value: T, config: ResolverConfig, options?: ExpandDynsOptions): T { const resolve = options?.resolveMatchers ?? true; return _expandDyns(value, config, resolve); } @@ -167,6 +136,10 @@ function _expandDyns(value: T, config: ResolverConfig, resolveMatchers: boole } else if (Array.isArray(value)) { return value.map((v) => _expandDyns(v, config, resolveMatchers)) as any; } else if (typeof value === "object" && value !== null) { + // DynItem — resolve from config + if ("isDyn" in value && (value as any).isDyn && "name" in value) { + return (config as any)[(value as any).name] as any; + } if (isMatcher(value)) { if ("resolve" in value && typeof (value as any).resolve === "function") { const resolved = (value as any).resolve(config); From 617c50b49dc53ba1bba633bb3a325f0523dae323 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 12:46:07 -0400 Subject: [PATCH 14/26] organize --- packages/spec-api/src/expectation.ts | 2 +- packages/spec-api/src/index.ts | 4 +- .../src/{matchers.ts => match-engine.ts} | 0 packages/spec-api/src/match.ts | 150 ------------------ packages/spec-api/src/matchers/index.ts | 46 ++++++ .../spec-api/src/matchers/match-base-url.ts | 67 ++++++++ .../spec-api/src/matchers/match-datetime.ts | 63 ++++++++ packages/spec-api/src/request-validations.ts | 2 +- packages/spec-api/src/response-utils.ts | 2 +- ...rs-engine.test.ts => match-engine.test.ts} | 12 +- .../test/matchers/match-base-url.test.ts | 4 +- .../test/matchers/match-datetime.test.ts | 4 +- 12 files changed, 189 insertions(+), 167 deletions(-) rename packages/spec-api/src/{matchers.ts => match-engine.ts} (100%) delete mode 100644 packages/spec-api/src/match.ts create mode 100644 packages/spec-api/src/matchers/index.ts create mode 100644 packages/spec-api/src/matchers/match-base-url.ts create mode 100644 packages/spec-api/src/matchers/match-datetime.ts rename packages/spec-api/test/{matchers-engine.test.ts => match-engine.test.ts} (96%) diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index f8a97e5d341..086361fc421 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -1,4 +1,4 @@ -import { matchValues } from "./matchers.js"; +import { matchValues } from "./match-engine.js"; import { validateBodyEmpty, validateBodyEquals, diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index dd0c949f22e..4c714996a48 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,5 +1,5 @@ -export { match, type ResolvableMockValueMatcher } from "./match.js"; -export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js"; +export { match, type ResolvableMockValueMatcher } from "./matchers/index.js"; +export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers/index.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/match-engine.ts similarity index 100% rename from packages/spec-api/src/matchers.ts rename to packages/spec-api/src/match-engine.ts diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts deleted file mode 100644 index 45e38d6bd73..00000000000 --- a/packages/spec-api/src/match.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { err, MatcherSymbol, type MatchResult, type MockValueMatcher, ok } from "./matchers.js"; -import type { ResolverConfig } from "./types.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})`; - }, - }; -} - -/** - * A MockValueMatcher that also carries a `resolve` method. - * Before resolution, `check()` performs a loose path-suffix validation. - * After resolution via `expandDyns`, the returned matcher does exact equality. - */ -export interface ResolvableMockValueMatcher extends MockValueMatcher { - resolve(config: ResolverConfig): MockValueMatcher; -} - -function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher { - return { - [MatcherSymbol]: true, - resolve(config: ResolverConfig): MockValueMatcher { - const expected = config.baseUrl + path; - return { - [MatcherSymbol]: true, - check(actual: unknown): MatchResult { - if (typeof actual !== "string") { - return err( - `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, - ); - } - if (actual !== expected) { - return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); - } - return ok(); - }, - toJSON(): string { - return expected; - }, - toString(): string { - return `match.baseUrl("${path}")`; - }, - }; - }, - check(actual: unknown): MatchResult { - if (typeof actual !== "string") { - return err( - `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, - ); - } - if (!actual.endsWith(path)) { - return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); - } - return ok(); - }, - toJSON(): string { - return path; - }, - toString(): string { - return `match.baseUrl("${path}")`; - }, - }; -} - -/** - * 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); - }, - }, - - /** - * Matcher for URL values that include the server's base URL. - * - * The matcher is created with just the path portion. At runtime, `expandDyns()` - * resolves it by injecting the server's actual base URL (e.g. `http://localhost:3000`). - * The resolved matcher validates that the actual value equals `baseUrl + path`. - * - * Works in both request validation (via `check()`) and response serialization (via `toJSON()`). - * - * @example - * ```ts - * match.baseUrl("/payload/pageable/next-page") - * // After resolution with baseUrl "http://localhost:3000": - * // check("http://localhost:3000/payload/pageable/next-page") → pass - * // toJSON() → "http://localhost:3000/payload/pageable/next-page" - * ``` - */ - baseUrl(path: string): ResolvableMockValueMatcher { - return createBaseUrlMatcher(path); - }, -}; diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts new file mode 100644 index 00000000000..dbd5d1bb4f5 --- /dev/null +++ b/packages/spec-api/src/matchers/index.ts @@ -0,0 +1,46 @@ +export { + err, + isMatcher, + MatcherSymbol, + matchValues, + ok, + type MatchResult, + type MockValueMatcher, +} from "../match-engine.js"; +export { baseUrlMatcher, type ResolvableMockValueMatcher } from "./match-base-url.js"; +export { dateTimeMatcher } from "./match-datetime.js"; + +import { baseUrlMatcher } from "./match-base-url.js"; +import { dateTimeMatcher } from "./match-datetime.js"; + +/** + * 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: dateTimeMatcher, + + /** + * Matcher for URL values that include the server's base URL. + * + * The matcher is created with just the path portion. At runtime, `expandDyns()` + * resolves it by injecting the server's actual base URL (e.g. `http://localhost:3000`). + * The resolved matcher validates that the actual value equals `baseUrl + path`. + * + * @example + * ```ts + * match.baseUrl("/payload/pageable/next-page") + * ``` + */ + baseUrl: baseUrlMatcher, +}; diff --git a/packages/spec-api/src/matchers/match-base-url.ts b/packages/spec-api/src/matchers/match-base-url.ts new file mode 100644 index 00000000000..1de2c4c6efc --- /dev/null +++ b/packages/spec-api/src/matchers/match-base-url.ts @@ -0,0 +1,67 @@ +import { + err, + MatcherSymbol, + type MatchResult, + type MockValueMatcher, + ok, +} from "../match-engine.js"; +import type { ResolverConfig } from "../types.js"; + +/** + * A MockValueMatcher that also carries a `resolve` method. + * Before resolution, `check()` performs a loose path-suffix validation. + * After resolution via `expandDyns`, the returned matcher does exact equality. + */ +export interface ResolvableMockValueMatcher extends MockValueMatcher { + resolve(config: ResolverConfig): MockValueMatcher; +} + +function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher { + return { + [MatcherSymbol]: true, + resolve(config: ResolverConfig): MockValueMatcher { + const expected = config.baseUrl + path; + return { + [MatcherSymbol]: true, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (actual !== expected) { + return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return expected; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; + }, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!actual.endsWith(path)) { + return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return path; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; +} + +export function baseUrlMatcher(path: string): ResolvableMockValueMatcher { + return createBaseUrlMatcher(path); +} diff --git a/packages/spec-api/src/matchers/match-datetime.ts b/packages/spec-api/src/matchers/match-datetime.ts new file mode 100644 index 00000000000..dd4dde1d27c --- /dev/null +++ b/packages/spec-api/src/matchers/match-datetime.ts @@ -0,0 +1,63 @@ +import { + err, + MatcherSymbol, + type MatchResult, + type MockValueMatcher, + ok, +} from "../match-engine.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})`; + }, + }; +} + +export const dateTimeMatcher = { + 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/request-validations.ts b/packages/spec-api/src/request-validations.ts index 545cdd3cd3e..a6a6957c5fa 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -1,6 +1,6 @@ import deepEqual from "deep-equal"; import { parseString } from "xml2js"; -import { matchValues } from "./matchers.js"; +import { matchValues } from "./match-engine.js"; import { CollectionFormat, RequestExt } from "./types.js"; import { ValidationError } from "./validation-error.js"; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index ff197932a0b..31fd61d93ff 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -1,4 +1,4 @@ -import { isMatcher } from "./matchers.js"; +import { isMatcher } from "./match-engine.js"; import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js"; /** diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/match-engine.test.ts similarity index 96% rename from packages/spec-api/test/matchers-engine.test.ts rename to packages/spec-api/test/match-engine.test.ts index 800f0b87684..d6522b82533 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { match } from "../src/match.js"; import { err, isMatcher, @@ -7,7 +6,8 @@ import { matchValues, MockValueMatcher, ok, -} from "../src/matchers.js"; +} from "../src/match-engine.js"; +import { match } from "../src/matchers/index.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; @@ -176,17 +176,13 @@ describe("matchValues", () => { }); it("should handle resolved baseUrl matchers", () => { - const resolved = match - .baseUrl("/next-page") - .resolve({ baseUrl: "http://localhost:3000" }); + const resolved = match.baseUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); const expected = { link: resolved }; expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); }); it("should fail resolved baseUrl matchers on wrong value", () => { - const resolved = match - .baseUrl("/next-page") - .resolve({ baseUrl: "http://localhost:3000" }); + const resolved = match.baseUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); const expected = { link: resolved }; expectFail( matchValues({ link: "http://localhost:4000/next-page" }, expected), diff --git a/packages/spec-api/test/matchers/match-base-url.test.ts b/packages/spec-api/test/matchers/match-base-url.test.ts index 61b479f7076..8adc04e3838 100644 --- a/packages/spec-api/test/matchers/match-base-url.test.ts +++ b/packages/spec-api/test/matchers/match-base-url.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { match } from "../../src/match.js"; -import { isMatcher, type MatchResult } from "../../src/matchers.js"; +import { isMatcher, type MatchResult } from "../../src/match-engine.js"; +import { match } from "../../src/matchers/index.js"; import { expandDyns } from "../../src/response-utils.js"; import { ResolverConfig } from "../../src/types.js"; diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 447d5cc8787..50936c11936 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { match } from "../../src/match.js"; -import { type MatchResult } from "../../src/matchers.js"; +import { type MatchResult } from "../../src/match-engine.js"; +import { match } from "../../src/matchers/index.js"; function expectPass(result: MatchResult) { expect(result).toEqual({ pass: true }); From 08cfba3153afe2690b8bedcf8c6049dcc7c8a5e5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 13:13:51 -0400 Subject: [PATCH 15/26] cleanup --- .../specs/payload/pageable/mockapi.ts | 2 +- .../{match-datetime.ts => datetime.ts} | 0 packages/spec-api/src/matchers/index.ts | 13 +++---- .../{match-base-url.ts => local-url.ts} | 14 +++---- packages/spec-api/test/match-engine.test.ts | 18 ++++----- ...atch-datetime.test.ts => datetime.test.ts} | 17 +-------- ...tch-base-url.test.ts => local-url.test.ts} | 38 ++++++------------- .../test/matchers/matcher-test-utils.ts | 17 +++++++++ 8 files changed, 51 insertions(+), 68 deletions(-) rename packages/spec-api/src/matchers/{match-datetime.ts => datetime.ts} (100%) rename packages/spec-api/src/matchers/{match-base-url.ts => local-url.ts} (77%) rename packages/spec-api/test/matchers/{match-datetime.test.ts => datetime.test.ts} (92%) rename packages/spec-api/test/matchers/{match-base-url.test.ts => local-url.test.ts} (80%) create mode 100644 packages/spec-api/test/matchers/matcher-test-utils.ts diff --git a/packages/http-specs/specs/payload/pageable/mockapi.ts b/packages/http-specs/specs/payload/pageable/mockapi.ts index ce2e69a3c86..1b825df6d8d 100644 --- a/packages/http-specs/specs/payload/pageable/mockapi.ts +++ b/packages/http-specs/specs/payload/pageable/mockapi.ts @@ -656,7 +656,7 @@ Scenarios.Payload_Pageable_XmlPagination_listWithNextLink = passOnSuccess([ cat - ${match.baseUrl("/payload/pageable/xml/list-with-next-link/nextPage")} + ${match.localUrl("/payload/pageable/xml/list-with-next-link/nextPage")} `, headers: { diff --git a/packages/spec-api/src/matchers/match-datetime.ts b/packages/spec-api/src/matchers/datetime.ts similarity index 100% rename from packages/spec-api/src/matchers/match-datetime.ts rename to packages/spec-api/src/matchers/datetime.ts diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts index dbd5d1bb4f5..480b3cbf26d 100644 --- a/packages/spec-api/src/matchers/index.ts +++ b/packages/spec-api/src/matchers/index.ts @@ -1,3 +1,6 @@ +import { dateTimeMatcher } from "./datetime.js"; +import { baseUrlMatcher } from "./local-url.js"; + export { err, isMatcher, @@ -7,11 +10,7 @@ export { type MatchResult, type MockValueMatcher, } from "../match-engine.js"; -export { baseUrlMatcher, type ResolvableMockValueMatcher } from "./match-base-url.js"; -export { dateTimeMatcher } from "./match-datetime.js"; - -import { baseUrlMatcher } from "./match-base-url.js"; -import { dateTimeMatcher } from "./match-datetime.js"; +export { dateTimeMatcher } from "./datetime.js"; /** * Namespace for built-in matchers. @@ -39,8 +38,8 @@ export const match = { * * @example * ```ts - * match.baseUrl("/payload/pageable/next-page") + * match.localUrl("/payload/pageable/next-page") * ``` */ - baseUrl: baseUrlMatcher, + localUrl: baseUrlMatcher, }; diff --git a/packages/spec-api/src/matchers/match-base-url.ts b/packages/spec-api/src/matchers/local-url.ts similarity index 77% rename from packages/spec-api/src/matchers/match-base-url.ts rename to packages/spec-api/src/matchers/local-url.ts index 1de2c4c6efc..c45d5ca2f29 100644 --- a/packages/spec-api/src/matchers/match-base-url.ts +++ b/packages/spec-api/src/matchers/local-url.ts @@ -16,7 +16,7 @@ export interface ResolvableMockValueMatcher extends MockValueMatche resolve(config: ResolverConfig): MockValueMatcher; } -function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher { +export function baseUrlMatcher(path: string): ResolvableMockValueMatcher { return { [MatcherSymbol]: true, resolve(config: ResolverConfig): MockValueMatcher { @@ -26,11 +26,11 @@ function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher check(actual: unknown): MatchResult { if (typeof actual !== "string") { return err( - `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, ); } if (actual !== expected) { - return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); + return err(`match.localUrl: expected "${expected}" but got "${actual}"`); } return ok(); }, @@ -38,14 +38,14 @@ function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher return expected; }, toString(): string { - return `match.baseUrl("${path}")`; + return `match.localUrl("${path}")`; }, }; }, check(actual: unknown): MatchResult { if (typeof actual !== "string") { return err( - `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, ); } if (!actual.endsWith(path)) { @@ -61,7 +61,3 @@ function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher }, }; } - -export function baseUrlMatcher(path: string): ResolvableMockValueMatcher { - return createBaseUrlMatcher(path); -} diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index d6522b82533..40373b8c034 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -17,8 +17,8 @@ describe("isMatcher", () => { }); it("should return true for baseUrl matchers (both unresolved and resolved)", () => { - expect(isMatcher(match.baseUrl("/path"))).toBe(true); - const resolved = match.baseUrl("/path").resolve({ baseUrl: "http://localhost:3000" }); + expect(isMatcher(match.localUrl("/path"))).toBe(true); + const resolved = match.localUrl("/path").resolve({ baseUrl: "http://localhost:3000" }); expect(isMatcher(resolved)).toBe(true); }); @@ -176,13 +176,13 @@ describe("matchValues", () => { }); it("should handle resolved baseUrl matchers", () => { - const resolved = match.baseUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); + const resolved = match.localUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); const expected = { link: resolved }; expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); }); it("should fail resolved baseUrl matchers on wrong value", () => { - const resolved = match.baseUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); + const resolved = match.localUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); const expected = { link: resolved }; expectFail( matchValues({ link: "http://localhost:4000/next-page" }, expected), @@ -191,7 +191,7 @@ describe("matchValues", () => { }); it("should use loose path-suffix check for unresolved baseUrl matchers", () => { - const expected = { link: match.baseUrl("/next-page") }; + const expected = { link: match.localUrl("/next-page") }; expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); expectFail( matchValues({ link: "http://localhost:3000/other-page" }, expected), @@ -217,7 +217,7 @@ describe("integration with expandDyns", () => { }); it("should resolve baseUrl matchers to their full URL", () => { - const content = { next: match.baseUrl("/next-page") }; + const content = { next: match.localUrl("/next-page") }; const expanded = expandDyns(content, config); expect(expanded.next).toBe("http://localhost:3000/next-page"); }); @@ -225,7 +225,7 @@ describe("integration with expandDyns", () => { it("should resolve all matchers to their plain values", () => { const content = { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), - next: match.baseUrl("/next-page"), + next: match.localUrl("/next-page"), }; const expanded = expandDyns(content, config); expect(expanded.timestamp).toBe("2022-08-26T18:38:00.000Z"); @@ -249,13 +249,13 @@ describe("integration with json() Resolver", () => { }); it("should serialize baseUrl matchers to their resolved value via serialize()", () => { - const body = json({ next: match.baseUrl("/items/page2") }); + const body = json({ next: match.localUrl("/items/page2") }); const raw = (body.rawContent as any).serialize(config); expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}'); }); it("should resolve baseUrl matchers via resolve()", () => { - const body = json({ next: match.baseUrl("/items/page2") }); + const body = json({ next: match.localUrl("/items/page2") }); const resolved = (body.rawContent as any).resolve(config) as Record; expect(isMatcher(resolved.next)).toBe(true); expectPass((resolved.next as any).check("http://localhost:3000/items/page2")); diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/datetime.test.ts similarity index 92% rename from packages/spec-api/test/matchers/match-datetime.test.ts rename to packages/spec-api/test/matchers/datetime.test.ts index 50936c11936..8099a755a5f 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/datetime.test.ts @@ -1,21 +1,6 @@ import { describe, expect, it } from "vitest"; -import { type MatchResult } from "../../src/match-engine.js"; import { match } from "../../src/matchers/index.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); - } - } -} +import { expectFail, expectPass } from "./matcher-test-utils.js"; describe("match.dateTime.rfc3339()", () => { it("should throw for invalid datetime", () => { diff --git a/packages/spec-api/test/matchers/match-base-url.test.ts b/packages/spec-api/test/matchers/local-url.test.ts similarity index 80% rename from packages/spec-api/test/matchers/match-base-url.test.ts rename to packages/spec-api/test/matchers/local-url.test.ts index 8adc04e3838..6dd115a8baa 100644 --- a/packages/spec-api/test/matchers/match-base-url.test.ts +++ b/packages/spec-api/test/matchers/local-url.test.ts @@ -1,31 +1,17 @@ import { describe, expect, it } from "vitest"; -import { isMatcher, type MatchResult } from "../../src/match-engine.js"; +import { isMatcher } from "../../src/match-engine.js"; import { match } from "../../src/matchers/index.js"; import { expandDyns } from "../../src/response-utils.js"; import { ResolverConfig } from "../../src/types.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); - } - } -} +import { expectFail, expectPass } from "./matcher-test-utils.js"; describe("match.baseUrl()", () => { it("should be identified by isMatcher", () => { - expect(isMatcher(match.baseUrl("/some/path"))).toBe(true); + expect(isMatcher(match.localUrl("/some/path"))).toBe(true); }); describe("unresolved check() — loose path-suffix validation", () => { - const matcher = match.baseUrl("/payload/pageable/next-page"); + const matcher = match.localUrl("/payload/pageable/next-page"); it("should match any URL ending with the path", () => { expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page")); @@ -48,17 +34,17 @@ describe("match.baseUrl()", () => { describe("unresolved toJSON / toString", () => { it("toJSON should return the path", () => { - expect(match.baseUrl("/some/path").toJSON()).toBe("/some/path"); + expect(match.localUrl("/some/path").toJSON()).toBe("/some/path"); }); it("toString should return a descriptive string", () => { - expect(match.baseUrl("/some/path").toString()).toBe('match.baseUrl("/some/path")'); + expect(match.localUrl("/some/path").toString()).toBe('match.baseUrl("/some/path")'); }); }); describe("resolved matcher", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; - const resolved = match.baseUrl("/payload/pageable/next-page").resolve(config); + const resolved = match.localUrl("/payload/pageable/next-page").resolve(config); describe("check()", () => { it("should match the exact full URL (baseUrl + path)", () => { @@ -110,7 +96,7 @@ describe("match.baseUrl()", () => { }); describe("resolution with different base URLs", () => { - const unresolved = match.baseUrl("/api/items"); + const unresolved = match.localUrl("/api/items"); it("should resolve with localhost", () => { const resolved = unresolved.resolve({ baseUrl: "http://localhost:3000" }); @@ -132,7 +118,7 @@ describe("match.baseUrl()", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; it("should resolve baseUrl matchers to their full URL", () => { - const content = { next: match.baseUrl("/next-page") }; + const content = { next: match.localUrl("/next-page") }; const expanded = expandDyns(content, config); expect(expanded.next).toBe("http://localhost:3000/next-page"); }); @@ -140,7 +126,7 @@ describe("match.baseUrl()", () => { it("should resolve baseUrl matchers nested in objects", () => { const content = { data: { - nextLink: match.baseUrl("/items/page2"), + nextLink: match.localUrl("/items/page2"), }, }; const expanded = expandDyns(content, config); @@ -148,14 +134,14 @@ describe("match.baseUrl()", () => { }); it("should resolve baseUrl matchers in arrays", () => { - const content = { links: [match.baseUrl("/page1"), match.baseUrl("/page2")] }; + const content = { links: [match.localUrl("/page1"), match.localUrl("/page2")] }; const expanded = expandDyns(content, config); expect(expanded.links[0]).toBe("http://localhost:3000/page1"); expect(expanded.links[1]).toBe("http://localhost:3000/page2"); }); it("should serialize resolved matcher in JSON.stringify", () => { - const content = { next: match.baseUrl("/next-page") }; + const content = { next: match.localUrl("/next-page") }; const expanded = expandDyns(content, config); expect(JSON.stringify(expanded)).toBe('{"next":"http://localhost:3000/next-page"}'); }); diff --git a/packages/spec-api/test/matchers/matcher-test-utils.ts b/packages/spec-api/test/matchers/matcher-test-utils.ts new file mode 100644 index 00000000000..0067fbd8da3 --- /dev/null +++ b/packages/spec-api/test/matchers/matcher-test-utils.ts @@ -0,0 +1,17 @@ +import { expect } from "vitest"; +import { MatchResult } from "../../src/match-engine.js"; + +export function expectPass(result: MatchResult) { + expect(result).toEqual({ pass: true }); +} + +export 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); + } + } +} From f4f44bc925c4bc4af5152ebc0318df5fc49e03a6 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 13:26:00 -0400 Subject: [PATCH 16/26] Tweaks --- packages/spec-api/src/index.ts | 9 +++- packages/spec-api/src/match-engine.ts | 22 ++++++--- packages/spec-api/src/matchers/datetime.ts | 7 ++- packages/spec-api/src/matchers/index.ts | 1 + packages/spec-api/src/matchers/local-url.ts | 50 ++++++++++----------- packages/spec-api/src/response-utils.ts | 8 ++-- packages/spec-api/test/match-engine.test.ts | 2 +- 7 files changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 4c714996a48..c559058fd0c 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,5 +1,10 @@ -export { match, type ResolvableMockValueMatcher } from "./matchers/index.js"; -export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers/index.js"; +export { + isMatcher, + match, + matchValues, + type MatchResult, + type MockValueMatcher, +} from "./matchers/index.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/match-engine.ts b/packages/spec-api/src/match-engine.ts index 657523847bb..df81dc3dc26 100644 --- a/packages/spec-api/src/match-engine.ts +++ b/packages/spec-api/src/match-engine.ts @@ -39,6 +39,15 @@ export interface MockValueMatcher { toString(): string; } +/** Create a MockValueMatcher with the MatcherSymbol already set. */ +export function createMatcher(matcher: { + check(actual: unknown): MatchResult; + toJSON(): T; + toString(): string; +}): MockValueMatcher { + return { [MatcherSymbol]: true, ...matcher }; +} + /** Type guard to check if a value is a MockValueMatcher */ export function isMatcher(value: unknown): value is MockValueMatcher { return ( @@ -130,21 +139,24 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " const expectedPresentKeys = Object.keys(expectedObj).filter( (k) => expectedObj[k] !== undefined, ); - const expectedAbsentKeys = Object.keys(expectedObj).filter( - (k) => expectedObj[k] === undefined, - ); + const expectedAbsentKeys = Object.keys(expectedObj).filter((k) => expectedObj[k] === undefined); const actualKeys = Object.keys(actualObj); // Verify keys that should be absent are not in actual for (const key of expectedAbsentKeys) { if (key in actualObj && actualObj[key] !== undefined) { - return pathErr(`Key "${key}" should not be present but got ${formatValue(actualObj[key])}`, path); + return pathErr( + `Key "${key}" should not be present but got ${formatValue(actualObj[key])}`, + path, + ); } } if (expectedPresentKeys.length !== actualKeys.length) { const missing = expectedPresentKeys.filter((k) => !(k in actualObj)); - const extra = actualKeys.filter((k) => !expectedPresentKeys.includes(k) && !expectedAbsentKeys.includes(k)); + const extra = actualKeys.filter( + (k) => !expectedPresentKeys.includes(k) && !expectedAbsentKeys.includes(k), + ); const parts: string[] = [ `Key count mismatch: expected ${expectedPresentKeys.length} but got ${actualKeys.length}`, ]; diff --git a/packages/spec-api/src/matchers/datetime.ts b/packages/spec-api/src/matchers/datetime.ts index dd4dde1d27c..bfbe83f782c 100644 --- a/packages/spec-api/src/matchers/datetime.ts +++ b/packages/spec-api/src/matchers/datetime.ts @@ -1,6 +1,6 @@ import { + createMatcher, err, - MatcherSymbol, type MatchResult, type MockValueMatcher, ok, @@ -20,8 +20,7 @@ function createDateTimeMatcher( if (isNaN(expectedMs)) { throw new Error(`${label}: invalid datetime value: ${value}`); } - return { - [MatcherSymbol]: true, + return createMatcher({ check(actual: unknown): MatchResult { if (typeof actual !== "string") { return err( @@ -50,7 +49,7 @@ function createDateTimeMatcher( toString(): string { return `${label}(${value})`; }, - }; + }); } export const dateTimeMatcher = { diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts index 480b3cbf26d..6a1e72c4b56 100644 --- a/packages/spec-api/src/matchers/index.ts +++ b/packages/spec-api/src/matchers/index.ts @@ -2,6 +2,7 @@ import { dateTimeMatcher } from "./datetime.js"; import { baseUrlMatcher } from "./local-url.js"; export { + createMatcher, err, isMatcher, MatcherSymbol, diff --git a/packages/spec-api/src/matchers/local-url.ts b/packages/spec-api/src/matchers/local-url.ts index c45d5ca2f29..4c40e1e11cd 100644 --- a/packages/spec-api/src/matchers/local-url.ts +++ b/packages/spec-api/src/matchers/local-url.ts @@ -1,6 +1,6 @@ import { + createMatcher, err, - MatcherSymbol, type MatchResult, type MockValueMatcher, ok, @@ -18,19 +18,36 @@ export interface ResolvableMockValueMatcher extends MockValueMatche export function baseUrlMatcher(path: string): ResolvableMockValueMatcher { return { - [MatcherSymbol]: true, + ...createMatcher({ + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!actual.endsWith(path)) { + return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return path; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }), resolve(config: ResolverConfig): MockValueMatcher { const expected = config.baseUrl + path; - return { - [MatcherSymbol]: true, + return createMatcher({ check(actual: unknown): MatchResult { if (typeof actual !== "string") { return err( - `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, ); } if (actual !== expected) { - return err(`match.localUrl: expected "${expected}" but got "${actual}"`); + return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); } return ok(); }, @@ -38,26 +55,9 @@ export function baseUrlMatcher(path: string): ResolvableMockValueMatcher return expected; }, toString(): string { - return `match.localUrl("${path}")`; + return `match.baseUrl("${path}")`; }, - }; - }, - check(actual: unknown): MatchResult { - if (typeof actual !== "string") { - return err( - `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, - ); - } - if (!actual.endsWith(path)) { - return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); - } - return ok(); - }, - toJSON(): string { - return path; - }, - toString(): string { - return `match.baseUrl("${path}")`; + }); }, }; } diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 31fd61d93ff..6e6a7853519 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -33,7 +33,7 @@ const XML_DECLARATION = ``; * The XML declaration prefix is automatically prepended. * * Can be used as a plain function or as a tagged template literal. - * When used as a tagged template, interpolated matchers (e.g. `match.baseUrl`) + * When used as a tagged template, interpolated matchers (e.g. `match.localUrl`) * are resolved at serialization time via `expandDyns`. * * @example @@ -42,7 +42,7 @@ const XML_DECLARATION = ``; * xml("hello") * * // Tagged template with matcher - * xml`${match.baseUrl("/next")}` + * xml`${match.localUrl("/next")}` * ``` * * @returns {MockBody} response body with application/xml content type. @@ -94,7 +94,7 @@ export function dynItem(name: T): DynItem< * Tagged template for building strings with deferred resolution. * Interpolated values can be: * - `dynItem("baseUrl")` — resolved from `ResolverConfig` - * - Matchers (e.g. `match.baseUrl(...)`) — resolved via `expandDyns` + * - Matchers (e.g. `match.localUrl(...)`) — resolved via `expandDyns` * - Other `dyn` templates — recursively resolved * - Plain strings/numbers — used as-is */ @@ -121,7 +121,7 @@ export interface ExpandDynsOptions { /** * Recursively expands all dynamic values. * - Dyn functions are called with the config. - * - Resolvable matchers (e.g. `match.baseUrl`) are resolved via `resolve(config)`. + * - Resolvable matchers (e.g. `match.localUrl`) are resolved via `resolve(config)`. * - By default, matchers are resolved to their `toJSON()` plain value. * Pass `{ resolveMatchers: false }` to preserve matchers for use with `matchValues`. */ diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index 40373b8c034..aaf9a765273 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -186,7 +186,7 @@ describe("matchValues", () => { const expected = { link: resolved }; expectFail( matchValues({ link: "http://localhost:4000/next-page" }, expected), - "match.baseUrl", + "match.localUrl", ); }); From 0a7844465704cd081e202bf780c39ba147ee994f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 13:37:44 -0400 Subject: [PATCH 17/26] Better names --- packages/spec-api/src/match-engine.ts | 21 ++++++++++++---- packages/spec-api/src/matchers/datetime.ts | 2 +- packages/spec-api/src/matchers/local-url.ts | 14 +++++------ packages/spec-api/src/response-utils.ts | 4 +-- packages/spec-api/test/match-engine.test.ts | 2 +- .../spec-api/test/matchers/datetime.test.ts | 17 +++---------- .../spec-api/test/matchers/local-url.test.ts | 25 ++++++++----------- 7 files changed, 41 insertions(+), 44 deletions(-) diff --git a/packages/spec-api/src/match-engine.ts b/packages/spec-api/src/match-engine.ts index df81dc3dc26..e8b83892b03 100644 --- a/packages/spec-api/src/match-engine.ts +++ b/packages/spec-api/src/match-engine.ts @@ -33,19 +33,30 @@ 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) */ + /** The raw value to use when serializing */ + serialize(): T; + /** @internal Delegates to serialize() for JSON.stringify compatibility */ toJSON(): T; - /** Human-readable description for error messages */ + /** Human-readable description for debugging */ toString(): string; } /** Create a MockValueMatcher with the MatcherSymbol already set. */ export function createMatcher(matcher: { check(actual: unknown): MatchResult; - toJSON(): T; - toString(): string; + serialize(): T; + toString?: () => string; }): MockValueMatcher { - return { [MatcherSymbol]: true, ...matcher }; + return { + [MatcherSymbol]: true, + ...matcher, + toJSON() { + return matcher.serialize(); + }, + toString() { + return matcher.toString?.() ?? String(matcher.serialize()); + }, + }; } /** Type guard to check if a value is a MockValueMatcher */ diff --git a/packages/spec-api/src/matchers/datetime.ts b/packages/spec-api/src/matchers/datetime.ts index bfbe83f782c..3f45be46764 100644 --- a/packages/spec-api/src/matchers/datetime.ts +++ b/packages/spec-api/src/matchers/datetime.ts @@ -43,7 +43,7 @@ function createDateTimeMatcher( } return ok(); }, - toJSON(): string { + serialize(): string { return value; }, toString(): string { diff --git a/packages/spec-api/src/matchers/local-url.ts b/packages/spec-api/src/matchers/local-url.ts index 4c40e1e11cd..06f56e21347 100644 --- a/packages/spec-api/src/matchers/local-url.ts +++ b/packages/spec-api/src/matchers/local-url.ts @@ -26,15 +26,15 @@ export function baseUrlMatcher(path: string): ResolvableMockValueMatcher ); } if (!actual.endsWith(path)) { - return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); + return err(`match.localUrl: expected URL ending with "${path}" but got "${actual}"`); } return ok(); }, - toJSON(): string { + serialize(): string { return path; }, toString(): string { - return `match.baseUrl("${path}")`; + return `match.localUrl("${path}")`; }, }), resolve(config: ResolverConfig): MockValueMatcher { @@ -43,19 +43,19 @@ export function baseUrlMatcher(path: string): ResolvableMockValueMatcher check(actual: unknown): MatchResult { if (typeof actual !== "string") { return err( - `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, ); } if (actual !== expected) { - return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); + return err(`match.localUrl: expected "${expected}" but got "${actual}"`); } return ok(); }, - toJSON(): string { + serialize(): string { return expected; }, toString(): string { - return `match.baseUrl("${path}")`; + return `match.localUrl("${path}")`; }, }); }, diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 6e6a7853519..2d688dcfa1e 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -143,9 +143,9 @@ function _expandDyns(value: T, config: ResolverConfig, resolveMatchers: boole if (isMatcher(value)) { if ("resolve" in value && typeof (value as any).resolve === "function") { const resolved = (value as any).resolve(config); - return resolveMatchers ? (resolved.toJSON() as any) : (resolved as any); + return resolveMatchers ? (resolved.serialize() as any) : (resolved as any); } - return resolveMatchers ? (value.toJSON() as any) : (value as any); + return resolveMatchers ? (value.serialize() as any) : (value as any); } const obj = value as Record; return Object.fromEntries( diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index aaf9a765273..168865addf9 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -131,8 +131,8 @@ describe("matchValues", () => { [Symbol.for("SpectorMatcher")]: true as const, check: (actual: any) => actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`), + serialize: () => "raw", toJSON: () => "raw", - toString: () => "custom", } as any; expectPass(matchValues("matched", matcher)); expectFail(matchValues("not-matched", matcher)); diff --git a/packages/spec-api/test/matchers/datetime.test.ts b/packages/spec-api/test/matchers/datetime.test.ts index 8099a755a5f..a983b807273 100644 --- a/packages/spec-api/test/matchers/datetime.test.ts +++ b/packages/spec-api/test/matchers/datetime.test.ts @@ -108,9 +108,9 @@ describe("match.dateTime.rfc3339()", () => { }); }); - describe("toJSON()", () => { + describe("serialize()", () => { it("should return the original value", () => { - expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toJSON()).toBe( + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe( "2022-08-26T18:38:00.000Z", ); }); @@ -120,7 +120,6 @@ describe("match.dateTime.rfc3339()", () => { 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( @@ -156,19 +155,11 @@ describe("match.dateTime.rfc7231()", () => { }); }); - describe("toJSON()", () => { + describe("serialize()", () => { it("should preserve RFC 7231 format", () => { - expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").serialize()).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/spec-api/test/matchers/local-url.test.ts b/packages/spec-api/test/matchers/local-url.test.ts index 6dd115a8baa..a4be0303ee7 100644 --- a/packages/spec-api/test/matchers/local-url.test.ts +++ b/packages/spec-api/test/matchers/local-url.test.ts @@ -5,7 +5,7 @@ import { expandDyns } from "../../src/response-utils.js"; import { ResolverConfig } from "../../src/types.js"; import { expectFail, expectPass } from "./matcher-test-utils.js"; -describe("match.baseUrl()", () => { +describe("match.localUrl()", () => { it("should be identified by isMatcher", () => { expect(isMatcher(match.localUrl("/some/path"))).toBe(true); }); @@ -32,13 +32,9 @@ describe("match.baseUrl()", () => { }); }); - describe("unresolved toJSON / toString", () => { - it("toJSON should return the path", () => { - expect(match.localUrl("/some/path").toJSON()).toBe("/some/path"); - }); - - it("toString should return a descriptive string", () => { - expect(match.localUrl("/some/path").toString()).toBe('match.baseUrl("/some/path")'); + describe("unresolved serialize", () => { + it("serialize should return the path", () => { + expect(match.localUrl("/some/path").serialize()).toBe("/some/path"); }); }); @@ -54,19 +50,19 @@ describe("match.baseUrl()", () => { it("should not match a different base URL", () => { expectFail( resolved.check("http://localhost:4000/payload/pageable/next-page"), - "match.baseUrl", + "match.localUrl", ); }); it("should not match a different path", () => { expectFail( resolved.check("http://localhost:3000/payload/pageable/other-page"), - "match.baseUrl", + "match.localUrl", ); }); it("should not match a partial URL", () => { - expectFail(resolved.check("/payload/pageable/next-page"), "match.baseUrl"); + expectFail(resolved.check("/payload/pageable/next-page"), "match.localUrl"); }); it("should not match non-string values", () => { @@ -75,9 +71,9 @@ describe("match.baseUrl()", () => { }); }); - describe("toJSON()", () => { + describe("serialize()", () => { it("should return the full URL", () => { - expect(resolved.toJSON()).toBe("http://localhost:3000/payload/pageable/next-page"); + expect(resolved.serialize()).toBe("http://localhost:3000/payload/pageable/next-page"); }); it("should serialize correctly in JSON.stringify", () => { @@ -87,10 +83,9 @@ describe("match.baseUrl()", () => { ); }); }); - describe("toString()", () => { it("should return a descriptive string", () => { - expect(resolved.toString()).toBe('match.baseUrl("/payload/pageable/next-page")'); + expect(resolved.toString()).toBe('match.localUrl("/payload/pageable/next-page")'); }); }); }); From f92aef2b2331e405d6ce494f828a444f3f1bc1f6 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 14:02:34 -0400 Subject: [PATCH 18/26] cleaner --- packages/spec-api/src/match-engine.ts | 53 +++++-- packages/spec-api/src/matchers/datetime.ts | 16 +- packages/spec-api/src/matchers/index.ts | 1 + packages/spec-api/src/matchers/local-url.ts | 79 +++------- packages/spec-api/src/response-utils.ts | 6 +- packages/spec-api/test/match-engine.test.ts | 36 ++--- .../spec-api/test/matchers/local-url.test.ts | 142 +++++------------- 7 files changed, 118 insertions(+), 215 deletions(-) diff --git a/packages/spec-api/src/match-engine.ts b/packages/spec-api/src/match-engine.ts index e8b83892b03..d1e47df77ca 100644 --- a/packages/spec-api/src/match-engine.ts +++ b/packages/spec-api/src/match-engine.ts @@ -32,29 +32,53 @@ export function err(message: string): MatchResult { export interface MockValueMatcher { readonly [MatcherSymbol]: true; /** Check whether the actual value matches the expectation */ - check(actual: unknown): MatchResult; + check(actual: unknown, config?: MatcherConfig): MatchResult; /** The raw value to use when serializing */ - serialize(): T; + serialize(config?: MatcherConfig): T; /** @internal Delegates to serialize() for JSON.stringify compatibility */ toJSON(): T; /** Human-readable description for debugging */ toString(): string; } -/** Create a MockValueMatcher with the MatcherSymbol already set. */ -export function createMatcher(matcher: { +/** Configuration available to matchers at runtime */ +export interface MatcherConfig { + baseUrl: string; +} + +const emptyConfig: MatcherConfig = { baseUrl: "" }; + +interface MatcherImpl { check(actual: unknown): MatchResult; serialize(): T; toString?: () => string; -}): MockValueMatcher { +} + +/** Create a MockValueMatcher with the MatcherSymbol already set. + * Accepts either a plain implementation object (for matchers that don't need config) + * or a factory function `(config) => impl` (for matchers that do). + */ +export function createMatcher( + implOrFactory: MatcherImpl | ((config: MatcherConfig) => MatcherImpl), +): MockValueMatcher { + const resolve = + typeof implOrFactory === "function" + ? (config: MatcherConfig) => implOrFactory(config) + : () => implOrFactory; return { [MatcherSymbol]: true, - ...matcher, + check(actual: unknown, config?: MatcherConfig): MatchResult { + return resolve(config ?? emptyConfig).check(actual); + }, + serialize(config?: MatcherConfig): T { + return resolve(config ?? emptyConfig).serialize(); + }, toJSON() { - return matcher.serialize(); + return resolve(emptyConfig).serialize(); }, toString() { - return matcher.toString?.() ?? String(matcher.serialize()); + const impl = resolve(emptyConfig); + return impl.toString?.() ?? String(impl.serialize()); }, }; } @@ -89,13 +113,18 @@ function pathErr(message: string, path: string): MatchResult { * 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 { +export function matchValues( + actual: unknown, + expected: unknown, + path: string = "$", + config: MatcherConfig = emptyConfig, +): MatchResult { if (expected === actual) { return ok(); } if (isMatcher(expected)) { - const result = expected.check(actual); + const result = expected.check(actual, config); if (!result.pass) { return pathErr(result.message, path); } @@ -124,7 +153,7 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " ); } for (let i = 0; i < expected.length; i++) { - const result = matchValues(actual[i], expected[i], `${path}[${i}]`); + const result = matchValues(actual[i], expected[i], `${path}[${i}]`, config); if (!result.pass) { return result; } @@ -180,7 +209,7 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " if (!(key in actualObj)) { return pathErr(`Missing key "${key}"`, path); } - const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`); + const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`, config); if (!result.pass) { return result; } diff --git a/packages/spec-api/src/matchers/datetime.ts b/packages/spec-api/src/matchers/datetime.ts index 3f45be46764..6d9723b67ba 100644 --- a/packages/spec-api/src/matchers/datetime.ts +++ b/packages/spec-api/src/matchers/datetime.ts @@ -1,10 +1,4 @@ -import { - createMatcher, - err, - type MatchResult, - type MockValueMatcher, - ok, -} from "../match-engine.js"; +import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.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 = @@ -21,7 +15,7 @@ function createDateTimeMatcher( throw new Error(`${label}: invalid datetime value: ${value}`); } return createMatcher({ - check(actual: unknown): MatchResult { + check(actual: unknown) { if (typeof actual !== "string") { return err( `${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, @@ -38,15 +32,15 @@ function createDateTimeMatcher( } if (actualMs !== expectedMs) { return err( - `${label}: timestamps differ — expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`, + `${label}: timestamps differ \u2014 expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`, ); } return ok(); }, - serialize(): string { + serialize() { return value; }, - toString(): string { + toString() { return `${label}(${value})`; }, }); diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts index 6a1e72c4b56..5d36971d38a 100644 --- a/packages/spec-api/src/matchers/index.ts +++ b/packages/spec-api/src/matchers/index.ts @@ -8,6 +8,7 @@ export { MatcherSymbol, matchValues, ok, + type MatcherConfig, type MatchResult, type MockValueMatcher, } from "../match-engine.js"; diff --git a/packages/spec-api/src/matchers/local-url.ts b/packages/spec-api/src/matchers/local-url.ts index 06f56e21347..ecc07c406cb 100644 --- a/packages/spec-api/src/matchers/local-url.ts +++ b/packages/spec-api/src/matchers/local-url.ts @@ -1,63 +1,24 @@ -import { - createMatcher, - err, - type MatchResult, - type MockValueMatcher, - ok, -} from "../match-engine.js"; -import type { ResolverConfig } from "../types.js"; +import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js"; -/** - * A MockValueMatcher that also carries a `resolve` method. - * Before resolution, `check()` performs a loose path-suffix validation. - * After resolution via `expandDyns`, the returned matcher does exact equality. - */ -export interface ResolvableMockValueMatcher extends MockValueMatcher { - resolve(config: ResolverConfig): MockValueMatcher; -} - -export function baseUrlMatcher(path: string): ResolvableMockValueMatcher { - return { - ...createMatcher({ - check(actual: unknown): MatchResult { - if (typeof actual !== "string") { - return err( - `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, - ); - } - if (!actual.endsWith(path)) { - return err(`match.localUrl: expected URL ending with "${path}" but got "${actual}"`); - } - return ok(); - }, - serialize(): string { - return path; - }, - toString(): string { - return `match.localUrl("${path}")`; - }, - }), - resolve(config: ResolverConfig): MockValueMatcher { +export function baseUrlMatcher(path: string): MockValueMatcher { + return createMatcher((config) => ({ + check(actual: unknown) { + if (typeof actual !== "string") { + return err( + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } const expected = config.baseUrl + path; - return createMatcher({ - check(actual: unknown): MatchResult { - if (typeof actual !== "string") { - return err( - `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, - ); - } - if (actual !== expected) { - return err(`match.localUrl: expected "${expected}" but got "${actual}"`); - } - return ok(); - }, - serialize(): string { - return expected; - }, - toString(): string { - return `match.localUrl("${path}")`; - }, - }); + if (actual !== expected) { + return err(`match.localUrl: expected "${expected}" but got "${actual}"`); + } + return ok(); + }, + serialize() { + return config.baseUrl + path; + }, + toString() { + return `match.localUrl("${path}")`; }, - }; + })); } diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 2d688dcfa1e..c277dae3c39 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -141,11 +141,7 @@ function _expandDyns(value: T, config: ResolverConfig, resolveMatchers: boole return (config as any)[(value as any).name] as any; } if (isMatcher(value)) { - if ("resolve" in value && typeof (value as any).resolve === "function") { - const resolved = (value as any).resolve(config); - return resolveMatchers ? (resolved.serialize() as any) : (resolved as any); - } - return resolveMatchers ? (value.serialize() as any) : (value as any); + return resolveMatchers ? (value.serialize(config) as any) : (value as any); } const obj = value as Record; return Object.fromEntries( diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index 168865addf9..c1ec6f01ea9 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -16,10 +16,8 @@ describe("isMatcher", () => { expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); }); - it("should return true for baseUrl matchers (both unresolved and resolved)", () => { + it("should return true for localUrl matchers", () => { expect(isMatcher(match.localUrl("/path"))).toBe(true); - const resolved = match.localUrl("/path").resolve({ baseUrl: "http://localhost:3000" }); - expect(isMatcher(resolved)).toBe(true); }); it("should return false for plain values", () => { @@ -175,27 +173,13 @@ describe("matchValues", () => { expectFail(result, "rfc3339 format"); }); - it("should handle resolved baseUrl matchers", () => { - const resolved = match.localUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); - const expected = { link: resolved }; - expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); - }); - - it("should fail resolved baseUrl matchers on wrong value", () => { - const resolved = match.localUrl("/next-page").resolve({ baseUrl: "http://localhost:3000" }); - const expected = { link: resolved }; - expectFail( - matchValues({ link: "http://localhost:4000/next-page" }, expected), - "match.localUrl", - ); - }); - - it("should use loose path-suffix check for unresolved baseUrl matchers", () => { + it("should use localUrl matchers with config for exact URL check", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; const expected = { link: match.localUrl("/next-page") }; - expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected, "$", config)); expectFail( - matchValues({ link: "http://localhost:3000/other-page" }, expected), - 'ending with "/next-page"', + matchValues({ link: "http://localhost:3000/other-page" }, expected, "$", config), + "match.localUrl", ); }); }); @@ -216,7 +200,7 @@ describe("integration with expandDyns", () => { expect(expanded.items[0]).toBe("2022-08-26T18:38:00.000Z"); }); - it("should resolve baseUrl matchers to their full URL", () => { + it("should resolve localUrl matchers to their full URL", () => { const content = { next: match.localUrl("/next-page") }; const expanded = expandDyns(content, config); expect(expanded.next).toBe("http://localhost:3000/next-page"); @@ -248,16 +232,16 @@ describe("integration with json() Resolver", () => { expect(isMatcher(resolved.value)).toBe(true); }); - it("should serialize baseUrl matchers to their resolved value via serialize()", () => { + it("should serialize localUrl matchers to their full URL via serialize()", () => { const body = json({ next: match.localUrl("/items/page2") }); const raw = (body.rawContent as any).serialize(config); expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}'); }); - it("should resolve baseUrl matchers via resolve()", () => { + it("should preserve localUrl matchers via resolve()", () => { const body = json({ next: match.localUrl("/items/page2") }); const resolved = (body.rawContent as any).resolve(config) as Record; expect(isMatcher(resolved.next)).toBe(true); - expectPass((resolved.next as any).check("http://localhost:3000/items/page2")); + expectPass((resolved.next as any).check("http://localhost:3000/items/page2", config)); }); }); diff --git a/packages/spec-api/test/matchers/local-url.test.ts b/packages/spec-api/test/matchers/local-url.test.ts index a4be0303ee7..f576d418c0f 100644 --- a/packages/spec-api/test/matchers/local-url.test.ts +++ b/packages/spec-api/test/matchers/local-url.test.ts @@ -1,144 +1,82 @@ import { describe, expect, it } from "vitest"; -import { isMatcher } from "../../src/match-engine.js"; +import { isMatcher, type MatcherConfig } from "../../src/match-engine.js"; import { match } from "../../src/matchers/index.js"; -import { expandDyns } from "../../src/response-utils.js"; -import { ResolverConfig } from "../../src/types.js"; import { expectFail, expectPass } from "./matcher-test-utils.js"; +const config: MatcherConfig = { baseUrl: "http://localhost:3000" }; + describe("match.localUrl()", () => { it("should be identified by isMatcher", () => { expect(isMatcher(match.localUrl("/some/path"))).toBe(true); }); - describe("unresolved check() — loose path-suffix validation", () => { + describe("check()", () => { const matcher = match.localUrl("/payload/pageable/next-page"); - it("should match any URL ending with the path", () => { - expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page")); - expectPass(matcher.check("https://example.com/payload/pageable/next-page")); + it("should match exact full URL", () => { + expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page", config)); }); - it("should not match a different path", () => { + it("should not match a different base URL", () => { expectFail( - matcher.check("http://localhost:3000/payload/pageable/other-page"), - 'ending with "/payload/pageable/next-page"', + matcher.check("http://localhost:4000/payload/pageable/next-page", config), + "match.localUrl", ); }); - it("should not match non-string values", () => { - expectFail(matcher.check(42), "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"); + it("should not match a different path", () => { + expectFail( + matcher.check("http://localhost:3000/payload/pageable/other-page", config), + "match.localUrl", + ); }); - }); - describe("unresolved serialize", () => { - it("serialize should return the path", () => { - expect(match.localUrl("/some/path").serialize()).toBe("/some/path"); + it("should not match non-string values", () => { + expectFail(matcher.check(42, config), "expected a string but got number"); + expectFail(matcher.check(null, config), "expected a string but got object"); + expectFail(matcher.check(undefined, config), "expected a string but got undefined"); }); }); - describe("resolved matcher", () => { - const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; - const resolved = match.localUrl("/payload/pageable/next-page").resolve(config); - - describe("check()", () => { - it("should match the exact full URL (baseUrl + path)", () => { - expectPass(resolved.check("http://localhost:3000/payload/pageable/next-page")); - }); - - it("should not match a different base URL", () => { - expectFail( - resolved.check("http://localhost:4000/payload/pageable/next-page"), - "match.localUrl", - ); - }); - - it("should not match a different path", () => { - expectFail( - resolved.check("http://localhost:3000/payload/pageable/other-page"), - "match.localUrl", - ); - }); - - it("should not match a partial URL", () => { - expectFail(resolved.check("/payload/pageable/next-page"), "match.localUrl"); - }); - - it("should not match non-string values", () => { - expectFail(resolved.check(42), "expected a string but got number"); - expectFail(resolved.check(null), "expected a string but got object"); - }); + describe("serialize()", () => { + it("should return the full URL with config", () => { + expect(match.localUrl("/some/path").serialize(config)).toBe( + "http://localhost:3000/some/path", + ); }); - describe("serialize()", () => { - it("should return the full URL", () => { - expect(resolved.serialize()).toBe("http://localhost:3000/payload/pageable/next-page"); - }); - - it("should serialize correctly in JSON.stringify", () => { - const obj = { nextLink: resolved }; - expect(JSON.stringify(obj)).toBe( - '{"nextLink":"http://localhost:3000/payload/pageable/next-page"}', - ); - }); - }); - describe("toString()", () => { - it("should return a descriptive string", () => { - expect(resolved.toString()).toBe('match.localUrl("/payload/pageable/next-page")'); - }); + it("should serialize correctly in JSON.stringify", () => { + const obj = { nextLink: match.localUrl("/some/path") }; + // toJSON() uses empty config, so just the path + expect(JSON.stringify(obj)).toBe('{"nextLink":"/some/path"}'); }); }); describe("resolution with different base URLs", () => { - const unresolved = match.localUrl("/api/items"); + const matcher = match.localUrl("/api/items"); it("should resolve with localhost", () => { - const resolved = unresolved.resolve({ baseUrl: "http://localhost:3000" }); - expectPass(resolved.check("http://localhost:3000/api/items")); + expectPass( + matcher.check("http://localhost:3000/api/items", { baseUrl: "http://localhost:3000" }), + ); }); it("should resolve with https URL", () => { - const resolved = unresolved.resolve({ baseUrl: "https://example.com" }); - expectPass(resolved.check("https://example.com/api/items")); + expectPass( + matcher.check("https://example.com/api/items", { baseUrl: "https://example.com" }), + ); }); it("should resolve with URL including port", () => { - const resolved = unresolved.resolve({ baseUrl: "http://127.0.0.1:8080" }); - expectPass(resolved.check("http://127.0.0.1:8080/api/items")); + expectPass( + matcher.check("http://127.0.0.1:8080/api/items", { baseUrl: "http://127.0.0.1:8080" }), + ); }); }); - describe("integration with expandDyns", () => { - const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; - - it("should resolve baseUrl matchers to their full URL", () => { - const content = { next: match.localUrl("/next-page") }; - const expanded = expandDyns(content, config); - expect(expanded.next).toBe("http://localhost:3000/next-page"); - }); - - it("should resolve baseUrl matchers nested in objects", () => { - const content = { - data: { - nextLink: match.localUrl("/items/page2"), - }, - }; - const expanded = expandDyns(content, config); - expect(expanded.data.nextLink).toBe("http://localhost:3000/items/page2"); - }); - - it("should resolve baseUrl matchers in arrays", () => { - const content = { links: [match.localUrl("/page1"), match.localUrl("/page2")] }; - const expanded = expandDyns(content, config); - expect(expanded.links[0]).toBe("http://localhost:3000/page1"); - expect(expanded.links[1]).toBe("http://localhost:3000/page2"); - }); - - it("should serialize resolved matcher in JSON.stringify", () => { - const content = { next: match.localUrl("/next-page") }; - const expanded = expandDyns(content, config); - expect(JSON.stringify(expanded)).toBe('{"next":"http://localhost:3000/next-page"}'); + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.localUrl("/some/path").toString()).toBe('match.localUrl("/some/path")'); }); }); }); From 20417351e1fed4a3b9c3d541733635985c1f8aab Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 14:13:04 -0400 Subject: [PATCH 19/26] fix lint and format --- packages/spector/src/app/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 0d5d320bb7b..587def9458c 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -7,7 +7,6 @@ import { RequestExt, ResolverConfig, ScenarioMockApi, - ValidationError, } from "@typespec/spec-api"; import { ScenariosMetadata } from "@typespec/spec-coverage-sdk"; import { Response, Router } from "express"; From 34ea8f8a1c7d0cede193d64d56153403160795f5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 18:44:08 -0400 Subject: [PATCH 20/26] specific utc enforce --- .../specs/encode/datetime/mockapi.ts | 16 ++--- packages/spec-api/src/matchers/datetime.ts | 10 +++ packages/spec-api/src/matchers/index.ts | 1 + .../spec-api/test/matchers/datetime.test.ts | 67 +++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index 1472f9ac2f3..96f1d2e5c08 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -5,7 +5,7 @@ export const Scenarios: Record = {}; function createQueryServerTests( uri: string, value: any, - format: "rfc7231" | "rfc3339" | undefined, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { return passOnSuccess({ uri, @@ -22,12 +22,12 @@ function createQueryServerTests( Scenarios.Encode_Datetime_Query_default = createQueryServerTests( "/encode/datetime/query/default", "2022-08-26T18:38:00.000Z", - "rfc3339", + "utcRfc3339", ); Scenarios.Encode_Datetime_Query_rfc3339 = createQueryServerTests( "/encode/datetime/query/rfc3339", "2022-08-26T18:38:00.000Z", - "rfc3339", + "utcRfc3339", ); Scenarios.Encode_Datetime_Query_rfc7231 = createQueryServerTests( "/encode/datetime/query/rfc7231", @@ -47,7 +47,7 @@ Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests( function createPropertyServerTests( uri: string, value: any, - format: "rfc7231" | "rfc3339" | undefined, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { const matcherBody = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ @@ -66,12 +66,12 @@ function createPropertyServerTests( Scenarios.Encode_Datetime_Property_default = createPropertyServerTests( "/encode/datetime/property/default", "2022-08-26T18:38:00.000Z", - "rfc3339", + "utcRfc3339", ); Scenarios.Encode_Datetime_Property_rfc3339 = createPropertyServerTests( "/encode/datetime/property/rfc3339", "2022-08-26T18:38:00.000Z", - "rfc3339", + "utcRfc3339", ); Scenarios.Encode_Datetime_Property_rfc7231 = createPropertyServerTests( "/encode/datetime/property/rfc7231", @@ -91,7 +91,7 @@ Scenarios.Encode_Datetime_Property_unixTimestampArray = createPropertyServerTest function createHeaderServerTests( uri: string, value: any, - format: "rfc7231" | "rfc3339" | undefined, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { const matcherHeaders = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ @@ -114,7 +114,7 @@ Scenarios.Encode_Datetime_Header_default = createHeaderServerTests( Scenarios.Encode_Datetime_Header_rfc3339 = createHeaderServerTests( "/encode/datetime/header/rfc3339", "2022-08-26T18:38:00.000Z", - "rfc3339", + "utcRfc3339", ); Scenarios.Encode_Datetime_Header_rfc7231 = createHeaderServerTests( "/encode/datetime/header/rfc7231", diff --git a/packages/spec-api/src/matchers/datetime.ts b/packages/spec-api/src/matchers/datetime.ts index 6d9723b67ba..83843f5eecb 100644 --- a/packages/spec-api/src/matchers/datetime.ts +++ b/packages/spec-api/src/matchers/datetime.ts @@ -1,6 +1,7 @@ import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js"; const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; +const utcRfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/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; @@ -50,6 +51,15 @@ export const dateTimeMatcher = { rfc3339(value: string): MockValueMatcher { return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern); }, + /** Like rfc3339 but rejects timezone offsets — only Z (UTC) suffix is allowed. */ + utcRfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher( + value, + "match.dateTime.utcRfc3339", + "utcRfc3339", + utcRfc3339Pattern, + ); + }, rfc7231(value: string): MockValueMatcher { return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); }, diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts index 5d36971d38a..054a5bb5c3e 100644 --- a/packages/spec-api/src/matchers/index.ts +++ b/packages/spec-api/src/matchers/index.ts @@ -26,6 +26,7 @@ export const match = { * @example * ```ts * match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") + * match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") // rejects offsets, only Z * match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT") * ``` */ diff --git a/packages/spec-api/test/matchers/datetime.test.ts b/packages/spec-api/test/matchers/datetime.test.ts index a983b807273..2803984fbfd 100644 --- a/packages/spec-api/test/matchers/datetime.test.ts +++ b/packages/spec-api/test/matchers/datetime.test.ts @@ -163,3 +163,70 @@ describe("match.dateTime.rfc7231()", () => { }); }); }); + +describe("match.dateTime.utcRfc3339()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.utcRfc3339("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime.utcRfc3339("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.utcRfc3339("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 reject +00:00 offset even though equivalent to Z", () => { + expectFail(matcher.check("2022-08-26T18:38:00.000+00:00"), "utcRfc3339 format"); + }); + + it("should reject timezone offset", () => { + expectFail(matcher.check("2022-08-26T14:38:00.000-04:00"), "utcRfc3339 format"); + }); + + it("should reject positive timezone offset", () => { + expectFail(matcher.check("2022-08-26T20:38:00.000+02:00"), "utcRfc3339 format"); + }); + + it("should reject RFC 7231 format", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "utcRfc3339 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("2022-08-26T18:39: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"); + }); + }); + + describe("serialize()", () => { + it("should return the original value", () => { + expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); + }); + + describe("toString()", () => { + it("should include utcRfc3339 in toString()", () => { + expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime.utcRfc3339(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); From f3e60451e59afc7ae959acb267e253ddeabff2c7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 18:52:11 -0400 Subject: [PATCH 21/26] do xml datetime too --- .../http-specs/specs/payload/xml/mockapi.ts | 69 +++++-------------- 1 file changed, 17 insertions(+), 52 deletions(-) diff --git a/packages/http-specs/specs/payload/xml/mockapi.ts b/packages/http-specs/specs/payload/xml/mockapi.ts index de2d3f85834..31687d0a999 100644 --- a/packages/http-specs/specs/payload/xml/mockapi.ts +++ b/packages/http-specs/specs/payload/xml/mockapi.ts @@ -1,4 +1,11 @@ -import { MockRequest, passOnCode, passOnSuccess, ScenarioMockApi, xml } from "@typespec/spec-api"; +import { + match, + MockRequest, + passOnCode, + passOnSuccess, + ScenarioMockApi, + xml, +} from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -129,22 +136,19 @@ export const modelWithEnum = ` `; -export const modelWithDatetime = ` +export const modelWithDatetime = xml` - 2022-08-26T18:38:00.000Z - Fri, 26 Aug 2022 14:38:00 GMT + ${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")} + ${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")} `; -// Some clients serialize UTC datetimes without trailing zero milliseconds. Both -// "2022-08-26T18:38:00.000Z" and "2022-08-26T18:38:00Z" are valid RFC3339 representations -// of the same instant; accept either form. -const modelWithDatetimeNoMs = ` - - 2022-08-26T18:38:00Z - Fri, 26 Aug 2022 14:38:00 GMT - -`; +const Payload_Xml_ModelWithDatetime = createServerTests( + "/payload/xml/modelWithDatetime", + modelWithDatetime, +); +Scenarios.Payload_Xml_ModelWithDatetimeValue_get = Payload_Xml_ModelWithDatetime.get; +Scenarios.Payload_Xml_ModelWithDatetimeValue_put = Payload_Xml_ModelWithDatetime.put; function createServerTests(uri: string, data?: any) { return { @@ -261,45 +265,6 @@ const Payload_Xml_ModelWithEnum = createServerTests("/payload/xml/modelWithEnum" Scenarios.Payload_Xml_ModelWithEnumValue_get = Payload_Xml_ModelWithEnum.get; Scenarios.Payload_Xml_ModelWithEnumValue_put = Payload_Xml_ModelWithEnum.put; -Scenarios.Payload_Xml_ModelWithDatetimeValue_get = passOnSuccess({ - uri: "/payload/xml/modelWithDatetime", - method: "get", - request: {}, - response: { - status: 200, - body: xml(modelWithDatetime), - }, - kind: "MockApiDefinition", -}); - -Scenarios.Payload_Xml_ModelWithDatetimeValue_put = passOnSuccess({ - uri: "/payload/xml/modelWithDatetime", - method: "put", - request: { - body: xml(modelWithDatetime), - }, - handler: (req: MockRequest) => { - req.expect.containsHeader("content-type", "application/xml"); - // Accept both "2022-08-26T18:38:00.000Z" and "2022-08-26T18:38:00Z" as equivalent UTC datetimes. - let firstError: unknown; - try { - req.expect.xmlBodyEquals(modelWithDatetime); - } catch (e) { - firstError = e; - } - if (firstError !== undefined) { - req.expect.xmlBodyEquals(modelWithDatetimeNoMs); - } - return { - status: 204, - }; - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", -}); - export const xmlError = ` Something went wrong From 5ae72d34f436f68e958f37da2557c90194281b75 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 19:16:21 -0400 Subject: [PATCH 22/26] fix order --- packages/http-specs/specs/payload/xml/mockapi.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/http-specs/specs/payload/xml/mockapi.ts b/packages/http-specs/specs/payload/xml/mockapi.ts index 9c06a58dcd0..4f304c6324b 100644 --- a/packages/http-specs/specs/payload/xml/mockapi.ts +++ b/packages/http-specs/specs/payload/xml/mockapi.ts @@ -291,6 +291,13 @@ const Payload_Xml_ModelWithDatetime = createServerTests( Scenarios.Payload_Xml_ModelWithDatetimeValue_get = Payload_Xml_ModelWithDatetime.get; Scenarios.Payload_Xml_ModelWithDatetimeValue_put = Payload_Xml_ModelWithDatetime.put; +export const xmlError = ` + + Something went wrong + 400 + +`; + // ──────────────────────────────────────────────────────────────────────────── // Scenario registrations // ──────────────────────────────────────────────────────────────────────────── @@ -519,13 +526,6 @@ const Payload_Xml_ModelWithEnum = createServerTests("/payload/xml/modelWithEnum" Scenarios.Payload_Xml_ModelWithEnumValue_get = Payload_Xml_ModelWithEnum.get; Scenarios.Payload_Xml_ModelWithEnumValue_put = Payload_Xml_ModelWithEnum.put; -export const xmlError = ` - - Something went wrong - 400 - -`; - Scenarios.Payload_Xml_XmlErrorValue_get = passOnCode(400, { uri: "/payload/xml/error", method: "get", From af44f9c3e5cfcbe02e9136389e7ece8cc6705d3a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 19:22:29 -0400 Subject: [PATCH 23/26] fix --- .../http-specs/specs/payload/xml/mockapi.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/http-specs/specs/payload/xml/mockapi.ts b/packages/http-specs/specs/payload/xml/mockapi.ts index 4f304c6324b..4af0d1e3dc6 100644 --- a/packages/http-specs/specs/payload/xml/mockapi.ts +++ b/packages/http-specs/specs/payload/xml/mockapi.ts @@ -1,6 +1,6 @@ import { match, - MockRequest, + type MockBody, passOnCode, passOnSuccess, ScenarioMockApi, @@ -302,7 +302,12 @@ export const xmlError = ` // Scenario registrations // ──────────────────────────────────────────────────────────────────────────── +function isMockBody(data: any): data is MockBody { + return typeof data === "object" && data !== null && "contentType" in data; +} + function createServerTests(uri: string, data?: any) { + const body = isMockBody(data) ? data : xml(data); return { get: passOnSuccess({ uri, @@ -310,7 +315,7 @@ function createServerTests(uri: string, data?: any) { request: {}, response: { status: 200, - body: xml(data), + body, }, kind: "MockApiDefinition", }), @@ -318,14 +323,7 @@ function createServerTests(uri: string, data?: any) { uri, method: "put", request: { - body: xml(data), - }, - handler: (req: MockRequest) => { - req.expect.containsHeader("content-type", "application/xml"); - req.expect.xmlBodyEquals(data); - return { - status: 204, - }; + body, }, response: { status: 204, From afe263452082caea5685fb09d1f957e415c13bb0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 20:43:00 -0400 Subject: [PATCH 24/26] fix xml validation --- packages/spec-api/src/expectation.ts | 15 +- packages/spec-api/src/request-validations.ts | 83 +++++++-- packages/spec-api/src/response-utils.ts | 26 ++- packages/spector/src/app/app.ts | 13 +- packages/spector/test/xml-validation.test.ts | 180 +++++++++++++++++++ 5 files changed, 284 insertions(+), 33 deletions(-) create mode 100644 packages/spector/test/xml-validation.test.ts diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index 086361fc421..17aa8dadd6a 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -9,7 +9,7 @@ import { validateRawBodyEquals, validateXmlBodyEquals, } from "./request-validations.js"; -import { CollectionFormat, RequestExt } from "./types.js"; +import { CollectionFormat, RequestExt, Resolver, ResolverConfig } from "./types.js"; import { ValidationError } from "./validation-error.js"; /** @@ -96,12 +96,15 @@ export class RequestExpectation { } /** - * Expect the body of the request to be semantically equivalent to the provided XML string. - * The XML declaration prefix will automatically be added to expectedBody. - * @param expectedBody expected value of request body. + * Expect the body of the request to be semantically equivalent to the provided XML. + * Accepts a plain string or a Resolver (e.g. from `xml\`...\``). + * When a Resolver with matchers is provided, matcher-aware comparison is used. + * The XML declaration prefix will automatically be added. + * @param expectedBody expected XML body as a string or Resolver. + * @param config resolver config (required when expectedBody is a Resolver). * @throws {ValidationError} if there is an error. */ - public xmlBodyEquals(expectedBody: string): void { - validateXmlBodyEquals(this.originalRequest, expectedBody); + public xmlBodyEquals(expectedBody: string | Resolver, config?: ResolverConfig): void { + validateXmlBodyEquals(this.originalRequest, expectedBody, config); } } diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index a6a6957c5fa..4a4b1b632f3 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -1,7 +1,7 @@ import deepEqual from "deep-equal"; import { parseString } from "xml2js"; -import { matchValues } from "./match-engine.js"; -import { CollectionFormat, RequestExt } from "./types.js"; +import { matchValues, type MockValueMatcher } from "./match-engine.js"; +import { CollectionFormat, RequestExt, Resolver, ResolverConfig } from "./types.js"; import { ValidationError } from "./validation-error.js"; export const BODY_NOT_EQUAL_ERROR_MESSAGE = "Body provided doesn't match expected body"; @@ -47,34 +47,79 @@ export const validateBodyEquals = ( } }; -export const validateXmlBodyEquals = (request: RequestExt, expectedBody: string): void => { +export const validateXmlBodyEquals = ( + request: RequestExt, + expectedBody: string | Resolver, + config?: ResolverConfig, +): void => { + const resolvedConfig = config ?? { baseUrl: "" }; + // When expectedBody is a Resolver (e.g. from xml`...`), serialize() already includes the XML declaration. + // When it's a plain string, we need to prepend it. + const expectedXml = + typeof expectedBody === "string" + ? `` + expectedBody + : expectedBody.serialize(resolvedConfig); + if (request.rawBody === undefined || isBodyEmpty(request.rawBody)) { - throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedBody, request.rawBody); + throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedXml, request.rawBody); } - expectedBody = `` + expectedBody; - - let actualParsedBody = ""; + let actualParsed: unknown; parseString(request.rawBody, (err: Error | null, result: any): void => { - if (err !== null) { - throw err; - } - actualParsedBody = result; + if (err !== null) throw err; + actualParsed = result; }); - let expectedParsedBody = ""; - parseString(expectedBody, (err: Error | null, result: any): void => { - if (err !== null) { - throw err; - } - expectedParsedBody = result; + let expectedParsed: unknown; + parseString(expectedXml, (err: Error | null, result: any): void => { + if (err !== null) throw err; + expectedParsed = result; }); - if (!deepEqual(actualParsedBody, expectedParsedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.rawBody); + // If the expected body is a DynValue with matchers, use matcher-aware comparison + const matchers = + typeof expectedBody !== "string" && "getMatchers" in expectedBody + ? (expectedBody as any).getMatchers(resolvedConfig) + : []; + + if (matchers.length > 0) { + const matcherMap = new Map(); + for (const { serialized, matcher } of matchers) { + matcherMap.set(serialized, matcher); + } + expectedParsed = substituteMatchers(expectedParsed, matcherMap); + + const result = matchValues(actualParsed, expectedParsed); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedXml, + request.rawBody, + ); + } + } else { + if (!deepEqual(actualParsed, expectedParsed, { strict: true })) { + throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedXml, request.rawBody); + } } }; +function substituteMatchers(value: unknown, matcherMap: Map): unknown { + if (typeof value === "string") { + return matcherMap.get(value) ?? value; + } + if (Array.isArray(value)) { + return value.map((v) => substituteMatchers(v, matcherMap)); + } + if (typeof value === "object" && value !== null) { + const obj = value as Record; + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, substituteMatchers(v, matcherMap)]), + ); + } + return value; +} + export const validateCoercedDateBodyEquals = ( request: RequestExt, expectedBody: unknown | undefined, diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index c277dae3c39..d815ba85f37 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -1,4 +1,4 @@ -import { isMatcher } from "./match-engine.js"; +import { isMatcher, type MockValueMatcher } from "./match-engine.js"; import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js"; /** @@ -76,6 +76,8 @@ export function multipart( export interface DynValue extends Resolver { readonly isDyn: true; (config: ResolverConfig): string; + /** Returns all matchers embedded in this template with their serialized values. */ + getMatchers(config: ResolverConfig): Array<{ serialized: string; matcher: MockValueMatcher }>; } export interface DynItem { @@ -110,9 +112,31 @@ export function dyn(strings: readonly string[], ...values: unknown[]): DynValue template.isDyn = true as const; template.serialize = template; template.resolve = template; + template.getMatchers = (config: ResolverConfig) => { + const result: Array<{ serialized: string; matcher: MockValueMatcher }> = []; + for (const v of values) { + collectMatchers(v, config, result); + } + return result; + }; return template; } +function collectMatchers( + value: unknown, + config: ResolverConfig, + out: Array<{ serialized: string; matcher: MockValueMatcher }>, +): void { + if (isMatcher(value)) { + out.push({ serialized: String(value.serialize(config)), matcher: value }); + } else if (typeof value === "function" && "isDyn" in value && value.isDyn) { + const dynVal = value as DynValue; + if (dynVal.getMatchers) { + out.push(...dynVal.getMatchers(config)); + } + } +} + export interface ExpandDynsOptions { /** When true, matchers are resolved to their `toJSON()` value. Default: true. */ resolveMatchers?: boolean; diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 587def9458c..c2f6a8ef311 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -115,13 +115,12 @@ function validateBody( break; } case "application/xml": { - const raw = - typeof body.rawContent === "string" - ? body.rawContent - : body.rawContent?.serialize(config); - req.expect.xmlBodyEquals( - (raw as any).replace(``, ""), - ); + if (typeof body.rawContent === "string") { + const xmlStr = body.rawContent.replace(``, ""); + req.expect.xmlBodyEquals(xmlStr); + } else if (body.rawContent) { + req.expect.xmlBodyEquals(body.rawContent, config); + } break; } default: { diff --git a/packages/spector/test/xml-validation.test.ts b/packages/spector/test/xml-validation.test.ts new file mode 100644 index 00000000000..025b9c23c5f --- /dev/null +++ b/packages/spector/test/xml-validation.test.ts @@ -0,0 +1,180 @@ +import { + match, + MockRequest, + xml, + type MockBody, + type RequestExt, + type ResolverConfig, +} from "@typespec/spec-api"; +import { describe, expect, it } from "vitest"; + +const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + +function makeRequestExt(rawBody: string): RequestExt { + return { + rawBody, + protocol: "http", + get: () => "localhost:3000", + headers: {}, + query: {}, + params: {}, + } as unknown as RequestExt; +} + +/** + * Simulate how spector validates XML (replicates the logic in app.ts validateBody). + */ +function validateXmlBody(body: MockBody, rawBody: string) { + const req = new MockRequest(makeRequestExt(rawBody)); + + if (typeof body.rawContent === "string") { + const xmlStr = body.rawContent.replace(``, ""); + req.expect.xmlBodyEquals(xmlStr); + } else if (body.rawContent) { + req.expect.xmlBodyEquals(body.rawContent as any, config); + } +} + +describe("XML validation with matchers", () => { + describe("datetime matchers", () => { + const body = xml` + + ${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")} + ${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")} +`; + + it("should accept exact match", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T18:38:00.000ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + ).not.toThrow(); + }); + + it("should accept datetime without fractional seconds", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T18:38:00ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + ).not.toThrow(); + }); + + it("should accept datetime with extra precision", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T18:38:00.0000000ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + ).not.toThrow(); + }); + + it("should reject wrong time", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T19:00:00.000ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject timezone offset for utcRfc3339", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T18:38:00.000+00:00Fri, 26 Aug 2022 14:38:00 GMT`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject wrong rfc7231 value", () => { + expect(() => + validateXmlBody( + body, + `2022-08-26T18:38:00.000ZMon, 01 Jan 2024 00:00:00 GMT`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject non-date string", () => { + expect(() => + validateXmlBody( + body, + `not-a-dateFri, 26 Aug 2022 14:38:00 GMT`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject empty body", () => { + expect(() => validateXmlBody(body, "")).toThrow("Body should exists"); + }); + }); + + describe("plain values alongside matchers", () => { + const body = xml` + + test + ${match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")} +`; + + it("should pass when plain values and matcher values are correct", () => { + expect(() => + validateXmlBody( + body, + `test2022-08-26T18:38:00Z`, + ), + ).not.toThrow(); + }); + + it("should reject when plain value differs", () => { + expect(() => + validateXmlBody( + body, + `wrong2022-08-26T18:38:00Z`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject when matcher value differs", () => { + expect(() => + validateXmlBody( + body, + `test2023-01-01T00:00:00Z`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + }); + + describe("plain XML without matchers", () => { + const body = xml("hello"); + + it("should accept exact match", () => { + expect(() => + validateXmlBody( + body, + `hello`, + ), + ).not.toThrow(); + }); + + it("should reject different value", () => { + expect(() => + validateXmlBody( + body, + `world`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should use strict comparison for plain xml (no datetime flexibility)", () => { + const plainBody = xml("2022-08-26T18:38:00.000Z"); + expect(() => + validateXmlBody( + plainBody, + `2022-08-26T18:38:00Z`, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + }); +}); From e493a704a0855a95fce960ae9f04196cc0576029 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 20:50:35 -0400 Subject: [PATCH 25/26] simplify --- packages/spec-api/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index c559058fd0c..84f4cb23200 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,7 +1,10 @@ export { + createMatcher, + err, isMatcher, match, matchValues, + ok, type MatchResult, type MockValueMatcher, } from "./matchers/index.js"; From b62486d43f61f6a85cccec0182405fbc21290a7c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 20:50:48 -0400 Subject: [PATCH 26/26] simplify test --- packages/spector/test/xml-validation.test.ts | 216 ++++++++----------- 1 file changed, 94 insertions(+), 122 deletions(-) diff --git a/packages/spector/test/xml-validation.test.ts b/packages/spector/test/xml-validation.test.ts index 025b9c23c5f..121725a02b4 100644 --- a/packages/spector/test/xml-validation.test.ts +++ b/packages/spector/test/xml-validation.test.ts @@ -1,8 +1,10 @@ import { + createMatcher, + err, match, - MockRequest, + ok, + validateXmlBodyEquals, xml, - type MockBody, type RequestExt, type ResolverConfig, } from "@typespec/spec-api"; @@ -10,171 +12,141 @@ import { describe, expect, it } from "vitest"; const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; -function makeRequestExt(rawBody: string): RequestExt { - return { - rawBody, - protocol: "http", - get: () => "localhost:3000", - headers: {}, - query: {}, - params: {}, - } as unknown as RequestExt; +function makeRequest(rawBody: string): RequestExt { + return { rawBody } as unknown as RequestExt; } -/** - * Simulate how spector validates XML (replicates the logic in app.ts validateBody). - */ -function validateXmlBody(body: MockBody, rawBody: string) { - const req = new MockRequest(makeRequestExt(rawBody)); - - if (typeof body.rawContent === "string") { - const xmlStr = body.rawContent.replace(``, ""); - req.expect.xmlBodyEquals(xmlStr); - } else if (body.rawContent) { - req.expect.xmlBodyEquals(body.rawContent as any, config); - } -} - -describe("XML validation with matchers", () => { - describe("datetime matchers", () => { - const body = xml` - - ${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")} - ${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")} -`; - - it("should accept exact match", () => { - expect(() => - validateXmlBody( - body, - `2022-08-26T18:38:00.000ZFri, 26 Aug 2022 14:38:00 GMT`, - ), - ).not.toThrow(); - }); - - it("should accept datetime without fractional seconds", () => { - expect(() => - validateXmlBody( - body, - `2022-08-26T18:38:00ZFri, 26 Aug 2022 14:38:00 GMT`, - ), - ).not.toThrow(); - }); - - it("should accept datetime with extra precision", () => { +describe("validateXmlBodyEquals", () => { + describe("with plain string (no matchers)", () => { + it("should accept matching XML", () => { expect(() => - validateXmlBody( - body, - `2022-08-26T18:38:00.0000000ZFri, 26 Aug 2022 14:38:00 GMT`, + validateXmlBodyEquals( + makeRequest(`1`), + "1", ), ).not.toThrow(); }); - it("should reject wrong time", () => { + it("should reject mismatched XML", () => { expect(() => - validateXmlBody( - body, - `2022-08-26T19:00:00.000ZFri, 26 Aug 2022 14:38:00 GMT`, + validateXmlBodyEquals( + makeRequest(`2`), + "1", ), ).toThrow("Body provided doesn't match expected body"); }); - it("should reject timezone offset for utcRfc3339", () => { - expect(() => - validateXmlBody( - body, - `2022-08-26T18:38:00.000+00:00Fri, 26 Aug 2022 14:38:00 GMT`, - ), - ).toThrow("Body provided doesn't match expected body"); + it("should reject empty body", () => { + expect(() => validateXmlBodyEquals(makeRequest(""), "")).toThrow("Body should exists"); }); + }); - it("should reject wrong rfc7231 value", () => { + describe("with Resolver containing matchers", () => { + it("should use matcher check instead of strict equality", () => { + // A custom matcher that accepts any number + const anyNumber = createMatcher({ + check(actual) { + return typeof actual === "string" && /^\d+$/.test(actual) + ? ok() + : err("expected a number string"); + }, + serialize: () => "PLACEHOLDER", + }); + + const body = xml`${anyNumber}`; + + // "42" is a number string → should pass expect(() => - validateXmlBody( - body, - `2022-08-26T18:38:00.000ZMon, 01 Jan 2024 00:00:00 GMT`, + validateXmlBodyEquals( + makeRequest(`42`), + body.rawContent as any, + config, ), - ).toThrow("Body provided doesn't match expected body"); - }); + ).not.toThrow(); - it("should reject non-date string", () => { + // "abc" is not a number → should fail expect(() => - validateXmlBody( - body, - `not-a-dateFri, 26 Aug 2022 14:38:00 GMT`, + validateXmlBodyEquals( + makeRequest(`abc`), + body.rawContent as any, + config, ), ).toThrow("Body provided doesn't match expected body"); }); - it("should reject empty body", () => { - expect(() => validateXmlBody(body, "")).toThrow("Body should exists"); - }); - }); + it("should validate plain elements strictly alongside matchers", () => { + const anyNumber = createMatcher({ + check(actual) { + return typeof actual === "string" && /^\d+$/.test(actual) + ? ok() + : err("expected a number string"); + }, + serialize: () => "0", + }); - describe("plain values alongside matchers", () => { - const body = xml` - - test - ${match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")} -`; + const body = xml`test${anyNumber}`; - it("should pass when plain values and matcher values are correct", () => { + // Both correct expect(() => - validateXmlBody( - body, - `test2022-08-26T18:38:00Z`, + validateXmlBodyEquals( + makeRequest( + `test5`, + ), + body.rawContent as any, + config, ), ).not.toThrow(); - }); - it("should reject when plain value differs", () => { + // Plain element wrong expect(() => - validateXmlBody( - body, - `wrong2022-08-26T18:38:00Z`, + validateXmlBodyEquals( + makeRequest( + `wrong5`, + ), + body.rawContent as any, + config, ), ).toThrow("Body provided doesn't match expected body"); }); - it("should reject when matcher value differs", () => { - expect(() => - validateXmlBody( - body, - `test2023-01-01T00:00:00Z`, - ), - ).toThrow("Body provided doesn't match expected body"); - }); - }); - - describe("plain XML without matchers", () => { - const body = xml("hello"); + it("should work with datetime matchers", () => { + const body = xml`${match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")}`; - it("should accept exact match", () => { + // Without fractional seconds — same point in time expect(() => - validateXmlBody( - body, - `hello`, + validateXmlBodyEquals( + makeRequest( + `2022-08-26T18:38:00Z`, + ), + body.rawContent as any, + config, ), ).not.toThrow(); - }); - it("should reject different value", () => { + // Different time expect(() => - validateXmlBody( - body, - `world`, + validateXmlBodyEquals( + makeRequest( + `2023-01-01T00:00:00Z`, + ), + body.rawContent as any, + config, ), ).toThrow("Body provided doesn't match expected body"); }); - it("should use strict comparison for plain xml (no datetime flexibility)", () => { - const plainBody = xml("2022-08-26T18:38:00.000Z"); + it("should work with multiple matchers", () => { + const body = xml`${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")}${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")}`; + expect(() => - validateXmlBody( - plainBody, - `2022-08-26T18:38:00Z`, + validateXmlBodyEquals( + makeRequest( + `2022-08-26T18:38:00.0000000ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + body.rawContent as any, + config, ), - ).toThrow("Body provided doesn't match expected body"); + ).not.toThrow(); }); }); });