From 48f6e8f28f7dd9b3eb1d39ab0d3d5b30eb58ad09 Mon Sep 17 00:00:00 2001 From: Vivek JM <24496671+vivekjm@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:29:12 +0530 Subject: [PATCH 1/2] feat: support unordered diagnostic expectations --- ...ered-diagnostic-expectations-2026-08-18.md | 7 ++ packages/compiler/src/testing/expect.ts | 117 +++++++++++++++++- packages/compiler/test/testing/expect.test.ts | 50 ++++++++ 3 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 .chronus/changes/unordered-diagnostic-expectations-2026-08-18.md create mode 100644 packages/compiler/test/testing/expect.test.ts diff --git a/.chronus/changes/unordered-diagnostic-expectations-2026-08-18.md b/.chronus/changes/unordered-diagnostic-expectations-2026-08-18.md new file mode 100644 index 00000000000..60fd7144ca1 --- /dev/null +++ b/.chronus/changes/unordered-diagnostic-expectations-2026-08-18.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add a `fixedOrder` option to `expectDiagnostics`. Set it to `false` to match expected diagnostics regardless of their emitted order. diff --git a/packages/compiler/src/testing/expect.ts b/packages/compiler/src/testing/expect.ts index cfa68880089..5ca91598cad 100644 --- a/packages/compiler/src/testing/expect.ts +++ b/packages/compiler/src/testing/expect.ts @@ -53,6 +53,14 @@ export interface DiagnosticMatch { end?: number; } +export interface ExpectDiagnosticsOptions { + /** Require the number of diagnostics to match exactly. Defaults to true. */ + strict?: boolean; + + /** Require diagnostics to appear in the expected order. Defaults to true. */ + fixedOrder?: boolean; +} + /** * Validate the diagnostic array contains exactly the given diagnostics. * @param diagnostics Array of the diagnostics @@ -60,19 +68,31 @@ export interface DiagnosticMatch { export function expectDiagnostics( diagnostics: readonly Diagnostic[], match: DiagnosticMatch | DiagnosticMatch[], - options = { - strict: true, - }, + options: ExpectDiagnosticsOptions = {}, ) { const array = isArray(match) ? match : [match]; + const strict = options.strict ?? true; + const fixedOrder = options.fixedOrder ?? true; - if (options.strict && array.length !== diagnostics.length) { + if ((strict && array.length !== diagnostics.length) || array.length > diagnostics.length) { fail( `Expected ${array.length} diagnostics but found ${diagnostics.length}:\n ${formatDiagnostics( diagnostics, )}`, ); } + + if (!fixedOrder) { + if (!hasUnorderedMatch(diagnostics, array)) { + fail( + `Could not match the expected diagnostics regardless of order:\n${formatDiagnostics( + diagnostics, + )}`, + ); + } + return; + } + for (let i = 0; i < array.length; i++) { const diagnostic = diagnostics[i]; const expectation = array[i]; @@ -139,6 +159,95 @@ export function expectDiagnostics( } } +function hasUnorderedMatch( + diagnostics: readonly Diagnostic[], + expectations: readonly DiagnosticMatch[], +): boolean { + const diagnosticMatches = new Array(diagnostics.length).fill(-1); + + function assignExpectation(expectationIndex: number, visited: boolean[]): boolean { + for (let diagnosticIndex = 0; diagnosticIndex < diagnostics.length; diagnosticIndex++) { + if ( + visited[diagnosticIndex] || + !diagnosticMatchesExpectation(diagnostics[diagnosticIndex], expectations[expectationIndex]) + ) { + continue; + } + + visited[diagnosticIndex] = true; + const previousExpectation = diagnosticMatches[diagnosticIndex]; + if (previousExpectation === -1 || assignExpectation(previousExpectation, visited)) { + diagnosticMatches[diagnosticIndex] = expectationIndex; + return true; + } + } + return false; + } + + return expectations.every((_, index) => + assignExpectation(index, new Array(diagnostics.length).fill(false)), + ); +} + +function diagnosticMatchesExpectation( + diagnostic: Diagnostic, + expectation: DiagnosticMatch, +): boolean { + if (expectation.code !== undefined && diagnostic.code !== expectation.code) { + return false; + } + if ( + expectation.message !== undefined && + !strOrRegexMatches(diagnostic.message, expectation.message) + ) { + return false; + } + if (expectation.severity !== undefined && diagnostic.severity !== expectation.severity) { + return false; + } + if ( + expectation.file === undefined && + expectation.pos === undefined && + expectation.end === undefined + ) { + return true; + } + if (diagnostic.target === NoTarget) { + return false; + } + + const source = getSourceLocation(diagnostic.target); + if ( + expectation.file !== undefined && + !strOrRegexMatches( + source.file.path, + typeof expectation.file === "string" + ? resolveVirtualPath(expectation.file) + : expectation.file, + ) + ) { + return false; + } + if (expectation.pos !== undefined && source.pos !== expectation.pos) { + return false; + } + if (expectation.end !== undefined && source.end !== expectation.end) { + return false; + } + return true; +} + +function strOrRegexMatches(value: string, expectation: string | RegExp): boolean { + if (typeof expectation === "string") { + return value === expectation; + } + + const lastIndex = expectation.lastIndex; + const result = expectation.test(value); + expectation.lastIndex = lastIndex; + return result; +} + function matchStrOrRegex(value: string, expectation: string | RegExp, assertMessage: string) { if (typeof expectation === "string") { strictEqual(value, expectation, assertMessage); diff --git a/packages/compiler/test/testing/expect.test.ts b/packages/compiler/test/testing/expect.test.ts new file mode 100644 index 00000000000..55b80311f67 --- /dev/null +++ b/packages/compiler/test/testing/expect.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vitest"; +import { NoTarget, type Diagnostic } from "../../src/core/types.js"; +import { expectDiagnostics } from "../../src/testing/expect.js"; + +function diagnostic(code: string, message: string): Diagnostic { + return { + code, + message, + severity: "error", + target: NoTarget, + }; +} + +it("requires diagnostics to use the expected order by default", () => { + const diagnostics = [diagnostic("first", "First"), diagnostic("second", "Second")]; + + expect(() => expectDiagnostics(diagnostics, [{ code: "second" }, { code: "first" }])).toThrow( + "Diagnostic at index 0 has non matching code", + ); +}); + +it("can match diagnostics regardless of order", () => { + const diagnostics = [diagnostic("first", "First"), diagnostic("second", "Second")]; + + expectDiagnostics(diagnostics, [{ code: "second" }, { code: "first" }], { + fixedOrder: false, + }); +}); + +it("matches overlapping expectations without depending on greedy order", () => { + const diagnostics = [diagnostic("shared", "Specific"), diagnostic("shared", "Other")]; + + expectDiagnostics(diagnostics, [{ code: "shared" }, { message: "Specific" }], { + fixedOrder: false, + }); +}); + +it("allows unmatched diagnostics in non-strict unordered mode", () => { + const diagnostics = [diagnostic("first", "First"), diagnostic("second", "Second")]; + + expectDiagnostics(diagnostics, { code: "second" }, { strict: false, fixedOrder: false }); +}); + +it("retains strict count validation in unordered mode", () => { + const diagnostics = [diagnostic("first", "First"), diagnostic("second", "Second")]; + + expect(() => expectDiagnostics(diagnostics, { code: "second" }, { fixedOrder: false })).toThrow( + "Expected 1 diagnostics but found 2", + ); +}); From 46ac718ba80f6854cb535a153369d4b576af3fe5 Mon Sep 17 00:00:00 2001 From: Vivek JM <24496671+vivekjm@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:10:13 +0530 Subject: [PATCH 2/2] Address unordered diagnostics review feedback --- packages/compiler/src/testing/expect.ts | 33 +++++++++++++------ packages/compiler/test/testing/expect.test.ts | 23 +++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/testing/expect.ts b/packages/compiler/src/testing/expect.ts index 5ca91598cad..077cfde6077 100644 --- a/packages/compiler/src/testing/expect.ts +++ b/packages/compiler/src/testing/expect.ts @@ -1,4 +1,4 @@ -import { fail, match, strictEqual } from "assert"; +import { fail, strictEqual } from "assert"; import { getSourceLocation } from "../core/diagnostics.js"; import { formatDiagnostic } from "../core/logger/console-sink.js"; import { NoTarget, type Diagnostic } from "../core/types.js"; @@ -62,8 +62,12 @@ export interface ExpectDiagnosticsOptions { } /** - * Validate the diagnostic array contains exactly the given diagnostics. - * @param diagnostics Array of the diagnostics + * Validate diagnostics against the given expectations. By default, the count and order must match + * exactly. Set `strict` to false to allow additional diagnostics, or `fixedOrder` to false to match + * expectations regardless of diagnostic order. + * @param diagnostics Array of diagnostics. + * @param match Expected diagnostic properties. + * @param options Options controlling count and order matching. */ export function expectDiagnostics( diagnostics: readonly Diagnostic[], @@ -85,9 +89,9 @@ export function expectDiagnostics( if (!fixedOrder) { if (!hasUnorderedMatch(diagnostics, array)) { fail( - `Could not match the expected diagnostics regardless of order:\n${formatDiagnostics( - diagnostics, - )}`, + `Could not match the expected diagnostics regardless of order:\nExpected diagnostics:\n${formatDiagnosticMatches( + array, + )}\nDiagnostics found:\n${formatDiagnostics(diagnostics)}`, ); } return; @@ -159,6 +163,17 @@ export function expectDiagnostics( } } +function formatDiagnosticMatches(expectations: readonly DiagnosticMatch[]): string { + return expectations + .map( + (expectation, index) => + `${index}: ${JSON.stringify(expectation, (_, value) => + value instanceof RegExp ? value.toString() : value, + )}`, + ) + .join("\n"); +} + function hasUnorderedMatch( diagnostics: readonly Diagnostic[], expectations: readonly DiagnosticMatch[], @@ -249,9 +264,7 @@ function strOrRegexMatches(value: string, expectation: string | RegExp): boolean } function matchStrOrRegex(value: string, expectation: string | RegExp, assertMessage: string) { - if (typeof expectation === "string") { - strictEqual(value, expectation, assertMessage); - } else { - match(value, expectation, assertMessage); + if (!strOrRegexMatches(value, expectation)) { + fail(assertMessage); } } diff --git a/packages/compiler/test/testing/expect.test.ts b/packages/compiler/test/testing/expect.test.ts index 55b80311f67..0abab4ad608 100644 --- a/packages/compiler/test/testing/expect.test.ts +++ b/packages/compiler/test/testing/expect.test.ts @@ -35,6 +35,29 @@ it("matches overlapping expectations without depending on greedy order", () => { }); }); +it("matches stateful regular expressions consistently", () => { + const diagnostics = [diagnostic("first", "Shared one"), diagnostic("second", "Shared two")]; + const message = /shared/gi; + + expectDiagnostics(diagnostics, [{ message }, { message }], { fixedOrder: false }); + expect(message.lastIndex).toBe(0); + expectDiagnostics(diagnostics, [{ message }, { message }], { fixedOrder: false }); +}); + +it("includes unmatched expectations in unordered failure messages", () => { + const diagnostics = [diagnostic("actual", "Actual diagnostic")]; + + expect(() => + expectDiagnostics( + diagnostics, + { code: "expected", message: /missing/i }, + { fixedOrder: false }, + ), + ).toThrow( + 'Expected diagnostics:\n0: {"code":"expected","message":"/missing/i"}\nDiagnostics found:', + ); +}); + it("allows unmatched diagnostics in non-strict unordered mode", () => { const diagnostics = [diagnostic("first", "First"), diagnostic("second", "Second")];