Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public interface IProxyConfiguration
bool NoWatch { get; set; }
int Port { get; set; }
bool Record { get; set; }
/// <summary>
/// Absolute path of the folder where reporters write report files.
/// When null or empty, reports are written to the current working directory.
/// </summary>
string? ReportsFolder { get; set; }
bool ShowTimestamps { get; }
long? TimeoutSeconds { get; set; }
bool ValidateSchemas { get; }
Expand Down
2 changes: 1 addition & 1 deletion DevProxy.Integration.Tests/JsonReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace DevProxy.Integration.Tests;

public sealed class JsonReporterTests
{
private sealed class TestJsonReporter() : JsonReporter(NullLogger<JsonReporter>.Instance, new HashSet<UrlToWatch>())
private sealed class TestJsonReporter() : JsonReporter(NullLogger<JsonReporter>.Instance, new HashSet<UrlToWatch>(), new TestProxyConfiguration())
{
public string? Report(string key, object value) => GetReport(new(key, value));
}
Expand Down
100 changes: 100 additions & 0 deletions DevProxy.Integration.Tests/ReporterReportsFolderTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that reporters honour <see cref="IProxyConfiguration.ReportsFolder"/>
/// and fall back to the current working directory when it isn't set.
/// </summary>
public sealed class ReporterReportsFolderTests
{
private static RecordingArgs Recording() =>
new([])
{
GlobalData = new()
{
[ProxyUtils.ReportsKey] = new Dictionary<string, object>
{
["TestPlugin"] = new { Value = 1 }
}
},
};

private static JsonReporter CreateReporter(string? reportsFolder) =>
new(NullLogger<JsonReporter>.Instance, new HashSet<UrlToWatch>(), 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);
}
}
}
1 change: 1 addition & 0 deletions DevProxy.Integration.Tests/TestProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 30 additions & 5 deletions DevProxy.Plugins/Reporters/BaseReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,22 @@

namespace DevProxy.Plugins.Reporters;

public abstract class BaseReporter(
ILogger logger,
ISet<UrlToWatch> urlsToWatch) : BasePlugin(logger, urlsToWatch)
public abstract class BaseReporter : BasePlugin
{
private readonly IProxyConfiguration? _proxyConfiguration;

protected BaseReporter(ILogger logger, ISet<UrlToWatch> urlsToWatch) : base(logger, urlsToWatch)
{
}

protected BaseReporter(
ILogger logger,
ISet<UrlToWatch> urlsToWatch,
IProxyConfiguration proxyConfiguration) : base(logger, urlsToWatch)
{
_proxyConfiguration = proxyConfiguration;
}

public abstract string FileExtension { get; }

public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken)
Expand Down Expand Up @@ -45,21 +57,34 @@ value is not Dictionary<string, object> 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);
}

Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync));
}

protected abstract string? GetReport(KeyValuePair<string, object> report);

/// <summary>
/// Folder where reports are written. When null or empty, reports are written
/// to the current working directory.
/// </summary>
protected string? ReportsFolder => _proxyConfiguration?.ReportsFolder;

protected string GetReportFilePath(string fileName) =>
string.IsNullOrEmpty(ReportsFolder) ? fileName : Path.Combine(ReportsFolder, fileName);
}
3 changes: 2 additions & 1 deletion DevProxy.Plugins/Reporters/JsonReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ namespace DevProxy.Plugins.Reporters;

public class JsonReporter(
ILogger<JsonReporter> logger,
ISet<UrlToWatch> urlsToWatch) : BaseReporter(logger, urlsToWatch)
ISet<UrlToWatch> urlsToWatch,
IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
private const string DefaultFileExtension = ".json";
private string _fileExtension = DefaultFileExtension;
Expand Down
3 changes: 2 additions & 1 deletion DevProxy.Plugins/Reporters/MarkdownReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters;

public class MarkdownReporter(
ILogger<MarkdownReporter> logger,
ISet<UrlToWatch> urlsToWatch) : BaseReporter(logger, urlsToWatch)
ISet<UrlToWatch> urlsToWatch,
IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
public override string Name => nameof(MarkdownReporter);
public override string FileExtension => ".md";
Expand Down
3 changes: 2 additions & 1 deletion DevProxy.Plugins/Reporters/PlainTextReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters;

public class PlainTextReporter(
ILogger<PlainTextReporter> logger,
ISet<UrlToWatch> urlsToWatch) : BaseReporter(logger, urlsToWatch)
ISet<UrlToWatch> urlsToWatch,
IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
public override string Name => nameof(PlainTextReporter);
public override string FileExtension => ".txt";
Expand Down
1 change: 1 addition & 0 deletions DevProxy.Tests/Fakes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
93 changes: 93 additions & 0 deletions DevProxy.Tests/ReportsFolderTests.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
35 changes: 35 additions & 0 deletions DevProxy/Commands/DevProxyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -443,6 +447,31 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions)
Description = "Use this option to record all request logs"
};

var reportDirOption = new Option<string?>(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<IEnumerable<int>>(WatchPidsOptionName)
{
AllowMultipleArgumentsPerToken = true,
Expand Down Expand Up @@ -612,6 +641,7 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions)
outputOption,
portOption,
recordOption,
reportDirOption,
timeoutOption,
urlsToWatchOption,
watchPidsOption,
Expand Down Expand Up @@ -710,6 +740,11 @@ private void ConfigureFromOptions(ParseResult parseResult)
{
_proxyConfiguration.Record = record.Value;
}
var reportDir = parseResult.GetValueOrDefault<string?>(ReportDirOptionName);
if (!string.IsNullOrWhiteSpace(reportDir))
{
_proxyConfiguration.ReportsFolder = ResolveReportDirOption(reportDir);
}
var watchPids = parseResult.GetValueOrDefault<IEnumerable<int>>(WatchPidsOptionName);
if (watchPids is not null && watchPids.Any())
{
Expand Down
Loading
Loading