From a5ed84e1391e507c5b2d0511416b3b8a46830926 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:14:04 +0000 Subject: [PATCH 1/4] fix: handle matchers in query param validation in spector createHandler Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/97f0d4fc-38c9-455c-b99d-f9dd980c6658 Co-authored-by: weidongxu-microsoft <53292327+weidongxu-microsoft@users.noreply.github.com> --- .chronus/changes/fix-query-matcher-handling-2026-4-3.md | 7 +++++++ packages/spector/src/app/app.ts | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/fix-query-matcher-handling-2026-4-3.md diff --git a/.chronus/changes/fix-query-matcher-handling-2026-4-3.md b/.chronus/changes/fix-query-matcher-handling-2026-4-3.md new file mode 100644 index 00000000000..c0a07d92c41 --- /dev/null +++ b/.chronus/changes/fix-query-matcher-handling-2026-4-3.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/spector" +--- + +Fix query parameter matcher handling: use `resolveMatchers: false` so matcher objects (e.g. `match.dateTime`) are checked semantically instead of being serialized to plain strings before comparison. diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index c2f6a8ef311..4087742ca83 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, @@ -158,9 +159,11 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) } if (apiDefinition.request?.query) { - const query = expandDyns(apiDefinition.request.query, config); + const query = expandDyns(apiDefinition.request.query, config, { resolveMatchers: false }); Object.entries(query).forEach(([key, value]) => { - if (Array.isArray(value)) { + if (isMatcher(value)) { + req.expect.deepEqual(req.query[key], value); + } else if (Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { req.expect.containsQueryParam(key, String(value)); From 7528d513cae01a188b9d30843ed04445159a466d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:16:18 +0000 Subject: [PATCH 2/4] fix: simplify query matcher/array branches to single deepEqual condition Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/97f0d4fc-38c9-455c-b99d-f9dd980c6658 Co-authored-by: weidongxu-microsoft <53292327+weidongxu-microsoft@users.noreply.github.com> --- packages/spector/src/app/app.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 4087742ca83..f028e2d938d 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -161,9 +161,7 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) if (apiDefinition.request?.query) { const query = expandDyns(apiDefinition.request.query, config, { resolveMatchers: false }); Object.entries(query).forEach(([key, value]) => { - if (isMatcher(value)) { - req.expect.deepEqual(req.query[key], value); - } else if (Array.isArray(value)) { + if (isMatcher(value) || Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { req.expect.containsQueryParam(key, String(value)); From 22b55dcc234fb5d0420b1777774f83874eb3abe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:27:08 +0000 Subject: [PATCH 3/4] test: add regression tests for query param matcher preservation through expandDyns Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/3d8e85a8-9e45-4d55-9f8c-7882641b3c27 Co-authored-by: weidongxu-microsoft <53292327+weidongxu-microsoft@users.noreply.github.com> --- packages/spec-api/test/match-engine.test.ts | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index c1ec6f01ea9..c9325e90d28 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -217,6 +217,48 @@ describe("integration with expandDyns", () => { }); }); +describe("integration with expandDyns({ resolveMatchers: false })", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should preserve matcher objects instead of resolving them to plain strings", () => { + const content = { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; + const expanded = expandDyns(content, config, { resolveMatchers: false }); + // Matcher must survive as a matcher, not be converted to a plain string + expect(isMatcher(expanded.timestamp)).toBe(true); + }); + + it("should allow matchValues to do semantic datetime comparison after expandDyns with resolveMatchers:false", () => { + // Regression test: query params with datetime matchers must use semantic comparison. + // Without resolveMatchers:false, expandDyns converts the matcher to the plain string + // "2022-08-26T18:38:00.000Z", and a strict === comparison against the actual value + // "2022-08-26T18:38:00Z" (no milliseconds) would fail even though they represent the + // same point in time. + const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") }; + const expanded = expandDyns(queryDef, config, { resolveMatchers: false }); + + // The actual query string received from an HTTP request (no milliseconds) + const actualQueryValue = "2022-08-26T18:38:00Z"; + + // Simulates what createHandler does: isMatcher → deepEqual → matchValues → matcher.check() + expect(isMatcher(expanded.input)).toBe(true); + expectPass(matchValues(actualQueryValue, expanded.input, "$", config)); + }); + + it("should demonstrate why resolveMatchers:true (default) breaks semantic query param matching", () => { + // With the default resolveMatchers:true, the matcher is eagerly converted to a plain string. + // A strict string comparison then fails for semantically equivalent but format-different values. + const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") }; + const expandedWithResolve = expandDyns(queryDef, config); // resolveMatchers: true (default) + + // The matcher is gone — replaced by its serialized string + expect(isMatcher(expandedWithResolve.input)).toBe(false); + expect(expandedWithResolve.input).toBe("2022-08-26T18:38:00.000Z"); + + // Strict string comparison fails for an equivalent datetime without milliseconds + expect(expandedWithResolve.input === "2022-08-26T18:38:00Z").toBe(false); + }); +}); + describe("integration with json() Resolver", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; From 006ef066cb8fb32071f2a3f977219453ca19341d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:41:02 +0000 Subject: [PATCH 4/4] fix: apply same matcher-preservation fix to header validation, add regression test Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/05805cb9-1d23-42fd-8b80-fd06410b09dc Co-authored-by: weidongxu-microsoft <53292327+weidongxu-microsoft@users.noreply.github.com> --- packages/spec-api/test/match-engine.test.ts | 16 +++++++++++++++- packages/spector/src/app/app.ts | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts index c9325e90d28..689f642b4e4 100644 --- a/packages/spec-api/test/match-engine.test.ts +++ b/packages/spec-api/test/match-engine.test.ts @@ -228,7 +228,7 @@ describe("integration with expandDyns({ resolveMatchers: false })", () => { }); it("should allow matchValues to do semantic datetime comparison after expandDyns with resolveMatchers:false", () => { - // Regression test: query params with datetime matchers must use semantic comparison. + // Regression test: query params/headers with datetime matchers must use semantic comparison. // Without resolveMatchers:false, expandDyns converts the matcher to the plain string // "2022-08-26T18:38:00.000Z", and a strict === comparison against the actual value // "2022-08-26T18:38:00Z" (no milliseconds) would fail even though they represent the @@ -244,6 +244,20 @@ describe("integration with expandDyns({ resolveMatchers: false })", () => { expectPass(matchValues(actualQueryValue, expanded.input, "$", config)); }); + it("should allow matchValues to do semantic datetime comparison for header values after expandDyns with resolveMatchers:false", () => { + // Regression test: headers with datetime matchers must use semantic comparison, same as query params. + // Without resolveMatchers:false the matcher is serialized early and isMatcher() returns false, + // so the code falls through to containsHeader() with String(value) — a strict string equality + // that fails for semantically equivalent but format-different datetime strings. + const headerDef = { "x-ms-date": match.dateTime.rfc7231("Fri, 26 Aug 2022 18:38:00 GMT") }; + const expanded = expandDyns(headerDef, config, { resolveMatchers: false }); + + // isMatcher must still be true so createHandler routes through deepEqual / matchValues + expect(isMatcher(expanded["x-ms-date"])).toBe(true); + // Semantic check passes for the exact same RFC 7231 string + expectPass(matchValues("Fri, 26 Aug 2022 18:38:00 GMT", expanded["x-ms-date"], "$", config)); + }); + it("should demonstrate why resolveMatchers:true (default) breaks semantic query param matching", () => { // With the default resolveMatchers:true, the matcher is eagerly converted to a plain string. // A strict string comparison then fails for semantically equivalent but format-different values. diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index f028e2d938d..a6b5cc2c62a 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -146,10 +146,10 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) // Validate headers if present in the request if (apiDefinition.request?.headers) { - const headers = expandDyns(apiDefinition.request.headers, config); + const headers = expandDyns(apiDefinition.request.headers, config, { resolveMatchers: false }); Object.entries(headers).forEach(([key, value]) => { if (key.toLowerCase() !== "content-type") { - if (Array.isArray(value)) { + if (isMatcher(value) || Array.isArray(value)) { req.expect.deepEqual(req.headers[key], value); } else { req.expect.containsHeader(key.toLowerCase(), String(value));