From cd6701e1159e4cfb70834ecfa512151a7f051243 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:46:48 +0100 Subject: [PATCH] Add --report-dir option and reportsFolder setting for report output Reporters wrote reports to Dev Proxy's current working directory, forcing scripts to change cwd (which also changes how config paths resolve) just to control where reports land. - New reportsFolder setting in devproxyrc.json, resolved relative to the config file (supports ~/, ~appFolder, ~dataFolder tokens) - New --report-dir option, resolved relative to the working directory, which overrides reportsFolder - BaseReporter writes to the folder (creating it if needed); default stays the working directory - Updated v4.0.0 rc schema Closes dotnet/dev-proxy#2071 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Proxy/IProxyConfiguration.cs | 5 + .../JsonReporterTests.cs | 2 +- .../ReporterReportsFolderTests.cs | 100 ++++++++++++++++++ .../TestProxyConfiguration.cs | 1 + DevProxy.Plugins/Reporters/BaseReporter.cs | 35 +++++- DevProxy.Plugins/Reporters/JsonReporter.cs | 3 +- .../Reporters/MarkdownReporter.cs | 3 +- .../Reporters/PlainTextReporter.cs | 3 +- DevProxy.Tests/Fakes.cs | 1 + DevProxy.Tests/ReportsFolderTests.cs | 93 ++++++++++++++++ DevProxy/Commands/DevProxyCommand.cs | 35 ++++++ DevProxy/Proxy/ProxyConfiguration.cs | 25 +++++ schemas/v4.0.0/rc.schema.json | 4 + 13 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 DevProxy.Integration.Tests/ReporterReportsFolderTests.cs create mode 100644 DevProxy.Tests/ReportsFolderTests.cs diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index 251469eb5..e5cb256e4 100644 --- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs +++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs @@ -45,6 +45,11 @@ public interface IProxyConfiguration bool NoWatch { get; set; } int Port { get; set; } bool Record { get; set; } + /// + /// Absolute path of the folder where reporters write report files. + /// When null or empty, reports are written to the current working directory. + /// + string? ReportsFolder { get; set; } bool ShowTimestamps { get; } long? TimeoutSeconds { get; set; } bool ValidateSchemas { get; } diff --git a/DevProxy.Integration.Tests/JsonReporterTests.cs b/DevProxy.Integration.Tests/JsonReporterTests.cs index 0fd6cb47f..b3314b5d8 100644 --- a/DevProxy.Integration.Tests/JsonReporterTests.cs +++ b/DevProxy.Integration.Tests/JsonReporterTests.cs @@ -12,7 +12,7 @@ namespace DevProxy.Integration.Tests; public sealed class JsonReporterTests { - private sealed class TestJsonReporter() : JsonReporter(NullLogger.Instance, new HashSet()) + private sealed class TestJsonReporter() : JsonReporter(NullLogger.Instance, new HashSet(), new TestProxyConfiguration()) { public string? Report(string key, object value) => GetReport(new(key, value)); } diff --git a/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs b/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs new file mode 100644 index 000000000..d16194fdd --- /dev/null +++ b/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Reporters; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Verifies that reporters honour +/// and fall back to the current working directory when it isn't set. +/// +public sealed class ReporterReportsFolderTests +{ + private static RecordingArgs Recording() => + new([]) + { + GlobalData = new() + { + [ProxyUtils.ReportsKey] = new Dictionary + { + ["TestPlugin"] = new { Value = 1 } + } + }, + }; + + private static JsonReporter CreateReporter(string? reportsFolder) => + new(NullLogger.Instance, new HashSet(), new TestProxyConfiguration { ReportsFolder = reportsFolder }); + + [Fact] + public async Task WritesReportToReportsFolder_CreatingItWhenMissing() + { + var root = Directory.CreateTempSubdirectory("devproxy-reports-"); + var cwd = Directory.CreateTempSubdirectory("devproxy-cwd-"); + var originalCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(cwd.FullName); + try + { + var reportsFolder = Path.Combine(root.FullName, "nested", "reports"); + var reporter = CreateReporter(reportsFolder); + + await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None); + + Assert.True(File.Exists(Path.Combine(reportsFolder, "TestPlugin_JsonReporter.json"))); + Assert.Empty(Directory.GetFiles(cwd.FullName)); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + root.Delete(true); + cwd.Delete(true); + } + } + + [Fact] + public async Task AppendsTimestamp_WhenReportExistsInReportsFolder() + { + var root = Directory.CreateTempSubdirectory("devproxy-reports-"); + try + { + await File.WriteAllTextAsync(Path.Combine(root.FullName, "TestPlugin_JsonReporter.json"), "{}"); + var reporter = CreateReporter(root.FullName); + + await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None); + + Assert.Equal(2, Directory.GetFiles(root.FullName, "TestPlugin_JsonReporter*.json").Length); + } + finally + { + root.Delete(true); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task WritesReportToWorkingDirectory_WhenReportsFolderNotSet(string? reportsFolder) + { + var cwd = Directory.CreateTempSubdirectory("devproxy-cwd-"); + var originalCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(cwd.FullName); + try + { + var reporter = CreateReporter(reportsFolder); + + await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None); + + Assert.True(File.Exists(Path.Combine(cwd.FullName, "TestPlugin_JsonReporter.json"))); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + cwd.Delete(true); + } + } +} diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index 4297df6ed..2de0ea3c2 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -30,6 +30,7 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTree public bool NoWatch { get; set; } public int Port { get; set; } public bool Record { get; set; } + public string? ReportsFolder { get; set; } public bool ShowTimestamps => false; public long? TimeoutSeconds { get; set; } public bool ValidateSchemas => false; diff --git a/DevProxy.Plugins/Reporters/BaseReporter.cs b/DevProxy.Plugins/Reporters/BaseReporter.cs index 84fc5866a..d5138a580 100644 --- a/DevProxy.Plugins/Reporters/BaseReporter.cs +++ b/DevProxy.Plugins/Reporters/BaseReporter.cs @@ -9,10 +9,22 @@ namespace DevProxy.Plugins.Reporters; -public abstract class BaseReporter( - ILogger logger, - ISet urlsToWatch) : BasePlugin(logger, urlsToWatch) +public abstract class BaseReporter : BasePlugin { + private readonly IProxyConfiguration? _proxyConfiguration; + + protected BaseReporter(ILogger logger, ISet urlsToWatch) : base(logger, urlsToWatch) + { + } + + protected BaseReporter( + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration) : base(logger, urlsToWatch) + { + _proxyConfiguration = proxyConfiguration; + } + public abstract string FileExtension { get; } public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) @@ -45,16 +57,20 @@ value is not Dictionary reports || continue; } - var fileName = $"{report.Key}_{Name}{FileExtension}"; + var fileName = GetReportFilePath($"{report.Key}_{Name}{FileExtension}"); Logger.LogDebug("File name for report {Report}: {FileName}", report.Key, fileName); if (File.Exists(fileName)) { Logger.LogDebug("File {FileName} already exists, appending timestamp", fileName); - fileName = $"{report.Key}_{Name}_{DateTime.Now:yyyyMMddHHmmss}{FileExtension}"; + fileName = GetReportFilePath($"{report.Key}_{Name}_{DateTime.Now:yyyyMMddHHmmss}{FileExtension}"); } Logger.LogInformation("Writing report {ReportKey} to {FileName}...", report.Key, fileName); + if (!string.IsNullOrEmpty(ReportsFolder)) + { + _ = Directory.CreateDirectory(ReportsFolder); + } await File.WriteAllTextAsync(fileName, reportContents, cancellationToken); } @@ -62,4 +78,13 @@ value is not Dictionary reports || } protected abstract string? GetReport(KeyValuePair report); + + /// + /// Folder where reports are written. When null or empty, reports are written + /// to the current working directory. + /// + protected string? ReportsFolder => _proxyConfiguration?.ReportsFolder; + + protected string GetReportFilePath(string fileName) => + string.IsNullOrEmpty(ReportsFolder) ? fileName : Path.Combine(ReportsFolder, fileName); } \ No newline at end of file diff --git a/DevProxy.Plugins/Reporters/JsonReporter.cs b/DevProxy.Plugins/Reporters/JsonReporter.cs index 05fc0d805..396610db3 100644 --- a/DevProxy.Plugins/Reporters/JsonReporter.cs +++ b/DevProxy.Plugins/Reporters/JsonReporter.cs @@ -12,7 +12,8 @@ namespace DevProxy.Plugins.Reporters; public class JsonReporter( ILogger logger, - ISet urlsToWatch) : BaseReporter(logger, urlsToWatch) + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration) { private const string DefaultFileExtension = ".json"; private string _fileExtension = DefaultFileExtension; diff --git a/DevProxy.Plugins/Reporters/MarkdownReporter.cs b/DevProxy.Plugins/Reporters/MarkdownReporter.cs index 5e8f130e3..7d25b92e8 100644 --- a/DevProxy.Plugins/Reporters/MarkdownReporter.cs +++ b/DevProxy.Plugins/Reporters/MarkdownReporter.cs @@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters; public class MarkdownReporter( ILogger logger, - ISet urlsToWatch) : BaseReporter(logger, urlsToWatch) + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration) { public override string Name => nameof(MarkdownReporter); public override string FileExtension => ".md"; diff --git a/DevProxy.Plugins/Reporters/PlainTextReporter.cs b/DevProxy.Plugins/Reporters/PlainTextReporter.cs index 0ec0490f3..893c85ef3 100644 --- a/DevProxy.Plugins/Reporters/PlainTextReporter.cs +++ b/DevProxy.Plugins/Reporters/PlainTextReporter.cs @@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters; public class PlainTextReporter( ILogger logger, - ISet urlsToWatch) : BaseReporter(logger, urlsToWatch) + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration) { public override string Name => nameof(PlainTextReporter); public override string FileExtension => ".txt"; diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs index 39447f292..1f1f36aeb 100644 --- a/DevProxy.Tests/Fakes.cs +++ b/DevProxy.Tests/Fakes.cs @@ -85,6 +85,7 @@ internal sealed class FakeProxyConfiguration : IProxyConfiguration, IProcessTree public bool NoWatch { get; set; } public int Port { get; set; } = 8000; public bool Record { get; set; } + public string? ReportsFolder { get; set; } public bool ShowTimestamps => false; public long? TimeoutSeconds { get; set; } public bool ValidateSchemas => false; diff --git a/DevProxy.Tests/ReportsFolderTests.cs b/DevProxy.Tests/ReportsFolderTests.cs new file mode 100644 index 000000000..bf9225af9 --- /dev/null +++ b/DevProxy.Tests/ReportsFolderTests.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Commands; +using DevProxy.Proxy; +using Microsoft.Extensions.Configuration; + +using Xunit; + +namespace DevProxy.Tests; + +public class ReportsFolderTests +{ + [Fact] + public void ReportsFolder_FromConfigFile_ResolvesRelativeToConfigFile() + { + var configDir = Directory.CreateTempSubdirectory("devproxy-config-"); + try + { + var configPath = Path.Combine(configDir.FullName, "devproxyrc.json"); + File.WriteAllText(configPath, """{ "reportsFolder": "out/reports" }"""); + var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build(); + + var proxyConfiguration = new ProxyConfiguration(configuration); + + Assert.Equal( + Path.Combine(Path.GetDirectoryName(proxyConfiguration.ConfigFile)!, "out", "reports"), + proxyConfiguration.ReportsFolder); + } + finally + { + configDir.Delete(true); + } + } + + [Fact] + public void ReportsFolder_FromConfigFile_KeepsAbsolutePath() + { + var configDir = Directory.CreateTempSubdirectory("devproxy-config-"); + try + { + var reportsFolder = Path.Combine(Path.GetTempPath(), "devproxy-absolute-reports"); + var configPath = Path.Combine(configDir.FullName, "devproxyrc.json"); + File.WriteAllText(configPath, $$"""{ "reportsFolder": {{System.Text.Json.JsonSerializer.Serialize(reportsFolder)}} }"""); + var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build(); + + var proxyConfiguration = new ProxyConfiguration(configuration); + + Assert.Equal(Path.GetFullPath(reportsFolder), proxyConfiguration.ReportsFolder); + } + finally + { + configDir.Delete(true); + } + } + + [Fact] + public void ReportsFolder_NotConfigured_IsNull() + { + var configDir = Directory.CreateTempSubdirectory("devproxy-config-"); + try + { + var configPath = Path.Combine(configDir.FullName, "devproxyrc.json"); + File.WriteAllText(configPath, "{}"); + var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build(); + + var proxyConfiguration = new ProxyConfiguration(configuration); + + Assert.Null(proxyConfiguration.ReportsFolder); + } + finally + { + configDir.Delete(true); + } + } + + [Fact] + public void ReportDirOption_ResolvesRelativeToWorkingDirectory() + { + var expected = Path.Combine(Directory.GetCurrentDirectory(), "reports"); + + Assert.Equal(expected, DevProxyCommand.ResolveReportDirOption("reports")); + } + + [Fact] + public void ReportDirOption_ExpandsHomeDirectory() + { + var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "reports"); + + Assert.Equal(expected, DevProxyCommand.ResolveReportDirOption($"~{Path.DirectorySeparatorChar}reports")); + } +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 6577c0bcb..f4094b094 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -29,6 +29,7 @@ sealed class DevProxyCommand : RootCommand internal const string IpAddressOptionName = "--ip-address"; internal const string LogLevelOptionName = "--log-level"; internal const string RecordOptionName = "--record"; + internal const string ReportDirOptionName = "--report-dir"; internal const string WatchPidsOptionName = "--watch-pids"; internal const string WatchProcessNamesOptionName = "--watch-process-names"; internal const string WatchProcessTreeOptionName = "--watch-process-tree"; @@ -227,6 +228,9 @@ public static string DetachedLogFilePath Environment.GetEnvironmentVariable("TERM")); // Per https://no-color.org, NO_COLOR disables color only when present and not empty. + internal static string ResolveReportDirOption(string value) => + Path.GetFullPath(ProxyUtils.ReplacePathTokens(value)); + internal static bool ShouldDisableColor(string[] args, string? noColorEnv, string? termEnv) => args.Contains(NoColorOptionName) || !string.IsNullOrEmpty(noColorEnv) || @@ -443,6 +447,31 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions) Description = "Use this option to record all request logs" }; + var reportDirOption = new Option(ReportDirOptionName) + { + Description = "Folder where reporters write report files. Overrides reportsFolder from the config file. Relative paths resolve against the current working directory. Default: the current working directory", + HelpName = "path" + }; + reportDirOption.Validators.Add(input => + { + if (input.Tokens.Count == 0) + { + return; + } + + var value = input.Tokens[0].Value; + if (string.IsNullOrWhiteSpace(value)) + { + input.AddError("The report directory path can't be empty."); + return; + } + + if (File.Exists(ResolveReportDirOption(value))) + { + input.AddError($"The report directory path '{value}' points to a file. Specify a folder."); + } + }); + var watchPidsOption = new Option>(WatchPidsOptionName) { AllowMultipleArgumentsPerToken = true, @@ -612,6 +641,7 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions) outputOption, portOption, recordOption, + reportDirOption, timeoutOption, urlsToWatchOption, watchPidsOption, @@ -710,6 +740,11 @@ private void ConfigureFromOptions(ParseResult parseResult) { _proxyConfiguration.Record = record.Value; } + var reportDir = parseResult.GetValueOrDefault(ReportDirOptionName); + if (!string.IsNullOrWhiteSpace(reportDir)) + { + _proxyConfiguration.ReportsFolder = ResolveReportDirOption(reportDir); + } var watchPids = parseResult.GetValueOrDefault>(WatchPidsOptionName); if (watchPids is not null && watchPids.Any()) { diff --git a/DevProxy/Proxy/ProxyConfiguration.cs b/DevProxy/Proxy/ProxyConfiguration.cs index 27b65c73a..fbeab55f8 100755 --- a/DevProxy/Proxy/ProxyConfiguration.cs +++ b/DevProxy/Proxy/ProxyConfiguration.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Models; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; using System.Text.Json.Serialization; namespace DevProxy.Proxy; @@ -44,6 +45,7 @@ public string ConfigFile public ReleaseType NewVersionNotification { get; set; } = ReleaseType.Stable; public int Port { get; set; } = 8000; public bool Record { get; set; } + public string? ReportsFolder { get; set; } public bool ShowSkipMessages { get; set; } = true; public bool ShowTimestamps { get; set; } = true; public long? TimeoutSeconds { get; set; } @@ -57,6 +59,29 @@ public ProxyConfiguration(IConfigurationRoot configurationRoot) { _configurationRoot = configurationRoot; _configurationRoot.Bind(this); + ReportsFolder = ResolveReportsFolder(ReportsFolder, _configurationRoot); + } + + // reportsFolder from the config file is relative to the config file + internal static string? ResolveReportsFolder(string? reportsFolder, IConfigurationRoot configurationRoot) + { + if (string.IsNullOrWhiteSpace(reportsFolder)) + { + return null; + } + + string? configFilePath = null; + try + { + configFilePath = GetConfigFilePath(configurationRoot); + } + catch (InvalidOperationException) + { + } + + return configFilePath is null + ? Path.GetFullPath(ProxyUtils.ReplacePathTokens(reportsFolder)) + : ProxyUtils.GetFullPath(reportsFolder, configFilePath); } internal static string GetConfigFilePath(IConfigurationRoot configurationRoot) diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json index 4db88db85..b7f256fbe 100644 --- a/schemas/v4.0.0/rc.schema.json +++ b/schemas/v4.0.0/rc.schema.json @@ -183,6 +183,10 @@ "type": "boolean", "description": "Whether to record requests and responses." }, + "reportsFolder": { + "type": "string", + "description": "Folder where reporters write report files. Relative paths resolve against the folder of this configuration file. Default: the current working directory." + }, "showSkipMessages": { "type": "boolean", "description": "Show messages for skipped requests."