diff --git a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs index f9b0bfe05..d47eeb24f 100644 --- a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs @@ -160,6 +160,49 @@ public async Task MockGenerator_WritesMockFile() } } + [Fact] + public async Task MockGenerator_PreservesRecordedOrderForSameUrl() + { + var plugin = new MockGeneratorPlugin(NullLogger.Instance, Watch); + + // enough entries to exceed the insertion-sort threshold of an unstable sort + const int count = 40; + var logs = Enumerable.Range(0, count) + .Select(i => TestExchange + .Request("GET", i % 2 == 0 ? "https://api.contoso.com/a" : "https://api.contoso.com/b") + .WithResponse( + HttpStatusCode.OK, + headers: [("Content-Type", "application/json")], + body: $$"""{ "seq": {{i}} }""") + .AsRequestLog(MessageType.InterceptedResponse)) + .ToList(); + + var args = Recording(logs); + var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None)); + try + { + var mockFile = dir.GetFiles("mocks-*.json").Single(); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(mockFile.FullName)); + var mocks = doc.RootElement.GetProperty("mocks").EnumerateArray() + .Select(m => ( + Url: m.GetProperty("request").GetProperty("url").GetString(), + Seq: m.GetProperty("response").GetProperty("body").GetProperty("seq").GetInt32())) + .ToArray(); + + Assert.Equal(count, mocks.Length); + + var expected = Enumerable.Range(0, count).Where(i => i % 2 == 1) + .Select(i => ("https://api.contoso.com/b", i)) + .Concat(Enumerable.Range(0, count).Where(i => i % 2 == 0) + .Select(i => ("https://api.contoso.com/a", i))); + Assert.Equal(expected, mocks.Select(m => (m.Url!, m.Seq))); + } + finally + { + dir.Delete(recursive: true); + } + } + [Fact] public async Task HttpFileGenerator_WritesHttpFile() { diff --git a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs index 3f354b551..22be7e4ba 100644 --- a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs @@ -81,8 +81,10 @@ request.Context.Session is null || } Logger.LogDebug("Sorting mocks..."); - // sort mocks descending by url length so that most specific mocks are first - mocks.Sort((a, b) => string.CompareOrdinal(b.Request!.Url, a.Request!.Url)); + // group mocks by URL (descending ordinal order) for readability. Generated + // mocks use exact URLs, so this order doesn't affect matching. The sort is + // stable so that repeated requests to the same URL keep their recorded order. + mocks = [.. mocks.OrderByDescending(m => m.Request!.Url, StringComparer.Ordinal)]; var mocksFile = new MockResponseConfiguration { Mocks = mocks };