From 6cfd2e85749bfde96c50799f83dbf312853254e1 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:44:15 +0100 Subject: [PATCH 1/2] Return written report files when recording stops through the API POST /proxy {"recording": false} already waits for plugins and reporters to finish. It now also returns the absolute paths of the report files reporters wrote, so scripts don't have to poll the folder or parse logs: {"recording": false, "reports": ["/abs/path/ExecutionSummaryPlugin_JsonReporter.json"]} Closes dotnet/dev-proxy#2069 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Utils/ProxyUtils.cs | 3 + .../JsonReporterTests.cs | 48 +++++++++++++++ DevProxy.Plugins/Reporters/BaseReporter.cs | 8 +++ DevProxy.Tests/Fakes.cs | 6 +- DevProxy.Tests/ProxyControllerTests.cs | 58 +++++++++++++++++++ DevProxy/ApiControllers/ProxyController.cs | 13 ++++- DevProxy/ApiControllers/ProxyInfo.cs | 7 +++ DevProxy/Proxy/IProxyStateController.cs | 6 +- DevProxy/Proxy/ProxyStateController.cs | 10 +++- .../dev-proxy/references/ci-cd-integration.md | 5 +- 10 files changed, 155 insertions(+), 9 deletions(-) diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index 7c5af6808..0e0897ecf 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -40,6 +40,9 @@ public static class ProxyUtils private static string _productVersion = string.Empty; public static readonly string ReportsKey = "Reports"; + // List of absolute paths of report files written by reporters + // while processing the current recording + public static readonly string ReportFilesKey = "ReportFiles"; // doesn't end with a path separator public static string? AppFolder => Path.GetDirectoryName(AppContext.BaseDirectory); diff --git a/DevProxy.Integration.Tests/JsonReporterTests.cs b/DevProxy.Integration.Tests/JsonReporterTests.cs index 0fd6cb47f..185366426 100644 --- a/DevProxy.Integration.Tests/JsonReporterTests.cs +++ b/DevProxy.Integration.Tests/JsonReporterTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Reporters; using DevProxy.Plugins.Reporting; using Microsoft.Extensions.Logging.Abstractions; @@ -28,4 +29,51 @@ public void FileExtension_IsDeterminedPerReport() _ = reporter.Report("OtherPlugin", new { Value = 1 }); Assert.Equal(".json", reporter.FileExtension); } + + [Fact] + public async Task AfterRecordingStop_RecordsAbsolutePathsOfWrittenReports() + { + var reporter = new TestJsonReporter(); + var reportFiles = new List(); + var args = new RecordingArgs([]) + { + GlobalData = new() + { + [ProxyUtils.ReportsKey] = new Dictionary + { + ["SomePlugin"] = new { Value = 1 }, + ["OtherPlugin"] = new { Value = 2 }, + }, + [ProxyUtils.ReportFilesKey] = reportFiles, + }, + }; + + var dir = Directory.CreateTempSubdirectory("devproxy-reporter-"); + var originalCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(dir.FullName); + try + { + await reporter.AfterRecordingStopAsync(args, CancellationToken.None); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + } + + try + { + Assert.Equal(2, reportFiles.Count); + Assert.All(reportFiles, f => + { + Assert.True(Path.IsPathFullyQualified(f)); + Assert.True(File.Exists(f)); + }); + Assert.Contains(reportFiles, f => Path.GetFileName(f) == "SomePlugin_JsonReporter.json"); + Assert.Contains(reportFiles, f => Path.GetFileName(f) == "OtherPlugin_JsonReporter.json"); + } + finally + { + dir.Delete(true); + } + } } diff --git a/DevProxy.Plugins/Reporters/BaseReporter.cs b/DevProxy.Plugins/Reporters/BaseReporter.cs index 84fc5866a..e95976608 100644 --- a/DevProxy.Plugins/Reporters/BaseReporter.cs +++ b/DevProxy.Plugins/Reporters/BaseReporter.cs @@ -56,6 +56,14 @@ value is not Dictionary reports || Logger.LogInformation("Writing report {ReportKey} to {FileName}...", report.Key, fileName); await File.WriteAllTextAsync(fileName, reportContents, cancellationToken); + + if (!e.GlobalData.TryGetValue(ProxyUtils.ReportFilesKey, out var reportFilesValue) || + reportFilesValue is not List reportFiles) + { + reportFiles = []; + e.GlobalData[ProxyUtils.ReportFilesKey] = reportFiles; + } + reportFiles.Add(Path.GetFullPath(fileName)); } Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 39447f292..70d59f93d 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -22,12 +22,14 @@ internal sealed class FakeProxyStateController : IProxyStateController public void StartRecording() => StartRecordingCalls++; - public Task StopRecordingAsync(CancellationToken cancellationToken) + public Task> StopRecordingAsync(CancellationToken cancellationToken) { StopRecordingCalls++; - return Task.CompletedTask; + return Task.FromResult(ReportFiles); } + public IReadOnlyList ReportFiles { get; set; } = []; + public Task MockRequestAsync(CancellationToken cancellationToken) { MockRequestCalls++; diff --git a/DevProxy.Tests/ProxyControllerTests.cs b/DevProxy.Tests/ProxyControllerTests.cs index c9158900c..64025c907 100644 --- a/DevProxy.Tests/ProxyControllerTests.cs +++ b/DevProxy.Tests/ProxyControllerTests.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using DevProxy.ApiControllers; using Microsoft.AspNetCore.Mvc; using Xunit; @@ -26,4 +27,61 @@ public async Task RaiseMockRequestAsync_ReturnsNoContentAfterMockRequestComplete _ = Assert.IsType(result); Assert.Equal(1, stateController.MockRequestCalls); } + + private static ProxyController CreateController(FakeProxyStateController stateController, out X509Certificate2 cert) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + cert = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); + return new ProxyController(stateController, new FakeProxyConfiguration(), cert); + } + + [Fact] + public async Task SetAsync_StopRecording_ReturnsReportFiles() + { + var stateController = new FakeProxyStateController + { + ReportFiles = ["/tmp/ExecutionSummaryPlugin_JsonReporter.json"] + }; + var controller = CreateController(stateController, out var cert); + using (cert) + { + var result = await controller.SetAsync(new ProxyInfo { Recording = false }, CancellationToken.None); + + var ok = Assert.IsType(result); + var info = Assert.IsType(ok.Value); + Assert.Equal(1, stateController.StopRecordingCalls); + Assert.Equal(["/tmp/ExecutionSummaryPlugin_JsonReporter.json"], info.Reports); + } + } + + [Fact] + public async Task SetAsync_StartRecording_OmitsReports() + { + var stateController = new FakeProxyStateController(); + var controller = CreateController(stateController, out var cert); + using (cert) + { + var result = await controller.SetAsync(new ProxyInfo { Recording = true }, CancellationToken.None); + + var ok = Assert.IsType(result); + var info = Assert.IsType(ok.Value); + Assert.Null(info.Reports); + Assert.DoesNotContain("reports", JsonSerializer.Serialize(info, JsonSerializerOptions.Web), StringComparison.Ordinal); + } + } + + [Fact] + public async Task SetAsync_WithReports_ReturnsValidationProblem() + { + var stateController = new FakeProxyStateController(); + var controller = CreateController(stateController, out var cert); + using (cert) + { + var result = await controller.SetAsync(new ProxyInfo { Recording = false, Reports = ["x"] }, CancellationToken.None); + + _ = Assert.IsAssignableFrom(result); + Assert.Equal(0, stateController.StopRecordingCalls); + } + } } diff --git a/DevProxy/ApiControllers/ProxyController.cs b/DevProxy/ApiControllers/ProxyController.cs index f85f5bf7e..fbf0652d8 100644 --- a/DevProxy/ApiControllers/ProxyController.cs +++ b/DevProxy/ApiControllers/ProxyController.cs @@ -41,6 +41,13 @@ public async Task SetAsync([FromBody] ProxyInfo proxyInfo, Cancel return ValidationProblem(ModelState); } + if (proxyInfo.Reports != null) + { + ModelState.AddModelError("Reports", "Reports cannot be set"); + return ValidationProblem(ModelState); + } + + IReadOnlyList? reports = null; if (proxyInfo.Recording.HasValue) { if (proxyInfo.Recording.Value) @@ -49,11 +56,13 @@ public async Task SetAsync([FromBody] ProxyInfo proxyInfo, Cancel } else { - await _proxyStateController.StopRecordingAsync(cancellationToken); + reports = await _proxyStateController.StopRecordingAsync(cancellationToken); } } - return Ok(ProxyInfo.From(_proxyStateController.ProxyState, _proxyConfiguration)); + var result = ProxyInfo.From(_proxyStateController.ProxyState, _proxyConfiguration); + result.Reports = reports; + return Ok(result); } [HttpPost("mockRequest")] diff --git a/DevProxy/ApiControllers/ProxyInfo.cs b/DevProxy/ApiControllers/ProxyInfo.cs index c97e9b87e..d0d292d0b 100644 --- a/DevProxy/ApiControllers/ProxyInfo.cs +++ b/DevProxy/ApiControllers/ProxyInfo.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Proxy; +using System.Text.Json.Serialization; namespace DevProxy.ApiControllers; @@ -13,6 +14,12 @@ public sealed class ProxyInfo { public bool? Recording { get; set; } public string? ConfigFile { get; init; } + /// + /// Absolute paths of the report files written after recording stopped. + /// Only returned in response to stopping recording. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Reports { get; set; } public static ProxyInfo From(IProxyState proxyState, IProxyConfiguration proxyConfiguration) { diff --git a/DevProxy/Proxy/IProxyStateController.cs b/DevProxy/Proxy/IProxyStateController.cs index 87a981034..09534a865 100644 --- a/DevProxy/Proxy/IProxyStateController.cs +++ b/DevProxy/Proxy/IProxyStateController.cs @@ -10,7 +10,11 @@ public interface IProxyStateController { IProxyState ProxyState { get; } void StartRecording(); - Task StopRecordingAsync(CancellationToken cancellationToken); + /// + /// Stops recording and waits for plugins to process the recorded requests. + /// + /// Absolute paths of the report files written by reporters. + Task> StopRecordingAsync(CancellationToken cancellationToken); Task MockRequestAsync(CancellationToken cancellationToken); void StopProxy(); } \ No newline at end of file diff --git a/DevProxy/Proxy/ProxyStateController.cs b/DevProxy/Proxy/ProxyStateController.cs index 3e0a65c48..4e3db7aa8 100644 --- a/DevProxy/Proxy/ProxyStateController.cs +++ b/DevProxy/Proxy/ProxyStateController.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; using DevProxy.Commands; namespace DevProxy.Proxy; @@ -34,11 +35,11 @@ public void StartRecording() PrintRecordingIndicator(ProxyState.IsRecording); } - public async Task StopRecordingAsync(CancellationToken cancellationToken) + public async Task> StopRecordingAsync(CancellationToken cancellationToken) { if (!ProxyState.IsRecording) { - return; + return []; } ProxyState.IsRecording = false; @@ -53,6 +54,8 @@ public async Task StopRecordingAsync(CancellationToken cancellationToken) { GlobalData = ProxyState.GlobalData }; + var reportFiles = new List(); + ProxyState.GlobalData[ProxyUtils.ReportFilesKey] = reportFiles; foreach (var plugin in _plugins.Where(p => p.Enabled)) { try @@ -64,7 +67,10 @@ public async Task StopRecordingAsync(CancellationToken cancellationToken) ExceptionHandler(ex); } } + _ = ProxyState.GlobalData.Remove(ProxyUtils.ReportFilesKey); _logger.LogInformation("DONE"); + + return reportFiles; } public async Task MockRequestAsync(CancellationToken cancellationToken) diff --git a/skills/dev-proxy/references/ci-cd-integration.md b/skills/dev-proxy/references/ci-cd-integration.md index f3a98d222..8e902068d 100644 --- a/skills/dev-proxy/references/ci-cd-integration.md +++ b/skills/dev-proxy/references/ci-cd-integration.md @@ -350,7 +350,8 @@ TOKEN=$(./devproxy/devproxy api token) curl --noproxy '*' --fail -X POST http://127.0.0.1:8897/proxy -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"recording": true}' # Run tests npm test -# Stop recording +# Stop recording; responds after reports are written, e.g. +# {"recording":false,"reports":["/abs/path/ExecutionSummaryPlugin_JsonReporter.json"]} curl --noproxy '*' --fail -X POST http://127.0.0.1:8897/proxy -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"recording": false}' # Stop proxy ./devproxy/devproxy stop @@ -370,7 +371,7 @@ done | Endpoint | Method | Description | |----------|--------|-------------| | `/proxy` | GET | Get proxy status (`recording`, `configFile`) | -| `/proxy` | POST | Start/stop recording: `{"recording": true/false}` | +| `/proxy` | POST | Start/stop recording: `{"recording": true/false}`. Stopping responds after plugins and reporters finish and returns the absolute paths of the written report files in `reports` | | `/proxy/stopProxy` | POST | Graceful shutdown | | `/proxy/jwtToken` | POST | Generate JWT for testing | | `/proxy/mockRequest` | POST | Trigger mock request (equivalent of pressing `w`) | From 9d4f5b26fde4cc16ad623191d8df26892d129f5a Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:49:33 +0100 Subject: [PATCH 2/2] Collect report files per recording to avoid races between overlapping stops Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Proxy/ProxyEvents.cs | 5 +++++ DevProxy.Abstractions/Utils/ProxyUtils.cs | 3 --- DevProxy.Integration.Tests/JsonReporterTests.cs | 3 +-- DevProxy.Plugins/Reporters/BaseReporter.cs | 9 +-------- DevProxy/Proxy/ProxyStateController.cs | 6 +----- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/DevProxy.Abstractions/Proxy/ProxyEvents.cs b/DevProxy.Abstractions/Proxy/ProxyEvents.cs index edc4cb248..14a772563 100644 --- a/DevProxy.Abstractions/Proxy/ProxyEvents.cs +++ b/DevProxy.Abstractions/Proxy/ProxyEvents.cs @@ -104,6 +104,11 @@ public class RecordingArgs(IEnumerable requestLogs) : ProxyEventArgs { public IEnumerable RequestLogs { get; set; } = requestLogs ?? throw new ArgumentNullException(nameof(requestLogs)); + /// + /// Absolute paths of the report files written by reporters while + /// processing this recording. + /// + public ICollection ReportFiles { get; } = []; } public class RequestLogArgs(RequestLog requestLog) diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index 0e0897ecf..7c5af6808 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -40,9 +40,6 @@ public static class ProxyUtils private static string _productVersion = string.Empty; public static readonly string ReportsKey = "Reports"; - // List of absolute paths of report files written by reporters - // while processing the current recording - public static readonly string ReportFilesKey = "ReportFiles"; // doesn't end with a path separator public static string? AppFolder => Path.GetDirectoryName(AppContext.BaseDirectory); diff --git a/DevProxy.Integration.Tests/JsonReporterTests.cs b/DevProxy.Integration.Tests/JsonReporterTests.cs index 185366426..fe6a540da 100644 --- a/DevProxy.Integration.Tests/JsonReporterTests.cs +++ b/DevProxy.Integration.Tests/JsonReporterTests.cs @@ -34,7 +34,6 @@ public void FileExtension_IsDeterminedPerReport() public async Task AfterRecordingStop_RecordsAbsolutePathsOfWrittenReports() { var reporter = new TestJsonReporter(); - var reportFiles = new List(); var args = new RecordingArgs([]) { GlobalData = new() @@ -44,7 +43,6 @@ public async Task AfterRecordingStop_RecordsAbsolutePathsOfWrittenReports() ["SomePlugin"] = new { Value = 1 }, ["OtherPlugin"] = new { Value = 2 }, }, - [ProxyUtils.ReportFilesKey] = reportFiles, }, }; @@ -62,6 +60,7 @@ public async Task AfterRecordingStop_RecordsAbsolutePathsOfWrittenReports() try { + var reportFiles = args.ReportFiles; Assert.Equal(2, reportFiles.Count); Assert.All(reportFiles, f => { diff --git a/DevProxy.Plugins/Reporters/BaseReporter.cs b/DevProxy.Plugins/Reporters/BaseReporter.cs index e95976608..e20550ef6 100644 --- a/DevProxy.Plugins/Reporters/BaseReporter.cs +++ b/DevProxy.Plugins/Reporters/BaseReporter.cs @@ -56,14 +56,7 @@ value is not Dictionary reports || Logger.LogInformation("Writing report {ReportKey} to {FileName}...", report.Key, fileName); await File.WriteAllTextAsync(fileName, reportContents, cancellationToken); - - if (!e.GlobalData.TryGetValue(ProxyUtils.ReportFilesKey, out var reportFilesValue) || - reportFilesValue is not List reportFiles) - { - reportFiles = []; - e.GlobalData[ProxyUtils.ReportFilesKey] = reportFiles; - } - reportFiles.Add(Path.GetFullPath(fileName)); + e.ReportFiles.Add(Path.GetFullPath(fileName)); } Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); diff --git a/DevProxy/Proxy/ProxyStateController.cs b/DevProxy/Proxy/ProxyStateController.cs index 4e3db7aa8..df7e89370 100644 --- a/DevProxy/Proxy/ProxyStateController.cs +++ b/DevProxy/Proxy/ProxyStateController.cs @@ -4,7 +4,6 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; using DevProxy.Commands; namespace DevProxy.Proxy; @@ -54,8 +53,6 @@ public async Task> StopRecordingAsync(CancellationToken ca { GlobalData = ProxyState.GlobalData }; - var reportFiles = new List(); - ProxyState.GlobalData[ProxyUtils.ReportFilesKey] = reportFiles; foreach (var plugin in _plugins.Where(p => p.Enabled)) { try @@ -67,10 +64,9 @@ public async Task> StopRecordingAsync(CancellationToken ca ExceptionHandler(ex); } } - _ = ProxyState.GlobalData.Remove(ProxyUtils.ReportFilesKey); _logger.LogInformation("DONE"); - return reportFiles; + return [.. recordingArgs.ReportFiles]; } public async Task MockRequestAsync(CancellationToken cancellationToken)