From efa33012f7e7fcdff89bb441f2930198ff3ec054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Thu, 22 Feb 2024 18:04:02 -0500 Subject: [PATCH] Add automated tests --- Build.ps1 | 34 +----- .../Workleap.DotNet.CodingStandards.props | 29 +++++ .../Workleap.DotNet.CodingStandards.targets | 31 ++++++ .../Workleap.DotNet.CodingStandards.props | 3 + .../Workleap.DotNet.CodingStandards.targets | 3 + .../Workleap.DotNet.CodingStandards.props | 30 +---- .../Workleap.DotNet.CodingStandards.targets | 32 +----- .../Helpers/PathHelpers.cs | 17 +++ .../Helpers/ProjectBuilder.cs | 103 ++++++++++++++++++ .../Helpers/SarifFile.cs | 16 +++ .../Helpers/SarifFileRun.cs | 9 ++ .../Helpers/SarifFileRunResult.cs | 20 ++++ .../Helpers/SarifFileRunResultMessage.cs | 14 +++ .../Helpers/SharedHttpClient.cs | 70 ++++++++++++ .../PackageFixture.cs | 42 +++++++ .../UnitTest1.cs | 67 ++++++++++++ ...rkleap.DotNet.CodingStandards.Tests.csproj | 26 +++++ wl-dotnet-codingstandards.sln | 30 +++++ 18 files changed, 486 insertions(+), 90 deletions(-) create mode 100644 src/build/Workleap.DotNet.CodingStandards.props create mode 100644 src/build/Workleap.DotNet.CodingStandards.targets create mode 100644 src/buildMultiTargeting/Workleap.DotNet.CodingStandards.props create mode 100644 src/buildMultiTargeting/Workleap.DotNet.CodingStandards.targets create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/ProjectBuilder.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFile.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRun.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResult.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResultMessage.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SharedHttpClient.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/UnitTest1.cs create mode 100644 tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj create mode 100644 wl-dotnet-codingstandards.sln diff --git a/Build.ps1 b/Build.ps1 index 94a0fa4..1a08e8e 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -16,30 +16,6 @@ Process { $outputDir = Join-Path $PSScriptRoot ".output" $nupkgsPath = Join-Path $outputDir "*.nupkg" - $testProjectName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetRandomFileName()) - $testProjectDir = Join-Path $outputDir $testProjectName - $testProjectPath = Join-Path $testProjectDir "$testProjectName.csproj" - $testNugetConfigPath = Join-Path $testProjectDir "nuget.config" - $testNugetConfigContents = @" - - - - - - - - - - - - - - - - - -"@ - try { Push-Location $workingDir Remove-Item $outputDir -Force -Recurse -ErrorAction SilentlyContinue @@ -54,14 +30,8 @@ Process { # Pack using NuGet.exe Exec { & nuget pack Workleap.DotNet.CodingStandards.nuspec -OutputDirectory $outputDir -Version $version -ForceEnglishOutput } - # Create a new test console project, add our newly created package and try to build it in release mode - # The default .NET console project template with top-level statements should not trigger any warnings - # We treat warnings as errors even though it's supposed to be already enabled by our package, - # just in case the package is not working as expected - Exec { & dotnet new console --name $testProjectName --output $testProjectDir } - Set-Content -Path $testNugetConfigPath -Value $testNugetConfigContents - Exec { & dotnet add $testProjectPath package Workleap.DotNet.CodingStandards --version $version } - Exec { & dotnet build $testProjectPath --configuration Release /p:TreatWarningsAsErrors=true } + # Run tests + Exec { & dotnet test } # Push to a NuGet feed if the environment variables are set if (($null -ne $env:NUGET_SOURCE ) -and ($null -ne $env:NUGET_API_KEY)) { diff --git a/src/build/Workleap.DotNet.CodingStandards.props b/src/build/Workleap.DotNet.CodingStandards.props new file mode 100644 index 0000000..a144399 --- /dev/null +++ b/src/build/Workleap.DotNet.CodingStandards.props @@ -0,0 +1,29 @@ + + + true + strict + true + true + latest-all + true + + + true + + + true + true + true + true + true + + + true + + + true + + + true + + diff --git a/src/build/Workleap.DotNet.CodingStandards.targets b/src/build/Workleap.DotNet.CodingStandards.targets new file mode 100644 index 0000000..583b67e --- /dev/null +++ b/src/build/Workleap.DotNet.CodingStandards.targets @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + true + + + + + + diff --git a/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.props b/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.props new file mode 100644 index 0000000..bea1969 --- /dev/null +++ b/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.props @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.targets b/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.targets new file mode 100644 index 0000000..57f9970 --- /dev/null +++ b/src/buildMultiTargeting/Workleap.DotNet.CodingStandards.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/buildTransitive/Workleap.DotNet.CodingStandards.props b/src/buildTransitive/Workleap.DotNet.CodingStandards.props index a144399..bea1969 100644 --- a/src/buildTransitive/Workleap.DotNet.CodingStandards.props +++ b/src/buildTransitive/Workleap.DotNet.CodingStandards.props @@ -1,29 +1,3 @@ - - true - strict - true - true - latest-all - true - - - true - - - true - true - true - true - true - - - true - - - true - - - true - - + + \ No newline at end of file diff --git a/src/buildTransitive/Workleap.DotNet.CodingStandards.targets b/src/buildTransitive/Workleap.DotNet.CodingStandards.targets index 583b67e..57f9970 100644 --- a/src/buildTransitive/Workleap.DotNet.CodingStandards.targets +++ b/src/buildTransitive/Workleap.DotNet.CodingStandards.targets @@ -1,31 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - true - - - - - - + + \ No newline at end of file diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs new file mode 100644 index 0000000..5c831af --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs @@ -0,0 +1,17 @@ +using Meziantou.Framework; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal static class PathHelpers +{ + public static FullPath GetRootDirectory() + { + var directory = FullPath.CurrentDirectory(); + while (!Directory.Exists(directory / ".git")) + { + directory = directory.Parent; + } + + return directory; + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/ProjectBuilder.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/ProjectBuilder.cs new file mode 100644 index 0000000..a4f95e2 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/ProjectBuilder.cs @@ -0,0 +1,103 @@ +using Meziantou.Framework; +using System.Xml.Linq; +using Xunit.Abstractions; +using System.Text.Json; +using CliWrap; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class ProjectBuilder : IAsyncDisposable +{ + private const string SarifFileName = "BuildOutput.sarif"; + + private readonly TemporaryDirectory _directory; + private readonly ITestOutputHelper _testOutputHelper; + + public ProjectBuilder(PackageFixture fixture, ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + + _directory = TemporaryDirectory.Create(); + _directory.CreateTextFile("NuGet.config", $""" + + + + + + + + + + + + + + + + + + + """); + + File.Copy(PathHelpers.GetRootDirectory() / "global.json", _directory.FullPath / "global.json"); + } + + public ProjectBuilder AddFile(string relativePath, string content) + { + File.WriteAllText(_directory.FullPath / relativePath, content); + return this; + } + + public ProjectBuilder AddCsprojFile(Dictionary properties = null) + { + var element = new XElement("PropertyGroup"); + if (properties != null) + { + foreach (var prop in properties) + { + element.Add(new XElement(prop.Key), prop.Value); + } + } + + var content = $""" + + + exe + net$(NETCoreAppMaximumVersion) + enable + enable + {SarifFileName},version=2.1 + + {element} + + + + + + """; + + File.WriteAllText(_directory.FullPath / "test.csproj", content); + return this; + } + + public async Task BuildAndGetOutput(string[] buildArguments = null) + { + var result = await Cli.Wrap("dotnet") + .WithWorkingDirectory(_directory.FullPath) + .WithArguments(["build", .. (buildArguments ?? [])]) + .WithEnvironmentVariables(env => env.Set("CI", null).Set("GITHUB_ACTIONS", null)) + .WithStandardOutputPipe(PipeTarget.ToDelegate(_testOutputHelper.WriteLine)) + .WithStandardErrorPipe(PipeTarget.ToDelegate(_testOutputHelper.WriteLine)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + _testOutputHelper.WriteLine("Process exit code: " + result.ExitCode); + + var bytes = File.ReadAllBytes(_directory.FullPath / SarifFileName); + var sarif = JsonSerializer.Deserialize(bytes); + _testOutputHelper.WriteLine("Sarif result:\n" + string.Join("\n", sarif.AllResults().Select(r => r.ToString()))); + return sarif; + } + + public ValueTask DisposeAsync() => _directory.DisposeAsync(); +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFile.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFile.cs new file mode 100644 index 0000000..604aa25 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFile.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class SarifFile +{ + [JsonPropertyName("runs")] + public SarifFileRun[] Runs { get; set; } + + public IEnumerable AllResults() => Runs.SelectMany(r => r.Results); + + public bool HasError() => AllResults().Any(r => r.Level == "error"); + public bool HasError(string ruleId) => AllResults().Any(r => r.Level == "error" && r.RuleId == ruleId); + public bool HasWarning(string ruleId) => AllResults().Any(r => r.Level == "warning" && r.RuleId == ruleId); + public bool HasNote(string ruleId) => AllResults().Any(r => r.Level == "note" && r.RuleId == ruleId); +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRun.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRun.cs new file mode 100644 index 0000000..dcb7e3c --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRun.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class SarifFileRun +{ + [JsonPropertyName("results")] + public SarifFileRunResult[] Results { get; set; } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResult.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResult.cs new file mode 100644 index 0000000..c33edea --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResult.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class SarifFileRunResult +{ + [JsonPropertyName("ruleId")] + public string RuleId { get; set; } + + [JsonPropertyName("level")] + public string Level { get; set; } + + [JsonPropertyName("message")] + public SarifFileRunResultMessage Message { get; set; } + + public override string ToString() + { + return $"{Level}:{RuleId} {Message}"; + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResultMessage.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResultMessage.cs new file mode 100644 index 0000000..9e9caa1 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SarifFileRunResultMessage.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class SarifFileRunResultMessage +{ + [JsonPropertyName("text")] + public string Text { get; set; } + + public override string ToString() + { + return Text; + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SharedHttpClient.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SharedHttpClient.cs new file mode 100644 index 0000000..a647f20 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SharedHttpClient.cs @@ -0,0 +1,70 @@ +#nullable enable +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; +internal static class SharedHttpClient +{ + public static HttpClient Instance { get; } = CreateHttpClient(); + + private static HttpClient CreateHttpClient() + { + var socketHandler = new SocketsHttpHandler() + { + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1), + PooledConnectionLifetime = TimeSpan.FromMinutes(1), + }; + + return new HttpClient(new HttpRetryMessageHandler(socketHandler), disposeHandler: true); + } + private sealed class HttpRetryMessageHandler(HttpMessageHandler handler) : DelegatingHandler(handler) + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + const int MaxRetries = 5; + var defaultDelay = TimeSpan.FromMilliseconds(200); + for (var i = 1; ; i++, defaultDelay *= 2) + { + TimeSpan? delayHint = null; + HttpResponseMessage? result = null; + + try + { + result = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!IsLastAttempt(i) && ((int)result.StatusCode >= 500 || result.StatusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests)) + { + // Use "Retry-After" value, if available. Typically, this is sent with + // either a 503 (Service Unavailable) or 429 (Too Many Requests): + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + + delayHint = result.Headers.RetryAfter switch + { + { Date: { } date } => date - DateTimeOffset.UtcNow, + { Delta: { } delta } => delta, + _ => null, + }; + + result.Dispose(); + } + else + { + return result; + } + } + catch (HttpRequestException) + { + result?.Dispose(); + if (IsLastAttempt(i)) + throw; + } + catch (TaskCanceledException ex) when (ex.CancellationToken != cancellationToken) // catch "The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing" + { + result?.Dispose(); + if (IsLastAttempt(i)) + throw; + } + + await Task.Delay(delayHint is { } someDelay && someDelay > TimeSpan.Zero ? someDelay : defaultDelay, cancellationToken).ConfigureAwait(false); + + static bool IsLastAttempt(int i) => i >= MaxRetries; + } + } + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs b/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs new file mode 100644 index 0000000..8d8e325 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs @@ -0,0 +1,42 @@ +using CliWrap; +using Meziantou.Framework; +using Workleap.DotNet.CodingStandards.Tests.Helpers; + +namespace Workleap.DotNet.CodingStandards.Tests; + +public sealed class PackageFixture : IAsyncLifetime +{ + private readonly TemporaryDirectory _packageDirectory = TemporaryDirectory.Create(); + + public FullPath PackageDirectory => _packageDirectory.FullPath; + + public async Task InitializeAsync() + { + // On CI the exe is already present + var exe = "nuget"; + if (OperatingSystem.IsWindows()) + { + var downloadPath = FullPath.GetTempPath() / $"nuget-{Guid.NewGuid()}.exe"; + await DownloadFileAsync("https://dist.nuget.org/win-x86-commandline/latest/nuget.exe", downloadPath); + exe = downloadPath; + } + + var nuspecPath = PathHelpers.GetRootDirectory() / "Workleap.DotNet.CodingStandards.nuspec"; + await Cli.Wrap(exe) + .WithArguments(["pack", nuspecPath, "-ForceEnglishOutput", "-Version", "999.9.9", "-OutputDirectory", _packageDirectory.FullPath]) + .ExecuteAsync(); + } + + public async Task DisposeAsync() + { + await _packageDirectory.DisposeAsync(); + } + + private static async Task DownloadFileAsync(string url, FullPath path) + { + path.CreateParentDirectory(); + await using var nugetStream = await SharedHttpClient.Instance.GetStreamAsync(url); + await using var fileStream = File.Create(path); + await nugetStream.CopyToAsync(fileStream); + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/UnitTest1.cs b/tests/Workleap.DotNet.CodingStandards.Tests/UnitTest1.cs new file mode 100644 index 0000000..3cf5d63 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/UnitTest1.cs @@ -0,0 +1,67 @@ +using Workleap.DotNet.CodingStandards.Tests.Helpers; +using Xunit.Abstractions; + +namespace Workleap.DotNet.CodingStandards.Tests; + +public class UnitTest1(PackageFixture fixture, ITestOutputHelper testOutputHelper) : IClassFixture +{ + [Fact] + public async Task BannedSymbolsAreReported() + { + await using var project = new ProjectBuilder(fixture, testOutputHelper); + project.AddCsprojFile(); + project.AddFile("sample.cs", "_ = System.DateTime.Now;"); + var data = await project.BuildAndGetOutput(); + Assert.True(data.HasWarning("RS0030")); + } + + [Fact] + public async Task WarningsAsErrorOnGitHubActions() + { + await using var project = new ProjectBuilder(fixture, testOutputHelper); + project.AddCsprojFile(); + project.AddFile("sample.cs", "_ = System.DateTime.Now;"); + var data = await project.BuildAndGetOutput(["--configuration", "Release", "/p:GITHUB_ACTIONS=true"]); + Assert.True(data.HasError("RS0030")); + } + + [Fact] + public async Task NamingConvention_Invalid() + { + await using var project = new ProjectBuilder(fixture, testOutputHelper); + project.AddCsprojFile(); + project.AddFile("sample.cs", """ + _ = ""; + + class Sample + { + private readonly int field; + + public Sample(int a) => field = a; + + public int A() => field; + } + """); + var data = await project.BuildAndGetOutput(["--configuration", "Release"]); + Assert.True(data.HasError("IDE1006")); + } + + [Fact] + public async Task NamingConvention_Valid() + { + await using var project = new ProjectBuilder(fixture, testOutputHelper); + project.AddCsprojFile(); + project.AddFile("sample.cs", """ + _ = ""; + + class Sample + { + private int _field; + } + """); + var data = await project.BuildAndGetOutput(["--configuration", "Release"]); + Assert.False(data.HasError("IDE1006")); + Assert.False(data.HasWarning("IDE1006")); + } + +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj b/tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj new file mode 100644 index 0000000..4732c62 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/wl-dotnet-codingstandards.sln b/wl-dotnet-codingstandards.sln new file mode 100644 index 0000000..56e8104 --- /dev/null +++ b/wl-dotnet-codingstandards.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C1F5A879-3A26-4621-ADB7-3B1C59CBC8B3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Workleap.DotNet.CodingStandards.Tests", "tests\Workleap.DotNet.CodingStandards.Tests\Workleap.DotNet.CodingStandards.Tests.csproj", "{640037BA-49DF-4BBD-9858-3DC89E2739FD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {640037BA-49DF-4BBD-9858-3DC89E2739FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {640037BA-49DF-4BBD-9858-3DC89E2739FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {640037BA-49DF-4BBD-9858-3DC89E2739FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {640037BA-49DF-4BBD-9858-3DC89E2739FD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {640037BA-49DF-4BBD-9858-3DC89E2739FD} = {C1F5A879-3A26-4621-ADB7-3B1C59CBC8B3} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {18E64023-58E9-4BA2-BCE7-4BD5E1A023C1} + EndGlobalSection +EndGlobal