├── .github ├── dotnet.json ├── workflows │ ├── changelog.config │ ├── dotnet-file.yml │ ├── dotnet-env.yml │ ├── changelog.yml │ ├── includes.yml │ ├── publish.yml │ ├── build.yml │ └── triage.yml ├── release.yml └── dependabot.yml ├── assets ├── 32.png ├── icon.png ├── img │ ├── ciretry.png │ ├── comment.png │ ├── timings.png │ └── progress.png ├── css │ └── style.scss └── icon.svg ├── _config.yml ├── src ├── dotnet-retest │ ├── icon.png │ ├── Properties │ │ └── launchSettings.json │ ├── Extensions.cs │ ├── readme.md │ ├── DotnetMuxer.cs │ ├── help.md │ ├── dotnet-retest.csproj │ ├── Process.cs │ ├── Program.cs │ ├── RetestCommand.cs │ └── TrxCommand.cs ├── Directory.props ├── Sample2 │ ├── Sample2.csproj │ └── NUnitTest.cs ├── Sample │ ├── Sample.csproj │ └── UnitTest1.cs ├── nuget.config ├── Directory.Build.props └── Directory.Build.targets ├── Directory.Build.rsp ├── .gitattributes ├── .gitignore ├── license.txt ├── dotnet-retest.sln ├── osmfeula.txt ├── .editorconfig ├── .netconfig ├── readme.md └── changelog.md /.github/dotnet.json: -------------------------------------------------------------------------------- 1 | [ 2 | "8.x" 3 | ] 4 | -------------------------------------------------------------------------------- /assets/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/32.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/img/ciretry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/img/ciretry.png -------------------------------------------------------------------------------- /assets/img/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/img/comment.png -------------------------------------------------------------------------------- /assets/img/timings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/img/timings.png -------------------------------------------------------------------------------- /assets/img/progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/assets/img/progress.png -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | 3 | exclude: [ 'src/', '*.sln', '*.slnx', 'Gemfile*', '*.rsp' ] 4 | -------------------------------------------------------------------------------- /src/dotnet-retest/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/HEAD/src/dotnet-retest/icon.png -------------------------------------------------------------------------------- /src/Directory.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dotnet-retest 5 | 6 | 7 | -------------------------------------------------------------------------------- /Directory.Build.rsp: -------------------------------------------------------------------------------- 1 | # See https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild-response-files 2 | -nr:false 3 | -m:1 4 | -v:m 5 | -clp:Summary;ForceNoAlign 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # normalize by default 2 | * text=auto encoding=UTF-8 3 | *.sh text eol=lf 4 | *.sbn eol=lf 5 | 6 | # These are windows specific files which we may as well ensure are 7 | # always crlf on checkout 8 | *.bat text eol=crlf 9 | *.cmd text eol=crlf 10 | -------------------------------------------------------------------------------- /src/dotnet-retest/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "dotnet-retest": { 4 | "commandName": "Project", 5 | "commandLineArgs": "-v verbose -- --filter \"FullyQualifiedName!=Sample.UnitTest1.FailsAlways\"", 6 | "workingDirectory": "..\\Sample" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /.github/workflows/changelog.config: -------------------------------------------------------------------------------- 1 | usernames-as-github-logins=true 2 | issues_wo_labels=true 3 | pr_wo_labels=true 4 | exclude-labels=bydesign,dependencies,duplicate,discussion,question,invalid,wontfix,need info,docs 5 | enhancement-label=:sparkles: Implemented enhancements: 6 | bugs-label=:bug: Fixed bugs: 7 | issues-label=:hammer: Other: 8 | pr-label=:twisted_rightwards_arrows: Merged: 9 | unreleased=false 10 | -------------------------------------------------------------------------------- /src/dotnet-retest/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | 4 | namespace Devlooped; 5 | 6 | static class Extensions 7 | { 8 | public static StringBuilder AppendLineIndented(this StringBuilder builder, string value, string indent) 9 | { 10 | foreach (var line in value.ReplaceLineEndings().Split(Environment.NewLine)) 11 | builder.Append(indent).AppendLine(line); 12 | 13 | return builder; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "jekyll-theme-slate"; 5 | 6 | .inner { 7 | max-width: 960px; 8 | } 9 | 10 | pre, code { 11 | background-color: unset; 12 | font-size: unset; 13 | } 14 | 15 | code { 16 | font-size: 0.80em; 17 | } 18 | 19 | h1 > img { 20 | border: unset; 21 | box-shadow: unset; 22 | vertical-align: middle; 23 | -moz-box-shadow: unset; 24 | -o-box-shadow: unset; 25 | -ms-box-shadow: unset; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/dotnet-file.yml: -------------------------------------------------------------------------------- 1 | # Synchronizes .netconfig-configured files with dotnet-file 2 | name: dotnet-file 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: [ 'dotnet-file' ] 9 | 10 | env: 11 | DOTNET_NOLOGO: true 12 | 13 | jobs: 14 | run: 15 | permissions: 16 | contents: write 17 | uses: devlooped/oss/.github/workflows/dotnet-file-core.yml@main 18 | secrets: 19 | BOT_NAME: ${{ secrets.BOT_NAME }} 20 | BOT_EMAIL: ${{ secrets.BOT_EMAIL }} 21 | GH_TOKEN: ${{ secrets.GH_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | artifacts 4 | pack 5 | TestResults 6 | results 7 | BenchmarkDotNet.Artifacts 8 | /app 9 | .vs 10 | .vscode 11 | .genaiscript 12 | .idea 13 | local.settings.json 14 | .env 15 | 16 | *.suo 17 | *.sdf 18 | *.userprefs 19 | *.user 20 | *.nupkg 21 | *.metaproj 22 | *.tmp 23 | *.log 24 | *.cache 25 | *.binlog 26 | *.zip 27 | __azurite*.* 28 | __*__ 29 | 30 | .nuget 31 | *.lock.json 32 | *.nuget.props 33 | *.nuget.targets 34 | 35 | node_modules 36 | _site 37 | .jekyll-metadata 38 | .jekyll-cache 39 | .sass-cache 40 | Gemfile.lock 41 | package-lock.json 42 | -------------------------------------------------------------------------------- /src/Sample2/Sample2.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Sample/Sample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - bydesign 5 | - dependencies 6 | - duplicate 7 | - question 8 | - invalid 9 | - wontfix 10 | - need info 11 | - techdebt 12 | authors: 13 | - devlooped-bot 14 | - dependabot 15 | - github-actions 16 | categories: 17 | - title: ✨ Implemented enhancements 18 | labels: 19 | - enhancement 20 | - title: 🐛 Fixed bugs 21 | labels: 22 | - bug 23 | - title: 📝 Documentation updates 24 | labels: 25 | - docs 26 | - documentation 27 | - title: 🔨 Other 28 | labels: 29 | - '*' 30 | exclude: 31 | labels: 32 | - dependencies 33 | -------------------------------------------------------------------------------- /src/Sample2/NUnitTest.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using NUnit.Framework; 3 | using Assert = NUnit.Framework.Assert; 4 | 5 | namespace Sample; 6 | 7 | [TestFixture] 8 | public class NUnitTest 9 | { 10 | [TestCase("this test, shouldn't break")] 11 | [TestCase("successful case")] 12 | public void ParameterEscapingRetries(string value) 13 | { 14 | // get a simple sha from the string to use as filename using the hex value from the sha1 of the value 15 | var file = "failed" + string.Concat(SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(value)).Select(b => b.ToString("x2"))) + ".txt"; 16 | 17 | if (!File.Exists(file)) 18 | { 19 | File.WriteAllText(file, ""); 20 | Assert.Fail("Fails once"); 21 | } 22 | 23 | File.Delete(file); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/dotnet-retest/readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Open Source Maintenance Fee 4 | 5 | To ensure the long-term sustainability of this project, use of dotnet-retest requires an 6 | [Open Source Maintenance Fee](https://opensourcemaintenancefee.org). While the source 7 | code is freely available under the terms of the [MIT License](https://github.com/devlooped/dotnet-retest/blob/main/license.txt), all other aspects of the 8 | project --including opening or commenting on issues, participating in discussions and 9 | downloading releases-- require [adherence to the Maintenance Fee](https://github.com/devlooped/dotnet-retest/blob/main/osmfeula.txt). 10 | 11 | In short, if you use this project to generate revenue, the [Maintenance Fee is required](https://github.com/devlooped/dotnet-retest/blob/main/osmfeula.txt). 12 | 13 | To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlooped) at the corresponding OSMF tier (starting at just $10!). 14 | 15 | 16 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Daniel Cazzulino and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/dotnet-retest/DotnetMuxer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Runtime.InteropServices; 4 | 5 | namespace Devlooped; 6 | 7 | static class DotnetMuxer 8 | { 9 | public static FileInfo? Path { get; } 10 | 11 | static DotnetMuxer() 12 | { 13 | var muxerFileName = ExecutableName("dotnet"); 14 | var fxDepsFile = GetDataFromAppDomain("FX_DEPS_FILE"); 15 | 16 | if (string.IsNullOrEmpty(fxDepsFile)) 17 | return; 18 | 19 | var muxerDir = new FileInfo(fxDepsFile).Directory?.Parent?.Parent?.Parent; 20 | if (muxerDir == null) 21 | return; 22 | 23 | var muxerCandidate = new FileInfo(System.IO.Path.Combine(muxerDir.FullName, muxerFileName)); 24 | if (muxerCandidate.Exists) 25 | Path = muxerCandidate; 26 | } 27 | 28 | public static string? GetDataFromAppDomain(string propertyName) 29 | => AppContext.GetData(propertyName) as string; 30 | 31 | public static string ExecutableName(this string withoutExtension) 32 | => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 33 | ? withoutExtension + ".exe" 34 | : withoutExtension; 35 | } -------------------------------------------------------------------------------- /.github/workflows/dotnet-env.yml: -------------------------------------------------------------------------------- 1 | name: dotnet-env 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**/*.*proj' 9 | 10 | jobs: 11 | which-dotnet: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | 17 | steps: 18 | - name: 🤖 defaults 19 | uses: devlooped/actions-bot@v1 20 | with: 21 | name: ${{ secrets.BOT_NAME }} 22 | email: ${{ secrets.BOT_EMAIL }} 23 | gh_token: ${{ secrets.GH_TOKEN }} 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: 🤘 checkout 27 | uses: actions/checkout@v4 28 | with: 29 | token: ${{ env.GH_TOKEN }} 30 | 31 | - name: 🤌 dotnet 32 | uses: devlooped/actions-which-dotnet@v1 33 | 34 | - name: ✍ pull request 35 | uses: peter-evans/create-pull-request@v7 36 | with: 37 | base: main 38 | branch: which-dotnet 39 | delete-branch: true 40 | labels: dependencies 41 | title: "⚙ Update dotnet versions" 42 | body: "Update dotnet versions" 43 | commit-message: "Update dotnet versions" 44 | token: ${{ env.GH_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: changelog 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | changelog: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 🤖 defaults 12 | uses: devlooped/actions-bot@v1 13 | with: 14 | name: ${{ secrets.BOT_NAME }} 15 | email: ${{ secrets.BOT_EMAIL }} 16 | gh_token: ${{ secrets.GH_TOKEN }} 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: 🤘 checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | ref: main 24 | token: ${{ env.GH_TOKEN }} 25 | 26 | - name: ⚙ ruby 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: 3.0.3 30 | 31 | - name: ⚙ changelog 32 | run: | 33 | gem install github_changelog_generator 34 | github_changelog_generator --user ${GITHUB_REPOSITORY%/*} --project ${GITHUB_REPOSITORY##*/} --token $GH_TOKEN --o changelog.md --config-file .github/workflows/changelog.config 35 | 36 | - name: 🚀 changelog 37 | run: | 38 | git add changelog.md 39 | (git commit -m "🖉 Update changelog with ${GITHUB_REF#refs/*/}" && git push) || echo "Done" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: nuget 7 | directory: / 8 | schedule: 9 | interval: daily 10 | groups: 11 | Azure: 12 | patterns: 13 | - "Azure*" 14 | - "Microsoft.Azure*" 15 | Identity: 16 | patterns: 17 | - "System.IdentityModel*" 18 | - "Microsoft.IdentityModel*" 19 | System: 20 | patterns: 21 | - "System*" 22 | exclude-patterns: 23 | - "System.IdentityModel*" 24 | Extensions: 25 | patterns: 26 | - "Microsoft.Extensions*" 27 | exclude-patterns: 28 | - "Microsoft.Extensions.AI*" 29 | ExtensionsAI: 30 | patterns: 31 | - "Microsoft.Extensions.AI*" 32 | Web: 33 | patterns: 34 | - "Microsoft.AspNetCore*" 35 | Tests: 36 | patterns: 37 | - "Microsoft.NET.Test*" 38 | - "xunit*" 39 | - "coverlet*" 40 | ThisAssembly: 41 | patterns: 42 | - "ThisAssembly*" 43 | ProtoBuf: 44 | patterns: 45 | - "protobuf-*" 46 | Spectre: 47 | patterns: 48 | - "Spectre.Console*" 49 | -------------------------------------------------------------------------------- /src/dotnet-retest/help.md: -------------------------------------------------------------------------------- 1 | ```shell 2 | USAGE: 3 | dotnet retest [OPTIONS] [-- [dotnet test options]] 4 | 5 | OPTIONS: 6 | DEFAULT 7 | -h, --help Prints help information 8 | --version Prints version information 9 | --retries 3 Maximum retries when re-running failed tests 10 | --no-summary Whether to emit a summary to console/GitHub 11 | --output Include test output in report 12 | -v, --verbosity Quiet Output display verbosity: 13 | - quiet: only failed tests are displayed 14 | - normal: failed and skipped tests are 15 | displayed 16 | - verbose: failed, skipped and passed tests 17 | are displayed 18 | --gh-comment True Report as GitHub PR comment 19 | --gh-summary True Report as GitHub step summary 20 | ``` 21 | -------------------------------------------------------------------------------- /src/nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/includes.yml: -------------------------------------------------------------------------------- 1 | name: +Mᐁ includes 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - '**.md' 9 | - '!changelog.md' 10 | - 'osmfeula.txt' 11 | 12 | jobs: 13 | includes: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - name: 🤖 defaults 20 | uses: devlooped/actions-bot@v1 21 | with: 22 | name: ${{ secrets.BOT_NAME }} 23 | email: ${{ secrets.BOT_EMAIL }} 24 | gh_token: ${{ secrets.GH_TOKEN }} 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: 🤘 checkout 28 | uses: actions/checkout@v4 29 | with: 30 | token: ${{ env.GH_TOKEN }} 31 | 32 | - name: +Mᐁ includes 33 | uses: devlooped/actions-includes@v1 34 | 35 | - name: 📝 OSMF EULA 36 | shell: pwsh 37 | run: | 38 | $file = "osmfeula.txt" 39 | $props = "src/Directory.Build.props" 40 | if (-not (test-path $file) -or -not (test-path $props)) { 41 | exit 0 42 | } 43 | 44 | $product = dotnet msbuild $props -getproperty:Product 45 | if (-not $product) { 46 | write-error "To use OSMF EULA, ensure the $(Product) property is set in Directory.props" 47 | exit 1 48 | } 49 | 50 | ((get-content -raw $file) -replace '\$product\$',$product).trim() | set-content $file 51 | 52 | - name: ✍ pull request 53 | uses: peter-evans/create-pull-request@v6 54 | with: 55 | add-paths: | 56 | **.md 57 | osmfeula.txt 58 | base: main 59 | branch: markdown-includes 60 | delete-branch: true 61 | labels: docs 62 | author: ${{ env.BOT_AUTHOR }} 63 | committer: ${{ env.BOT_AUTHOR }} 64 | commit-message: +Mᐁ includes 65 | title: +Mᐁ includes 66 | body: +Mᐁ includes 67 | token: ${{ env.GH_TOKEN }} 68 | -------------------------------------------------------------------------------- /dotnet-retest.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.11.35005.142 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-retest", "src\dotnet-retest\dotnet-retest.csproj", "{30849648-147D-41B7-ACBE-D54AD360E0C8}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "src\Sample\Sample.csproj", "{5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample2", "src\Sample2\Sample2.csproj", "{0F3664F5-4955-4D04-9DBA-FA144E3621F1}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {0F3664F5-4955-4D04-9DBA-FA144E3621F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {0F3664F5-4955-4D04-9DBA-FA144E3621F1}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {0F3664F5-4955-4D04-9DBA-FA144E3621F1}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {0F3664F5-4955-4D04-9DBA-FA144E3621F1}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {36DBDAFF-AF65-47FF-847A-A2BBD8AB8A95} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /src/Sample/UnitTest1.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | 3 | namespace Sample; 4 | 5 | public class UnitTest1 6 | { 7 | [Fact] 8 | public void FailsAlways() 9 | { 10 | throw new InvalidOperationException("Always fails"); 11 | } 12 | 13 | [Theory] 14 | [InlineData(1)] 15 | [InlineData(2)] 16 | public void Test1(int value) 17 | { 18 | Assert.True(value > 0); 19 | } 20 | 21 | [Theory] 22 | [InlineData("this test, shouldn't break")] 23 | [InlineData("successful case")] 24 | public void ParameterEscapingRetries(string value) 25 | { 26 | // get a simple sha from the string to use as filename using the hex value from the sha1 of the value 27 | var file = "failed" + string.Concat(SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(value)).Select(b => b.ToString("x2"))) + ".txt"; 28 | 29 | if (!File.Exists(file)) 30 | { 31 | File.WriteAllText(file, ""); 32 | Assert.Fail("Fails once"); 33 | } 34 | 35 | File.Delete(file); 36 | } 37 | 38 | [Fact] 39 | public void FailsOnce() 40 | { 41 | if (!File.Exists("failsonce.txt")) 42 | { 43 | File.WriteAllText("failsonce.txt", ""); 44 | Assert.Fail("Fails once"); 45 | } 46 | 47 | File.Delete("failsonce.txt"); 48 | } 49 | 50 | [Fact] 51 | public void FailsTwice() 52 | { 53 | // Add random delay to simulate actual test execution 54 | Thread.Sleep(Random.Shared.Next(1000, 5000)); 55 | 56 | var attempt = int.Parse( 57 | File.Exists("failstwice.txt") ? 58 | File.ReadAllText("failstwice.txt") : 59 | "0"); 60 | 61 | if (attempt < 2) 62 | { 63 | File.WriteAllText("failstwice.txt", (attempt + 1).ToString()); 64 | Assert.Fail("Fails twice"); 65 | } 66 | 67 | // Succeeds 68 | File.Delete("failstwice.txt"); 69 | } 70 | 71 | public static IEnumerable GetNumbers() => Enumerable.Range(0, 1100).Select(x => new object[] { x }); 72 | 73 | [Theory] 74 | [MemberData(nameof(GetNumbers))] 75 | public void NumberIsPositive(int value) 76 | { 77 | Assert.True(value >= 0); 78 | } 79 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Builds a final release version and pushes to nuget.org 2 | # whenever a release is published. 3 | # Requires: secrets.NUGET_API_KEY 4 | 5 | name: publish 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | env: 11 | DOTNET_NOLOGO: true 12 | Configuration: Release 13 | PackOnBuild: true 14 | GeneratePackageOnBuild: true 15 | VersionLabel: ${{ github.ref }} 16 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 17 | MSBUILDTERMINALLOGGER: auto 18 | 19 | jobs: 20 | publish: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: 🤘 checkout 24 | uses: actions/checkout@v4 25 | with: 26 | submodules: recursive 27 | fetch-depth: 0 28 | 29 | - name: 🙏 build 30 | run: dotnet build -m:1 -bl:build.binlog 31 | 32 | - name: ⚙ install 33 | working-directory: bin 34 | run: dotnet tool update -g dotnet-retest --prerelease --add-source . 35 | 36 | - name: 🧪 test 37 | run: | 38 | dotnet retest -- ./src/Sample/ --filter "FullyQualifiedName!=Sample.UnitTest1.FailsAlways" 39 | dotnet retest -- ./src/Sample2/ --filter "FullyQualifiedName!=Sample.UnitTest1.FailsAlways" 40 | 41 | - name: 🐛 logs 42 | uses: actions/upload-artifact@v4 43 | if: runner.debug && always() 44 | with: 45 | name: logs 46 | path: '*.binlog' 47 | 48 | - name: ⬆️ upload 49 | if: success() 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: pkg 53 | path: bin/* 54 | 55 | - name: 🚀 sleet 56 | env: 57 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 58 | if: env.SLEET_CONNECTION != '' 59 | run: | 60 | dotnet tool install -g --version 4.0.18 sleet 61 | sleet push bin --config none -f --verbose -p "SLEET_FEED_CONTAINER=nuget" -p "SLEET_FEED_CONNECTIONSTRING=${{ secrets.SLEET_CONNECTION }}" -p "SLEET_FEED_TYPE=azure" || echo "No packages found" 62 | 63 | - name: 🚀 nuget 64 | env: 65 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 66 | if: env.NUGET_API_KEY != '' 67 | run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate 68 | -------------------------------------------------------------------------------- /src/dotnet-retest/dotnet-retest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | Devlooped 7 | retest 8 | CS9107 9 | false 10 | 11 | dotnet-retest 12 | dotnet-retest 13 | readme.md 14 | dotnet dotnet-tool 15 | 16 | OSMFEULA.txt 17 | true 18 | 19 | $([System.DateTime]::Now.ToString("yyyy-MM-dd")) 20 | $(GITHUB_REF_NAME) 21 | 22 | 23 | LatestMajor 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Builds and runs tests in all three supported OSes 2 | # Pushes CI feed if secrets.SLEET_CONNECTION is provided 3 | 4 | name: build 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | configuration: 9 | type: choice 10 | description: Configuration 11 | options: 12 | - Release 13 | - Debug 14 | push: 15 | branches: [ main, dev, 'dev/*', 'feature/*', 'rel/*' ] 16 | paths-ignore: 17 | - changelog.md 18 | - readme.md 19 | pull_request: 20 | types: [opened, synchronize, reopened] 21 | 22 | env: 23 | DOTNET_NOLOGO: true 24 | PackOnBuild: true 25 | GeneratePackageOnBuild: true 26 | VersionPrefix: 42.42.${{ github.run_number }} 27 | VersionLabel: ${{ github.ref }} 28 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 29 | MSBUILDTERMINALLOGGER: auto 30 | Configuration: ${{ github.event.inputs.configuration || 'Release' }} 31 | 32 | defaults: 33 | run: 34 | shell: bash 35 | 36 | jobs: 37 | build: 38 | name: build-${{ matrix.os }} 39 | runs-on: ${{ matrix.os }} 40 | strategy: 41 | matrix: 42 | os: [ 'windows-latest', 'ubuntu-latest', 'macOS-latest' ] 43 | steps: 44 | - name: 🤘 checkout 45 | uses: actions/checkout@v4 46 | 47 | - name: 🙏 build 48 | run: dotnet build -m:1 -bl:build.binlog 49 | 50 | - name: ⚙ install 51 | working-directory: bin 52 | run: dotnet tool update -g dotnet-retest --prerelease --add-source . 53 | 54 | - name: 🧪 test 55 | run: | 56 | dotnet retest -- ./src/Sample/ --filter "FullyQualifiedName!=Sample.UnitTest1.FailsAlways" 57 | dotnet retest -- ./src/Sample2/ --filter "FullyQualifiedName!=Sample.UnitTest1.FailsAlways" 58 | 59 | - name: 🐛 logs 60 | uses: actions/upload-artifact@v4 61 | if: runner.debug && always() 62 | with: 63 | name: logs 64 | path: '*.binlog' 65 | 66 | # Only push CI package to sleet feed if building on ubuntu (fastest) 67 | - name: 🚀 sleet 68 | env: 69 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 70 | if: env.SLEET_CONNECTION != '' && matrix.os == 'ubuntu-latest' 71 | run: | 72 | dotnet tool install -g --version 4.0.18 sleet 73 | sleet push bin --config none -f --verbose -p "SLEET_FEED_CONTAINER=nuget" -p "SLEET_FEED_CONNECTIONSTRING=${{ secrets.SLEET_CONNECTION }}" -p "SLEET_FEED_TYPE=azure" || echo "No packages found" 74 | 75 | dotnet-format: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: 🤘 checkout 79 | uses: actions/checkout@v4 80 | with: 81 | submodules: recursive 82 | fetch-depth: 0 83 | 84 | - name: ✓ ensure format 85 | run: | 86 | dotnet format whitespace --verify-no-changes -v:diag --exclude ~/.nuget 87 | dotnet format style --verify-no-changes -v:diag --exclude ~/.nuget 88 | -------------------------------------------------------------------------------- /src/dotnet-retest/Process.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Text; 5 | 6 | namespace Devlooped; 7 | 8 | static class Process 9 | { 10 | public static bool TryExecute(string program, IEnumerable arguments, out string? output) 11 | => TryExecuteCore(program, arguments, null, out output); 12 | 13 | public static bool TryExecute(string program, IEnumerable arguments, string input, out string? output) 14 | => TryExecuteCore(program, arguments, input, out output); 15 | 16 | public static bool TryExecute(string program, string arguments, out string? output) 17 | => TryExecuteCore(program, arguments, null, out output); 18 | 19 | public static bool TryExecute(string program, string arguments, string input, out string? output) 20 | => TryExecuteCore(program, arguments, input, out output); 21 | 22 | static bool TryExecuteCore(string program, IEnumerable arguments, string? input, out string? output) 23 | => TryExecuteCore(new ProcessStartInfo(program, arguments) 24 | { 25 | RedirectStandardOutput = true, 26 | RedirectStandardError = true, 27 | RedirectStandardInput = input != null 28 | }, input, out output); 29 | 30 | static bool TryExecuteCore(string program, string arguments, string? input, out string? output) 31 | => TryExecuteCore(new ProcessStartInfo(program, arguments) 32 | { 33 | RedirectStandardOutput = true, 34 | RedirectStandardError = true, 35 | RedirectStandardInput = input != null 36 | }, input, out output); 37 | 38 | static bool TryExecuteCore(ProcessStartInfo info, string? input, out string? output) 39 | { 40 | try 41 | { 42 | info.StandardOutputEncoding = Encoding.UTF8; 43 | //if (input != null) 44 | // info.StandardInputEncoding = Encoding.UTF8; 45 | 46 | var proc = System.Diagnostics.Process.Start(info); 47 | if (proc == null) 48 | { 49 | output = null; 50 | return false; 51 | } 52 | 53 | var gotError = false; 54 | proc.ErrorDataReceived += (_, __) => gotError = true; 55 | 56 | if (input != null) 57 | { 58 | // Write the input to the standard input stream 59 | proc.StandardInput.WriteLine(input); 60 | proc.StandardInput.Close(); 61 | } 62 | 63 | output = proc.StandardOutput.ReadToEnd(); 64 | if (!proc.WaitForExit(5000)) 65 | { 66 | proc.Kill(); 67 | output = null; 68 | return false; 69 | } 70 | 71 | var error = proc.StandardError.ReadToEnd(); 72 | gotError |= error.Length > 0; 73 | output = output.Trim(); 74 | if (string.IsNullOrEmpty(output)) 75 | output = null; 76 | 77 | return !gotError && proc.ExitCode == 0; 78 | } 79 | catch (Exception ex) 80 | { 81 | output = ex.Message; 82 | return false; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /osmfeula.txt: -------------------------------------------------------------------------------- 1 | End User License Agreement 2 | 3 | This Open Source Maintenance Fee Agreement ("Agreement") is a legal agreement 4 | between you ("User") and Devlooped ("Project") for the use of 5 | dotnet-retest ("Software"), an open source software project licensed under 6 | the MIT License ("OSI License"), an OSI-approved open source license. 7 | Project offers a Binary Release of the Software to Users in exchange for a 8 | maintenance fee ("Fee"). "Binary Release" refers to pre-compiled executable 9 | versions of the Software provided by Project. By accessing or using the 10 | Binary Release, User agrees to be bound by the terms of this Agreement. 11 | 12 | 1. Applicability 13 | 14 | Project agrees to provide User with the Binary Release in exchange for the 15 | Fees outlined in Section 2, subject to the terms of this Agreement. The Fee 16 | applies only to Users that generate revenue by the Software. 17 | Non-revenue-generating use of the Software is exempt from this Fee. In 18 | addition, Users who pay separate support and/or maintenance fees to the 19 | maintainers of the Software are exempt from the Fee outlined in this 20 | Agreement. This distinction ensures that duplicate fees are not imposed, 21 | promoting fairness and consistency while respecting alternative support 22 | arrangements. 23 | 24 | 2. Monthly Fee and Payment Terms 25 | 26 | Revenue-generating Users required to pay the Fee shall follow the payment 27 | terms set forth by the Project. Failure to comply with these terms may result 28 | in suspending access to the Binary Release. However, this does not restrict 29 | the User from obtaining or redistributing binaries from other sources or 30 | self-compiling them. 31 | 32 | 3. Nature of the Fee 33 | 34 | The Fee is not a license fee. The Software's source code is licensed to User 35 | under the OSI License and remains freely distributable under the terms of the 36 | OSI License and any applicable open-source licenses. 37 | 38 | 4. Conflicts with OSI License 39 | 40 | To the extent any term of this Agreement conflicts with User's rights 41 | under the OSI License regarding the Software, the OSI License shall govern. 42 | This Agreement applies only to the Binary Release and does not limit User's 43 | ability to access, modify, or distribute the Software's source code or 44 | self-compiled binaries. User may independently compile binaries from the 45 | Software's source code without this Agreement, subject to OSI License terms. 46 | User may redistribute the Binary Release received under this Agreement, 47 | provided such redistribution complies with the OSI License (e.g., including 48 | copyright and permission notices). This Agreement imposes no additional 49 | restrictions on such rights. 50 | 51 | 5. Disclaimer of Warranty and Limitation of Liability 52 | 53 | THE SOFTWARE AND BINARY RELEASE ARE PROVIDED BY THE PROJECT "AS IS" AND ANY 54 | EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 55 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 56 | DISCLAIMED. IN NO EVENT SHALL THE PROJECT OR ITS CONTRIBUTORS BE LIABLE FOR 57 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 58 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 59 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 60 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 61 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 62 | OF THE SOFTWARE AND BINARY RELEASE, EVEN IF ADVISED OF THE POSSIBILITY OF 63 | SUCH DAMAGE. 64 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/dotnet-retest/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Devlooped; 10 | using NuGet.Configuration; 11 | using NuGet.Protocol.Core.Types; 12 | using NuGet.Versioning; 13 | using Spectre.Console; 14 | using Spectre.Console.Cli; 15 | using Spectre.Console.Cli.Help; 16 | using Spectre.Console.Rendering; 17 | 18 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 19 | Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8; 20 | 21 | var app = new CommandApp(); 22 | 23 | // Alias -? to -h for help 24 | if (args.Contains("-?")) 25 | args = args.Select(x => x == "-?" ? "-h" : x).ToArray(); 26 | 27 | if (args.Contains("--debug")) 28 | { 29 | Debugger.Launch(); 30 | args = args.Where(args => args != "--debug").ToArray(); 31 | } 32 | 33 | app.Configure(config => 34 | { 35 | config.SetHelpProvider(new Helper(config.Settings)); 36 | config.SetApplicationName("dotnet retest"); 37 | 38 | if (Environment.GetEnvironmentVariables().Contains("NO_COLOR") && 39 | config.Settings.HelpProviderStyles?.Options is { } options) 40 | options.DefaultValue = Style.Plain; 41 | }); 42 | 43 | if (args.Contains("--version")) 44 | { 45 | AnsiConsole.MarkupLine($"{ThisAssembly.Project.ToolCommandName} version [lime]{ThisAssembly.Project.Version}[/] ({ThisAssembly.Project.BuildDate})"); 46 | AnsiConsole.MarkupLine($"[link]{ThisAssembly.Git.Url}/releases/tag/{ThisAssembly.Project.BuildRef}[/]"); 47 | 48 | foreach (var message in await CheckUpdates(args)) 49 | AnsiConsole.MarkupLine(message); 50 | 51 | return 0; 52 | } 53 | 54 | var updates = Task.Run(() => CheckUpdates(args)); 55 | var exit = app.Run(args); 56 | 57 | if (await updates is { Length: > 0 } messages) 58 | { 59 | foreach (var message in messages) 60 | AnsiConsole.MarkupLine(message); 61 | } 62 | 63 | return exit; 64 | 65 | static async Task CheckUpdates(string[] args) 66 | { 67 | if (args.Contains("-u") || args.Contains("--unattended")) 68 | return []; 69 | 70 | var providers = Repository.Provider.GetCoreV3(); 71 | var repository = new SourceRepository(new PackageSource("https://api.nuget.org/v3/index.json"), providers); 72 | var resource = await repository.GetResourceAsync(); 73 | var localVersion = new NuGetVersion(ThisAssembly.Project.Version); 74 | var metadata = await resource.GetMetadataAsync(ThisAssembly.Project.PackageId, true, false, 75 | new SourceCacheContext 76 | { 77 | NoCache = true, 78 | RefreshMemoryCache = true, 79 | }, 80 | NuGet.Common.NullLogger.Instance, CancellationToken.None); 81 | 82 | var update = metadata 83 | .Select(x => x.Identity) 84 | .Where(x => x.Version > localVersion) 85 | .OrderByDescending(x => x.Version) 86 | .Select(x => x.Version) 87 | .FirstOrDefault(); 88 | 89 | if (update != null) 90 | { 91 | return [ 92 | $"There is a new version of [yellow]{ThisAssembly.Project.PackageId}[/]: [dim]v{localVersion.ToNormalizedString()}[/] -> [lime]v{update.ToNormalizedString()}[/]", 93 | $"Update with: [yellow]dotnet[/] tool update -g {ThisAssembly.Project.PackageId}" 94 | ]; 95 | } 96 | 97 | return []; 98 | } 99 | 100 | class Helper(ICommandAppSettings settings) : HelpProvider(settings) 101 | { 102 | const string dotnet = "[-- [dotnet test options]]"; 103 | 104 | public override IEnumerable GetUsage(ICommandModel model, ICommandInfo? command) 105 | => [new Markup( 106 | $""" 107 | [yellow]USAGE:[/] 108 | {settings.ApplicationName} [[OPTIONS]] [grey]{dotnet.EscapeMarkup()}[/] 109 | 110 | """)]; 111 | } 112 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome:http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Don't use tabs for indentation. 7 | [*] 8 | indent_style = space 9 | # (Please don't specify an indent_size here; that has too many unintended consequences.) 10 | 11 | # Code files 12 | [*.{cs,csx,vb,vbx}] 13 | indent_size = 4 14 | 15 | # Xml project files 16 | [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,msbuildproj,props,targets}] 17 | indent_size = 2 18 | 19 | # Xml config files 20 | [*.{ruleset,config,nuspec,resx,vsixmanifest,vsct}] 21 | indent_size = 2 22 | 23 | # YAML files 24 | [*.{yaml,yml}] 25 | indent_size = 2 26 | 27 | # JSON files 28 | [*.json] 29 | indent_size = 2 30 | 31 | # Dotnet code style settings: 32 | [*.{cs,vb}] 33 | # Sort using and Import directives with System.* appearing first 34 | dotnet_sort_system_directives_first = true 35 | # Avoid "this." and "Me." if not necessary 36 | dotnet_style_qualification_for_field = false:suggestion 37 | dotnet_style_qualification_for_property = false:suggestion 38 | dotnet_style_qualification_for_method = false:suggestion 39 | dotnet_style_qualification_for_event = false:suggestion 40 | 41 | # Use language keywords instead of framework type names for type references 42 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 43 | dotnet_style_predefined_type_for_member_access = true:suggestion 44 | 45 | # Suggest more modern language features when available 46 | dotnet_style_object_initializer = true:suggestion 47 | dotnet_style_collection_initializer = true:suggestion 48 | dotnet_style_coalesce_expression = true:suggestion 49 | dotnet_style_null_propagation = true:suggestion 50 | dotnet_style_explicit_tuple_names = true:suggestion 51 | 52 | # CSharp code style settings: 53 | 54 | # IDE0040: Add accessibility modifiers 55 | dotnet_style_require_accessibility_modifiers = omit_if_default:error 56 | 57 | # IDE0040: Add accessibility modifiers 58 | dotnet_diagnostic.IDE0040.severity = error 59 | 60 | [*.cs] 61 | # Top-level files are definitely OK 62 | csharp_using_directive_placement = outside_namespace:silent 63 | csharp_style_namespace_declarations = block_scoped:silent 64 | csharp_prefer_simple_using_statement = true:suggestion 65 | csharp_prefer_braces = true:silent 66 | 67 | # Prefer "var" everywhere 68 | csharp_style_var_for_built_in_types = true:suggestion 69 | csharp_style_var_when_type_is_apparent = true:suggestion 70 | csharp_style_var_elsewhere = true:suggestion 71 | 72 | # Prefer method-like constructs to have an expression-body 73 | csharp_style_expression_bodied_methods = true:none 74 | csharp_style_expression_bodied_constructors = true:none 75 | csharp_style_expression_bodied_operators = true:none 76 | 77 | # Prefer property-like constructs to have an expression-body 78 | csharp_style_expression_bodied_properties = true:none 79 | csharp_style_expression_bodied_indexers = true:none 80 | csharp_style_expression_bodied_accessors = true:none 81 | 82 | # Suggest more modern language features when available 83 | csharp_style_pattern_matching_over_is_with_cast_check = true:error 84 | csharp_style_pattern_matching_over_as_with_null_check = true:error 85 | csharp_style_inlined_variable_declaration = true:suggestion 86 | csharp_style_throw_expression = true:suggestion 87 | csharp_style_conditional_delegate_call = true:suggestion 88 | 89 | # Newline settings 90 | csharp_new_line_before_open_brace = all 91 | csharp_new_line_before_else = true 92 | csharp_new_line_before_catch = true 93 | csharp_new_line_before_finally = true 94 | csharp_new_line_before_members_in_object_initializers = true 95 | csharp_new_line_before_members_in_anonymous_types = true 96 | 97 | # Test settings 98 | [**/*Tests*/**{.cs,.vb}] 99 | # xUnit1013: Public method should be marked as test. Allows using records as test classes 100 | dotnet_diagnostic.xUnit1013.severity = none 101 | 102 | # CS9113: Parameter is unread (usually, ITestOutputHelper) 103 | dotnet_diagnostic.CS9113.severity = none 104 | 105 | # Default severity for analyzer diagnostics with category 'Style' 106 | dotnet_analyzer_diagnostic.category-Style.severity = none 107 | 108 | # VSTHRD200: Use "Async" suffix for async methods 109 | dotnet_diagnostic.VSTHRD200.severity = none 110 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: 'triage' 2 | on: 3 | schedule: 4 | - cron: '42 0 * * *' 5 | 6 | workflow_dispatch: 7 | # Manual triggering through the GitHub UI, API, or CLI 8 | inputs: 9 | daysBeforeClose: 10 | description: "Days before closing stale or need info issues" 11 | required: true 12 | default: "30" 13 | daysBeforeStale: 14 | description: "Days before labeling stale" 15 | required: true 16 | default: "180" 17 | daysSinceClose: 18 | description: "Days since close to lock" 19 | required: true 20 | default: "30" 21 | daysSinceUpdate: 22 | description: "Days since update to lock" 23 | required: true 24 | default: "30" 25 | 26 | permissions: 27 | actions: write # For managing the operation state cache 28 | issues: write 29 | contents: read 30 | 31 | jobs: 32 | stale: 33 | # Do not run on forks 34 | if: github.repository_owner == 'devlooped' 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: ⌛ rate 38 | shell: pwsh 39 | if: github.event_name != 'workflow_dispatch' 40 | env: 41 | GH_TOKEN: ${{ secrets.DEVLOOPED_TOKEN }} 42 | run: | 43 | # add random sleep since we run on fixed schedule 44 | $wait = get-random -max 180 45 | echo "Waiting random $wait seconds to start" 46 | sleep $wait 47 | # get currently authenticated user rate limit info 48 | $rate = gh api rate_limit | convertfrom-json | select -expandproperty rate 49 | # if we don't have at least 100 requests left, wait until reset 50 | if ($rate.remaining -lt 100) { 51 | $wait = ($rate.reset - (Get-Date (Get-Date).ToUniversalTime() -UFormat %s)) 52 | echo "Rate limit remaining is $($rate.remaining), waiting for $($wait / 1000) seconds to reset" 53 | sleep $wait 54 | $rate = gh api rate_limit | convertfrom-json | select -expandproperty rate 55 | echo "Rate limit has reset to $($rate.remaining) requests" 56 | } 57 | 58 | - name: ✏️ stale labeler 59 | # pending merge: https://github.com/actions/stale/pull/1176 60 | uses: kzu/stale@c8450312ba97b204bf37545cb249742144d6ca69 61 | with: 62 | ascending: true # Process the oldest issues first 63 | stale-issue-label: 'stale' 64 | stale-issue-message: | 65 | Due to lack of recent activity, this issue has been labeled as 'stale'. 66 | It will be closed if no further activity occurs within ${{ fromJson(inputs.daysBeforeClose || 30 ) }} more days. 67 | Any new comment will remove the label. 68 | close-issue-message: | 69 | This issue will now be closed since it has been labeled 'stale' without activity for ${{ fromJson(inputs.daysBeforeClose || 30 ) }} days. 70 | days-before-stale: ${{ fromJson(inputs.daysBeforeStale || 180) }} 71 | days-before-close: ${{ fromJson(inputs.daysBeforeClose || 30 ) }} 72 | days-before-pr-close: -1 # Do not close PRs labeled as 'stale' 73 | exempt-all-milestones: true 74 | exempt-all-assignees: true 75 | exempt-issue-labels: priority,sponsor,backed 76 | exempt-authors: kzu 77 | 78 | - name: 🤘 checkout actions 79 | uses: actions/checkout@v4 80 | with: 81 | repository: 'microsoft/vscode-github-triage-actions' 82 | ref: v42 83 | 84 | - name: ⚙ install actions 85 | run: npm install --production 86 | 87 | - name: 🔒 issues locker 88 | uses: ./locker 89 | with: 90 | token: ${{ secrets.DEVLOOPED_TOKEN }} 91 | ignoredLabel: priority 92 | daysSinceClose: ${{ fromJson(inputs.daysSinceClose || 30) }} 93 | daysSinceUpdate: ${{ fromJson(inputs.daysSinceUpdate || 30) }} 94 | 95 | - name: 🔒 need info closer 96 | uses: ./needs-more-info-closer 97 | with: 98 | token: ${{ secrets.DEVLOOPED_TOKEN }} 99 | label: 'need info' 100 | closeDays: ${{ fromJson(inputs.daysBeforeClose || 30) }} 101 | closeComment: "This issue has been closed automatically because it needs more information and has not had recent activity.\n\nHappy Coding!" 102 | pingDays: 80 103 | pingComment: "Hey @${assignee}, this issue might need further attention.\n\n@${author}, you can help us out by closing this issue if the problem no longer exists, or adding more information." -------------------------------------------------------------------------------- /.netconfig: -------------------------------------------------------------------------------- 1 | [file] 2 | url = https://github.com/devlooped/oss 3 | [file ".netconfig"] 4 | url = https://github.com/devlooped/oss/blob/main/.netconfig 5 | skip 6 | [file "readme.md"] 7 | url = https://github.com/devlooped/oss/blob/main/readme.md 8 | skip 9 | [file ".github/workflows/combine-prs.yml"] 10 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/combine-prs.yml 11 | skip 12 | [file "src/kzu.snk"] 13 | url = https://github.com/devlooped/oss/blob/main/src/kzu.snk 14 | skip 15 | [file ".editorconfig"] 16 | url = https://github.com/devlooped/oss/blob/main/.editorconfig 17 | sha = e81ab754b366d52d92bd69b24bef1d5b1c610634 18 | etag = 7298c6450967975a8782b5c74f3071e1910fc59686e48f9c9d5cd7c68213cf59 19 | weak 20 | [file ".gitattributes"] 21 | url = https://github.com/devlooped/oss/blob/main/.gitattributes 22 | sha = 4a9aa321c4982b83c185cf8dffed181ff84667d5 23 | etag = 09cad18280ed04b67f7f87591e5481510df04d44c3403231b8af885664d8fd58 24 | weak 25 | [file ".github/dependabot.yml"] 26 | url = https://github.com/devlooped/oss/blob/main/.github/dependabot.yml 27 | sha = e733294084fb3e75d517a2e961e87df8faae7dc6 28 | etag = 3bf8d9214a15c049ca5cfe80d212a8cbe4753b8a638a9804ef73d34c7def9618 29 | weak 30 | [file ".github/release.yml"] 31 | url = https://github.com/devlooped/oss/blob/main/.github/release.yml 32 | sha = 0c23e24704625cf75b2cb1fdc566cef7e20af313 33 | etag = 310df162242c95ed19ed12e3c96a65f77e558b46dced676ad5255eb12caafe75 34 | weak 35 | [file ".github/workflows/build.yml"] 36 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/build.yml 37 | skip 38 | [file ".github/workflows/changelog.config"] 39 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/changelog.config 40 | sha = 08d83cb510732f861416760d37702f9f55bd7f9e 41 | etag = 556a28914eeeae78ca924b1105726cdaa211af365671831887aec81f5f4301b4 42 | weak 43 | [file ".github/workflows/changelog.yml"] 44 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/changelog.yml 45 | sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 46 | etag = ad1efa56d6024ee1add2bcda81a7e4e38d0e9069473c6ff70374d5ce06af1f5a 47 | weak 48 | [file ".github/workflows/dotnet-file.yml"] 49 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/dotnet-file.yml 50 | sha = 8fa147d4799d73819040736c399d0b1db2c2d86c 51 | etag = 1ca805a23656e99c03f9d478dba8ccef6e571f5de2ac0e9bb7e3c5216c99a694 52 | weak 53 | [file ".github/workflows/includes.yml"] 54 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/includes.yml 55 | sha = 6a6de0580b40e305f7a0f41b406d4aabaa0756fe 56 | etag = 1a5cd7a883700c328105910cc212f5f8c9f3759fc1af023e048a9f486da794c1 57 | weak 58 | [file ".github/workflows/publish.yml"] 59 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml 60 | skip 61 | [file ".gitignore"] 62 | url = https://github.com/devlooped/oss/blob/main/.gitignore 63 | sha = 3776526342afb3f57da7e80f2095e5fdca3c31c9 64 | etag = 11767f73556aa4c6c8bcc153b77ee8e8114f99fa3b885b0a7d66d082f91e77b3 65 | weak 66 | [file "Directory.Build.rsp"] 67 | url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp 68 | sha = 0f7f7f7e8a29de9b535676f75fe7c67e629a5e8c 69 | etag = 0ccae83fc51f400bfd7058170bfec7aba11455e24a46a0d7e6a358da6486e255 70 | weak 71 | [file "_config.yml"] 72 | url = https://github.com/devlooped/oss/blob/main/_config.yml 73 | sha = 68b409c486842062e0de0e5b11e6fdb7cd12d6e2 74 | etag = d608aa0ddaedc2d8a87260f50756e8d8314964ad4671b76bd085bcb458757010 75 | weak 76 | [file "assets/css/style.scss"] 77 | url = https://github.com/devlooped/oss/blob/main/assets/css/style.scss 78 | sha = 9db26e2710b084d219d6355339d822f159bf5780 79 | etag = f710d8919abfd5a8d00050b74ba7d0bb05c6d02e40842a3012eb96555c208504 80 | weak 81 | [file "license.txt"] 82 | url = https://github.com/devlooped/oss/blob/main/license.txt 83 | sha = 0683ee777d7d878d4bf013d7deea352685135a05 84 | etag = 2c6335b37e4ae05eea7c01f5d0c9d82b49c488f868a8b5ba7bff7c6ff01f3994 85 | weak 86 | [file "src/Directory.Build.props"] 87 | url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props 88 | sha = c509be4378ff6789df4f66338cb88119453c0975 89 | etag = cbbdc1a4d3030f353f3e5306a6c380238dd4ed0945aad2d56ba87b49fcfcd66d 90 | weak 91 | [file "src/Directory.Build.targets"] 92 | url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets 93 | sha = 4339749ef4b8f66def75931df09ef99c149f8421 94 | etag = 8b4492765755c030c4c351e058a92f53ab493cab440c1c0ef431f6635c4dae0e 95 | weak 96 | [file "src/dotnet-retest/TrxCommand.cs"] 97 | url = https://github.com/devlooped/dotnet-trx/blob/main/src/dotnet-trx/TrxCommand.cs 98 | sha = 9797acb675002234920073d5822fb500de672bf3 99 | etag = 85984491f45c937ed82872a9efd39e6d2fd4d5eb437862578cf84184981d87f7 100 | weak 101 | [file "src/dotnet-retest/Process.cs"] 102 | url = https://github.com/devlooped/dotnet-trx/blob/main/src/dotnet-trx/Process.cs 103 | sha = 8385c4a7355e472f4a94939a5da5ea47cc43b4a3 104 | etag = d36430441b9b7d4f444707afb6434665c4a38204da5fab6ca298cd8382b9d684 105 | weak 106 | [file "src/dotnet-retest/Extensions.cs"] 107 | url = https://github.com/devlooped/dotnet-trx/blob/main/src/dotnet-trx/Extensions.cs 108 | sha = 59102327ff8db52de1d7487d53e6bfef5891b5cd 109 | etag = 422d00a9218315f8f53d34a3a16d4cd1001fe71042bc9e04cd5439f62f3cce0b 110 | weak 111 | [file ".github/workflows/dotnet-file-core.yml"] 112 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/dotnet-file-core.yml 113 | skip 114 | [file ".github/workflows/triage.yml"] 115 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/triage.yml 116 | sha = 33000c0c4ab4eb4e0e142fa54515b811a189d55c 117 | etag = 013a47739e348f06891f37c45164478cca149854e6cd5c5158e6f073f852b61a 118 | weak 119 | [file "src/nuget.config"] 120 | url = https://github.com/devlooped/oss/blob/main/src/nuget.config 121 | sha = 032439dbf180fca0539a5bd3a019f18ab3484b76 122 | etag = da7c0104131bd474b52fc9bc9f9bda6470e24ae38d4fb9f5c4f719bc01370ab5 123 | weak 124 | [file ".github/actions/dotnet/action.yml"] 125 | url = https://github.com/devlooped/oss/blob/main/.github/actions/dotnet/action.yml 126 | skip 127 | [file ".github/workflows/dotnet-env.yml"] 128 | url = https://github.com/devlooped/oss/blob/main/.github/workflows/dotnet-env.yml 129 | sha = 77e83f238196d2723640abef0c7b6f43994f9747 130 | etag = fcb9759a96966df40dcd24906fd328ddec05953b7e747a6bb8d0d1e4c3865274 131 | weak 132 | [file "osmfeula.txt"] 133 | url = https://github.com/devlooped/.github/blob/main/osmfeula.txt 134 | etag = 91ea15c07bfd784036c6ca931f5b2df7e9767b8367146d96c79caef09d63899f 135 | weak 136 | sha = 666a2a7c315f72199c418f11482a950fc69a8901 137 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Icon](assets/32.png) dotnet retest 2 | ============ 3 | 4 | [![Version](https://img.shields.io/nuget/v/dotnet-retest?color=royalblue)](https://www.nuget.org/packages/dotnet-retest) 5 | [![Downloads](https://img.shields.io/nuget/dt/dotnet-retest)](https://www.nuget.org/packages/dotnet-retest) 6 | [![License](https://img.shields.io/github/license/devlooped/dotnet-retest?color=blue)](https://github.com//devlooped/dotnet-retest/blob/main/license.txt) 7 | [![Build](https://img.shields.io/github/actions/workflow/status/devlooped/dotnet-retest/build.yml?branch=main)](https://github.com/devlooped/dotnet-retest/actions) 8 | 9 | 10 | Runs `dotnet test` with retries for failed tests automatically, and pretty-prints aggregated 11 | test results, integrating also with GitHub PR comments just like [dotnet-trx](https://github.com/devlooped/dotnet-trx). 12 | 13 | ![Demo](https://raw.githubusercontent.com/devlooped/dotnet-retest/main/assets/img/ciretry.png) 14 | 15 | When running locally, it provides live progress on each run: 16 | 17 | ![Demo](https://raw.githubusercontent.com/devlooped/dotnet-retest/main/assets/img/progress.png) 18 | 19 | and timing and outcome for each attempt: 20 | 21 | ![Demo](https://raw.githubusercontent.com/devlooped/dotnet-retest/main/assets/img/timings.png) 22 | 23 | Typical usage: `dotnet retest [OPTIONS] [-- [dotnet test options]]`: 24 | 25 | ```yml 26 | - name: 🧪 test 27 | run: | 28 | dotnet tool update -g dotnet-retest 29 | dotnet retest -- --no-build [other test options and args] 30 | ``` 31 | 32 | PR comment integration: 33 | 34 | ![PR comment](https://raw.githubusercontent.com/devlooped/dotnet-retest/main/assets/img/comment.png) 35 | 36 | > NOTE: this behavior is triggered by the presence of the `GITHUB_REF_NAME` and `CI` environment variables. 37 | 38 | 39 | ```shell 40 | USAGE: 41 | dotnet retest [OPTIONS] [-- [dotnet test options]] 42 | 43 | OPTIONS: 44 | DEFAULT 45 | -h, --help Prints help information 46 | --version Prints version information 47 | --retries 3 Maximum retries when re-running failed tests 48 | --no-summary Whether to emit a summary to console/GitHub 49 | --output Include test output in report 50 | -v, --verbosity Quiet Output display verbosity: 51 | - quiet: only failed tests are displayed 52 | - normal: failed and skipped tests are 53 | displayed 54 | - verbose: failed, skipped and passed tests 55 | are displayed 56 | --gh-comment True Report as GitHub PR comment 57 | --gh-summary True Report as GitHub step summary 58 | ``` 59 | 60 | 61 | 62 | > NOTE: rendering the passed tests requires `verbose` verbosity, since typically 63 | > you'll just want to see the failed tests in the report, especially in projects with 64 | > large number of tests. 65 | 66 | Install: 67 | 68 | ```shell 69 | dotnet tool install -g dotnet-retest 70 | ``` 71 | 72 | Update: 73 | 74 | ```shell 75 | dotnet tool update -g dotnet-retest 76 | ``` 77 | 78 | 79 | 80 | ## Open Source Maintenance Fee 81 | 82 | To ensure the long-term sustainability of this project, use of dotnet-retest requires an 83 | [Open Source Maintenance Fee](https://opensourcemaintenancefee.org). While the source 84 | code is freely available under the terms of the [MIT License](https://github.com/devlooped/dotnet-retest/blob/main/license.txt), all other aspects of the 85 | project --including opening or commenting on issues, participating in discussions and 86 | downloading releases-- require [adherence to the Maintenance Fee](https://github.com/devlooped/dotnet-retest/blob/main/osmfeula.txt). 87 | 88 | In short, if you use this project to generate revenue, the [Maintenance Fee is required](https://github.com/devlooped/dotnet-retest/blob/main/osmfeula.txt). 89 | 90 | To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlooped) at the corresponding OSMF tier (starting at just $10!). 91 | 92 | 93 | # Sponsors 94 | 95 | 96 | [![Clarius Org](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/clarius.png "Clarius Org")](https://github.com/clarius) 97 | [![MFB Technologies, Inc.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/MFB-Technologies-Inc.png "MFB Technologies, Inc.")](https://github.com/MFB-Technologies-Inc) 98 | [![DRIVE.NET, Inc.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/drivenet.png "DRIVE.NET, Inc.")](https://github.com/drivenet) 99 | [![Keith Pickford](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Keflon.png "Keith Pickford")](https://github.com/Keflon) 100 | [![Thomas Bolon](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tbolon.png "Thomas Bolon")](https://github.com/tbolon) 101 | [![Kori Francis](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/kfrancis.png "Kori Francis")](https://github.com/kfrancis) 102 | [![Uno Platform](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/unoplatform.png "Uno Platform")](https://github.com/unoplatform) 103 | [![Reuben Swartz](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/rbnswartz.png "Reuben Swartz")](https://github.com/rbnswartz) 104 | [![Jacob Foshee](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jfoshee.png "Jacob Foshee")](https://github.com/jfoshee) 105 | [![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Mrxx99.png "")](https://github.com/Mrxx99) 106 | [![Eric Johnson](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/eajhnsn1.png "Eric Johnson")](https://github.com/eajhnsn1) 107 | [![David JENNI](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/davidjenni.png "David JENNI")](https://github.com/davidjenni) 108 | [![Jonathan ](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Jonathan-Hickey.png "Jonathan ")](https://github.com/Jonathan-Hickey) 109 | [![Charley Wu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/akunzai.png "Charley Wu")](https://github.com/akunzai) 110 | [![Ken Bonny](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/KenBonny.png "Ken Bonny")](https://github.com/KenBonny) 111 | [![Simon Cropp](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/SimonCropp.png "Simon Cropp")](https://github.com/SimonCropp) 112 | [![agileworks-eu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/agileworks-eu.png "agileworks-eu")](https://github.com/agileworks-eu) 113 | [![Zheyu Shen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/arsdragonfly.png "Zheyu Shen")](https://github.com/arsdragonfly) 114 | [![Vezel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/vezel-dev.png "Vezel")](https://github.com/vezel-dev) 115 | [![ChilliCream](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ChilliCream.png "ChilliCream")](https://github.com/ChilliCream) 116 | [![4OTC](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/4OTC.png "4OTC")](https://github.com/4OTC) 117 | [![Vincent Limo](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/v-limo.png "Vincent Limo")](https://github.com/v-limo) 118 | [![Jordan S. Jones](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jordansjones.png "Jordan S. Jones")](https://github.com/jordansjones) 119 | [![domischell](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/DominicSchell.png "domischell")](https://github.com/DominicSchell) 120 | [![Justin Wendlandt](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jwendl.png "Justin Wendlandt")](https://github.com/jwendl) 121 | [![Adrian Alonso](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/adalon.png "Adrian Alonso")](https://github.com/adalon) 122 | [![Michael Hagedorn](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Eule02.png "Michael Hagedorn")](https://github.com/Eule02) 123 | [![torutek](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/torutek.png "torutek")](https://github.com/torutek) 124 | [![Ryan McCaffery](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/mccaffers.png "Ryan McCaffery")](https://github.com/mccaffers) 125 | [![Alex Wiese](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/alexwiese.png "Alex Wiese")](https://github.com/alexwiese) 126 | 127 | 128 | 129 | 130 | [![Sponsor this project](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor.png "Sponsor this project")](https://github.com/sponsors/devlooped) 131 |   132 | 133 | [Learn more about GitHub Sponsors](https://github.com/sponsors) 134 | 135 | 136 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.0.0](https://github.com/devlooped/dotnet-retest/tree/v1.0.0) (2025-09-30) 4 | 5 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v1.0.0-beta...v1.0.0) 6 | 7 | ## [v1.0.0-beta](https://github.com/devlooped/dotnet-retest/tree/v1.0.0-beta) (2025-09-30) 8 | 9 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.7.2...v1.0.0-beta) 10 | 11 | :sparkles: Implemented enhancements: 12 | 13 | - Render failing tests in verbose setting [\#98](https://github.com/devlooped/dotnet-retest/pull/98) (@kzu) 14 | - Improve escaping sequences in test filters [\#97](https://github.com/devlooped/dotnet-retest/pull/97) (@kzu) 15 | - Add support for pre-filtered lists of retest runs [\#96](https://github.com/devlooped/dotnet-retest/pull/96) (@kzu) 16 | 17 | :bug: Fixed bugs: 18 | 19 | - Command --filter "FullyQualifiedName!=TestProject1.UnitTest1.Test1" breaks the retry [\#91](https://github.com/devlooped/dotnet-retest/issues/91) 20 | - Paramterized tests fail to rerun if they contain a comma [\#89](https://github.com/devlooped/dotnet-retest/issues/89) 21 | 22 | :hammer: Other: 23 | 24 | - DotNet 9 support [\#87](https://github.com/devlooped/dotnet-retest/issues/87) 25 | - Feature request: more verbosity maybe? [\#86](https://github.com/devlooped/dotnet-retest/issues/86) 26 | - Is the unattended check correct? [\#80](https://github.com/devlooped/dotnet-retest/issues/80) 27 | 28 | :twisted_rightwards_arrows: Merged: 29 | 30 | - Fix unattended execution check [\#99](https://github.com/devlooped/dotnet-retest/pull/99) (@kzu) 31 | 32 | ## [v0.7.2](https://github.com/devlooped/dotnet-retest/tree/v0.7.2) (2025-07-30) 33 | 34 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.7.1...v0.7.2) 35 | 36 | :sparkles: Implemented enhancements: 37 | 38 | - Allow running on latest & greatest installed [\#93](https://github.com/devlooped/dotnet-retest/pull/93) (@kzu) 39 | 40 | ## [v0.7.1](https://github.com/devlooped/dotnet-retest/tree/v0.7.1) (2025-03-02) 41 | 42 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.7.0...v0.7.1) 43 | 44 | :bug: Fixed bugs: 45 | 46 | - Include standard error in the result to make it easier to find errors. [\#56](https://github.com/devlooped/dotnet-retest/issues/56) 47 | 48 | ## [v0.7.0](https://github.com/devlooped/dotnet-retest/tree/v0.7.0) (2025-02-25) 49 | 50 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.5...v0.7.0) 51 | 52 | :bug: Fixed bugs: 53 | 54 | - Error on output when projects contain more than 1000 unit tests [\#47](https://github.com/devlooped/dotnet-retest/issues/47) 55 | 56 | :twisted_rightwards_arrows: Merged: 57 | 58 | - Include messages to standard error, this to make the log complete [\#57](https://github.com/devlooped/dotnet-retest/pull/57) (@Tasteful) 59 | 60 | ## [v0.6.5](https://github.com/devlooped/dotnet-retest/tree/v0.6.5) (2025-02-18) 61 | 62 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.4...v0.6.5) 63 | 64 | :sparkles: Implemented enhancements: 65 | 66 | - Bring changes from trx new verbosity levels [\#64](https://github.com/devlooped/dotnet-retest/pull/64) (@kzu) 67 | 68 | :bug: Fixed bugs: 69 | 70 | - Fix broken verbose reporting of successful tests [\#70](https://github.com/devlooped/dotnet-retest/pull/70) (@kzu) 71 | 72 | :twisted_rightwards_arrows: Merged: 73 | 74 | - Showcase that we can run/render 1k+ tests [\#71](https://github.com/devlooped/dotnet-retest/pull/71) (@kzu) 75 | - Remove confusing and duplicate -v for version [\#67](https://github.com/devlooped/dotnet-retest/pull/67) (@kzu) 76 | 77 | ## [v0.6.4](https://github.com/devlooped/dotnet-retest/tree/v0.6.4) (2025-02-18) 78 | 79 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.3...v0.6.4) 80 | 81 | :bug: Fixed bugs: 82 | 83 | - Avoid extra noise in log for CI builds [\#58](https://github.com/devlooped/dotnet-retest/issues/58) 84 | - Column width inside Azure DevOps pipeline [\#54](https://github.com/devlooped/dotnet-retest/issues/54) 85 | - Retry attempts are not working anymore in 0.6.X [\#48](https://github.com/devlooped/dotnet-retest/issues/48) 86 | - Make retry logic more resilient to changes in xunit/vstest output [\#62](https://github.com/devlooped/dotnet-retest/pull/62) (@kzu) 87 | 88 | :twisted_rightwards_arrows: Merged: 89 | 90 | - Avoid noise in auto-update of progress [\#59](https://github.com/devlooped/dotnet-retest/pull/59) (@Tasteful) 91 | - Send output directly to System.Console to avoid line breaks [\#55](https://github.com/devlooped/dotnet-retest/pull/55) (@Tasteful) 92 | 93 | ## [v0.6.3](https://github.com/devlooped/dotnet-retest/tree/v0.6.3) (2024-09-02) 94 | 95 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.2...v0.6.3) 96 | 97 | :sparkles: Implemented enhancements: 98 | 99 | - Add a way to opt-out of the summary [\#31](https://github.com/devlooped/dotnet-retest/pull/31) (@kzu) 100 | 101 | :hammer: Other: 102 | 103 | - Command option to disable trx output [\#29](https://github.com/devlooped/dotnet-retest/issues/29) 104 | 105 | :twisted_rightwards_arrows: Merged: 106 | 107 | - Ensure readme is encoded in UTF-8 [\#33](https://github.com/devlooped/dotnet-retest/pull/33) (@kzu) 108 | - Improve style disable when NO\_COLOR [\#30](https://github.com/devlooped/dotnet-retest/pull/30) (@kzu) 109 | 110 | ## [v0.6.2](https://github.com/devlooped/dotnet-retest/tree/v0.6.2) (2024-08-24) 111 | 112 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.1...v0.6.2) 113 | 114 | :bug: Fixed bugs: 115 | 116 | - Fix issue when filename has \[ or \] characters [\#27](https://github.com/devlooped/dotnet-retest/pull/27) (@kzu) 117 | 118 | ## [v0.6.1](https://github.com/devlooped/dotnet-retest/tree/v0.6.1) (2024-08-08) 119 | 120 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.0...v0.6.1) 121 | 122 | ## [v0.6.0](https://github.com/devlooped/dotnet-retest/tree/v0.6.0) (2024-08-07) 123 | 124 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.5.0...v0.6.0) 125 | 126 | :sparkles: Implemented enhancements: 127 | 128 | - Rename attempts to retries [\#19](https://github.com/devlooped/dotnet-retest/pull/19) (@kzu) 129 | 130 | ## [v0.5.0](https://github.com/devlooped/dotnet-retest/tree/v0.5.0) (2024-08-07) 131 | 132 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.4.1...v0.5.0) 133 | 134 | :sparkles: Implemented enhancements: 135 | 136 | - Add ouctome column and screenshots [\#18](https://github.com/devlooped/dotnet-retest/pull/18) (@kzu) 137 | 138 | ## [v0.4.1](https://github.com/devlooped/dotnet-retest/tree/v0.4.1) (2024-08-07) 139 | 140 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.4.0...v0.4.1) 141 | 142 | :sparkles: Implemented enhancements: 143 | 144 | - Don't allow non-trx logger on non-Windows OS [\#17](https://github.com/devlooped/dotnet-retest/pull/17) (@kzu) 145 | - Make progress description column multiline [\#16](https://github.com/devlooped/dotnet-retest/pull/16) (@kzu) 146 | - Increase console verbosity to get better progress [\#15](https://github.com/devlooped/dotnet-retest/pull/15) (@kzu) 147 | 148 | ## [v0.4.0](https://github.com/devlooped/dotnet-retest/tree/v0.4.0) (2024-08-06) 149 | 150 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.3.0...v0.4.0) 151 | 152 | :sparkles: Implemented enhancements: 153 | 154 | - Allow for extra loggers in addition to trx [\#14](https://github.com/devlooped/dotnet-retest/pull/14) (@kzu) 155 | - Improve progress reporting by showing output [\#13](https://github.com/devlooped/dotnet-retest/pull/13) (@kzu) 156 | 157 | ## [v0.3.0](https://github.com/devlooped/dotnet-retest/tree/v0.3.0) (2024-07-31) 158 | 159 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.3...v0.3.0) 160 | 161 | ## [v0.2.3](https://github.com/devlooped/dotnet-retest/tree/v0.2.3) (2024-07-29) 162 | 163 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.2...v0.2.3) 164 | 165 | ## [v0.2.2](https://github.com/devlooped/dotnet-retest/tree/v0.2.2) (2024-07-29) 166 | 167 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.1...v0.2.2) 168 | 169 | :sparkles: Implemented enhancements: 170 | 171 | - Bring fix for test error markup rendering [\#10](https://github.com/devlooped/dotnet-retest/pull/10) (@kzu) 172 | 173 | ## [v0.2.1](https://github.com/devlooped/dotnet-retest/tree/v0.2.1) (2024-07-29) 174 | 175 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.0...v0.2.1) 176 | 177 | ## [v0.2.0](https://github.com/devlooped/dotnet-retest/tree/v0.2.0) (2024-07-21) 178 | 179 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.1.0...v0.2.0) 180 | 181 | :sparkles: Implemented enhancements: 182 | 183 | - Take into account duplicate FQN from theories [\#6](https://github.com/devlooped/dotnet-retest/pull/6) (@kzu) 184 | 185 | ## [v0.1.0](https://github.com/devlooped/dotnet-retest/tree/v0.1.0) (2024-07-21) 186 | 187 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/cc678481a604157a20545f0a37a4fe7e119a77b3...v0.1.0) 188 | 189 | :sparkles: Implemented enhancements: 190 | 191 | - Don't prefix trx report options unnecessarily [\#5](https://github.com/devlooped/dotnet-retest/pull/5) (@kzu) 192 | - Run retest with sample on all platforms [\#3](https://github.com/devlooped/dotnet-retest/pull/3) (@kzu) 193 | 194 | :twisted_rightwards_arrows: Merged: 195 | 196 | - Showcase CI runs [\#4](https://github.com/devlooped/dotnet-retest/pull/4) (@kzu) 197 | 198 | 199 | 200 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 201 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | false 6 | 7 | true 14 | 15 | 16 | 17 | 18 | $(CI) 19 | 20 | 21 | 22 | Daniel Cazzulino 23 | Devlooped 24 | Copyright (C) Daniel Cazzulino and Contributors. All rights reserved. 25 | false 26 | MIT 27 | 28 | 29 | icon.png 30 | readme.md 31 | 32 | icon.png 33 | readme.md 34 | 35 | true 36 | true 37 | 38 | $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\bin')) 39 | 40 | 41 | true 42 | true 43 | 44 | 45 | true 46 | 47 | 48 | 49 | Release 50 | Latest 51 | 52 | 53 | false 54 | 55 | embedded 56 | true 57 | enable 58 | 59 | strict 60 | 61 | 62 | $(MSBuildProjectName) 63 | $(MSBuildProjectName.IndexOf('.')) 64 | $(MSBuildProjectName.Substring(0, $(RootNamespaceDot))) 65 | 66 | 67 | $(DefaultItemExcludes);*.binlog;*.zip;*.rsp;*.items;**/TestResults/**/*.* 68 | 69 | true 70 | true 71 | true 72 | true 73 | 74 | 75 | true 76 | 77 | 78 | false 79 | 80 | 81 | NU5105;$(NoWarn) 82 | 83 | true 84 | 85 | 86 | true 87 | 88 | 89 | LatestMinor 90 | 91 | 92 | 93 | 94 | $(MSBuildThisFileDirectory)kzu.snk 95 | 101 | 002400000480000094000000060200000024000052534131000400000100010051155fd0ee280be78d81cc979423f1129ec5dd28edce9cd94fd679890639cad54c121ebdb606f8659659cd313d3b3db7fa41e2271158dd602bb0039a142717117fa1f63d93a2d288a1c2f920ec05c4858d344a45d48ebd31c1368ab783596b382b611d8c92f9c1b3d338296aa21b12f3bc9f34de87756100c172c52a24bad2db 102 | 00352124762f2aa5 103 | true 104 | 105 | 106 | 107 | 115 | 42.42.42 116 | 117 | 118 | 119 | <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) 120 | <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) 121 | 122 | 123 | <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) 124 | 125 | <_VersionLabel>$(_VersionLabel.Replace('refs/pull/', 'pr')) 126 | 127 | <_VersionLabel>$(_VersionLabel.Replace('/merge', '')) 128 | 129 | <_VersionLabel>$(_VersionLabel.Replace('/', '-')) 130 | 131 | <_VersionLabel>$(_VersionLabel.Replace('_', '-')) 132 | 133 | 134 | $(_VersionLabel) 135 | 136 | $(_VersionLabel) 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 165 | 166 | 1.0.0 167 | $(VersionPrefix)-$(VersionSuffix) 168 | $(VersionPrefix) 169 | 170 | 171 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /src/Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CI;$(DefineConstants) 6 | 7 | 8 | 9 | 10 | false 11 | false 12 | true 13 | 14 | 15 | 16 | true 17 | true 18 | 19 | 20 | 21 | 31 | false 32 | true 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 49 | 50 | 51 | 55 | 56 | 60 | 61 | 62 | 64 | 65 | 1.0.0 66 | $(VersionPrefix)-$(VersionSuffix) 67 | $(VersionPrefix) 68 | 69 | 70 | 71 | 72 | $(PackFolder) 73 | $(PackFolderPath.Replace('\$(TargetFramework)', '')) 74 | $(IntermediateOutputPath)$(PackFolderPath)\ 75 | $(OutputPath)$(PackFolderPath)\ 76 | $(OutputPath) 77 | 78 | 79 | 80 | 81 | pr$(GITHUB_REF.Replace('refs/pull/', '').Replace('/merge', '')) 82 | $(GITHUB_REF.Replace('refs/heads/', '').Replace('refs/tags/', '')) 83 | 84 | $(BUILD_SOURCEBRANCH.Replace('refs/heads/', '').Replace('refs/tags/', '')) 85 | 86 | pr$(APPVEYOR_PULL_REQUEST_NUMBER) 87 | $(APPVEYOR_REPO_TAG_NAME) 88 | $(APPVEYOR_REPO_BRANCH) 89 | 90 | $(TEAMCITY_BUILD_BRANCH) 91 | 92 | pr$(TRAVIS_PULL_REQUEST) 93 | $(TRAVIS_BRANCH) 94 | 95 | pr$(CIRCLE_PR_NUMBER) 96 | $(CIRCLE_TAG) 97 | $(CIRCLE_BRANCH) 98 | 99 | $(CI_COMMIT_TAG) 100 | pr$(CI_MERGE_REQUEST_IID) 101 | pr$(CI_EXTERNAL_PULL_REQUEST_IID) 102 | $(CI_COMMIT_BRANCH) 103 | 104 | pr$(BUDDY_EXECUTION_PULL_REQUEST_NO) 105 | $(BUDDY_EXECUTION_TAG) 106 | $(BUDDY_EXECUTION_BRANCH) 107 | 108 | 109 | 110 | 111 | CoreResGen;$(CoreCompileDependsOn) 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | $(IntermediateOutputPath)\$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.'))%(Filename).g$(DefaultLanguageSourceExtension) 121 | $(Language) 122 | $(RootNamespace) 123 | $(RootNamespace).$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').TrimEnd('.')) 124 | %(Filename) 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 148 | 149 | 150 | 151 | $(PrivateRepositoryUrl) 152 | 153 | 154 | 155 | $(SourceRevisionId) 156 | $(SourceRevisionId.Substring(0, 9)) 157 | 158 | $(RepositorySha) 159 | 160 | 161 | 162 | 163 | <_GitSourceRoot Include="@(SourceRoot -> WithMetadataValue('SourceControl', 'git'))" /> 164 | 165 | 166 | 167 | @(_GitSourceRoot) 168 | 169 | $([System.IO.Path]::GetFileNameWithoutExtension($(PrivateRepositoryUrl))) 170 | $(ProductFromUrl) 171 | 172 | 173 | 174 | 175 | 180 | 181 | $(RepositoryUrl.Replace('.git', '')) 182 | $(Description) 183 | $(RepositoryUrl.Replace('.git', ''))/blob/main/changelog.md 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /src/dotnet-retest/RetestCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.CommandLine; 4 | using System.ComponentModel; 5 | using System.Diagnostics; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Runtime.InteropServices; 9 | using System.Threading.Tasks; 10 | using CliWrap; 11 | using CliWrap.Buffered; 12 | using Devlooped.Web; 13 | using Mono.Options; 14 | using NuGet.Packaging; 15 | using Spectre.Console; 16 | using Spectre.Console.Cli; 17 | using Spectre.Console.Rendering; 18 | using static System.Net.Mime.MediaTypeNames; 19 | using static Devlooped.TrxCommand; 20 | using static Spectre.Console.AnsiConsole; 21 | 22 | namespace Devlooped; 23 | 24 | public partial class RetestCommand : AsyncCommand 25 | { 26 | record TestResult(string FullName, bool Failed); 27 | 28 | public override async Task ExecuteAsync(CommandContext context, RetestSettings settings) 29 | { 30 | var args = context.Remaining.Raw.ToList(); 31 | // A typical mistake would be to pass dotnet test args directly without the -- separator 32 | // so account for this automatically so users fall in the pit of success 33 | if (args.Count == 0) 34 | { 35 | foreach (var key in context.Remaining.Parsed) 36 | { 37 | foreach (var value in key) 38 | { 39 | // Revert multiple --key [value] into multiple --key --value 40 | args.Add(key.Key); 41 | if (value != null) 42 | args.Add(value); 43 | } 44 | } 45 | } 46 | 47 | string? path = null; 48 | var hastrx = false; 49 | var hasconsole = false; 50 | var haslogger = false; 51 | 52 | new OptionSet 53 | { 54 | { "l|logger=", v => 55 | { 56 | hastrx = v.StartsWith("trx"); 57 | hasconsole = v.StartsWith("console"); 58 | haslogger = true; 59 | } 60 | }, 61 | { "results-directory=", v => path = v }, 62 | }.Parse(args); 63 | 64 | // In non-Windows OSes, the trx logger must be the only one if specified 65 | if (haslogger && !hastrx && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 66 | { 67 | MarkupLine("[red]If a logger is specified, it can only be trx in non-Windows platforms.[/]"); 68 | return 1; 69 | } 70 | 71 | var trx = new TrxSettings 72 | { 73 | Path = path ?? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), 74 | Output = settings.Output, 75 | Verbosity = settings.Verbosity, 76 | GitHubComment = settings.GitHubComment, 77 | GitHubSummary = settings.GitHubSummary, 78 | Skipped = settings.Skipped, 79 | Recursive = false, 80 | }; 81 | 82 | if (trx.Validate() is { Successful: false } result) 83 | { 84 | MarkupLine($"[red]Invalid trx settings: {result.Message}[/]"); 85 | return 1; 86 | } 87 | 88 | if (path == null) 89 | { 90 | // We always ensure the final dotnet test call has a valid path. So 91 | // if we didn't get one from args, we need to add it here. 92 | args.Insert(0, "--results-directory"); 93 | args.Insert(1, trx.Path); 94 | } 95 | 96 | var ci = Environment.GetEnvironmentVariable("CI") == "true"; 97 | 98 | // Ensure we add the console logger to get more detailed progress in non-CI environments 99 | // Limiting to Windows which is what I personally tested. Linux fails with multiple loggers too. 100 | if (!hasconsole && !ci && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 101 | { 102 | args.Insert(0, "--logger"); 103 | args.Insert(1, "console;verbosity=normal"); 104 | } 105 | 106 | // Ensure we add the trx logger. Note that there can be other loggers too 107 | if (!hastrx) 108 | { 109 | args.Insert(0, "--logger"); 110 | args.Insert(1, "trx"); 111 | } 112 | 113 | Debug.Assert(DotnetMuxer.Path != null); 114 | 115 | var failed = new HashSet(); 116 | var attempts = 0; 117 | BufferedCommandResult? runFailure = null; 118 | 119 | ProgressColumn[] columns = ci ? 120 | [new TaskDescriptionColumn { Alignment = Justify.Left }] : 121 | [new OutcomeSpinnerColumn(), new ElapsedTimeColumn(), new MultilineTaskDescriptionColumn()]; 122 | 123 | var exitCode = await Progress() 124 | .Columns(columns) 125 | .AutoRefresh(!ci) 126 | .StartAsync(async ctx => 127 | { 128 | while (true) 129 | { 130 | attempts++; 131 | var task = ctx.AddTask($"Running tests, attempt #{attempts}"); 132 | 133 | try 134 | { 135 | task.StartTask(); 136 | 137 | // Ensure we don't build on retries after initial attempt, 138 | // just in case --no-build was't passed in the original command. 139 | if (attempts > 1 && !args.Contains("--no-build")) 140 | args.Insert(0, "--no-build"); 141 | 142 | var prefix = attempts == 1 ? 143 | $"Running tests" : 144 | $"Retrying {failed.Count} failed test{(failed.Count > 1 ? "s" : "")}"; 145 | 146 | task.Description = prefix; 147 | if (ci) 148 | { 149 | ctx.Refresh(); 150 | } 151 | 152 | var exit = await RunTestsAsync(DotnetMuxer.Path.FullName, [.. args], failed, new Progress(line => 153 | { 154 | if (ci) 155 | { 156 | System.Console.WriteLine(line); 157 | } 158 | else if (line.Trim() is { Length: > 0 } description) 159 | { 160 | task.Description = prefix + $": [grey]{description.EscapeMarkup()}[/]"; 161 | } 162 | })); 163 | 164 | // By setting the exit code to the task, the OutcomeSpinnerColumn can render appropately 165 | task.Value = exit.ExitCode; 166 | // Restore description without last progress (if any) 167 | task.Description = prefix; 168 | 169 | if (exit.ExitCode == 0) 170 | return 0; 171 | 172 | if (!exit.StandardOutput.Contains(trx.Path)) 173 | { 174 | runFailure = exit; 175 | return exit.ExitCode; 176 | } 177 | 178 | if (attempts >= settings.Attempts) 179 | return exit.ExitCode; 180 | 181 | var outcomes = GetTestResults(trx.Path); 182 | // On first attempt, we just batch add all failed tests 183 | if (attempts == 1) 184 | { 185 | failed.AddRange(outcomes.Where(x => x.Value == true).Select(x => x.Key)); 186 | } 187 | else 188 | { 189 | // Remove from failed the tests that are no longer failed in this attempt 190 | failed.RemoveWhere(x => outcomes.TryGetValue(x, out var isFailed) && !isFailed); 191 | } 192 | } 193 | finally 194 | { 195 | task.StopTask(); 196 | } 197 | 198 | if (settings.Verbosity == Verbosity.Verbose) 199 | { 200 | foreach (var test in failed) 201 | MarkupLine($"\t:cross_mark: #{attempts} {test}"); 202 | } 203 | } 204 | }); 205 | 206 | if (runFailure != null) 207 | { 208 | MarkupLine($"[red]Error:[/] Failed to run tests."); 209 | WriteLine(runFailure.StandardOutput); 210 | } 211 | 212 | if (settings.NoSummary != true && Directory.Exists(trx.Path)) 213 | { 214 | new TrxCommand().Execute(context, trx); 215 | } 216 | 217 | return exitCode; 218 | } 219 | 220 | Dictionary GetTestResults(string path) 221 | { 222 | var outcomes = new Dictionary(); 223 | if (!Directory.Exists(path)) 224 | return outcomes; 225 | 226 | var ids = new HashSet(); 227 | 228 | // Process from newest files to oldest so that newest result we find (by test id) is the one we keep 229 | // NOTE: we always emit results to a given directory, so we don't need to search for trx files in subdirectories 230 | foreach (var trx in Directory.EnumerateFiles(path, "*.trx", SearchOption.TopDirectoryOnly).OrderByDescending(File.GetLastWriteTime)) 231 | { 232 | using var file = File.OpenRead(trx); 233 | // Clears namespaces 234 | var doc = HtmlDocument.Load(file, new HtmlReaderSettings { CaseFolding = Sgml.CaseFolding.None }); 235 | foreach (var result in doc.CssSelectElements("UnitTestResult")) 236 | { 237 | var id = result.Attribute("testId")!.Value; 238 | // Process only once per test id, this avoids duplicates when multiple trx files are processed 239 | if (ids.Add(id)) 240 | { 241 | var isFailed = result.Attribute("outcome")?.Value == "Failed"; 242 | var method = doc.CssSelectElement($"UnitTest[id={id}] TestMethod"); 243 | Debug.Assert(method != null); 244 | // NOTE: we may have duplicate test FQN due to theories, which we'd run again in this case. 245 | // Eventually, we might want to figure out how to filter theories in a cross-framework compatible 246 | // way, but for now, filtering by FQN should be enough, even if not 100% optimal. 247 | var fqn = $"{method.Attribute("className")?.Value}.{method.Attribute("name")?.Value}"; 248 | if (!outcomes.TryGetValue(fqn, out var wasFailed) || !wasFailed) 249 | // Only change the outcome if it was not already failed 250 | outcomes[fqn] = isFailed; 251 | } 252 | } 253 | } 254 | 255 | return outcomes; 256 | } 257 | 258 | static readonly RootCommand command = new() 259 | { 260 | Options = 261 | { 262 | new Option("--filter") 263 | } 264 | }; 265 | 266 | async Task RunTestsAsync(string dotnet, List args, IEnumerable failed, IProgress progress) 267 | { 268 | var finalArgs = args; 269 | var filter = string.Join('|', failed.Select(failed => $"FullyQualifiedName~{failed.Replace("\"", "%22").Replace(",", "%2C").Replace("(", "\\(").Replace(")", "\\)")}")); 270 | if (filter.Length > 0) 271 | { 272 | var parsed = command.Parse(args); 273 | if (parsed.GetValue("--filter") is { } existing) 274 | { 275 | finalArgs = [.. parsed.UnmatchedTokens]; 276 | filter = $"({existing})&({filter})"; 277 | } 278 | finalArgs.InsertRange(0, ["--filter", filter]); 279 | } 280 | 281 | finalArgs.Insert(0, "test"); 282 | 283 | var result = await Cli.Wrap(dotnet) 284 | .WithArguments(finalArgs) 285 | .WithWorkingDirectory(Directory.GetCurrentDirectory()) 286 | .WithValidation(CommandResultValidation.None) 287 | .WithStandardErrorPipe(PipeTarget.ToDelegate(progress.Report)) 288 | .WithStandardOutputPipe(PipeTarget.ToDelegate(progress.Report)) 289 | .ExecuteBufferedAsync(); 290 | 291 | return result; 292 | } 293 | 294 | public class RetestSettings : CommandSettings 295 | { 296 | [Description("Prints version information")] 297 | [CommandOption("--version")] 298 | public bool Version { get; init; } 299 | 300 | [Description("Maximum retries when re-running failed tests")] 301 | [CommandOption("--retries")] 302 | [DefaultValue(3)] 303 | public int Retries 304 | { 305 | get => Attempts - 1; 306 | init => Attempts = value + 1; 307 | } 308 | 309 | [Description("Maximum attempts to run tests")] 310 | [CommandOption("--attempts", IsHidden = true)] 311 | public int Attempts { get; init; } 312 | 313 | [Description("Whether to emit a summary to console/GitHub")] 314 | [CommandOption("--no-summary")] 315 | [DefaultValue(false)] 316 | public bool NoSummary { get; init; } 317 | 318 | #region trx 319 | 320 | [Description("Include test output in report")] 321 | [CommandOption("--output")] 322 | [DefaultValue(false)] 323 | public bool Output { get; init; } 324 | 325 | /// 326 | /// Output verbosity. 327 | /// 328 | [Description( 329 | """ 330 | Output display verbosity: 331 | - quiet: only failed tests are displayed 332 | - normal: failed and skipped tests are displayed 333 | - verbose: failed, skipped and passed tests are displayed 334 | """)] 335 | [CommandOption("-v|--verbosity")] 336 | [DefaultValue(Verbosity.Quiet)] 337 | public Verbosity Verbosity { get; set; } = Verbosity.Quiet; 338 | 339 | /// 340 | /// Whether to include skipped tests in the output. 341 | /// 342 | [Description("Include skipped tests in report")] 343 | [CommandOption("--skipped", IsHidden = true)] 344 | [DefaultValue(true)] 345 | public bool Skipped 346 | { 347 | get => Verbosity != Verbosity.Quiet; 348 | set 349 | { 350 | if (!value) 351 | Verbosity = Verbosity.Quiet; 352 | } 353 | } 354 | 355 | /// 356 | /// Report as GitHub PR comment. 357 | /// 358 | [Description("Report as GitHub PR comment")] 359 | [CommandOption("--gh-comment")] 360 | [DefaultValue(true)] 361 | public bool GitHubComment { get; init; } = true; 362 | 363 | /// 364 | /// Report as GitHub PR comment. 365 | /// 366 | [Description("Report as GitHub step summary")] 367 | [CommandOption("--gh-summary")] 368 | [DefaultValue(true)] 369 | public bool GitHubSummary { get; init; } = true; 370 | 371 | #endregion 372 | } 373 | 374 | class MultilineTaskDescriptionColumn : ProgressColumn 375 | { 376 | public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime) 377 | { 378 | return new Markup(task.Description ?? string.Empty) 379 | .Overflow(Overflow.Ellipsis) 380 | .Justify(Justify.Left); 381 | } 382 | } 383 | 384 | class OutcomeSpinnerColumn : ProgressColumn 385 | { 386 | readonly SpinnerColumn spinner = new(); 387 | 388 | public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime) 389 | { 390 | if (!task.IsFinished) 391 | return spinner.Render(options, task, deltaTime); 392 | 393 | if (task.Value == 0) 394 | return new Markup(":check_mark_button:"); 395 | else 396 | return new Markup(":cross_mark:"); 397 | } 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/dotnet-retest/TrxCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Text; 8 | using System.Text.Json; 9 | using System.Text.RegularExpressions; 10 | using System.Xml.Linq; 11 | using Devlooped.Web; 12 | using Humanizer; 13 | using Spectre.Console; 14 | using Spectre.Console.Cli; 15 | using static Devlooped.Process; 16 | using static Spectre.Console.AnsiConsole; 17 | 18 | namespace Devlooped; 19 | 20 | public partial class TrxCommand : Command 21 | { 22 | static readonly JsonSerializerOptions indentedJson = new() { WriteIndented = true }; 23 | const string Header = ""; 24 | const string Footer = ""; 25 | 26 | static string Author => 27 | $"from [{ThisAssembly.Project.PackageId}]({ThisAssembly.Project.PackageProjectUrl}) v{ThisAssembly.Project.Version} on {RuntimeInformation.FrameworkDescription} with [:purple_heart:](https://github.com/sponsors/devlooped) by @devlooped"; 28 | 29 | public enum Verbosity 30 | { 31 | [Description("Only failed tests are displayed")] 32 | Quiet, 33 | [Description("Failed and skipped tests are displayed")] 34 | Normal, 35 | [Description("Failed, skipped and passed tests are displayed")] 36 | Verbose, 37 | } 38 | 39 | public class TrxSettings : CommandSettings 40 | { 41 | [Description("Prints version information")] 42 | [CommandOption("--version")] 43 | public bool Version { get; init; } 44 | 45 | [Description("Optional base directory for *.trx files discovery. Defaults to current directory.")] 46 | [CommandOption("-p|--path")] 47 | public string? Path { get; set; } 48 | 49 | [Description("Include test output")] 50 | [CommandOption("-o|--output")] 51 | [DefaultValue(false)] 52 | public bool Output { get; set; } 53 | 54 | [Description("Recursively search for *.trx files")] 55 | [CommandOption("-r|--recursive")] 56 | [DefaultValue(true)] 57 | public bool Recursive { get; set; } = true; 58 | 59 | /// 60 | /// Output verbosity. 61 | /// 62 | [Description( 63 | """ 64 | Output display verbosity: 65 | - quiet: only failed tests are displayed 66 | - normal: failed and skipped tests are displayed 67 | - verbose: failed, skipped and passed tests are displayed 68 | """)] 69 | [CommandOption("-v|--verbosity")] 70 | [DefaultValue(Verbosity.Quiet)] 71 | public Verbosity Verbosity { get; set; } = Verbosity.Quiet; 72 | 73 | /// 74 | /// Whether to include skipped tests in the output. 75 | /// 76 | [Description("Include skipped tests in output")] 77 | [CommandOption("--skipped", IsHidden = true)] 78 | [DefaultValue(true)] 79 | public bool Skipped 80 | { 81 | get => Verbosity != Verbosity.Quiet; 82 | set 83 | { 84 | if (!value) 85 | Verbosity = Verbosity.Quiet; 86 | } 87 | } 88 | 89 | /// 90 | /// Whether to return a -1 exit code on test failures. 91 | /// 92 | [Description("Do not return a -1 exit code on test failures")] 93 | [CommandOption("--no-exit-code")] 94 | public bool NoExitCode { get; set; } 95 | 96 | /// 97 | /// Report as GitHub PR comment. 98 | /// 99 | [Description("Report as GitHub PR comment")] 100 | [CommandOption("--gh-comment")] 101 | [DefaultValue(true)] 102 | public bool GitHubComment { get; set; } = true; 103 | 104 | /// 105 | /// Report as GitHub PR comment. 106 | /// 107 | [Description("Report as GitHub step summary")] 108 | [CommandOption("--gh-summary")] 109 | [DefaultValue(true)] 110 | public bool GitHubSummary { get; set; } = true; 111 | 112 | public override ValidationResult Validate() 113 | { 114 | // Validate, normalize and default path. 115 | var path = Path ?? Directory.GetCurrentDirectory(); 116 | if (!System.IO.Path.IsPathFullyQualified(path)) 117 | path = System.IO.Path.Combine(Directory.GetCurrentDirectory(), path); 118 | 119 | Path = File.Exists(path) ? new FileInfo(path).DirectoryName! : System.IO.Path.GetFullPath(path); 120 | 121 | return base.Validate(); 122 | } 123 | } 124 | 125 | public override int Execute(CommandContext context, TrxSettings settings) 126 | { 127 | if (Environment.GetEnvironmentVariable("RUNNER_DEBUG") == "1") 128 | WriteLine(JsonSerializer.Serialize(new { settings }, indentedJson)); 129 | 130 | // We get this validated by the settings, so it's always non-null. 131 | var path = settings.Path!; 132 | var search = settings.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; 133 | var testIds = new HashSet(); 134 | var passed = 0; 135 | var failed = 0; 136 | var skipped = 0; 137 | var duration = TimeSpan.Zero; 138 | var failures = new List(); 139 | 140 | // markdown details for gh comment 141 | var details = new StringBuilder().AppendLine( 142 | $""" 143 |
144 | 145 | :test_tube: Details on {OS} 146 | 147 | """); 148 | 149 | var results = new List(); 150 | 151 | Status().Start("Discovering test results...", ctx => 152 | { 153 | // Process from newest files to oldest so that newest result we find (by test id) is the one we keep 154 | foreach (var trx in Directory.EnumerateFiles(path, "*.trx", search).OrderByDescending(File.GetLastWriteTime)) 155 | { 156 | ctx.Status($"Discovering test results in {Path.GetFileName(trx).EscapeMarkup()}..."); 157 | using var file = File.OpenRead(trx); 158 | // Clears namespaces 159 | var doc = HtmlDocument.Load(file, new HtmlReaderSettings { CaseFolding = Sgml.CaseFolding.None }); 160 | foreach (var result in doc.CssSelectElements("UnitTestResult")) 161 | { 162 | var id = result.Attribute("testId")!.Value; 163 | // Process only once per test id, this avoids duplicates when multiple trx files are processed 164 | if (testIds.Add(id)) 165 | results.Add(result); 166 | } 167 | } 168 | 169 | ctx.Status("Sorting tests by name..."); 170 | results.Sort(new Comparison((x, y) => x.Attribute("testName")!.Value.CompareTo(y.Attribute("testName")!.Value))); 171 | }); 172 | 173 | foreach (var result in results) 174 | { 175 | var test = result.Attribute("testName")!.Value.EscapeMarkup(); 176 | var elapsed = TimeSpan.Parse(result.Attribute("duration")?.Value ?? "0"); 177 | var output = settings.Output ? result.CssSelectElement("StdOut")?.Value : default; 178 | 179 | switch (result.Attribute("outcome")?.Value) 180 | { 181 | case "Passed": 182 | passed++; 183 | duration += elapsed; 184 | if (settings.Verbosity != Verbosity.Verbose) 185 | break; 186 | 187 | MarkupLine($":check_mark_button: {test}"); 188 | if (output == null) 189 | details.AppendLine($":white_check_mark: {test}"); 190 | else 191 | details.AppendLine( 192 | $""" 193 |
194 | 195 | :white_check_mark: {test} 196 | 197 | """) 198 | .AppendLineIndented(output, "> > ") 199 | .AppendLine( 200 | """ 201 | 202 |
203 | """); 204 | break; 205 | case "Failed": 206 | failed++; 207 | duration += elapsed; 208 | MarkupLine($":cross_mark: {test}"); 209 | details.AppendLine( 210 | $""" 211 |
212 | 213 | :x: {test} 214 | 215 | """); 216 | WriteError(path, failures, result, details); 217 | if (output != null) 218 | details.AppendLineIndented(output, "> > "); 219 | details.AppendLine().AppendLine("
").AppendLine(); 220 | break; 221 | case "NotExecuted": 222 | skipped++; 223 | if (settings.Verbosity == Verbosity.Quiet) 224 | break; 225 | 226 | var reason = result.CssSelectElement("Output > ErrorInfo > Message")?.Value; 227 | Markup($"[dim]:white_question_mark: {test}[/]"); 228 | details.Append($":grey_question: {test}"); 229 | 230 | if (reason != null) 231 | { 232 | Markup($"[dim] => {reason.EscapeMarkup()}[/]"); 233 | details.Append($" => {reason}"); 234 | } 235 | 236 | WriteLine(); 237 | details.AppendLine(); 238 | break; 239 | default: 240 | break; 241 | } 242 | 243 | if (output != null) 244 | { 245 | Write(new Panel($"[dim]{output.ReplaceLineEndings().EscapeMarkup()}[/]") 246 | { 247 | Border = BoxBorder.None, 248 | Padding = new Padding(5, 0, 0, 0), 249 | }); 250 | } 251 | } 252 | 253 | details.AppendLine().AppendLine("
"); 254 | 255 | var summary = new Summary(passed, failed, skipped, duration); 256 | WriteLine(); 257 | MarkupSummary(summary); 258 | WriteLine(); 259 | 260 | if (Environment.GetEnvironmentVariable("CI") == "true" && 261 | (settings.GitHubComment || settings.GitHubSummary)) 262 | { 263 | GitHubReport(settings, summary, details); 264 | if (failures.Count > 0) 265 | { 266 | // Send workflow commands for each failure to be annotated in GH CI 267 | // TODO: somehow the notice does not end up pointing to the right file/line 268 | // TODO: we should do newline replacement with "%0A" here too 269 | //foreach (var failure in failures) 270 | // WriteLine($"::error file={failure.File},line={failure.Line},title={failure.Title}::{failure.Message}"); 271 | } 272 | } 273 | 274 | return settings.NoExitCode || failed == 0 ? 0 : -1; 275 | } 276 | 277 | static void MarkupSummary(Summary summary) 278 | { 279 | Markup($":backhand_index_pointing_right: Run {summary.Total} tests in ~ {summary.Duration.Humanize()}"); 280 | 281 | if (summary.Failed > 0) 282 | MarkupLine($" :cross_mark:"); 283 | else 284 | MarkupLine($" :check_mark_button:"); 285 | 286 | if (summary.Passed > 0) 287 | MarkupLine($" :check_mark_button: {summary.Passed} passed"); 288 | 289 | if (summary.Failed > 0) 290 | MarkupLine($" :cross_mark: {summary.Failed} failed"); 291 | 292 | if (summary.Skipped > 0) 293 | MarkupLine($" :white_question_mark: {summary.Skipped} skipped"); 294 | } 295 | 296 | static void GitHubReport(TrxSettings settings, Summary summary, StringBuilder details) 297 | { 298 | // Don't report anything if there's nothing to report. 299 | if (summary.Total == 0) 300 | return; 301 | 302 | if (Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") != "pull_request" || 303 | Environment.GetEnvironmentVariable("GITHUB_ACTIONS") != "true") 304 | return; 305 | 306 | if (TryExecute("gh", "--version", out var output) && output?.StartsWith("gh version") != true) 307 | return; 308 | 309 | // See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 310 | if (Environment.GetEnvironmentVariable("GITHUB_REF_NAME") is not { } branch || 311 | !branch.EndsWith("/merge") || 312 | !int.TryParse(branch[..^6], out var pr) || 313 | Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") is not { Length: > 0 } repo || 314 | Environment.GetEnvironmentVariable("GITHUB_RUN_ID") is not { Length: > 0 } runId || 315 | Environment.GetEnvironmentVariable("GITHUB_JOB") is not { Length: > 0 } jobName || 316 | Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") is not { Length: > 0 } serverUrl) 317 | return; 318 | 319 | // Some day, it might just show-up and we'd be forwards compatible. 320 | // See https://github.com/orgs/community/discussions/129314 and https://github.com/actions/runner/issues/324 321 | // Pending PR that introduces this envvar: https://github.com/actions/runner/pull/4053 322 | var jobId = Environment.GetEnvironmentVariable("JOB_CHECK_RUN_ID"); 323 | 324 | // Provide a mechanism that would work on matrix in the meantime 325 | if (Environment.GetEnvironmentVariable("GH_JOB_NAME") is { Length: > 0 } ghJobName) 326 | jobName = ghJobName; 327 | 328 | string? jobUrl = default; 329 | 330 | if (!string.IsNullOrEmpty(jobId)) 331 | jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/jobs/{jobId}?pr={pr}"; 332 | 333 | var sb = new StringBuilder(); 334 | var elapsed = FormatTimeSpan(summary.Duration); 335 | long commentId = 0; 336 | 337 | if (jobUrl == null && TryExecute("gh", 338 | ["api", $"repos/{repo}/actions/runs/{runId}/jobs", "--jq", $"[.jobs[] | select(.name == \"{jobName}\") | .id]"], 339 | out var jobsJson) && jobsJson != null && JsonSerializer.Deserialize(jobsJson) is { Length: 1 } jobIds) 340 | { 341 | jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/job/{jobIds[0]}?pr={pr}"; 342 | } 343 | 344 | static string Link(string image, string? url) => url == null ? image + " " : $"[{image}]({url}) "; 345 | 346 | static StringBuilder AppendBadges(Summary summary, StringBuilder builder, string elapsed, string? jobUrl) 347 | { 348 | elapsed = elapsed.Replace(" ", "%20"); 349 | 350 | // ![5 passed](https://img.shields.io/badge/❌-linux%20in%2015m%206s-blue) ![5 passed](https://img.shields.io/badge/os-macOS%20✅-blue) 351 | if (summary.Failed > 0) 352 | builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/❌-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 353 | else if (summary.Passed > 0) 354 | builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/✅-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 355 | else 356 | builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/⚪-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 357 | 358 | if (summary.Passed > 0) 359 | builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/passed-{summary.Passed}-brightgreen)", jobUrl)); 360 | if (summary.Failed > 0) 361 | builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/failed-{summary.Failed}-red)", jobUrl)); 362 | if (summary.Skipped > 0) 363 | builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/skipped-{summary.Skipped}-silver)", jobUrl)); 364 | 365 | builder.AppendLine(); 366 | return builder; 367 | } 368 | 369 | // Find potentially existing comment to update 370 | if (TryExecute("gh", 371 | ["api", $"repos/{repo}/issues/{pr}/comments", "--jq", "[.[] | { id:.id, body:.body } | select(.body | contains(\""; 406 | 407 | var input = Path.GetTempFileName(); 408 | if (settings.GitHubComment) 409 | { 410 | if (commentId > 0) 411 | { 412 | // API requires a json payload 413 | File.WriteAllText(input, JsonSerializer.Serialize(new { body })); 414 | TryExecute("gh", $"api repos/{repo}/issues/comments/{commentId} -X PATCH --input {input}", out _); 415 | } 416 | else 417 | { 418 | // CLI can use the straight body 419 | File.WriteAllText(input, body); 420 | TryExecute("gh", $"pr comment {pr} --body-file {input}", out _); 421 | } 422 | } 423 | 424 | if (settings.GitHubSummary && 425 | Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY") is { Length: > 0 } summaryPath) 426 | { 427 | File.AppendAllText(summaryPath, 428 | AppendBadges(summary, new(), elapsed, jobUrl) 429 | .AppendLine() 430 | .Append(details) 431 | .AppendLine() 432 | .AppendLine(Author) 433 | .ToString()); 434 | } 435 | } 436 | 437 | void WriteError(string baseDir, List failures, XElement result, StringBuilder details) 438 | { 439 | if (result.CssSelectElement("Message")?.Value is not string message || 440 | result.CssSelectElement("StackTrace")?.Value is not string stackTrace) 441 | return; 442 | 443 | var testName = result.Attribute("testName")!.Value; 444 | var testId = result.Attribute("testId")!.Value; 445 | var method = result.Document!.CssSelectElement($"UnitTest[id={testId}] TestMethod"); 446 | var lines = stackTrace.ReplaceLineEndings().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); 447 | 448 | if (method != null) 449 | { 450 | var fullName = $"{method.Attribute("className")?.Value}.{method.Attribute("name")?.Value}"; 451 | var last = Array.FindLastIndex(lines, x => x.Contains(fullName)); 452 | // Stop lines when we find the last one from the test method 453 | if (last != -1) 454 | lines = lines[..(last + 1)]; 455 | } 456 | 457 | Failed? failed = null; 458 | var cli = new StringBuilder(); 459 | details.Append("> ```"); 460 | if (stackTrace.Contains(".vb:line")) 461 | details.AppendLine("vb"); 462 | else 463 | details.AppendLine("csharp"); 464 | 465 | // First line should be the actual error message. 466 | details.AppendLineIndented(message.ReplaceLineEndings(), "> "); 467 | 468 | foreach (var line in lines.Select(x => x.EscapeMarkup())) 469 | { 470 | var match = ParseFile().Match(line); 471 | if (!match.Success) 472 | { 473 | cli.AppendLine(line); 474 | details.AppendLineIndented(line, "> "); 475 | continue; 476 | } 477 | 478 | var file = match.Groups["file"].Value; 479 | var pos = match.Groups["line"].Value; 480 | var relative = file; 481 | if (Path.IsPathRooted(file) && file.StartsWith(baseDir)) 482 | relative = file[baseDir.Length..].TrimStart(Path.DirectorySeparatorChar); 483 | 484 | // NOTE: we replace whichever was last, since we want the annotation on the 485 | // last one with a filename, which will be the test itself (see previous skip from last found). 486 | failed = new Failed(testName, 487 | message.ReplaceLineEndings(), 488 | stackTrace.ReplaceLineEndings(), 489 | relative, int.Parse(pos)); 490 | 491 | cli.AppendLine(line.Replace(file, $"[link={file}][steelblue1_1]{relative}[/][/]")); 492 | // TODO: can we render a useful link in comment details? 493 | details.AppendLineIndented(line.Replace(file, relative), "> "); 494 | } 495 | 496 | var error = new Panel( 497 | $""" 498 | [red]{message.EscapeMarkup()}[/] 499 | [dim]{cli}[/] 500 | """); 501 | error.Padding = new Padding(5, 0, 0, 0); 502 | error.Border = BoxBorder.None; 503 | Write(error); 504 | 505 | // Use a blockquote for the entire error message 506 | 507 | details.AppendLine("> ```"); 508 | 509 | // Add to collected failures we may report to GH CI 510 | if (failed != null) 511 | failures.Add(failed); 512 | } 513 | 514 | static string Runtime => RuntimeInformation.RuntimeIdentifier.Replace("-", "‐"); 515 | static string OS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 516 | // Otherwise we end up with this, yuck: Darwin 23.5.0 Darwin Kernel Version 23.5.0: Wed May 1 20:12:39 PDT 2024; root:xnu-10063.121.3~5/RELEASE_ARM64_VMAPPLE 517 | ? $"macOS {Environment.OSVersion.VersionString}" : 518 | RuntimeInformation.OSDescription; 519 | 520 | static string FormatTimeSpan(TimeSpan timeSpan) 521 | { 522 | var parts = new List(); 523 | 524 | if (timeSpan.Hours > 0) 525 | parts.Add($"{timeSpan.Hours}h"); 526 | 527 | if (timeSpan.Minutes > 0) 528 | parts.Add($"{timeSpan.Minutes}m"); 529 | 530 | if (timeSpan.Seconds > 0 || parts.Count == 0) // Always include seconds if no other parts 531 | parts.Add($"{timeSpan.Seconds}s"); 532 | 533 | return string.Join(" ", parts); 534 | } 535 | 536 | // in C:\path\to\file.cs:line 123 537 | [GeneratedRegex(@" in (?.+):line (?\d+)", RegexOptions.Compiled)] 538 | private static partial Regex ParseFile(); 539 | 540 | [GeneratedRegex(@"")] 541 | private static partial Regex TrxRunId(); 542 | 543 | record Summary(int Passed, int Failed, int Skipped, TimeSpan Duration) 544 | { 545 | public int Total => Passed + Failed + Skipped; 546 | } 547 | 548 | record Failed(string Test, string Title, string Message, string File, int Line); 549 | } 550 | --------------------------------------------------------------------------------