diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs index 251469eb..e5cb256e 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 0fd6cb47..b3314b5d 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 00000000..d16194fd --- /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 4297df6e..2de0ea3c 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 84fc5866..d5138a58 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 05fc0d80..396610db 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 5e8f130e..7d25b92e 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 0ec0490f..893c85ef 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 39447f29..1f1f36ae 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 00000000..bf9225af --- /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 6577c0bc..f4094b09 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 27b65c73..fbeab55f 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 4db88db8..b7f256fb 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."