├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── build.yml │ ├── changelog.config │ ├── changelog.yml │ ├── dotnet-file.yml │ ├── includes.yml │ ├── publish.yml │ └── triage.yml ├── .gitignore ├── .netconfig ├── Directory.Build.rsp ├── _config.yml ├── assets ├── 32.png ├── css │ └── style.scss ├── icon.png ├── icon.svg └── img │ ├── ciretry.png │ ├── comment.png │ ├── progress.png │ └── timings.png ├── changelog.md ├── dotnet-retest.sln ├── license.txt ├── readme.md └── src ├── Directory.Build.props ├── Directory.Build.targets ├── Sample ├── Sample.csproj └── UnitTest1.cs ├── dotnet-retest ├── DotnetMuxer.cs ├── Extensions.cs ├── Process.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── RetestCommand.cs ├── TrxCommand.cs ├── dotnet-retest.csproj ├── help.md ├── icon.png └── readme.md └── nuget.config /.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 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # normalize by default 2 | * text=auto encoding=UTF-8 3 | *.sh text eol=lf 4 | 5 | # These are windows specific files which we may as well ensure are 6 | # always crlf on checkout 7 | *.bat text eol=crlf 8 | *.cmd text eol=crlf 9 | -------------------------------------------------------------------------------- /.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 | Web: 28 | patterns: 29 | - "Microsoft.AspNetCore*" 30 | Tests: 31 | patterns: 32 | - "Microsoft.NET.Test*" 33 | - "xunit*" 34 | - "coverlet*" 35 | ThisAssembly: 36 | patterns: 37 | - "ThisAssembly*" 38 | ProtoBuf: 39 | patterns: 40 | - "protobuf-*" 41 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: dotnet retest -- ./src/Sample/ 56 | 57 | - name: 🐛 logs 58 | uses: actions/upload-artifact@v4 59 | if: runner.debug && always() 60 | with: 61 | name: logs 62 | path: '*.binlog' 63 | 64 | # Only push CI package to sleet feed if building on ubuntu (fastest) 65 | - name: 🚀 sleet 66 | env: 67 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 68 | if: env.SLEET_CONNECTION != '' && matrix.os == 'ubuntu-latest' 69 | run: | 70 | dotnet tool install -g --version 4.0.18 sleet 71 | 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" 72 | 73 | dotnet-format: 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: 🤘 checkout 77 | uses: actions/checkout@v4 78 | with: 79 | submodules: recursive 80 | fetch-depth: 0 81 | 82 | - name: ✓ ensure format 83 | run: | 84 | dotnet format whitespace --verify-no-changes -v:diag --exclude ~/.nuget 85 | dotnet format style --verify-no-changes -v:diag --exclude ~/.nuget 86 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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 }} -------------------------------------------------------------------------------- /.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 | 11 | jobs: 12 | includes: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 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: +Mᐁ includes 32 | uses: devlooped/actions-includes@v1 33 | 34 | - name: ✍ pull request 35 | uses: peter-evans/create-pull-request@v6 36 | with: 37 | add-paths: '**.md' 38 | base: main 39 | branch: markdown-includes 40 | delete-branch: true 41 | labels: docs 42 | author: ${{ env.BOT_AUTHOR }} 43 | committer: ${{ env.BOT_AUTHOR }} 44 | commit-message: +Mᐁ includes 45 | title: +Mᐁ includes 46 | body: +Mᐁ includes 47 | token: ${{ env.GH_TOKEN }} 48 | -------------------------------------------------------------------------------- /.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: dotnet retest -- ./src/Sample/ 38 | 39 | - name: 🐛 logs 40 | uses: actions/upload-artifact@v4 41 | if: runner.debug && always() 42 | with: 43 | name: logs 44 | path: '*.binlog' 45 | 46 | - name: ⬆️ upload 47 | if: success() 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: pkg 51 | path: bin/* 52 | 53 | - name: 🚀 sleet 54 | env: 55 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 56 | if: env.SLEET_CONNECTION != '' 57 | run: | 58 | dotnet tool install -g --version 4.0.18 sleet 59 | 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" 60 | 61 | - name: 🚀 nuget 62 | env: 63 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 64 | if: env.NUGET_API_KEY != '' 65 | run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate 66 | -------------------------------------------------------------------------------- /.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." -------------------------------------------------------------------------------- /.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 | 15 | *.suo 16 | *.sdf 17 | *.userprefs 18 | *.user 19 | *.nupkg 20 | *.metaproj 21 | *.tmp 22 | *.log 23 | *.cache 24 | *.binlog 25 | *.zip 26 | __azurite*.* 27 | __*__ 28 | 29 | .nuget 30 | *.lock.json 31 | *.nuget.props 32 | *.nuget.targets 33 | 34 | node_modules 35 | _site 36 | .jekyll-metadata 37 | .jekyll-cache 38 | .sass-cache 39 | Gemfile.lock 40 | package-lock.json 41 | -------------------------------------------------------------------------------- /.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 = 5f92a68e302bae675b394ef343114139c075993e 23 | etag = 338ba6d92c8d1774363396739c2be4257bfc58026f4b0fe92cb0ae4460e1eff7 24 | weak 25 | [file ".github/dependabot.yml"] 26 | url = https://github.com/devlooped/oss/blob/main/.github/dependabot.yml 27 | sha = 49661dbf0720cde93eb5569be7523b5912351560 28 | etag = c147ea2f3431ca0338c315c4a45b56ee233c4d30f8d6ab698d0e1980a257fd6a 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 = 85829f2510f335f4a411867f3dbaaa116c3ab3de 56 | etag = 086f6b6316cc6ea7089c0dcc6980be519e6ed6e6201e65042ef41b82634ec0ee 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 = e0be248fff1d39133345283b8227372b36574b75 64 | etag = c449ec6f76803e1891357ca2b8b4fcb5b2e5deeff8311622fd92ca9fbf1e6575 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 = fa83a5161ba52bc5d510ce0ba75ee0b1f8d4bc63 74 | etag = 9139148f845adf503fd3c3c140eb64421fc476a1f9c027fc50825c0efb05f557 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 = 2fff747a9673b499c99f2da183cdd5263fdc9333 89 | etag = 0fccddf04f282fe98122ab2610dc2972c205a521254559bf013655c6271b0017 90 | weak 91 | [file "src/Directory.Build.targets"] 92 | url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets 93 | sha = a8b208093599263b7f2d1fe3854634c588ea5199 94 | etag = 19087699f05396205e6b050d999a43b175bd242f6e8fac86f6df936310178b03 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 = cf6a9a0eb401157bbd9d877575a49f98af515db8 99 | etag = f83f144e7ef6f5e28d22baf938a70f8e39ec50b6e8427d88fa9f8db302695e48 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | 3 | exclude: [ 'src/', '*.sln', 'Gemfile*', '*.rsp' ] -------------------------------------------------------------------------------- /assets/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/32.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/img/ciretry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/img/ciretry.png -------------------------------------------------------------------------------- /assets/img/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/img/comment.png -------------------------------------------------------------------------------- /assets/img/progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/img/progress.png -------------------------------------------------------------------------------- /assets/img/timings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/assets/img/timings.png -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.7.1](https://github.com/devlooped/dotnet-retest/tree/v0.7.1) (2025-03-02) 4 | 5 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.7.0...v0.7.1) 6 | 7 | :bug: Fixed bugs: 8 | 9 | - Include standard error in the result to make it easier to find errors. [\#56](https://github.com/devlooped/dotnet-retest/issues/56) 10 | 11 | ## [v0.7.0](https://github.com/devlooped/dotnet-retest/tree/v0.7.0) (2025-02-25) 12 | 13 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.5...v0.7.0) 14 | 15 | :bug: Fixed bugs: 16 | 17 | - Error on output when projects contain more than 1000 unit tests [\#47](https://github.com/devlooped/dotnet-retest/issues/47) 18 | 19 | :twisted_rightwards_arrows: Merged: 20 | 21 | - Include messages to standard error, this to make the log complete [\#57](https://github.com/devlooped/dotnet-retest/pull/57) (@Tasteful) 22 | 23 | ## [v0.6.5](https://github.com/devlooped/dotnet-retest/tree/v0.6.5) (2025-02-18) 24 | 25 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.4...v0.6.5) 26 | 27 | :sparkles: Implemented enhancements: 28 | 29 | - Bring changes from trx new verbosity levels [\#64](https://github.com/devlooped/dotnet-retest/pull/64) (@kzu) 30 | 31 | :bug: Fixed bugs: 32 | 33 | - Fix broken verbose reporting of successful tests [\#70](https://github.com/devlooped/dotnet-retest/pull/70) (@kzu) 34 | 35 | :twisted_rightwards_arrows: Merged: 36 | 37 | - Showcase that we can run/render 1k+ tests [\#71](https://github.com/devlooped/dotnet-retest/pull/71) (@kzu) 38 | - Remove confusing and duplicate -v for version [\#67](https://github.com/devlooped/dotnet-retest/pull/67) (@kzu) 39 | 40 | ## [v0.6.4](https://github.com/devlooped/dotnet-retest/tree/v0.6.4) (2025-02-18) 41 | 42 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.3...v0.6.4) 43 | 44 | :bug: Fixed bugs: 45 | 46 | - Avoid extra noise in log for CI builds [\#58](https://github.com/devlooped/dotnet-retest/issues/58) 47 | - Column width inside Azure DevOps pipeline [\#54](https://github.com/devlooped/dotnet-retest/issues/54) 48 | - Retry attempts are not working anymore in 0.6.X [\#48](https://github.com/devlooped/dotnet-retest/issues/48) 49 | - Make retry logic more resilient to changes in xunit/vstest output [\#62](https://github.com/devlooped/dotnet-retest/pull/62) (@kzu) 50 | 51 | :twisted_rightwards_arrows: Merged: 52 | 53 | - Avoid noise in auto-update of progress [\#59](https://github.com/devlooped/dotnet-retest/pull/59) (@Tasteful) 54 | - Send output directly to System.Console to avoid line breaks [\#55](https://github.com/devlooped/dotnet-retest/pull/55) (@Tasteful) 55 | 56 | ## [v0.6.3](https://github.com/devlooped/dotnet-retest/tree/v0.6.3) (2024-09-02) 57 | 58 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.2...v0.6.3) 59 | 60 | :sparkles: Implemented enhancements: 61 | 62 | - Add a way to opt-out of the summary [\#31](https://github.com/devlooped/dotnet-retest/pull/31) (@kzu) 63 | 64 | :hammer: Other: 65 | 66 | - Command option to disable trx output [\#29](https://github.com/devlooped/dotnet-retest/issues/29) 67 | 68 | :twisted_rightwards_arrows: Merged: 69 | 70 | - Ensure readme is encoded in UTF-8 [\#33](https://github.com/devlooped/dotnet-retest/pull/33) (@kzu) 71 | - Improve style disable when NO\_COLOR [\#30](https://github.com/devlooped/dotnet-retest/pull/30) (@kzu) 72 | 73 | ## [v0.6.2](https://github.com/devlooped/dotnet-retest/tree/v0.6.2) (2024-08-24) 74 | 75 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.1...v0.6.2) 76 | 77 | :bug: Fixed bugs: 78 | 79 | - Fix issue when filename has \[ or \] characters [\#27](https://github.com/devlooped/dotnet-retest/pull/27) (@kzu) 80 | 81 | ## [v0.6.1](https://github.com/devlooped/dotnet-retest/tree/v0.6.1) (2024-08-08) 82 | 83 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.6.0...v0.6.1) 84 | 85 | ## [v0.6.0](https://github.com/devlooped/dotnet-retest/tree/v0.6.0) (2024-08-07) 86 | 87 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.5.0...v0.6.0) 88 | 89 | :sparkles: Implemented enhancements: 90 | 91 | - Rename attempts to retries [\#19](https://github.com/devlooped/dotnet-retest/pull/19) (@kzu) 92 | 93 | ## [v0.5.0](https://github.com/devlooped/dotnet-retest/tree/v0.5.0) (2024-08-07) 94 | 95 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.4.1...v0.5.0) 96 | 97 | :sparkles: Implemented enhancements: 98 | 99 | - Add ouctome column and screenshots [\#18](https://github.com/devlooped/dotnet-retest/pull/18) (@kzu) 100 | 101 | ## [v0.4.1](https://github.com/devlooped/dotnet-retest/tree/v0.4.1) (2024-08-07) 102 | 103 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.4.0...v0.4.1) 104 | 105 | :sparkles: Implemented enhancements: 106 | 107 | - Don't allow non-trx logger on non-Windows OS [\#17](https://github.com/devlooped/dotnet-retest/pull/17) (@kzu) 108 | - Make progress description column multiline [\#16](https://github.com/devlooped/dotnet-retest/pull/16) (@kzu) 109 | - Increase console verbosity to get better progress [\#15](https://github.com/devlooped/dotnet-retest/pull/15) (@kzu) 110 | 111 | ## [v0.4.0](https://github.com/devlooped/dotnet-retest/tree/v0.4.0) (2024-08-06) 112 | 113 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.3.0...v0.4.0) 114 | 115 | :sparkles: Implemented enhancements: 116 | 117 | - Allow for extra loggers in addition to trx [\#14](https://github.com/devlooped/dotnet-retest/pull/14) (@kzu) 118 | - Improve progress reporting by showing output [\#13](https://github.com/devlooped/dotnet-retest/pull/13) (@kzu) 119 | 120 | ## [v0.3.0](https://github.com/devlooped/dotnet-retest/tree/v0.3.0) (2024-07-31) 121 | 122 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.3...v0.3.0) 123 | 124 | ## [v0.2.3](https://github.com/devlooped/dotnet-retest/tree/v0.2.3) (2024-07-29) 125 | 126 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.2...v0.2.3) 127 | 128 | ## [v0.2.2](https://github.com/devlooped/dotnet-retest/tree/v0.2.2) (2024-07-29) 129 | 130 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.1...v0.2.2) 131 | 132 | :sparkles: Implemented enhancements: 133 | 134 | - Bring fix for test error markup rendering [\#10](https://github.com/devlooped/dotnet-retest/pull/10) (@kzu) 135 | 136 | ## [v0.2.1](https://github.com/devlooped/dotnet-retest/tree/v0.2.1) (2024-07-29) 137 | 138 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.2.0...v0.2.1) 139 | 140 | ## [v0.2.0](https://github.com/devlooped/dotnet-retest/tree/v0.2.0) (2024-07-21) 141 | 142 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/v0.1.0...v0.2.0) 143 | 144 | :sparkles: Implemented enhancements: 145 | 146 | - Take into account duplicate FQN from theories [\#6](https://github.com/devlooped/dotnet-retest/pull/6) (@kzu) 147 | 148 | ## [v0.1.0](https://github.com/devlooped/dotnet-retest/tree/v0.1.0) (2024-07-21) 149 | 150 | [Full Changelog](https://github.com/devlooped/dotnet-retest/compare/cc678481a604157a20545f0a37a4fe7e119a77b3...v0.1.0) 151 | 152 | :sparkles: Implemented enhancements: 153 | 154 | - Don't prefix trx report options unnecessarily [\#5](https://github.com/devlooped/dotnet-retest/pull/5) (@kzu) 155 | - Run retest with sample on all platforms [\#3](https://github.com/devlooped/dotnet-retest/pull/3) (@kzu) 156 | 157 | :twisted_rightwards_arrows: Merged: 158 | 159 | - Showcase CI runs [\#4](https://github.com/devlooped/dotnet-retest/pull/4) (@kzu) 160 | 161 | 162 | 163 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 164 | -------------------------------------------------------------------------------- /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 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {30849648-147D-41B7-ACBE-D54AD360E0C8}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {5A647D73-B3A8-49E8-A8A3-AC3CAD00DF48}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {36DBDAFF-AF65-47FF-847A-A2BBD8AB8A95} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]]` (with optional `--attempts` which defaults to `3`): 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 | # Sponsors 81 | 82 | 83 | [![Clarius Org](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/clarius.png "Clarius Org")](https://github.com/clarius) 84 | [![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) 85 | [![Torutek](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/torutek-gh.png "Torutek")](https://github.com/torutek-gh) 86 | [![DRIVE.NET, Inc.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/drivenet.png "DRIVE.NET, Inc.")](https://github.com/drivenet) 87 | [![Keith Pickford](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Keflon.png "Keith Pickford")](https://github.com/Keflon) 88 | [![Thomas Bolon](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tbolon.png "Thomas Bolon")](https://github.com/tbolon) 89 | [![Kori Francis](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/kfrancis.png "Kori Francis")](https://github.com/kfrancis) 90 | [![Toni Wenzel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/twenzel.png "Toni Wenzel")](https://github.com/twenzel) 91 | [![Uno Platform](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/unoplatform.png "Uno Platform")](https://github.com/unoplatform) 92 | [![Dan Siegel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/dansiegel.png "Dan Siegel")](https://github.com/dansiegel) 93 | [![Reuben Swartz](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/rbnswartz.png "Reuben Swartz")](https://github.com/rbnswartz) 94 | [![Jacob Foshee](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jfoshee.png "Jacob Foshee")](https://github.com/jfoshee) 95 | [![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Mrxx99.png "")](https://github.com/Mrxx99) 96 | [![Eric Johnson](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/eajhnsn1.png "Eric Johnson")](https://github.com/eajhnsn1) 97 | [![Ix Technologies B.V.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/IxTechnologies.png "Ix Technologies B.V.")](https://github.com/IxTechnologies) 98 | [![David JENNI](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/davidjenni.png "David JENNI")](https://github.com/davidjenni) 99 | [![Jonathan ](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Jonathan-Hickey.png "Jonathan ")](https://github.com/Jonathan-Hickey) 100 | [![Charley Wu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/akunzai.png "Charley Wu")](https://github.com/akunzai) 101 | [![Jakob Tikjøb Andersen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jakobt.png "Jakob Tikjøb Andersen")](https://github.com/jakobt) 102 | [![Tino Hager](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tinohager.png "Tino Hager")](https://github.com/tinohager) 103 | [![Ken Bonny](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/KenBonny.png "Ken Bonny")](https://github.com/KenBonny) 104 | [![Simon Cropp](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/SimonCropp.png "Simon Cropp")](https://github.com/SimonCropp) 105 | [![agileworks-eu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/agileworks-eu.png "agileworks-eu")](https://github.com/agileworks-eu) 106 | [![sorahex](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/sorahex.png "sorahex")](https://github.com/sorahex) 107 | [![Zheyu Shen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/arsdragonfly.png "Zheyu Shen")](https://github.com/arsdragonfly) 108 | [![Vezel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/vezel-dev.png "Vezel")](https://github.com/vezel-dev) 109 | [![ChilliCream](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ChilliCream.png "ChilliCream")](https://github.com/ChilliCream) 110 | [![4OTC](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/4OTC.png "4OTC")](https://github.com/4OTC) 111 | [![Vincent Limo](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/v-limo.png "Vincent Limo")](https://github.com/v-limo) 112 | [![Jordan S. Jones](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jordansjones.png "Jordan S. Jones")](https://github.com/jordansjones) 113 | [![domischell](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/DominicSchell.png "domischell")](https://github.com/DominicSchell) 114 | [![Joseph Kingry](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jkingry.png "Joseph Kingry")](https://github.com/jkingry) 115 | 116 | 117 | 118 | 119 | [![Sponsor this project](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor.png "Sponsor this project")](https://github.com/sponsors/devlooped) 120 |   121 | 122 | [Learn more about GitHub Sponsors](https://github.com/sponsors) 123 | 124 | 125 | -------------------------------------------------------------------------------- /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 | Copyright (C) Daniel Cazzulino and Contributors. All rights reserved. 24 | false 25 | MIT 26 | 27 | 28 | icon.png 29 | readme.md 30 | 31 | icon.png 32 | readme.md 33 | 34 | true 35 | true 36 | 37 | $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\bin')) 38 | 39 | 40 | true 41 | true 42 | 43 | 44 | true 45 | 46 | 47 | 48 | Release 49 | Latest 50 | 51 | 52 | false 53 | 54 | embedded 55 | true 56 | enable 57 | 58 | strict 59 | 60 | 61 | $(MSBuildProjectName) 62 | $(MSBuildProjectName.IndexOf('.')) 63 | $(MSBuildProjectName.Substring(0, $(RootNamespaceDot))) 64 | 65 | 66 | $(DefaultItemExcludes);*.binlog;*.zip;*.rsp;*.items;**/TestResults/**/*.* 67 | 68 | true 69 | true 70 | true 71 | true 72 | 73 | 74 | true 75 | 76 | 77 | false 78 | 79 | 80 | NU5105;$(NoWarn) 81 | 82 | true 83 | 84 | 85 | true 86 | 87 | 88 | LatestMinor 89 | 90 | 91 | 92 | 93 | $(MSBuildThisFileDirectory)kzu.snk 94 | 100 | 002400000480000094000000060200000024000052534131000400000100010051155fd0ee280be78d81cc979423f1129ec5dd28edce9cd94fd679890639cad54c121ebdb606f8659659cd313d3b3db7fa41e2271158dd602bb0039a142717117fa1f63d93a2d288a1c2f920ec05c4858d344a45d48ebd31c1368ab783596b382b611d8c92f9c1b3d338296aa21b12f3bc9f34de87756100c172c52a24bad2db 101 | 00352124762f2aa5 102 | true 103 | 104 | 105 | 106 | 114 | 42.42.42 115 | 116 | 117 | 118 | <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) 119 | <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) 120 | 121 | 122 | <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) 123 | 124 | <_VersionLabel>$(_VersionLabel.Replace('refs/pull/', 'pr')) 125 | 126 | <_VersionLabel>$(_VersionLabel.Replace('/merge', '')) 127 | 128 | <_VersionLabel>$(_VersionLabel.Replace('/', '-')) 129 | 130 | 131 | $(_VersionLabel) 132 | 133 | $(_VersionLabel) 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 162 | 163 | 1.0.0 164 | $(VersionPrefix)-$(VersionSuffix) 165 | $(VersionPrefix) 166 | 167 | 168 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /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 | 170 | 171 | 172 | 177 | 178 | $(RepositoryUrl) 179 | $(Description) 180 | $(RepositoryUrl)/blob/main/changelog.md 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | -------------------------------------------------------------------------------- /src/Sample/Sample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Sample/UnitTest1.cs: -------------------------------------------------------------------------------- 1 | namespace Sample; 2 | 3 | public class UnitTest1 4 | { 5 | [Theory] 6 | [InlineData(1)] 7 | [InlineData(2)] 8 | public void Test1(int value) 9 | { 10 | Assert.True(value > 0); 11 | } 12 | 13 | [Fact] 14 | public void FailsOnce() 15 | { 16 | if (!File.Exists("failsonce.txt")) 17 | { 18 | File.WriteAllText("failsonce.txt", ""); 19 | Assert.Fail("Fails once"); 20 | } 21 | 22 | File.Delete("failsonce.txt"); 23 | } 24 | 25 | [Fact] 26 | public void FailsTwice() 27 | { 28 | // Add random delay to simulate actual test execution 29 | Thread.Sleep(Random.Shared.Next(1000, 5000)); 30 | 31 | var attempt = int.Parse( 32 | File.Exists("failstwice.txt") ? 33 | File.ReadAllText("failstwice.txt") : 34 | "0"); 35 | 36 | if (attempt < 2) 37 | { 38 | File.WriteAllText("failstwice.txt", (attempt + 1).ToString()); 39 | Assert.Fail("Fails twice"); 40 | } 41 | 42 | // Succeeds 43 | File.Delete("failstwice.txt"); 44 | } 45 | 46 | public static IEnumerable GetNumbers() => Enumerable.Range(0, 1100).Select(x => new object[] { x }); 47 | 48 | [Theory] 49 | [MemberData(nameof(GetNumbers))] 50 | public void NumberIsPositive(int value) 51 | { 52 | Assert.True(value >= 0); 53 | } 54 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/dotnet-retest/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Devlooped; 8 | using NuGet.Configuration; 9 | using NuGet.Protocol.Core.Types; 10 | using NuGet.Versioning; 11 | using Spectre.Console; 12 | using Spectre.Console.Cli; 13 | using Spectre.Console.Cli.Help; 14 | using Spectre.Console.Rendering; 15 | 16 | var app = new CommandApp(); 17 | 18 | // Alias -? to -h for help 19 | if (args.Contains("-?")) 20 | args = args.Select(x => x == "-?" ? "-h" : x).ToArray(); 21 | 22 | if (args.Contains("--debug")) 23 | { 24 | Debugger.Launch(); 25 | args = args.Where(args => args != "--debug").ToArray(); 26 | } 27 | 28 | app.Configure(config => 29 | { 30 | config.SetHelpProvider(new Helper(config.Settings)); 31 | config.SetApplicationName("dotnet retest"); 32 | 33 | if (Environment.GetEnvironmentVariables().Contains("NO_COLOR") && 34 | config.Settings.HelpProviderStyles?.Options is { } options) 35 | options.DefaultValue = Style.Plain; 36 | }); 37 | 38 | if (args.Contains("--version")) 39 | { 40 | AnsiConsole.MarkupLine($"{ThisAssembly.Project.ToolCommandName} version [lime]{ThisAssembly.Project.Version}[/] ({ThisAssembly.Project.BuildDate})"); 41 | AnsiConsole.MarkupLine($"[link]{ThisAssembly.Git.Url}/releases/tag/{ThisAssembly.Project.BuildRef}[/]"); 42 | 43 | foreach (var message in await CheckUpdates(args)) 44 | AnsiConsole.MarkupLine(message); 45 | 46 | return 0; 47 | } 48 | 49 | var updates = Task.Run(() => CheckUpdates(args)); 50 | var exit = app.Run(args); 51 | 52 | if (await updates is { Length: > 0 } messages) 53 | { 54 | foreach (var message in messages) 55 | AnsiConsole.MarkupLine(message); 56 | } 57 | 58 | return exit; 59 | 60 | static async Task CheckUpdates(string[] args) 61 | { 62 | if (args.Contains("-u") && !args.Contains("--unattended")) 63 | return []; 64 | 65 | var providers = Repository.Provider.GetCoreV3(); 66 | var repository = new SourceRepository(new PackageSource("https://api.nuget.org/v3/index.json"), providers); 67 | var resource = await repository.GetResourceAsync(); 68 | var localVersion = new NuGetVersion(ThisAssembly.Project.Version); 69 | var metadata = await resource.GetMetadataAsync(ThisAssembly.Project.PackageId, true, false, 70 | new SourceCacheContext 71 | { 72 | NoCache = true, 73 | RefreshMemoryCache = true, 74 | }, 75 | NuGet.Common.NullLogger.Instance, CancellationToken.None); 76 | 77 | var update = metadata 78 | .Select(x => x.Identity) 79 | .Where(x => x.Version > localVersion) 80 | .OrderByDescending(x => x.Version) 81 | .Select(x => x.Version) 82 | .FirstOrDefault(); 83 | 84 | if (update != null) 85 | { 86 | return [ 87 | $"There is a new version of [yellow]{ThisAssembly.Project.PackageId}[/]: [dim]v{localVersion.ToNormalizedString()}[/] -> [lime]v{update.ToNormalizedString()}[/]", 88 | $"Update with: [yellow]dotnet[/] tool update -g {ThisAssembly.Project.PackageId}" 89 | ]; 90 | } 91 | 92 | return []; 93 | } 94 | 95 | class Helper(ICommandAppSettings settings) : HelpProvider(settings) 96 | { 97 | const string dotnet = "[-- [dotnet test options]]"; 98 | 99 | public override IEnumerable GetUsage(ICommandModel model, ICommandInfo? command) 100 | => [new Markup( 101 | $""" 102 | [yellow]USAGE:[/] 103 | {settings.ApplicationName} [[OPTIONS]] [grey]{dotnet.EscapeMarkup()}[/] 104 | 105 | """)]; 106 | } 107 | -------------------------------------------------------------------------------- /src/dotnet-retest/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "dotnet-retest": { 4 | "commandName": "Project", 5 | "commandLineArgs": "", 6 | "workingDirectory": "..\\Sample" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/dotnet-retest/RetestCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Runtime.InteropServices; 8 | using System.Threading.Tasks; 9 | using CliWrap; 10 | using CliWrap.Buffered; 11 | using Devlooped.Web; 12 | using Mono.Options; 13 | using NuGet.Packaging; 14 | using Spectre.Console; 15 | using Spectre.Console.Cli; 16 | using Spectre.Console.Rendering; 17 | using static Devlooped.TrxCommand; 18 | using static Spectre.Console.AnsiConsole; 19 | 20 | namespace Devlooped; 21 | 22 | public partial class RetestCommand : AsyncCommand 23 | { 24 | record TestResult(string FullName, bool Failed); 25 | 26 | public override async Task ExecuteAsync(CommandContext context, RetestSettings settings) 27 | { 28 | var args = context.Remaining.Raw.ToList(); 29 | // A typical mistake would be to pass dotnet test args directly without the -- separator 30 | // so account for this automatically so users fall in the pit of success 31 | if (args.Count == 0) 32 | { 33 | foreach (var key in context.Remaining.Parsed) 34 | { 35 | foreach (var value in key) 36 | { 37 | // Revert multiple --key [value] into multiple --key --value 38 | args.Add(key.Key); 39 | if (value != null) 40 | args.Add(value); 41 | } 42 | } 43 | } 44 | 45 | string? path = null; 46 | var hastrx = false; 47 | var hasconsole = false; 48 | var haslogger = false; 49 | 50 | new OptionSet 51 | { 52 | { "l|logger=", v => 53 | { 54 | hastrx = v.StartsWith("trx"); 55 | hasconsole = v.StartsWith("console"); 56 | haslogger = true; 57 | } 58 | }, 59 | { "results-directory=", v => path = v }, 60 | }.Parse(args); 61 | 62 | // In non-Windows OSes, the trx logger must be the only one if specified 63 | if (haslogger && !hastrx && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 64 | { 65 | MarkupLine("[red]If a logger is specified, it can only be trx in non-Windows platforms.[/]"); 66 | return 1; 67 | } 68 | 69 | var trx = new TrxSettings 70 | { 71 | Path = path ?? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), 72 | Output = settings.Output, 73 | Verbosity = settings.Verbosity, 74 | GitHubComment = settings.GitHubComment, 75 | GitHubSummary = settings.GitHubSummary, 76 | Skipped = settings.Skipped, 77 | Recursive = false, 78 | }; 79 | 80 | if (trx.Validate() is { Successful: false } result) 81 | { 82 | MarkupLine($"[red]Invalid trx settings: {result.Message}[/]"); 83 | return 1; 84 | } 85 | 86 | if (path == null) 87 | { 88 | // We always ensure the final dotnet test call has a valid path. So 89 | // if we didn't get one from args, we need to add it here. 90 | args.Insert(0, "--results-directory"); 91 | args.Insert(1, trx.Path); 92 | } 93 | 94 | var ci = Environment.GetEnvironmentVariable("CI") == "true"; 95 | 96 | // Ensure we add the console logger to get more detailed progress in non-CI environments 97 | // Limiting to Windows which is what I personally tested. Linux fails with multiple loggers too. 98 | if (!hasconsole && !ci && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 99 | { 100 | args.Insert(0, "--logger"); 101 | args.Insert(1, "console;verbosity=normal"); 102 | } 103 | 104 | // Ensure we add the trx logger. Note that there can be other loggers too 105 | if (!hastrx) 106 | { 107 | args.Insert(0, "--logger"); 108 | args.Insert(1, "trx"); 109 | } 110 | 111 | Debug.Assert(DotnetMuxer.Path != null); 112 | 113 | var failed = new HashSet(); 114 | var attempts = 0; 115 | BufferedCommandResult? runFailure = null; 116 | 117 | ProgressColumn[] columns = ci ? 118 | [new TaskDescriptionColumn { Alignment = Justify.Left }] : 119 | [new OutcomeSpinnerColumn(), new ElapsedTimeColumn(), new MultilineTaskDescriptionColumn()]; 120 | 121 | var exitCode = await Progress() 122 | .Columns(columns) 123 | .AutoRefresh(!ci) 124 | .StartAsync(async ctx => 125 | { 126 | while (true) 127 | { 128 | attempts++; 129 | var task = ctx.AddTask($"Running tests, attempt #{attempts}"); 130 | 131 | try 132 | { 133 | task.StartTask(); 134 | 135 | // Ensure we don't build on retries after initial attempt, 136 | // just in case --no-build was't passed in the original command. 137 | if (attempts > 1 && !args.Contains("--no-build")) 138 | args.Insert(0, "--no-build"); 139 | 140 | var prefix = attempts == 1 ? 141 | $"Running tests" : 142 | $"Retrying {failed.Count} failed test{(failed.Count > 1 ? "s" : "")}"; 143 | 144 | task.Description = prefix; 145 | if (ci) 146 | { 147 | ctx.Refresh(); 148 | } 149 | 150 | var exit = await RunTestsAsync(DotnetMuxer.Path.FullName, new List(args), failed, new Progress(line => 151 | { 152 | if (ci) 153 | { 154 | System.Console.WriteLine(line); 155 | } 156 | else if (line.Trim() is { Length: > 0 } description) 157 | { 158 | task.Description = prefix + $": [grey]{description.EscapeMarkup()}[/]"; 159 | } 160 | })); 161 | 162 | // By setting the exit code to the task, the OutcomeSpinnerColumn can render appropately 163 | task.Value = exit.ExitCode; 164 | // Restore description without last progress (if any) 165 | task.Description = prefix; 166 | 167 | if (exit.ExitCode == 0) 168 | return 0; 169 | 170 | if (!exit.StandardOutput.Contains(trx.Path)) 171 | { 172 | runFailure = exit; 173 | return exit.ExitCode; 174 | } 175 | 176 | if (attempts >= settings.Attempts) 177 | return exit.ExitCode; 178 | 179 | var outcomes = GetTestResults(trx.Path); 180 | // On first attempt, we just batch add all failed tests 181 | if (attempts == 1) 182 | { 183 | failed.AddRange(outcomes.Where(x => x.Value == true).Select(x => x.Key)); 184 | } 185 | else 186 | { 187 | // Remove from failed the tests that are no longer failed in this attempt 188 | failed.RemoveWhere(x => outcomes.TryGetValue(x, out var isFailed) && !isFailed); 189 | } 190 | } 191 | finally 192 | { 193 | task.StopTask(); 194 | } 195 | } 196 | }); 197 | 198 | if (runFailure != null) 199 | { 200 | MarkupLine($"[red]Error:[/] Failed to run tests."); 201 | WriteLine(runFailure.StandardOutput); 202 | } 203 | 204 | if (settings.NoSummary != true && Directory.Exists(trx.Path)) 205 | { 206 | new TrxCommand().Execute(context, trx); 207 | } 208 | 209 | return exitCode; 210 | } 211 | 212 | Dictionary GetTestResults(string path) 213 | { 214 | var outcomes = new Dictionary(); 215 | if (!Directory.Exists(path)) 216 | return outcomes; 217 | 218 | var ids = new HashSet(); 219 | 220 | // Process from newest files to oldest so that newest result we find (by test id) is the one we keep 221 | // NOTE: we always emit results to a given directory, so we don't need to search for trx files in subdirectories 222 | foreach (var trx in Directory.EnumerateFiles(path, "*.trx", SearchOption.TopDirectoryOnly).OrderByDescending(File.GetLastWriteTime)) 223 | { 224 | using var file = File.OpenRead(trx); 225 | // Clears namespaces 226 | var doc = HtmlDocument.Load(file, new HtmlReaderSettings { CaseFolding = Sgml.CaseFolding.None }); 227 | foreach (var result in doc.CssSelectElements("UnitTestResult")) 228 | { 229 | var id = result.Attribute("testId")!.Value; 230 | // Process only once per test id, this avoids duplicates when multiple trx files are processed 231 | if (ids.Add(id)) 232 | { 233 | var isFailed = result.Attribute("outcome")?.Value == "Failed"; 234 | var method = doc.CssSelectElement($"UnitTest[id={id}] TestMethod"); 235 | Debug.Assert(method != null); 236 | // NOTE: we may have duplicate test FQN due to theories, which we'd run again in this case. 237 | // Eventually, we might want to figure out how to filter theories in a cross-framework compatible 238 | // way, but for now, filtering by FQN should be enough, even if not 100% optimal. 239 | var fqn = $"{method.Attribute("className")?.Value}.{method.Attribute("name")?.Value}"; 240 | if (!outcomes.TryGetValue(fqn, out var wasFailed) || !wasFailed) 241 | // Only change the outcome if it was not already failed 242 | outcomes[fqn] = isFailed; 243 | } 244 | } 245 | } 246 | 247 | return outcomes; 248 | } 249 | 250 | async Task RunTestsAsync(string dotnet, List args, IEnumerable failed, IProgress progress) 251 | { 252 | var testArgs = string.Join(" ", args); 253 | var finalArgs = args; 254 | var filter = string.Join('|', failed.Select(failed => $"FullyQualifiedName~{failed}")); 255 | if (filter.Length > 0) 256 | { 257 | testArgs = $"--filter \"{filter}\" {testArgs}"; 258 | finalArgs.InsertRange(0, ["--filter", filter]); 259 | } 260 | 261 | finalArgs.Insert(0, "test"); 262 | 263 | var result = await Cli.Wrap(dotnet) 264 | .WithArguments(finalArgs) 265 | .WithWorkingDirectory(Directory.GetCurrentDirectory()) 266 | .WithValidation(CommandResultValidation.None) 267 | .WithStandardErrorPipe(PipeTarget.ToDelegate(progress.Report)) 268 | .WithStandardOutputPipe(PipeTarget.ToDelegate(progress.Report)) 269 | .ExecuteBufferedAsync(); 270 | 271 | return result; 272 | } 273 | 274 | public class RetestSettings : CommandSettings 275 | { 276 | [Description("Prints version information")] 277 | [CommandOption("--version")] 278 | public bool Version { get; init; } 279 | 280 | [Description("Maximum retries when re-running failed tests")] 281 | [CommandOption("--retries")] 282 | [DefaultValue(3)] 283 | public int Retries 284 | { 285 | get => Attempts - 1; 286 | init => Attempts = value + 1; 287 | } 288 | 289 | [Description("Maximum attempts to run tests")] 290 | [CommandOption("--attempts", IsHidden = true)] 291 | public int Attempts { get; init; } 292 | 293 | [Description("Whether to emit a summary to console/GitHub")] 294 | [CommandOption("--no-summary")] 295 | [DefaultValue(false)] 296 | public bool NoSummary { get; init; } 297 | 298 | #region trx 299 | 300 | [Description("Include test output in report")] 301 | [CommandOption("--output")] 302 | [DefaultValue(false)] 303 | public bool Output { get; init; } 304 | 305 | /// 306 | /// Output verbosity. 307 | /// 308 | [Description( 309 | """ 310 | Output display verbosity: 311 | - quiet: only failed tests are displayed 312 | - normal: failed and skipped tests are displayed 313 | - verbose: failed, skipped and passed tests are displayed 314 | """)] 315 | [CommandOption("-v|--verbosity")] 316 | [DefaultValue(Verbosity.Quiet)] 317 | public Verbosity Verbosity { get; set; } = Verbosity.Quiet; 318 | 319 | /// 320 | /// Whether to include skipped tests in the output. 321 | /// 322 | [Description("Include skipped tests in report")] 323 | [CommandOption("--skipped", IsHidden = true)] 324 | [DefaultValue(true)] 325 | public bool Skipped 326 | { 327 | get => Verbosity != Verbosity.Quiet; 328 | set 329 | { 330 | if (!value) 331 | Verbosity = Verbosity.Quiet; 332 | } 333 | } 334 | 335 | /// 336 | /// Report as GitHub PR comment. 337 | /// 338 | [Description("Report as GitHub PR comment")] 339 | [CommandOption("--gh-comment")] 340 | [DefaultValue(true)] 341 | public bool GitHubComment { get; init; } = true; 342 | 343 | /// 344 | /// Report as GitHub PR comment. 345 | /// 346 | [Description("Report as GitHub step summary")] 347 | [CommandOption("--gh-summary")] 348 | [DefaultValue(true)] 349 | public bool GitHubSummary { get; init; } = true; 350 | 351 | #endregion 352 | } 353 | 354 | class MultilineTaskDescriptionColumn : ProgressColumn 355 | { 356 | public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime) 357 | { 358 | return new Markup(task.Description ?? string.Empty) 359 | .Overflow(Overflow.Ellipsis) 360 | .Justify(Justify.Left); 361 | } 362 | } 363 | 364 | class OutcomeSpinnerColumn : ProgressColumn 365 | { 366 | readonly SpinnerColumn spinner = new(); 367 | 368 | public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime) 369 | { 370 | if (!task.IsFinished) 371 | return spinner.Render(options, task, deltaTime); 372 | 373 | if (task.Value == 0) 374 | return new Markup(":check_mark_button:"); 375 | else 376 | return new Markup(":cross_mark:"); 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /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 | //foreach (var failure in failures) 269 | // WriteLine($"::error file={failure.File},line={failure.Line},title={failure.Title}::{failure.Message}"); 270 | } 271 | } 272 | 273 | return settings.NoExitCode || failed == 0 ? 0 : -1; 274 | } 275 | 276 | static void MarkupSummary(Summary summary) 277 | { 278 | Markup($":backhand_index_pointing_right: Run {summary.Total} tests in ~ {summary.Duration.Humanize()}"); 279 | 280 | if (summary.Failed > 0) 281 | MarkupLine($" :cross_mark:"); 282 | else 283 | MarkupLine($" :check_mark_button:"); 284 | 285 | if (summary.Passed > 0) 286 | MarkupLine($" :check_mark_button: {summary.Passed} passed"); 287 | 288 | if (summary.Failed > 0) 289 | MarkupLine($" :cross_mark: {summary.Failed} failed"); 290 | 291 | if (summary.Skipped > 0) 292 | MarkupLine($" :white_question_mark: {summary.Skipped} skipped"); 293 | } 294 | 295 | static void GitHubReport(TrxSettings settings, Summary summary, StringBuilder details) 296 | { 297 | // Don't report anything if there's nothing to report. 298 | if (summary.Total == 0) 299 | return; 300 | 301 | if (Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") != "pull_request" || 302 | Environment.GetEnvironmentVariable("GITHUB_ACTIONS") != "true") 303 | return; 304 | 305 | if (TryExecute("gh", "--version", out var output) && output?.StartsWith("gh version") != true) 306 | return; 307 | 308 | // See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 309 | if (Environment.GetEnvironmentVariable("GITHUB_REF_NAME") is not { } branch || 310 | !branch.EndsWith("/merge") || 311 | !int.TryParse(branch[..^6], out var pr) || 312 | Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") is not { Length: > 0 } repo || 313 | Environment.GetEnvironmentVariable("GITHUB_RUN_ID") is not { Length: > 0 } runId || 314 | Environment.GetEnvironmentVariable("GITHUB_JOB") is not { Length: > 0 } jobName || 315 | Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") is not { Length: > 0 } serverUrl) 316 | return; 317 | 318 | // Some day, it might just show-up and we'd be forwards compatible. 319 | // See https://github.com/orgs/community/discussions/129314 and https://github.com/actions/runner/issues/324 320 | var jobId = Environment.GetEnvironmentVariable("GITHUB_JOB_ID"); 321 | 322 | // Provide a mechanism that would work on matrix in the meantime 323 | if (Environment.GetEnvironmentVariable("GH_JOB_NAME") is { Length: > 0 } ghJobName) 324 | jobName = ghJobName; 325 | 326 | string? jobUrl = default; 327 | 328 | if (!string.IsNullOrEmpty(jobId)) 329 | jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/jobs/{jobId}?pr={pr}"; 330 | 331 | var sb = new StringBuilder(); 332 | var elapsed = FormatTimeSpan(summary.Duration); 333 | long commentId = 0; 334 | 335 | if (jobUrl == null && TryExecute("gh", 336 | ["api", $"repos/devlooped/dotnet-trx/actions/runs/{runId}/jobs", "--jq", $"[.jobs[] | select(.name == \"{jobName}\") | .id]"], 337 | out var jobsJson) && jobsJson != null && JsonSerializer.Deserialize(jobsJson) is { Length: 1 } jobIds) 338 | { 339 | jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/job/{jobIds[0]}?pr={pr}"; 340 | } 341 | 342 | static string Link(string image, string? url) => url == null ? image + " " : $"[{image}]({url}) "; 343 | 344 | static StringBuilder AppendBadges(Summary summary, StringBuilder builder, string elapsed, string? jobUrl) 345 | { 346 | elapsed = elapsed.Replace(" ", "%20"); 347 | 348 | // ![5 passed](https://img.shields.io/badge/❌-linux%20in%2015m%206s-blue) ![5 passed](https://img.shields.io/badge/os-macOS%20✅-blue) 349 | if (summary.Failed > 0) 350 | builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/❌-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 351 | else if (summary.Passed > 0) 352 | builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/✅-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 353 | else 354 | builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/⚪-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); 355 | 356 | if (summary.Passed > 0) 357 | builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/passed-{summary.Passed}-brightgreen)", jobUrl)); 358 | if (summary.Failed > 0) 359 | builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/failed-{summary.Failed}-red)", jobUrl)); 360 | if (summary.Skipped > 0) 361 | builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/skipped-{summary.Skipped}-silver)", jobUrl)); 362 | 363 | builder.AppendLine(); 364 | return builder; 365 | } 366 | 367 | // Find potentially existing comment to update 368 | if (TryExecute("gh", 369 | ["api", $"repos/{repo}/issues/{pr}/comments", "--jq", "[.[] | { id:.id, body:.body } | select(.body | contains(\""; 404 | 405 | var input = Path.GetTempFileName(); 406 | if (settings.GitHubComment) 407 | { 408 | if (commentId > 0) 409 | { 410 | // API requires a json payload 411 | File.WriteAllText(input, JsonSerializer.Serialize(new { body })); 412 | TryExecute("gh", $"api repos/{repo}/issues/comments/{commentId} -X PATCH --input {input}", out _); 413 | } 414 | else 415 | { 416 | // CLI can use the straight body 417 | File.WriteAllText(input, body); 418 | TryExecute("gh", $"pr comment {pr} --body-file {input}", out _); 419 | } 420 | } 421 | 422 | if (settings.GitHubSummary && 423 | Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY") is { Length: > 0 } summaryPath) 424 | { 425 | File.AppendAllText(summaryPath, 426 | AppendBadges(summary, new(), elapsed, jobUrl) 427 | .AppendLine() 428 | .Append(details) 429 | .AppendLine() 430 | .AppendLine(Author) 431 | .ToString()); 432 | } 433 | } 434 | 435 | void WriteError(string baseDir, List failures, XElement result, StringBuilder details) 436 | { 437 | if (result.CssSelectElement("Message")?.Value is not string message || 438 | result.CssSelectElement("StackTrace")?.Value is not string stackTrace) 439 | return; 440 | 441 | var testName = result.Attribute("testName")!.Value; 442 | var testId = result.Attribute("testId")!.Value; 443 | var method = result.Document!.CssSelectElement($"UnitTest[id={testId}] TestMethod"); 444 | var lines = stackTrace.ReplaceLineEndings().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); 445 | 446 | if (method != null) 447 | { 448 | var fullName = $"{method.Attribute("className")?.Value}.{method.Attribute("name")?.Value}"; 449 | var last = Array.FindLastIndex(lines, x => x.Contains(fullName)); 450 | // Stop lines when we find the last one from the test method 451 | if (last != -1) 452 | lines = lines[..(last + 1)]; 453 | } 454 | 455 | Failed? failed = null; 456 | var cli = new StringBuilder(); 457 | details.Append("> ```"); 458 | if (stackTrace.Contains(".vb:line")) 459 | details.AppendLine("vb"); 460 | else 461 | details.AppendLine("csharp"); 462 | 463 | foreach (var line in lines.Select(x => x.EscapeMarkup())) 464 | { 465 | var match = ParseFile().Match(line); 466 | if (!match.Success) 467 | { 468 | cli.AppendLine(line); 469 | details.AppendLineIndented(line, "> "); 470 | continue; 471 | } 472 | 473 | var file = match.Groups["file"].Value; 474 | var pos = match.Groups["line"].Value; 475 | var relative = file; 476 | if (Path.IsPathRooted(file) && file.StartsWith(baseDir)) 477 | relative = file[baseDir.Length..].TrimStart(Path.DirectorySeparatorChar); 478 | 479 | // NOTE: we replace whichever was last, since we want the annotation on the 480 | // last one with a filename, which will be the test itself (see previous skip from last found). 481 | failed = new Failed(testName, 482 | message.ReplaceLineEndings().Replace(Environment.NewLine, "%0A"), 483 | stackTrace.ReplaceLineEndings().Replace(Environment.NewLine, "%0A"), 484 | relative, int.Parse(pos)); 485 | 486 | cli.AppendLine(line.Replace(file, $"[link={file}][steelblue1_1]{relative}[/][/]")); 487 | // TODO: can we render a useful link in comment details? 488 | details.AppendLineIndented(line.Replace(file, relative), "> "); 489 | } 490 | 491 | var error = new Panel( 492 | $""" 493 | [red]{message.EscapeMarkup()}[/] 494 | [dim]{cli}[/] 495 | """); 496 | error.Padding = new Padding(5, 0, 0, 0); 497 | error.Border = BoxBorder.None; 498 | Write(error); 499 | 500 | // Use a blockquote for the entire error message 501 | 502 | details.AppendLine("> ```"); 503 | 504 | // Add to collected failures we may report to GH CI 505 | if (failed != null) 506 | failures.Add(failed); 507 | } 508 | 509 | static string Runtime => RuntimeInformation.RuntimeIdentifier.Replace("-", "‐"); 510 | static string OS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 511 | // 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 512 | ? $"macOS {Environment.OSVersion.VersionString}" : 513 | RuntimeInformation.OSDescription; 514 | 515 | static string FormatTimeSpan(TimeSpan timeSpan) 516 | { 517 | var parts = new List(); 518 | 519 | if (timeSpan.Hours > 0) 520 | parts.Add($"{timeSpan.Hours}h"); 521 | 522 | if (timeSpan.Minutes > 0) 523 | parts.Add($"{timeSpan.Minutes}m"); 524 | 525 | if (timeSpan.Seconds > 0 || parts.Count == 0) // Always include seconds if no other parts 526 | parts.Add($"{timeSpan.Seconds}s"); 527 | 528 | return string.Join(" ", parts); 529 | } 530 | 531 | // in C:\path\to\file.cs:line 123 532 | [GeneratedRegex(@" in (?.+):line (?\d+)", RegexOptions.Compiled)] 533 | private static partial Regex ParseFile(); 534 | 535 | [GeneratedRegex(@"")] 536 | private static partial Regex TrxRunId(); 537 | 538 | record Summary(int Passed, int Failed, int Skipped, TimeSpan Duration) 539 | { 540 | public int Total => Passed + Failed + Skipped; 541 | } 542 | 543 | record Failed(string Test, string Title, string Message, string File, int Line); 544 | } 545 | -------------------------------------------------------------------------------- /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 | true 14 | readme.md 15 | dotnet dotnet-tool 16 | 17 | $([System.DateTime]::Now.ToString("yyyy-MM-dd")) 18 | $(GITHUB_REF_NAME) 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /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/dotnet-retest/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/dotnet-retest/2c7c276e888a5a4a37adce3c8ba2896b0d83f627/src/dotnet-retest/icon.png -------------------------------------------------------------------------------- /src/dotnet-retest/readme.md: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------