diff --git a/DevProxy.Abstractions/Proxy/ProxyEvents.cs b/DevProxy.Abstractions/Proxy/ProxyEvents.cs index edc4cb24..14a77256 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.Integration.Tests/JsonReporterTests.cs b/DevProxy.Integration.Tests/JsonReporterTests.cs index 0fd6cb47..fe6a540d 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,50 @@ 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 args = new RecordingArgs([]) + { + GlobalData = new() + { + [ProxyUtils.ReportsKey] = new Dictionary + { + ["SomePlugin"] = new { Value = 1 }, + ["OtherPlugin"] = new { Value = 2 }, + }, + }, + }; + + 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 + { + var reportFiles = args.ReportFiles; + 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 84fc5866..e20550ef 100644 --- a/DevProxy.Plugins/Reporters/BaseReporter.cs +++ b/DevProxy.Plugins/Reporters/BaseReporter.cs @@ -56,6 +56,7 @@ value is not Dictionary reports || Logger.LogInformation("Writing report {ReportKey} to {FileName}...", report.Key, fileName); await File.WriteAllTextAsync(fileName, reportContents, cancellationToken); + e.ReportFiles.Add(Path.GetFullPath(fileName)); } Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 39447f29..70d59f93 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 c9158900..64025c90 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 f85f5bf7..fbf0652d 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 c97e9b87..d0d292d0 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 87a98103..09534a86 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 3e0a65c4..df7e8937 100644 --- a/DevProxy/Proxy/ProxyStateController.cs +++ b/DevProxy/Proxy/ProxyStateController.cs @@ -34,11 +34,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; @@ -65,6 +65,8 @@ public async Task StopRecordingAsync(CancellationToken cancellationToken) } } _logger.LogInformation("DONE"); + + return [.. recordingArgs.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 f3a98d22..8e902068 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`) |