diff --git a/Build.ps1 b/Build.ps1 index 94a0fa4..5b6ab1d 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 --configuration Release --logger "console;verbosity=detailed" } # 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9bf91b0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + enable + enable + + + + + + \ No newline at end of file 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/CodingStandardTests.cs b/tests/Workleap.DotNet.CodingStandards.Tests/CodingStandardTests.cs new file mode 100644 index 0000000..42e3252 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/CodingStandardTests.cs @@ -0,0 +1,66 @@ +using Workleap.DotNet.CodingStandards.Tests.Helpers; +using Xunit.Abstractions; + +namespace Workleap.DotNet.CodingStandards.Tests; + +public sealed class CodingStandardTests(PackageFixture fixture, ITestOutputHelper testOutputHelper) : IClassFixture +{ + [Fact] + public async Task BannedSymbolsAreReported() + { + 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() + { + 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() + { + 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() + { + 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/Helpers/PathHelpers.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs new file mode 100644 index 0000000..fda9c8d --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/PathHelpers.cs @@ -0,0 +1,15 @@ +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal static class PathHelpers +{ + public static string GetRootDirectory() + { + var directory = Environment.CurrentDirectory; + while (directory != null && !Directory.Exists(Path.Combine(directory, ".git"))) + { + directory = Path.GetDirectoryName(directory); + } + + return directory ?? throw new InvalidOperationException("Cannot find the root of the git repository"); + } +} 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..ac33beb --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/ProjectBuilder.cs @@ -0,0 +1,100 @@ +using System.Xml.Linq; +using Xunit.Abstractions; +using System.Text.Json; +using CliWrap; + +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class ProjectBuilder : IDisposable +{ + private const string SarifFileName = "BuildOutput.sarif"; + + private readonly TemporaryDirectory _directory; + private readonly ITestOutputHelper _testOutputHelper; + + public ProjectBuilder(PackageFixture fixture, ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + + this._directory = TemporaryDirectory.Create(); + this._directory.CreateTextFile("NuGet.config", $""" + + + + + + + + + + + + + + + + + + + """); + + File.Copy(Path.Combine(PathHelpers.GetRootDirectory(), "global.json"), this._directory.GetPath("global.json")); + } + + public void AddFile(string relativePath, string content) + { + File.WriteAllText(this._directory.GetPath(relativePath), content); + } + + public void 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(this._directory.GetPath("test.csproj"), content); + } + + public async Task BuildAndGetOutput(string[]? buildArguments = null) + { + var result = await Cli.Wrap("dotnet") + .WithWorkingDirectory(this._directory.FullPath) + .WithArguments(["build", .. (buildArguments ?? [])]) + .WithEnvironmentVariables(env => env.Set("CI", null).Set("GITHUB_ACTIONS", null)) + .WithStandardOutputPipe(PipeTarget.ToDelegate(this._testOutputHelper.WriteLine)) + .WithStandardErrorPipe(PipeTarget.ToDelegate(this._testOutputHelper.WriteLine)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + this._testOutputHelper.WriteLine("Process exit code: " + result.ExitCode); + + var bytes = await File.ReadAllBytesAsync(this._directory.GetPath(SarifFileName)); + var sarif = JsonSerializer.Deserialize(bytes) ?? throw new InvalidOperationException("The sarif file is invalid"); + this._testOutputHelper.WriteLine("Sarif result:\n" + string.Join("\n", sarif.AllResults().Select(r => r.ToString()))); + return sarif; + } + + public void Dispose() => this._directory.Dispose(); +} 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..f0aa47b --- /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() => this.Runs?.SelectMany(r => r.Results ?? []) ?? []; + + public bool HasError() => this.AllResults().Any(r => r.Level == "error"); + public bool HasError(string ruleId) => this.AllResults().Any(r => r.Level == "error" && r.RuleId == ruleId); + public bool HasWarning(string ruleId) => this.AllResults().Any(r => r.Level == "warning" && r.RuleId == ruleId); + public bool HasNote(string ruleId) => this.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..16b1bc4 --- /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..5b83c72 --- /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 $"{this.Level}:{this.RuleId} {this.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..25fc30d --- /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 this.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..bb29c4a --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/SharedHttpClient.cs @@ -0,0 +1,73 @@ +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/Helpers/TemporaryDirectory.cs b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/TemporaryDirectory.cs new file mode 100644 index 0000000..0803e7e --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Helpers/TemporaryDirectory.cs @@ -0,0 +1,39 @@ +namespace Workleap.DotNet.CodingStandards.Tests.Helpers; + +internal sealed class TemporaryDirectory : IDisposable +{ + private TemporaryDirectory(string fullPath) => this.FullPath = fullPath; + + public string FullPath { get; } + + public static TemporaryDirectory Create() + { + var path = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + _ = Directory.CreateDirectory(path); + return new TemporaryDirectory(path); + } + + public string GetPath(string relativePath) + { + return Path.Combine(this.FullPath, relativePath); + } + + public void CreateTextFile(string relativePath, string content) + { + var path = this.GetPath(relativePath); + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + public void Dispose() + { + try + { + Directory.Delete(this.FullPath, recursive: true); + } + catch + { + // We use this code in tests, so it's not important if a folder cannot be deleted + } + } +} diff --git a/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs b/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs new file mode 100644 index 0000000..2c8df3e --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/PackageFixture.cs @@ -0,0 +1,48 @@ +using CliWrap; +using CliWrap.Buffered; +using Workleap.DotNet.CodingStandards.Tests.Helpers; + +namespace Workleap.DotNet.CodingStandards.Tests; + +public sealed class PackageFixture : IAsyncLifetime +{ + private readonly TemporaryDirectory _packageDirectory = TemporaryDirectory.Create(); + + public string PackageDirectory => this._packageDirectory.FullPath; + + public async Task InitializeAsync() + { + var nuspecPath = Path.Combine(PathHelpers.GetRootDirectory(), "Workleap.DotNet.CodingStandards.nuspec"); + string[] args = ["pack", nuspecPath, "-ForceEnglishOutput", "-Version", "999.9.9", "-OutputDirectory", this._packageDirectory.FullPath]; + + if (OperatingSystem.IsWindows()) + { + var exe = Path.Combine(Path.GetTempPath(), $"nuget-{Guid.NewGuid()}.exe"); + await DownloadFileAsync("https://dist.nuget.org/win-x86-commandline/latest/nuget.exe", exe); + + _ = await Cli.Wrap(exe) + .WithArguments(args) + .ExecuteAsync(); + } + else + { + _ = await Cli.Wrap("nuget") + .WithArguments(args) + .ExecuteBufferedAsync(); + } + } + + public Task DisposeAsync() + { + this._packageDirectory.Dispose(); + return Task.CompletedTask; + } + + private static async Task DownloadFileAsync(string url, string path) + { + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + 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/Workleap.DotNet.CodingStandards.Tests.csproj b/tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj new file mode 100644 index 0000000..8c7d341 --- /dev/null +++ b/tests/Workleap.DotNet.CodingStandards.Tests/Workleap.DotNet.CodingStandards.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + 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